Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37ef86f32e | ||
|
|
60aec370d6 | ||
|
|
6a76b9eafe | ||
|
|
76ba13fbad | ||
|
|
150d38be62 | ||
|
|
a4bbed0cb8 | ||
|
|
90fa9e8741 | ||
|
|
4b20d6ecd0 | ||
|
|
6cac34e968 | ||
|
|
c6f77cec2e | ||
|
|
acc97da84b | ||
|
|
6b10ac2ab5 | ||
|
|
c7563f2c39 | ||
|
|
96676182f4 | ||
|
|
3769d88ebb | ||
|
|
d9c35744f6 | ||
|
|
33991f4e22 | ||
|
|
df300919fc |
No files matched your search
@@ -58,12 +58,12 @@ A random secret can be generated by using the following command: `openssl rand -
|
||||
|
||||
## Docker
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||
| `DOCKER_ENDPOINTS` | JSON array of named, capability-scoped Docker or Podman endpoints. Supports socket, explicitly insecure TCP, and TLS transports. See the [Docker and Podman integration guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - |
|
||||
| `DOCKER_HOSTNAMES` | Legacy comma-separated list of plaintext TCP hostnames. Ignored when `DOCKER_ENDPOINTS` is set | For example `localhost,docker.example.com` | - |
|
||||
| `DOCKER_PORTS` | Legacy comma-separated list of plaintext TCP ports. Ignored when `DOCKER_ENDPOINTS` is set | For example `2375,2376` | - |
|
||||
| `DOCKER_SOCKET_PATHS` | Legacy comma-separated list of mounted Docker-compatible socket paths. Ignored when `DOCKER_ENDPOINTS` is set | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||
| `DOCKER_SOCKET_PATHS` | Comma-separated mounted Docker-compatible socket paths. Homarr discovers each path as a selectable Docker environment | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||
| `DOCKER_HOSTNAMES` | Comma-separated plaintext TCP hostnames. Pair each hostname with the port in the same position in `DOCKER_PORTS` | For example `localhost,docker.example.com` | - |
|
||||
| `DOCKER_PORTS` | Comma-separated plaintext TCP ports paired with `DOCKER_HOSTNAMES` | For example `2375,2376` | - |
|
||||
| `DOCKER_ENDPOINTS` | Optional advanced JSON configuration for custom names, TLS, or restricted capabilities. It takes precedence over the simple variables above. See the [Docker and Podman connection guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - |
|
||||
|
||||
## Database
|
||||
|
||||
|
||||
@@ -24,12 +24,11 @@ import { dockerContainersWidget } from "@site/docs/widgets/docker-containers";
|
||||
]}
|
||||
/>
|
||||
|
||||
## Enabling the Docker integration
|
||||
## Connecting Docker to Homarr
|
||||
|
||||
This integration must first be enabled.
|
||||
If you directly run on the host system and you have docker installed, it will be enabled by default.
|
||||
Otherwise, you'll need to mount the [docker socket](https://docs.docker.com/engine/security/protect-access/) to the Homarr container,
|
||||
so Homarr can interact with the Docker service.
|
||||
Docker connections come directly from Homarr's environment variables. They are automatically available in the Docker management table and Docker widget; you do not create a Docker integration under **Management → Integrations**.
|
||||
|
||||
If Homarr runs directly on a host with Docker installed, it uses `/var/run/docker.sock` by default. When Homarr runs in a container, mount the [Docker socket](https://docs.docker.com/engine/security/protect-access/) into it so Homarr can interact with Docker.
|
||||
|
||||
In Docker Compose, you can simply add this line:
|
||||
|
||||
@@ -122,7 +121,23 @@ services:
|
||||
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
|
||||
```
|
||||
|
||||
For named endpoints, transport security, or least-privilege actions, prefer `DOCKER_ENDPOINTS`. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
|
||||
For remote socket proxies, list the hostnames and matching ports in the same order:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
DOCKER_HOSTNAMES: docker-proxy-home,docker-proxy-lab
|
||||
DOCKER_PORTS: 2375,2375
|
||||
```
|
||||
|
||||
Homarr automatically discovers each comma-separated entry as a separate Docker environment. All environments are selected by default; the Docker widget lets you select one or more without any additional setup.
|
||||
|
||||
:::warning[Plaintext TCP]
|
||||
Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy on a private Docker network, or TLS.
|
||||
:::
|
||||
|
||||
### Advanced endpoint configuration
|
||||
|
||||
Most users should use the comma-separated variables above. Use `DOCKER_ENDPOINTS` only when you need custom names, TLS, or restricted capabilities. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -163,16 +178,12 @@ Supported transports are:
|
||||
- `tls`: `host`, `port`, and an absolute `caPath`; add both `certPath` and `keyPath` for mutual TLS
|
||||
- `tcp`: `host`, `port`, and the explicit acknowledgement `allowInsecure: true`
|
||||
|
||||
:::warning[Plaintext TCP]
|
||||
Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy, or TLS. The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`.
|
||||
:::
|
||||
|
||||
When `DOCKER_ENDPOINTS` is set, it takes precedence over the legacy `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS` variables.
|
||||
The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`. When `DOCKER_ENDPOINTS` is set, it takes precedence over `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS`.
|
||||
|
||||
### Managing your Docker containers within Homarr
|
||||
|
||||
Homarr allows you to interact with Docker containers running on your system.
|
||||
The **Service setup** section compares the visible containers with your existing Homarr apps and integrations. It keeps every service distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
|
||||
The collapsed **Assisted Docker setup** helper optionally compares visible containers with your existing Homarr apps and integrations. Open it only when you want setup suggestions; it never blocks normal container management. Every service stays distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
|
||||
|
||||
Homarr can show a service as:
|
||||
|
||||
@@ -181,15 +192,15 @@ Homarr can show a service as:
|
||||
- **Already represented** or **Linked** when an app or integration already matches
|
||||
- **Address changed** when a service appears to match an integration but its reachable address has changed
|
||||
|
||||
Suggested addresses are ranked and explain where they came from. A published address or Docker endpoint host is normally usable from a browser; a container-name address is server-only and requires Homarr to share the container network. Wildcard addresses such as `0.0.0.0` are never offered as browser destinations. You can edit every suggestion before continuing.
|
||||
Set the usual server address once, then choose whether services normally use different ports, subdomains, or paths. Homarr uses that pattern to prefill every detected service. Each suggestion has one editable URL field with the generated and Docker-reported addresses available as autocomplete options, so you can select a suggestion or type any address directly.
|
||||
|
||||
:::info[Review before changing]
|
||||
Service discovery is read-only. Homarr never creates or changes an app or integration automatically; use the action on an individual suggestion to review and confirm its setup.
|
||||
Service discovery is read-only. Homarr never creates or changes an app or integration automatically. **Set up integration** opens the standard prefilled integration form, while **Create app** opens the existing Docker app form, so you can review and confirm the final setup.
|
||||
:::
|
||||
|
||||
Use the inbox filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same panel. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically.
|
||||
Suggestions appear as a compact task list. Expand an item only when you want to edit its URL or inspect relevant setup statuses. Use the filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same helper. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically.
|
||||
|
||||
The health badges are an evidence-based projection across Docker, integration configuration, app representation, and persisted widget links. Authentication, API request success, and widget query execution are labeled **Not observed** because Homarr does not currently retain trustworthy last-run results for these layers. Use the suggested integration test or board action to diagnose them live; **Not observed** is never presented as healthy.
|
||||
Compact status icons summarize the setup information Homarr can act on. Unknown and non-applicable runtime layers are omitted to keep the helper focused; use the integration form's connection test or the relevant board action for live diagnostics.
|
||||
|
||||
If one Docker endpoint is unavailable, Homarr reports that endpoint while continuing to show services from healthy endpoints. An unavailable endpoint is not treated as an empty Docker installation.
|
||||
|
||||
@@ -216,6 +227,10 @@ This opens a dedicated page with an xterm.js terminal that streams the container
|
||||
The Docker management table supports optional **CPU usage** and **Memory usage** columns.
|
||||
These columns are hidden by default and can be toggled on from the table column visibility settings.
|
||||
|
||||
#### Choosing Docker environments in the widget
|
||||
|
||||
The Docker widget can show one or more configured Docker environments. Leave **Docker environments** empty to include every endpoint, which is also the default for existing widgets. Selecting one or more entries limits container and statistics requests to those endpoints.
|
||||
|
||||
#### Add apps from containers
|
||||
|
||||
Homarr also lets you add apps from containers. You can simply select all containers you want to add an app for and then click the `Add to Homarr` button.
|
||||
|
||||
@@ -110,6 +110,10 @@ Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.resta
|
||||
names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different
|
||||
Docker or Podman endpoints from targeting the wrong service.
|
||||
|
||||
`docker.getContainers` also accepts an optional `endpointIds` array. Omit it or pass an empty array to query every configured
|
||||
Docker environment, or pass one or more IDs returned by `docker.getEndpoints` to query only those Dockerode clients. The
|
||||
Docker widget uses the same behavior for its environment selector.
|
||||
|
||||
## Partial integration failures
|
||||
|
||||
Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
|
||||
|
||||
@@ -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
|
||||
widget returns to its settings with the new integration already selected.
|
||||
|
||||
Users with full access can also test and update a selected integration from **Edit widget → Edit integration** on a
|
||||
board. Failed tests keep the editor open and show the same connection diagnostics as the management page. Successful
|
||||
changes apply to every app and widget that uses the integration.
|
||||
Users with full access can also test and update a selected integration directly in the **Edit widget → Edit integration**
|
||||
tab on a board. Failed tests keep the editor open and show the same connection diagnostics as the management page.
|
||||
Successful changes apply to every app and widget that uses the integration.
|
||||
|
||||
When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
|
||||
create one.
|
||||
|
||||
@@ -7,20 +7,15 @@ hide_title: true
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
import { IconAd } from "@tabler/icons-react";
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import { dockerContainersWidget } from ".";
|
||||
import { dockerIntegration } from "@site/docs/integrations/docker";
|
||||
|
||||
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
|
||||
|
||||
This widget displays the resource usage of your Docker containers and allows you to manage them.
|
||||
|
||||
:::info
|
||||
This widget can only be used with administrator privileges.
|
||||
This widget reads the Docker environments configured through environment variables. You do not need to create a Docker integration under **Management → Integrations**. The widget can only be used with administrator privileges.
|
||||
:::
|
||||
|
||||
### Screenshots
|
||||
@@ -29,16 +24,6 @@ import dockerTable from "./img/table.png";
|
||||
|
||||
<img src={dockerTable} alt="docker-stats table docker-containers" width={128 * 3} height={128 * 2} />
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: dockerIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Interactions
|
||||
|
||||
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
|
||||
|
||||
@@ -8,6 +8,12 @@ export const dockerContainersWidget: WidgetDefinition = {
|
||||
path: "../../widgets/docker-containers",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Docker environments",
|
||||
description: "Select one or more automatically discovered Docker environments, or leave empty to show all",
|
||||
values: "Any Docker socket or host configured for Homarr",
|
||||
defaultValue: "All Docker environments",
|
||||
},
|
||||
{
|
||||
name: "Columns to show",
|
||||
description: "Select which columns are visible in the table",
|
||||
|
||||
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
||||
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
|
||||
|
||||
The media server widget shows active sessions across your media servers.
|
||||
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||
|
||||
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
|
||||
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
|
||||
|
||||
+159
-123
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useCallback, useImperativeHandle, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
|
||||
@@ -27,8 +27,14 @@ import type { AnyMappedTestConnectionError } from "../../_components/test-connec
|
||||
|
||||
interface EditIntegrationFormProps {
|
||||
integration: RouterOutputs["integration"]["byId"];
|
||||
embedded?: boolean;
|
||||
hideButtons?: boolean;
|
||||
onSuccess?: () => void;
|
||||
formRef?: React.Ref<EditIntegrationFormHandle>;
|
||||
}
|
||||
|
||||
export interface EditIntegrationFormHandle {
|
||||
submit: () => Promise<boolean>;
|
||||
isDirty: () => boolean;
|
||||
}
|
||||
|
||||
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
||||
@@ -44,7 +50,12 @@ const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
||||
}),
|
||||
);
|
||||
|
||||
export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }: EditIntegrationFormProps) => {
|
||||
export const EditIntegrationForm = ({
|
||||
integration,
|
||||
hideButtons = false,
|
||||
onSuccess,
|
||||
formRef,
|
||||
}: EditIntegrationFormProps) => {
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const allSecretKinds = getAllSecretKindOptions(integration.kind);
|
||||
@@ -82,147 +93,172 @@ export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }
|
||||
|
||||
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
||||
|
||||
const handleSubmitAsync = async ({ app, ...values }: FormType) => {
|
||||
setError(null);
|
||||
let url: string;
|
||||
try {
|
||||
url = hasUrlSecret
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
const handleSubmitAsync = useCallback(
|
||||
async ({ app, ...values }: FormType) => {
|
||||
setError(null);
|
||||
let url: string;
|
||||
try {
|
||||
url = hasUrlSecret
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await mutateAsync({
|
||||
id: integration.id,
|
||||
...values,
|
||||
url,
|
||||
secrets: values.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: secret.value === "" ? null : secret.value,
|
||||
})),
|
||||
appId: app?.id ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await mutateAsync({
|
||||
id: integration.id,
|
||||
...values,
|
||||
url,
|
||||
secrets: values.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: secret.value === "" ? null : secret.value,
|
||||
})),
|
||||
appId: app?.id ?? null,
|
||||
});
|
||||
// We do it this way as we are unable to send a typesafe error through onError
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
requestAnimationFrame(() => errorRef.current?.focus());
|
||||
return false;
|
||||
}
|
||||
|
||||
// We do it this way as we are unable to send a typesafe error through onError
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.edit.notification.success.title"),
|
||||
message: t("integration.page.edit.notification.success.message"),
|
||||
});
|
||||
requestAnimationFrame(() => errorRef.current?.focus());
|
||||
return;
|
||||
}
|
||||
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
|
||||
onSuccess?.();
|
||||
if (!hideButtons) {
|
||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[hasUrlSecret, hideButtons, integration.id, mutateAsync, onSuccess, router, t, utils.integration, utils.widget],
|
||||
);
|
||||
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.edit.notification.success.title"),
|
||||
message: t("integration.page.edit.notification.success.message"),
|
||||
});
|
||||
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
|
||||
onSuccess?.();
|
||||
if (!embedded) {
|
||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
}
|
||||
};
|
||||
useImperativeHandle(
|
||||
formRef,
|
||||
() => ({
|
||||
submit: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
form.onSubmit(
|
||||
async (values) => {
|
||||
try {
|
||||
resolve(await handleSubmitAsync(values));
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
() => resolve(false),
|
||||
)();
|
||||
}),
|
||||
isDirty: () => form.isDirty(),
|
||||
}),
|
||||
[form, handleSubmitAsync],
|
||||
);
|
||||
|
||||
const isInitialSecretKinds =
|
||||
initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) &&
|
||||
form.values.secrets.length === initialSecretsKinds.length;
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>
|
||||
<Stack>
|
||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||
const formFields = (
|
||||
<Stack>
|
||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||
|
||||
{hasUrlSecret ? null : (
|
||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||
)}
|
||||
{hasUrlSecret ? null : (
|
||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||
)}
|
||||
|
||||
<Fieldset legend={t("integration.secrets.title")}>
|
||||
<Stack gap="sm">
|
||||
{allSecretKinds.length > 1 && (
|
||||
<SecretKindsSegmentedControl
|
||||
defaultKinds={initialSecretsKinds}
|
||||
secretKinds={allSecretKinds}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
{!isInitialSecretKinds
|
||||
? null
|
||||
: form.values.secrets.map((secret, index) => (
|
||||
<SecretCard
|
||||
key={secret.kind}
|
||||
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
|
||||
onCancel={() =>
|
||||
new Promise((resolve) => {
|
||||
// When nothing changed, just close the secret card
|
||||
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
|
||||
return resolve(true);
|
||||
}
|
||||
openConfirmModal({
|
||||
title: t("integration.secrets.reset.title"),
|
||||
children: t("integration.secrets.reset.message"),
|
||||
onCancel: () => resolve(false),
|
||||
onConfirm: () => {
|
||||
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
>
|
||||
<IntegrationSecretInput
|
||||
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
|
||||
key={secret.kind}
|
||||
kind={secret.kind}
|
||||
{...form.getInputProps(`secrets.${index}.value`)}
|
||||
/>
|
||||
</SecretCard>
|
||||
))}
|
||||
{isInitialSecretKinds
|
||||
? null
|
||||
: form.values.secrets.map(({ kind }, index) => (
|
||||
<Fieldset legend={t("integration.secrets.title")}>
|
||||
<Stack gap="sm">
|
||||
{allSecretKinds.length > 1 && (
|
||||
<SecretKindsSegmentedControl defaultKinds={initialSecretsKinds} secretKinds={allSecretKinds} form={form} />
|
||||
)}
|
||||
{!isInitialSecretKinds
|
||||
? null
|
||||
: form.values.secrets.map((secret, index) => (
|
||||
<SecretCard
|
||||
key={secret.kind}
|
||||
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
|
||||
onCancel={() =>
|
||||
new Promise((resolve) => {
|
||||
// When nothing changed, just close the secret card
|
||||
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
|
||||
return resolve(true);
|
||||
}
|
||||
openConfirmModal({
|
||||
title: t("integration.secrets.reset.title"),
|
||||
children: t("integration.secrets.reset.message"),
|
||||
onCancel: () => resolve(false),
|
||||
onConfirm: () => {
|
||||
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
>
|
||||
<IntegrationSecretInput
|
||||
withAsterisk
|
||||
key={kind}
|
||||
kind={kind}
|
||||
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
|
||||
key={secret.kind}
|
||||
kind={secret.kind}
|
||||
{...form.getInputProps(`secrets.${index}.value`)}
|
||||
/>
|
||||
))}
|
||||
{form.values.secrets.length === 0 && (
|
||||
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
|
||||
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
|
||||
|
||||
{error !== null && (
|
||||
<div ref={errorRef} role="alert" tabIndex={-1}>
|
||||
<IntegrationTestConnectionError error={error} url={form.values.url} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Group justify="end" align="center">
|
||||
{!embedded && (
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
</SecretCard>
|
||||
))}
|
||||
{isInitialSecretKinds
|
||||
? null
|
||||
: form.values.secrets.map(({ kind }, index) => (
|
||||
<IntegrationSecretInput
|
||||
withAsterisk
|
||||
key={kind}
|
||||
kind={kind}
|
||||
{...form.getInputProps(`secrets.${index}.value`)}
|
||||
/>
|
||||
))}
|
||||
{form.values.secrets.length === 0 && (
|
||||
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
|
||||
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
|
||||
|
||||
{error !== null && (
|
||||
<div ref={errorRef} role="alert" tabIndex={-1}>
|
||||
<IntegrationTestConnectionError error={error} url={form.values.url} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hideButtons && (
|
||||
<Group justify="end" align="center">
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("integration.testConnection.action.edit")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (hideButtons) {
|
||||
return formFields;
|
||||
}
|
||||
|
||||
return <form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>{formFields}</form>;
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof formSchema>;
|
||||
|
||||
+40
-5
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
dismissDockerReconciliationCandidate,
|
||||
filterDockerReconciliationInbox,
|
||||
getValidDockerServiceUrl,
|
||||
getTemplateUrl,
|
||||
} from "./docker-reconciliation-inbox";
|
||||
|
||||
const candidates = [
|
||||
@@ -37,9 +37,44 @@ describe("Docker reconciliation inbox", () => {
|
||||
expect(dismissDockerReconciliationCandidate(dismissed, "home:new")).toBe(dismissed);
|
||||
});
|
||||
|
||||
test("accepts only HTTP service URLs", () => {
|
||||
expect(getValidDockerServiceUrl("https://service.example.com")).toBe("https://service.example.com/");
|
||||
expect(getValidDockerServiceUrl("ftp://service.example.com")).toBeNull();
|
||||
expect(getValidDockerServiceUrl("service.example.com")).toBeNull();
|
||||
test("builds service URLs from the server origin and published ports", () => {
|
||||
expect(
|
||||
getTemplateUrl(
|
||||
{
|
||||
container: { name: "Custom service", ports: [{ PrivatePort: 3000, Type: "tcp" }] },
|
||||
match: null,
|
||||
},
|
||||
"home.lan",
|
||||
"hostPort",
|
||||
),
|
||||
).toBe("http://home.lan");
|
||||
|
||||
expect(
|
||||
getTemplateUrl(
|
||||
{
|
||||
container: {
|
||||
name: "Sonarr",
|
||||
ports: [
|
||||
{ PrivatePort: 3000, PublicPort: 3000, Type: "tcp" },
|
||||
{ PrivatePort: 8989, PublicPort: 9999, Type: "tcp" },
|
||||
],
|
||||
},
|
||||
match: { kind: "sonarr" },
|
||||
},
|
||||
"home.lan",
|
||||
"hostPort",
|
||||
),
|
||||
).toBe("http://home.lan:9999");
|
||||
|
||||
expect(
|
||||
getTemplateUrl(
|
||||
{
|
||||
container: { name: "Sonarr", ports: [{ PrivatePort: 8989, PublicPort: 9999, Type: "tcp" }] },
|
||||
match: { kind: "sonarr" },
|
||||
},
|
||||
"",
|
||||
"hostPort",
|
||||
),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { DockerServiceMatch, DockerServicePort, UrlTemplateMode } from "@homarr/definitions";
|
||||
import { buildAppUrl, buildIntegrationUrl, getIntegrationDefaultPort } from "@homarr/definitions";
|
||||
|
||||
export type DockerReconciliationInboxFilter = "attention" | "represented" | "all";
|
||||
|
||||
interface DockerReconciliationInboxCandidate {
|
||||
@@ -24,11 +27,28 @@ export const filterDockerReconciliationInbox = <Candidate extends DockerReconcil
|
||||
export const dismissDockerReconciliationCandidate = (dismissedCandidateKeys: string[], candidateKey: string) =>
|
||||
dismissedCandidateKeys.includes(candidateKey) ? dismissedCandidateKeys : [...dismissedCandidateKeys, candidateKey];
|
||||
|
||||
export const getValidDockerServiceUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
interface DockerTemplateCandidate {
|
||||
container: {
|
||||
name: string;
|
||||
ports?: DockerServicePort[];
|
||||
};
|
||||
match: Pick<DockerServiceMatch, "kind"> | null;
|
||||
}
|
||||
|
||||
export const getTemplateUrl = (candidate: DockerTemplateCandidate, serverOrigin: string, urlMode: UrlTemplateMode) => {
|
||||
if (!serverOrigin.trim()) return "";
|
||||
|
||||
const tcpPorts = candidate.container.ports?.filter(({ Type }) => Type.toLowerCase() === "tcp") ?? [];
|
||||
let publishedPort = tcpPorts.find(({ PublicPort }) => PublicPort)?.PublicPort;
|
||||
|
||||
if (candidate.match) {
|
||||
const defaultPort = getIntegrationDefaultPort(candidate.match.kind);
|
||||
const preferredPort = tcpPorts.find(
|
||||
({ PrivatePort, PublicPort }) => PrivatePort === defaultPort && PublicPort !== undefined,
|
||||
);
|
||||
publishedPort = preferredPort?.PublicPort ?? publishedPort;
|
||||
return buildIntegrationUrl(candidate.match.kind, serverOrigin, urlMode, publishedPort);
|
||||
}
|
||||
|
||||
return buildAppUrl(candidate.container.name, serverOrigin, urlMode, publishedPort);
|
||||
};
|
||||
@@ -1,46 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconArrowRight, IconEyeOff, IconPlugConnected, IconRefresh } from "@tabler/icons-react";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconApps,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconBrandDocker,
|
||||
IconMinus,
|
||||
IconPlugConnected,
|
||||
IconRefresh,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { UrlTemplateMode } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { normalizeServiceUrl, ServiceUrlTemplate } from "@homarr/onboarding";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import type { DockerReconciliationInboxFilter } from "./docker-reconciliation-inbox";
|
||||
import {
|
||||
dismissDockerReconciliationCandidate,
|
||||
filterDockerReconciliationInbox,
|
||||
getValidDockerServiceUrl,
|
||||
getTemplateUrl,
|
||||
} from "./docker-reconciliation-inbox";
|
||||
|
||||
type ReconciliationCandidate = RouterOutputs["docker"]["reconcileServices"]["candidates"][number];
|
||||
type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][number];
|
||||
|
||||
export const DockerReconciliation = () => {
|
||||
export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOrigin: string }) => {
|
||||
const t = useScopedI18n("docker.reconciliation");
|
||||
const tGlobal = useI18n();
|
||||
const utils = clientApi.useUtils();
|
||||
const panelId = useId();
|
||||
const [isOpen, setIsOpen] = useLocalStorage({
|
||||
key: "homarr-docker-reconciliation-open",
|
||||
defaultValue: false,
|
||||
});
|
||||
const reconciliation = clientApi.docker.reconcileServices.useQuery();
|
||||
const health = clientApi.docker.getServiceHealth.useQuery();
|
||||
const health = clientApi.docker.getServiceHealth.useQuery(undefined, { enabled: isOpen });
|
||||
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
|
||||
async onSuccess() {
|
||||
await Promise.all([
|
||||
@@ -61,6 +87,14 @@ export const DockerReconciliation = () => {
|
||||
key: "homarr-docker-reconciliation-dismissed",
|
||||
defaultValue: [],
|
||||
});
|
||||
const [serverOrigin, setServerOrigin] = useLocalStorage({
|
||||
key: "homarr-docker-service-origin",
|
||||
defaultValue: defaultServerOrigin,
|
||||
});
|
||||
const [urlMode, setUrlMode] = useLocalStorage<UrlTemplateMode>({
|
||||
key: "homarr-docker-service-url-mode",
|
||||
defaultValue: "hostPort",
|
||||
});
|
||||
|
||||
if (reconciliation.isError) {
|
||||
return (
|
||||
@@ -68,7 +102,7 @@ export const DockerReconciliation = () => {
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("loadError.message")}</Text>
|
||||
<Button variant="light" color="red" size="xs" w="fit-content" onClick={() => void reconciliation.refetch()}>
|
||||
{t("action.retry")}
|
||||
{tGlobal("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
@@ -79,231 +113,274 @@ export const DockerReconciliation = () => {
|
||||
|
||||
const unavailableEndpoints = reconciliation.data.endpoints.filter(({ status }) => status === "unavailable");
|
||||
const candidates = filterDockerReconciliationInbox(reconciliation.data.candidates, filter, dismissedCandidateKeys);
|
||||
const attentionCount = health.data
|
||||
? health.data.services.filter(
|
||||
({ key, status }) =>
|
||||
["newRecognized", "newApp", "moved"].includes(status) && !dismissedCandidateKeys.includes(key),
|
||||
).length
|
||||
: 0;
|
||||
const attentionCount = reconciliation.data.candidates.filter(
|
||||
({ candidateKey, state }) =>
|
||||
["newRecognized", "newApp", "moved"].includes(state) && !dismissedCandidateKeys.includes(candidateKey),
|
||||
).length;
|
||||
const isRefreshing = refreshInventory.isPending || reconciliation.isFetching || health.isFetching;
|
||||
const toggleLabel = isOpen ? tGlobal("common.action.hide") : t("action.review");
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack>
|
||||
<Paper withBorder p="sm">
|
||||
<Stack gap={isOpen ? "sm" : 0}>
|
||||
<Group justify="space-between" align="start">
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<IconPlugConnected size={20} />
|
||||
<Text fw={600}>{t("title")}</Text>
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("description")}
|
||||
</Text>
|
||||
</div>
|
||||
{health.data && <Badge variant="light">{t("attention", { count: String(attentionCount) })}</Badge>}
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<SegmentedControl
|
||||
value={filter}
|
||||
onChange={(value) => setFilter(value as DockerReconciliationInboxFilter)}
|
||||
data={[
|
||||
{ value: "attention", label: t("filter.attention") },
|
||||
{ value: "represented", label: t("filter.represented") },
|
||||
{ value: "all", label: t("filter.all") },
|
||||
]}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
{dismissedCandidateKeys.length > 0 && (
|
||||
<Button variant="subtle" size="xs" onClick={() => setDismissedCandidateKeys([])}>
|
||||
{t("action.restoreDismissed", { count: String(dismissedCandidateKeys.length) })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
loading={isRefreshing}
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={() => refreshInventory.mutate()}
|
||||
>
|
||||
{t("action.refresh")}
|
||||
</Button>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" size="lg" radius="md">
|
||||
<IconBrandDocker size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>{t("title")}</Text>
|
||||
<Badge variant="light" color={attentionCount > 0 ? "blue" : "gray"} aria-live="polite">
|
||||
{t("suggestions", { count: String(attentionCount) })}
|
||||
</Badge>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
rightSection={
|
||||
<IconChevronDown
|
||||
size={15}
|
||||
style={{ transform: isOpen ? "rotate(180deg)" : undefined, transition: "transform 150ms ease" }}
|
||||
/>
|
||||
}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
>
|
||||
{toggleLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{unavailableEndpoints.length > 0 && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t("endpointUnavailable", { names: unavailableEndpoints.map(({ name }) => name).join(", ") })}
|
||||
</Alert>
|
||||
)}
|
||||
<Collapse id={panelId} expanded={isOpen}>
|
||||
<Divider mb="sm" />
|
||||
<Stack gap="sm">
|
||||
<ServiceUrlTemplate
|
||||
serverOrigin={serverOrigin}
|
||||
onServerOriginChange={setServerOrigin}
|
||||
mode={urlMode}
|
||||
onModeChange={setUrlMode}
|
||||
/>
|
||||
|
||||
{candidates.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{reconciliation.data.candidates.length === 0 ? t("empty") : t("emptyFilter")}
|
||||
</Text>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }}>
|
||||
{candidates.map((candidate) => (
|
||||
<DockerReconciliationCandidate
|
||||
key={candidate.candidateKey}
|
||||
candidate={candidate}
|
||||
health={health.data?.services.find(({ key }) => key === candidate.candidateKey)}
|
||||
onDismiss={() =>
|
||||
setDismissedCandidateKeys((current) =>
|
||||
dismissDockerReconciliationCandidate(current, candidate.candidateKey),
|
||||
)
|
||||
}
|
||||
<Group justify="space-between">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={filter}
|
||||
onChange={(value) => setFilter(value as DockerReconciliationInboxFilter)}
|
||||
data={[
|
||||
{ value: "attention", label: t("filter.attention") },
|
||||
{ value: "represented", label: t("filter.represented") },
|
||||
{ value: "all", label: t("filter.all") },
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
<Group gap="xs">
|
||||
{dismissedCandidateKeys.length > 0 && (
|
||||
<Button variant="subtle" size="compact-xs" onClick={() => setDismissedCandidateKeys([])}>
|
||||
{t("action.restoreDismissed", { count: String(dismissedCandidateKeys.length) })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-xs"
|
||||
loading={isRefreshing}
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={() => refreshInventory.mutate()}
|
||||
>
|
||||
{tGlobal("docker.action.refresh.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{unavailableEndpoints.length > 0 && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t("endpointUnavailable", { names: unavailableEndpoints.map(({ name }) => name).join(", ") })}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{candidates.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{reconciliation.data.candidates.length === 0 ? t("empty") : t("emptyFilter")}
|
||||
</Text>
|
||||
) : (
|
||||
<Accordion multiple variant="separated" radius="sm" chevron={null}>
|
||||
{candidates.map((candidate) => (
|
||||
<DockerReconciliationCandidate
|
||||
key={candidate.candidateKey}
|
||||
candidate={candidate}
|
||||
health={health.data?.services.find(({ key }) => key === candidate.candidateKey)}
|
||||
serverOrigin={serverOrigin}
|
||||
urlMode={urlMode}
|
||||
onDismiss={() =>
|
||||
setDismissedCandidateKeys((current) =>
|
||||
dismissDockerReconciliationCandidate(current, candidate.candidateKey),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const DockerReconciliationCandidate = ({
|
||||
candidate,
|
||||
health,
|
||||
serverOrigin,
|
||||
urlMode,
|
||||
onDismiss,
|
||||
}: {
|
||||
candidate: ReconciliationCandidate;
|
||||
health: ServiceHealth | undefined;
|
||||
serverOrigin: string;
|
||||
urlMode: UrlTemplateMode;
|
||||
onDismiss: () => void;
|
||||
}) => {
|
||||
const t = useScopedI18n("docker.reconciliation");
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
const initialUrlCandidate =
|
||||
candidate.urlCandidates.find(
|
||||
({ url, scopes }) => url.length > 0 && (candidate.match ? true : scopes.includes("browser")),
|
||||
) ?? candidate.urlCandidates.find(({ source }) => source === "manual");
|
||||
const [selectedCandidateId, setSelectedCandidateId] = useState(initialUrlCandidate?.id ?? null);
|
||||
const [url, setUrl] = useState(initialUrlCandidate?.url ?? "");
|
||||
const selectedCandidate = candidate.urlCandidates.find(({ id }) => id === selectedCandidateId);
|
||||
const validatedUrl = getValidDockerServiceUrl(url);
|
||||
const isInvalidUrl = url.length > 0 && validatedUrl === null;
|
||||
const target = getCandidateTarget(candidate, validatedUrl ?? "");
|
||||
const tGlobal = useI18n();
|
||||
const { openModal: openAppModal } = useModalAction(AddDockerAppToHomarr);
|
||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
const templateUrl = getTemplateUrl(candidate, serverOrigin, urlMode);
|
||||
const urlSuggestions = Array.from(
|
||||
new Set([templateUrl, ...candidate.urlCandidates.map(({ url }) => url)].filter((url) => url.length > 0)),
|
||||
);
|
||||
const initialUrl = urlSuggestions[0] ?? "";
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
const isUrlEdited = useRef(false);
|
||||
const target = getCandidateTarget(candidate);
|
||||
const actionNeedsUrl = target.kind === "createApp" || target.kind === "setupIntegration";
|
||||
const isActionDisabled = actionNeedsUrl && validatedUrl === null;
|
||||
const visibleHealthLayers =
|
||||
health?.layers.filter(({ status }) => status !== "notApplicable" && status !== "notObserved") ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCandidateId(initialUrlCandidate?.id ?? null);
|
||||
setUrl(initialUrlCandidate?.url ?? "");
|
||||
}, [initialUrlCandidate?.id, initialUrlCandidate?.url]);
|
||||
if (isUrlEdited.current) return;
|
||||
setUrl(initialUrl);
|
||||
}, [initialUrl]);
|
||||
|
||||
return (
|
||||
<Card withBorder padding="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="start" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Avatar src={candidate.container.iconUrl} radius="sm">
|
||||
<Accordion.Item value={candidate.candidateKey}>
|
||||
<Group wrap="nowrap" gap={4} pr="sm">
|
||||
<Accordion.Control
|
||||
style={{ flex: 1 }}
|
||||
icon={
|
||||
<Avatar src={candidate.container.iconUrl} radius="sm" size="sm">
|
||||
{candidate.container.name.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text fw={600} lineClamp={1}>
|
||||
}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{candidate.container.name}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{candidate.endpointName}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={stateColor(candidate.state)} variant="light">
|
||||
{t(`state.${candidate.state}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{candidate.match && (
|
||||
<Group gap="xs">
|
||||
<Badge variant="outline">{candidate.match.kind}</Badge>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("match", { confidence: t(`confidence.${candidate.match.confidence}`) })}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{candidate.representation.signals.ambiguous && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t("ambiguous")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{health && (
|
||||
<div>
|
||||
<Text c="dimmed" size="xs" mb={4}>
|
||||
{t("health.title")}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{health.layers.map((layer) => (
|
||||
<Badge key={layer.layer} color={healthStatusColor(layer.status)} variant="dot" size="sm">
|
||||
{t(`health.layer.${layer.layer}`)}: {t(`health.status.${layer.status}`)}
|
||||
</Badge>
|
||||
))}
|
||||
<Badge color={stateColor(candidate.state)} variant="light" size="xs">
|
||||
{t(`state.${candidate.state}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text c="dimmed" size="xs">
|
||||
{candidate.endpointName}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label={t("url.label")}
|
||||
description={selectedCandidate ? t(`reason.${selectedCandidate.reason}`) : t("reason.manualHostRequired")}
|
||||
value={selectedCandidateId}
|
||||
data={candidate.urlCandidates.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.url || t("url.manual"),
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setSelectedCandidateId(value);
|
||||
setUrl(candidate.urlCandidates.find(({ id }) => id === value)?.url ?? "");
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("url.inputLabel")}
|
||||
aria-label={t("url.inputLabel")}
|
||||
value={url}
|
||||
placeholder="https://service.example.com"
|
||||
error={isInvalidUrl ? t("url.invalid") : undefined}
|
||||
onChange={(event) => setUrl(event.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" color="gray" leftSection={<IconEyeOff size={16} />} onClick={onDismiss}>
|
||||
{t("action.dismiss")}
|
||||
</Button>
|
||||
{target.kind === "createApp" ? (
|
||||
</Accordion.Control>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{target.kind === "createApp" && (
|
||||
<Button
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
disabled={isActionDisabled}
|
||||
size="compact-xs"
|
||||
leftSection={<IconApps size={14} />}
|
||||
onClick={() =>
|
||||
openModal({
|
||||
openAppModal({
|
||||
selectedContainers: [candidate.container],
|
||||
initialUrls: validatedUrl ? [validatedUrl] : undefined,
|
||||
initialUrls: [(normalizeServiceUrl(url) ?? url) || null],
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("action.createApp")}
|
||||
{tGlobal("integration.field.createApp.label")}
|
||||
</Button>
|
||||
) : (
|
||||
)}
|
||||
{target.kind === "setupIntegration" && (
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-xs"
|
||||
leftSection={<IconPlugConnected size={14} />}
|
||||
onClick={() =>
|
||||
openIntegrationModal({
|
||||
initialKind: target.integrationKind,
|
||||
initialName: candidate.container.name,
|
||||
initialUrl: normalizeServiceUrl(url) ?? url,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("action.setupIntegration")}
|
||||
</Button>
|
||||
)}
|
||||
{(target.kind === "reviewIntegration" || target.kind === "viewRepresentation") && (
|
||||
<Button
|
||||
component={Link}
|
||||
href={target.href}
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
disabled={isActionDisabled}
|
||||
onClick={(event) => {
|
||||
if (isActionDisabled) event.preventDefault();
|
||||
}}
|
||||
size="compact-xs"
|
||||
leftSection={target.kind === "reviewIntegration" ? <IconSettings size={14} /> : <IconEye size={14} />}
|
||||
>
|
||||
{t(`action.${target.kind}`)}
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip label={t("action.dismiss")} events={{ hover: true, focus: true, touch: false }}>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("action.dismiss")} onClick={onDismiss}>
|
||||
<IconEyeOff size={15} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Group>
|
||||
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
{candidate.representation.signals.ambiguous && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t("ambiguous")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{visibleHealthLayers.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xs">
|
||||
{visibleHealthLayers.map((layer) => (
|
||||
<Group key={layer.layer} gap="xs" wrap="nowrap">
|
||||
<HealthStatusIcon status={layer.status} />
|
||||
<Text size="xs">
|
||||
{t(`health.layer.${layer.layer}`)} ·{" "}
|
||||
<Text component="span" c="dimmed" inherit>
|
||||
{t(`health.status.${layer.status}`)}
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{actionNeedsUrl && (
|
||||
<Autocomplete
|
||||
label={tGlobal("integration.field.url.label")}
|
||||
value={url}
|
||||
data={urlSuggestions}
|
||||
placeholder={t("url.manual")}
|
||||
onChange={(value) => {
|
||||
isUrlEdited.current = true;
|
||||
setUrl(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const getCandidateTarget = (candidate: ReconciliationCandidate, url: string) => {
|
||||
const getCandidateTarget = (candidate: ReconciliationCandidate) => {
|
||||
if (candidate.representation.signals.ambiguous) {
|
||||
if (candidate.nextAction === "reviewIntegration" && candidate.match) {
|
||||
const params = new URLSearchParams({ search: getIntegrationName(candidate.match.kind) });
|
||||
@@ -312,9 +389,7 @@ const getCandidateTarget = (candidate: ReconciliationCandidate, url: string) =>
|
||||
return { kind: "viewRepresentation" as const, href: "/manage/apps" };
|
||||
}
|
||||
if (candidate.state === "newRecognized" && candidate.match) {
|
||||
const params = new URLSearchParams({ kind: candidate.match.kind, name: candidate.container.name });
|
||||
if (url) params.set("url", url);
|
||||
return { kind: "setupIntegration" as const, href: `/manage/integrations/new?${params.toString()}` };
|
||||
return { kind: "setupIntegration" as const, integrationKind: candidate.match.kind };
|
||||
}
|
||||
|
||||
if (candidate.state === "newApp") return { kind: "createApp" as const };
|
||||
@@ -337,8 +412,21 @@ const stateColor = (state: ReconciliationCandidate["state"]) => {
|
||||
return "blue";
|
||||
};
|
||||
|
||||
const healthStatusColor = (status: ServiceHealth["layers"][number]["status"]) => {
|
||||
if (["available", "configured", "linked"].includes(status)) return "green";
|
||||
if (["missing", "changed", "unused"].includes(status)) return "yellow";
|
||||
return "gray";
|
||||
const HealthStatusIcon = ({ status }: { status: ServiceHealth["layers"][number]["status"] }) => {
|
||||
let color = "gray";
|
||||
let icon = <IconMinus size={12} />;
|
||||
|
||||
if (["available", "configured", "linked"].includes(status)) {
|
||||
color = "green";
|
||||
icon = <IconCheck size={12} />;
|
||||
} else if (["missing", "changed", "unused"].includes(status)) {
|
||||
color = "yellow";
|
||||
icon = <IconAlertTriangle size={12} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeIcon color={color} variant="light" radius="xl" size="sm">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import { headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
import { env } from "@homarr/docker/env";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
@@ -16,7 +18,10 @@ export default async function DockerPage() {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { containers, endpoints, timestamp } = await api.docker.getContainers();
|
||||
const [{ containers, endpoints, timestamp }, requestHeaders] = await Promise.all([
|
||||
api.docker.getContainers(),
|
||||
headers(),
|
||||
]);
|
||||
const tDocker = await getScopedI18n("docker");
|
||||
|
||||
return (
|
||||
@@ -24,7 +29,7 @@ export default async function DockerPage() {
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tDocker("title")}</Title>
|
||||
<DockerReconciliation />
|
||||
<DockerReconciliation defaultServerOrigin={extractBaseUrlFromHeaders(requestHeaders)} />
|
||||
<DockerTable initialData={{ containers, endpoints, timestamp }} />
|
||||
</Stack>
|
||||
</>
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
import { Suspense, use } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import type * as WidgetModalsModule from "@homarr/widgets/modals";
|
||||
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
||||
|
||||
import type * as IntegrationEditModalModule from "~/components/integration/embedded-integration-edit-modal";
|
||||
import type { EmbeddedIntegrationEditModalProps } from "~/components/integration/embedded-integration-edit-modal";
|
||||
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
|
||||
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
|
||||
|
||||
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
|
||||
|
||||
@@ -29,42 +29,38 @@ export const loadWidgetEditModal = () => {
|
||||
|
||||
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
|
||||
|
||||
let integrationEditModalPromise: Promise<typeof IntegrationEditModalModule> | undefined;
|
||||
let integrationEditFormPromise: Promise<typeof IntegrationEditFormModule> | undefined;
|
||||
|
||||
const loadIntegrationEditModal = () => {
|
||||
if (integrationEditModalPromise) return integrationEditModalPromise;
|
||||
const loadIntegrationEditForm = () => {
|
||||
if (integrationEditFormPromise) return integrationEditFormPromise;
|
||||
|
||||
const currentPromise = import("~/components/integration/embedded-integration-edit-modal");
|
||||
integrationEditModalPromise = currentPromise;
|
||||
const currentPromise = import("~/components/integration/embedded-integration-edit-form");
|
||||
integrationEditFormPromise = currentPromise;
|
||||
void currentPromise.catch(() => {
|
||||
if (integrationEditModalPromise === currentPromise) integrationEditModalPromise = undefined;
|
||||
if (integrationEditFormPromise === currentPromise) integrationEditFormPromise = undefined;
|
||||
});
|
||||
return currentPromise;
|
||||
};
|
||||
|
||||
const LazyIntegrationEditModalContent = (props: {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: EmbeddedIntegrationEditModalProps;
|
||||
}) => {
|
||||
const { EmbeddedIntegrationEditModal } = use(loadIntegrationEditModal());
|
||||
const Component = EmbeddedIntegrationEditModal.component;
|
||||
return <Component {...props} />;
|
||||
const LazyIntegrationEditFormContent = (props: EmbeddedIntegrationEditFormProps) => {
|
||||
const { EmbeddedIntegrationEditForm } = use(loadIntegrationEditForm());
|
||||
return <EmbeddedIntegrationEditForm {...props} />;
|
||||
};
|
||||
|
||||
const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>((props) => (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<Center py="xl">
|
||||
<WidgetError
|
||||
error={error}
|
||||
resetErrorBoundary={() => {
|
||||
integrationEditModalPromise = undefined;
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
>
|
||||
const IntegrationEditErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => (
|
||||
<Center py="xl">
|
||||
<WidgetError
|
||||
error={error}
|
||||
resetErrorBoundary={() => {
|
||||
integrationEditFormPromise = undefined;
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
|
||||
const LazyIntegrationEditForm = (props: EmbeddedIntegrationEditFormProps) => (
|
||||
<ErrorBoundary FallbackComponent={IntegrationEditErrorFallback}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center py="xl">
|
||||
@@ -72,15 +68,10 @@ const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<LazyIntegrationEditModalContent {...props} />
|
||||
<LazyIntegrationEditFormContent {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)).withOptions({
|
||||
defaultTitle: (t) => t("item.edit.tab.integration"),
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: true,
|
||||
});
|
||||
);
|
||||
|
||||
interface LazyWidgetEditModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
@@ -90,17 +81,12 @@ interface LazyWidgetEditModalContentProps {
|
||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||
const Component = WidgetEditModal.component;
|
||||
const { openModal: openIntegrationEditModal } = useModalAction(LazyIntegrationEditModal);
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
innerProps={{
|
||||
...props.innerProps,
|
||||
onEditIntegration: (integrationId) =>
|
||||
openIntegrationEditModal({
|
||||
integrationId,
|
||||
onSuccess: props.innerProps.onIntegrationSaved,
|
||||
}),
|
||||
integrationEditForm: LazyIntegrationEditForm,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
import type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals";
|
||||
|
||||
import type { EditIntegrationFormHandle } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
|
||||
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
|
||||
|
||||
export type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals";
|
||||
|
||||
export const EmbeddedIntegrationEditForm = ({
|
||||
integrationId,
|
||||
handleRef,
|
||||
onSuccess,
|
||||
}: EmbeddedIntegrationEditFormProps) => {
|
||||
const t = useI18n();
|
||||
const integrationFormRef = useRef<EditIntegrationFormHandle>(null);
|
||||
const {
|
||||
data: integration,
|
||||
isPending,
|
||||
isError,
|
||||
refetch,
|
||||
} = clientApi.integration.byId.useQuery({
|
||||
id: integrationId,
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
handleRef,
|
||||
() => ({
|
||||
submitIfDirty: async () => {
|
||||
if (!integrationFormRef.current?.isDirty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return integrationFormRef.current.submit();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
|
||||
<Skeleton height={36} width="60%" />
|
||||
<Skeleton height={60} />
|
||||
<Skeleton height={120} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !integration) {
|
||||
return (
|
||||
<Alert color="red" title={t("common.error")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
|
||||
<Button variant="light" color="red" onClick={() => void refetch()}>
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} truncate>
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{getIntegrationName(integration.kind)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
{t("item.edit.integration.propagationNotice")}
|
||||
</Alert>
|
||||
<EditIntegrationForm integration={integration} hideButtons formRef={integrationFormRef} onSuccess={onSuccess} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,86 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
|
||||
|
||||
export interface EmbeddedIntegrationEditModalProps {
|
||||
integrationId: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const EmbeddedIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
data: integration,
|
||||
isPending,
|
||||
isError,
|
||||
refetch,
|
||||
} = clientApi.integration.byId.useQuery({
|
||||
id: innerProps.integrationId,
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
|
||||
<Skeleton height={36} width="60%" />
|
||||
<Skeleton height={60} />
|
||||
<Skeleton height={120} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !integration) {
|
||||
return (
|
||||
<Alert color="red" title={t("common.error")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
|
||||
<Button variant="light" color="red" onClick={() => void refetch()}>
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} truncate>
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{getIntegrationName(integration.kind)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
{t("item.edit.integration.propagationNotice")}
|
||||
</Alert>
|
||||
<EditIntegrationForm
|
||||
integration={integration}
|
||||
embedded
|
||||
onSuccess={() => {
|
||||
innerProps.onSuccess?.();
|
||||
actions.closeModal();
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
).withOptions({
|
||||
defaultTitle: (t) => t("item.edit.tab.integration"),
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: true,
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Group, ScrollArea, Title } from "@mantine/core";
|
||||
import { IconArrowLeft } from "@tabler/icons-react";
|
||||
import { Group, ScrollArea, Title, UnstyledButton } from "@mantine/core";
|
||||
import { IconArrowLeft, IconX } from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
|
||||
@@ -19,11 +20,15 @@ interface IntegrationSelectModalProps {
|
||||
enableMockIntegration?: boolean;
|
||||
allowedKinds?: readonly IntegrationKind[];
|
||||
completionBoardId?: string;
|
||||
initialKind?: IntegrationKind;
|
||||
initialUrl?: string;
|
||||
initialName?: string;
|
||||
}
|
||||
|
||||
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
|
||||
const [step, setStep] = useState<"select" | "form">("select");
|
||||
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(null);
|
||||
const t = useI18n();
|
||||
const [step, setStep] = useState<"select" | "form">(innerProps.initialKind ? "form" : "select");
|
||||
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(innerProps.initialKind ?? null);
|
||||
const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
|
||||
|
||||
const handleSelect = (kind: IntegrationKind) => {
|
||||
@@ -35,6 +40,7 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
setStep("select");
|
||||
setSelectedKind(null);
|
||||
};
|
||||
const handleFormBack = innerProps.initialKind ? actions.closeModal : handleBack;
|
||||
|
||||
const handleSuccess = (result?: CreatedIntegrationResult) => {
|
||||
actions.closeModal();
|
||||
@@ -46,14 +52,29 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
};
|
||||
|
||||
if (step === "form" && selectedKind) {
|
||||
let HeaderIcon = IconArrowLeft;
|
||||
let headerLabel = t("common.action.previous");
|
||||
if (innerProps.initialKind) {
|
||||
HeaderIcon = IconX;
|
||||
headerLabel = t("common.action.close");
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea.Autosize mah="80vh">
|
||||
<Group gap="xs" mb="md" style={{ cursor: "pointer" }} onClick={handleBack}>
|
||||
<IconArrowLeft size={18} />
|
||||
<IntegrationAvatar kind={selectedKind} size="sm" />
|
||||
<Title order={4}>{getIntegrationName(selectedKind)}</Title>
|
||||
</Group>
|
||||
<NewIntegrationForm kind={selectedKind} onSuccess={handleSuccess} onCancel={handleBack} />
|
||||
<UnstyledButton mb="md" onClick={handleFormBack} aria-label={headerLabel}>
|
||||
<Group gap="xs">
|
||||
<HeaderIcon size={18} />
|
||||
<IntegrationAvatar kind={selectedKind} size="sm" />
|
||||
<Title order={4}>{getIntegrationName(selectedKind)}</Title>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<NewIntegrationForm
|
||||
kind={selectedKind}
|
||||
initialUrl={innerProps.initialUrl}
|
||||
initialName={innerProps.initialName}
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={handleFormBack}
|
||||
/>
|
||||
</ScrollArea.Autosize>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ const dockerContainerTargetSchema = z.object({
|
||||
});
|
||||
const dockerContainerTargetsSchema = z.array(dockerContainerTargetSchema).min(1).max(100);
|
||||
|
||||
const normalizeGetContainersInput = (input: { endpointIds?: string[] } | undefined) => {
|
||||
if (!input?.endpointIds || input.endpointIds.length === 0) return {};
|
||||
return input;
|
||||
};
|
||||
|
||||
export const dockerRouter = createTRPCRouter({
|
||||
reconcileServices: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
@@ -65,12 +70,14 @@ export const dockerRouter = createTRPCRouter({
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "List all Docker containers with their state, image, CPU/memory usage, and ports",
|
||||
description:
|
||||
"List Docker containers with their state, image, CPU/memory usage, and ports. Optionally filter by endpoint IDs; omitted or empty means all endpoints.",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.query(async () => {
|
||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||
.input(z.object({ endpointIds: z.array(z.string().min(1)).max(100).optional() }).optional())
|
||||
.query(async ({ input }) => {
|
||||
const innerHandler = dockerContainersRequestHandler.handler(normalizeGetContainersInput(input));
|
||||
const result = await innerHandler.getDataAsync();
|
||||
|
||||
const { data, timestamp } = result;
|
||||
|
||||
@@ -112,13 +112,15 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
||||
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
|
||||
|
||||
if (sessionInfo.NowPlayingItem) {
|
||||
const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode;
|
||||
|
||||
currentlyPlaying = {
|
||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
||||
seasonNumber: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null,
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null,
|
||||
seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null,
|
||||
episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
|
||||
episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
playback: {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { extractHost, parseLocation } from "./jellyfin-integration";
|
||||
|
||||
describe("extractHost", () => {
|
||||
test("strips the port from an IPv4 endpoint", () => {
|
||||
expect(extractHost("192.168.1.100:54321")).toBe("192.168.1.100");
|
||||
});
|
||||
|
||||
test("returns an IPv4 address unchanged when there's no port", () => {
|
||||
expect(extractHost("8.8.8.8")).toBe("8.8.8.8");
|
||||
});
|
||||
|
||||
test("strips brackets and the port from a bracketed IPv6 endpoint", () => {
|
||||
expect(extractHost("[::1]:8096")).toBe("::1");
|
||||
});
|
||||
|
||||
test("returns a bare IPv6 address unchanged when there's no port", () => {
|
||||
expect(extractHost("::1")).toBe("::1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLocation", () => {
|
||||
test("returns null when there's no endpoint to classify", () => {
|
||||
expect(parseLocation(null)).toBeNull();
|
||||
expect(parseLocation(undefined)).toBeNull();
|
||||
expect(parseLocation("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for a value that isn't an IP", () => {
|
||||
expect(parseLocation("not-an-ip:1234")).toBeNull();
|
||||
});
|
||||
|
||||
test("classifies private IPv4 ranges as lan", () => {
|
||||
expect(parseLocation("192.168.1.100:54321")).toBe("lan");
|
||||
expect(parseLocation("10.0.0.5:1234")).toBe("lan");
|
||||
expect(parseLocation("172.16.5.5:1234")).toBe("lan");
|
||||
expect(parseLocation("127.0.0.1:8096")).toBe("lan");
|
||||
});
|
||||
|
||||
test("classifies a public IPv4 address as wan", () => {
|
||||
expect(parseLocation("8.8.8.8:1234")).toBe("wan");
|
||||
});
|
||||
|
||||
test("classifies IPv6 loopback and unique-local addresses as lan", () => {
|
||||
expect(parseLocation("[::1]:8096")).toBe("lan");
|
||||
expect(parseLocation("[fd12:3456:789a::1]:8096")).toBe("lan");
|
||||
});
|
||||
|
||||
test("classifies a public IPv6 address as wan", () => {
|
||||
expect(parseLocation("[2606:4700:4700::1111]:8096")).toBe("wan");
|
||||
});
|
||||
|
||||
test("unwraps an IPv4-mapped IPv6 address before classifying it", () => {
|
||||
expect(parseLocation("[::ffff:192.168.1.5]:8096")).toBe("lan");
|
||||
expect(parseLocation("[::ffff:8.8.8.8]:8096")).toBe("wan");
|
||||
});
|
||||
|
||||
test("classifies link-local addresses as lan", () => {
|
||||
// Link-local traffic can't traverse a router, so it's always LAN even though
|
||||
// it isn't a private range in the traditional (RFC 1918 / ULA) sense.
|
||||
expect(parseLocation("169.254.1.5:8096")).toBe("lan");
|
||||
expect(parseLocation("[fe80::1]:8096")).toBe("lan");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BlockList, isIP } from "node:net";
|
||||
import { Jellyfin } from "@jellyfin/sdk";
|
||||
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
@@ -21,6 +22,42 @@ function ticksToMs(ticks: number | null | undefined): number | null {
|
||||
return ticks ? Math.round(ticks / 10_000) : null;
|
||||
}
|
||||
|
||||
// Unlike Plex, Jellyfin doesn't report a session's network location directly - only the
|
||||
// client's RemoteEndPoint (an IP, optionally with a port). Classify it ourselves.
|
||||
const privateAddresses = new BlockList();
|
||||
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
|
||||
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
|
||||
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
|
||||
privateAddresses.addSubnet("127.0.0.0", 8, "ipv4");
|
||||
privateAddresses.addSubnet("169.254.0.0", 16, "ipv4");
|
||||
privateAddresses.addSubnet("fc00::", 7, "ipv6");
|
||||
privateAddresses.addSubnet("fe80::", 10, "ipv6");
|
||||
privateAddresses.addAddress("::1", "ipv6");
|
||||
|
||||
export function extractHost(remoteEndPoint: string): string {
|
||||
if (remoteEndPoint.startsWith("[")) {
|
||||
const end = remoteEndPoint.indexOf("]");
|
||||
return end === -1 ? remoteEndPoint : remoteEndPoint.slice(1, end);
|
||||
}
|
||||
// A bare IPv6 address (no brackets, no port) has more than one colon.
|
||||
const isIPv4WithPort = remoteEndPoint.split(":").length === 2;
|
||||
return isIPv4WithPort ? remoteEndPoint.slice(0, remoteEndPoint.lastIndexOf(":")) : remoteEndPoint;
|
||||
}
|
||||
|
||||
export function parseLocation(remoteEndPoint: string | null | undefined): "lan" | "wan" | null {
|
||||
if (!remoteEndPoint) return null;
|
||||
let host = extractHost(remoteEndPoint);
|
||||
let family = isIP(host);
|
||||
// A dual-stack server can report an IPv4 client as an IPv4-mapped IPv6 address
|
||||
// (e.g. "::ffff:192.168.1.5") - unwrap it so the IPv4 private ranges still apply.
|
||||
if (family === 6 && host.toLowerCase().startsWith("::ffff:") && isIP(host.slice(7)) === 4) {
|
||||
host = host.slice(7);
|
||||
family = 4;
|
||||
}
|
||||
if (!family) return null;
|
||||
return privateAddresses.check(host, family === 4 ? "ipv4" : "ipv6") ? "lan" : "wan";
|
||||
}
|
||||
|
||||
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
|
||||
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||
private readonly jellyfin: Jellyfin = new Jellyfin({
|
||||
@@ -56,17 +93,27 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
||||
if (sessionInfo.NowPlayingItem) {
|
||||
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
|
||||
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
|
||||
// Jellyfin only reliably fills in MediaSources[].Bitrate for a transcoding session -
|
||||
// /Sessions has no `fields` selector to force it, so fall back to summing the individual
|
||||
// media streams' own bitrates (populated from the source file's metadata) for direct play.
|
||||
const mediaStreamsBitrateBps = sessionInfo.NowPlayingItem.MediaStreams?.reduce(
|
||||
(sum, stream) => sum + (stream.BitRate ?? 0),
|
||||
0,
|
||||
);
|
||||
const bitrateBps =
|
||||
sessionInfo.TranscodingInfo?.Bitrate ?? sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? null;
|
||||
sessionInfo.TranscodingInfo?.Bitrate ??
|
||||
sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ??
|
||||
(mediaStreamsBitrateBps || null);
|
||||
const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null;
|
||||
const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode;
|
||||
|
||||
currentlyPlaying = {
|
||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
||||
seasonNumber: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null,
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null,
|
||||
seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null,
|
||||
episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
|
||||
episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
playback: {
|
||||
@@ -74,7 +121,7 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
||||
positionMs,
|
||||
durationMs,
|
||||
},
|
||||
location: null,
|
||||
location: parseLocation(sessionInfo.RemoteEndPoint),
|
||||
metadata: {
|
||||
video: {
|
||||
resolution:
|
||||
|
||||
@@ -115,9 +115,9 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
||||
type: mediaElement.$.live === "1" ? "tv" : PlexIntegration.getCurrentlyPlayingType(mediaElement.$.type),
|
||||
name: mediaElement.$.grandparentTitle ?? mediaElement.$.title ?? "Unknown",
|
||||
seasonName: mediaElement.$.parentTitle,
|
||||
seasonNumber: parseOptionalNumber(mediaElement.$.parentIndex),
|
||||
episodeName: mediaElement.$.title ?? null,
|
||||
episodeNumber: parseOptionalNumber(mediaElement.$.index),
|
||||
seasonNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.parentIndex) : null,
|
||||
episodeName: mediaElement.$.type === "episode" ? (mediaElement.$.title ?? null) : null,
|
||||
episodeNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.index) : null,
|
||||
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
|
||||
episodeCount: parseOptionalNumber(mediaElement.$.index),
|
||||
playback: {
|
||||
|
||||
@@ -8,35 +8,35 @@ import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { appHrefSchema } from "@homarr/validation/app";
|
||||
|
||||
interface AddDockerAppToHomarrProps {
|
||||
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
|
||||
initialUrls?: string[];
|
||||
initialUrls?: (string | null)[];
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
containerUrls: z.array(appHrefSchema),
|
||||
});
|
||||
|
||||
export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
containerUrls: z.array(z.string().url().nullable()),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
containerUrls: innerProps.selectedContainers.map((container, index) => {
|
||||
const initialUrl = innerProps.initialUrls?.[index];
|
||||
if (initialUrl) return initialUrl;
|
||||
const form = useZodForm(formSchema, {
|
||||
initialValues: {
|
||||
containerUrls: innerProps.selectedContainers.map((container, index) => {
|
||||
const initialUrl = innerProps.initialUrls?.[index];
|
||||
if (initialUrl !== undefined) return initialUrl;
|
||||
|
||||
const candidates = buildDockerServiceUrlCandidates({
|
||||
containerName: container.name,
|
||||
endpointHost: container.host,
|
||||
ports: container.ports,
|
||||
}).filter(({ url }) => url.length > 0);
|
||||
const candidates = buildDockerServiceUrlCandidates({
|
||||
containerName: container.name,
|
||||
endpointHost: container.host,
|
||||
ports: container.ports,
|
||||
}).filter(({ url }) => url.length > 0);
|
||||
|
||||
return candidates.find(({ scopes }) => scopes.includes("browser"))?.url ?? candidates[0]?.url ?? null;
|
||||
}),
|
||||
},
|
||||
return candidates.find(({ scopes }) => scopes.includes("browser"))?.url ?? candidates[0]?.url ?? null;
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
const { mutate, isPending } = clientApi.app.createMany.useMutation({
|
||||
onSuccess() {
|
||||
actions.closeModal();
|
||||
@@ -52,13 +52,13 @@ export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(
|
||||
});
|
||||
},
|
||||
});
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = ({ containerUrls }: z.infer<typeof formSchema>) => {
|
||||
mutate(
|
||||
innerProps.selectedContainers.map((container, index) => ({
|
||||
name: container.name,
|
||||
iconUrl: container.iconUrl,
|
||||
description: null,
|
||||
href: form.values.containerUrls[index] ?? null,
|
||||
href: containerUrls[index] || null,
|
||||
pingUrl: null,
|
||||
})),
|
||||
);
|
||||
@@ -68,28 +68,31 @@ export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(
|
||||
<LoadingOverlay visible={isPending} />
|
||||
<Stack>
|
||||
<List spacing={"xs"}>
|
||||
{innerProps.selectedContainers.map((container, index) => (
|
||||
<List.Item
|
||||
styles={{ itemWrapper: { width: "100%" }, itemLabel: { flex: 1 } }}
|
||||
icon={
|
||||
<Avatar
|
||||
variant="outline"
|
||||
radius={container.iconUrl ? "sm" : "md"}
|
||||
size={30}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
src={container.iconUrl}
|
||||
>
|
||||
{container.name.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
}
|
||||
key={container.id}
|
||||
>
|
||||
<Group justify="space-between" wrap={"nowrap"}>
|
||||
<Text lineClamp={1}>{container.name}</Text>
|
||||
<TextInput {...form.getInputProps(`containerUrls.${index}`)} />
|
||||
</Group>
|
||||
</List.Item>
|
||||
))}
|
||||
{innerProps.selectedContainers.map((container, index) => {
|
||||
const inputProps = form.getInputProps(`containerUrls.${index}`);
|
||||
return (
|
||||
<List.Item
|
||||
styles={{ itemWrapper: { width: "100%" }, itemLabel: { flex: 1 } }}
|
||||
icon={
|
||||
<Avatar
|
||||
variant="outline"
|
||||
radius={container.iconUrl ? "sm" : "md"}
|
||||
size={30}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
src={container.iconUrl}
|
||||
>
|
||||
{container.name.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
}
|
||||
key={container.id}
|
||||
>
|
||||
<Group justify="space-between" wrap={"nowrap"}>
|
||||
<Text lineClamp={1}>{container.name}</Text>
|
||||
<TextInput {...inputProps} value={inputProps.value ?? ""} />
|
||||
</Group>
|
||||
</List.Item>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal} variant="light" px={"xl"}>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { OnboardingAuthShell } from "./onboarding-auth-shell";
|
||||
export { OnboardingStudio } from "./onboarding-studio";
|
||||
export { normalizeServiceUrl } from "./discovery-selection";
|
||||
export { ServiceUrlTemplate } from "./service-url-template";
|
||||
export type { OnboardingEnvironment, OnboardingStudioProps } from "./types";
|
||||
@@ -358,6 +358,7 @@
|
||||
display: flex;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0.2rem;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-default);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { FloatingIndicator, Stack, TextInput, UnstyledButton } from "@mantine/core";
|
||||
|
||||
import type { UrlTemplateMode } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useOnboardingSounds } from "./use-onboarding-sounds";
|
||||
import classes from "./onboarding-studio.module.css";
|
||||
|
||||
interface FloatingControlProps<T extends string> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
options: readonly { value: T; label: ReactNode }[];
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
export const OnboardingFloatingControl = <T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
ariaLabel,
|
||||
}: FloatingControlProps<T>) => {
|
||||
const [rootRef, setRootRef] = useState<HTMLFieldSetElement | null>(null);
|
||||
const controlRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const sounds = useOnboardingSounds();
|
||||
|
||||
return (
|
||||
<div className={classes.floatingControlScroller}>
|
||||
<fieldset ref={setRootRef} className={classes.floatingControlRoot} aria-label={ariaLabel}>
|
||||
<FloatingIndicator
|
||||
target={controlRefs.current[value] ?? null}
|
||||
parent={rootRef}
|
||||
className={classes.floatingControlIndicator}
|
||||
/>
|
||||
{options.map((option) => {
|
||||
const selected = value === option.value;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={option.value}
|
||||
ref={(node) => {
|
||||
controlRefs.current[option.value] = node;
|
||||
}}
|
||||
type="button"
|
||||
className={classes.floatingControl}
|
||||
data-active={selected}
|
||||
aria-pressed={selected}
|
||||
onClick={() => {
|
||||
sounds.click();
|
||||
onChange(option.value);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ServiceUrlTemplateProps {
|
||||
serverOrigin: string;
|
||||
onServerOriginChange: (value: string) => void;
|
||||
mode: UrlTemplateMode;
|
||||
onModeChange: (value: UrlTemplateMode) => void;
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const ServiceUrlTemplate = ({
|
||||
serverOrigin,
|
||||
onServerOriginChange,
|
||||
mode,
|
||||
onModeChange,
|
||||
readOnly,
|
||||
required,
|
||||
}: ServiceUrlTemplateProps) => {
|
||||
const t = useScopedI18n("common.serviceUrlTemplate");
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t("serverTitle")}
|
||||
placeholder="home.lan · 192.168.1.10 · https://homarr.example.com"
|
||||
value={serverOrigin}
|
||||
onChange={(event) => onServerOriginChange(event.currentTarget.value)}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
withAsterisk={required}
|
||||
/>
|
||||
<OnboardingFloatingControl
|
||||
ariaLabel={t("urlModeLabel")}
|
||||
value={mode}
|
||||
onChange={onModeChange}
|
||||
options={[
|
||||
{ value: "hostPort", label: t("hostPort") },
|
||||
{ value: "subdomain", label: t("subdomain") },
|
||||
{ value: "path", label: t("path") },
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ColorSwatch,
|
||||
CopyButton,
|
||||
Fieldset,
|
||||
FloatingIndicator,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
@@ -99,6 +98,7 @@ import type { OnboardingStudioProps } from "./types";
|
||||
import { getBoardValidationErrors } from "./board-validation";
|
||||
import { OnboardingBackdrop } from "./onboarding-backdrop";
|
||||
import { OnboardingWordmark } from "./onboarding-wordmark";
|
||||
import { OnboardingFloatingControl, ServiceUrlTemplate } from "./service-url-template";
|
||||
import { useOnboardingSounds } from "./use-onboarding-sounds";
|
||||
import {
|
||||
normalizeServiceUrl,
|
||||
@@ -152,57 +152,6 @@ const initialSecondaryColor = "#fd7e14";
|
||||
const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = [];
|
||||
const emptyDiscoveredApps: DockerDiscoveryData["apps"] = [];
|
||||
|
||||
interface OnboardingFloatingControlProps<T extends string> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
options: readonly { value: T; label: ReactNode }[];
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
const OnboardingFloatingControl = <T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
ariaLabel,
|
||||
}: OnboardingFloatingControlProps<T>) => {
|
||||
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
||||
const controlRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const sounds = useOnboardingSounds();
|
||||
|
||||
return (
|
||||
<div className={classes.floatingControlScroller}>
|
||||
<div ref={setRootRef} className={classes.floatingControlRoot} role="group" aria-label={ariaLabel}>
|
||||
<FloatingIndicator
|
||||
target={controlRefs.current[value] ?? null}
|
||||
parent={rootRef}
|
||||
className={classes.floatingControlIndicator}
|
||||
/>
|
||||
{options.map((option) => {
|
||||
const selected = value === option.value;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={option.value}
|
||||
ref={(node) => {
|
||||
controlRefs.current[option.value] = node;
|
||||
}}
|
||||
type="button"
|
||||
className={classes.floatingControl}
|
||||
data-active={selected}
|
||||
aria-pressed={selected}
|
||||
onClick={() => {
|
||||
sounds.click();
|
||||
onChange(option.value);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingStudioProps) => {
|
||||
const t = useScopedI18n("init.studio");
|
||||
const tCommon = useScopedI18n("common.action");
|
||||
@@ -924,24 +873,13 @@ const Essentials = (props: StudioSectionProps) => {
|
||||
<Alert variant="light" icon={<IconInfoCircle size={18} />} my="xs">
|
||||
{t("serverOriginHelp")}
|
||||
</Alert>
|
||||
<TextInput
|
||||
label={t("serverTitle")}
|
||||
placeholder="home.lan · 192.168.1.10 · https://homarr.example.com"
|
||||
value={props.serverOrigin}
|
||||
onChange={(event) => props.setServerOrigin(event.currentTarget.value)}
|
||||
<ServiceUrlTemplate
|
||||
serverOrigin={props.serverOrigin}
|
||||
onServerOriginChange={props.setServerOrigin}
|
||||
mode={props.urlMode}
|
||||
onModeChange={props.setUrlMode}
|
||||
readOnly={!isHydrated}
|
||||
required
|
||||
withAsterisk
|
||||
/>
|
||||
<OnboardingFloatingControl
|
||||
ariaLabel={t("urlModeLabel")}
|
||||
value={props.urlMode}
|
||||
onChange={props.setUrlMode}
|
||||
options={[
|
||||
{ value: "hostPort", label: t("hostPort") },
|
||||
{ value: "subdomain", label: t("subdomain") },
|
||||
{ value: "path", label: t("path") },
|
||||
]}
|
||||
/>
|
||||
{props.serverOrigin ? (
|
||||
<Code block>{buildIntegrationUrl("sonarr", props.serverOrigin, props.urlMode)}</Code>
|
||||
|
||||
@@ -195,9 +195,24 @@ const mockContainers: {
|
||||
},
|
||||
];
|
||||
|
||||
const demoDockerEndpoint = {
|
||||
id: "demo",
|
||||
name: "Demo Docker",
|
||||
status: "available",
|
||||
kind: "docker",
|
||||
transport: "socket",
|
||||
capabilities: ["inventory"],
|
||||
source: "default",
|
||||
scope: "admin",
|
||||
} satisfies DockerEndpointStatus;
|
||||
|
||||
export const dockerContainersRequestHandler = createWidgetRequestHandler({
|
||||
async requestAsync() {
|
||||
async requestAsync({ endpointIds }: { endpointIds?: string[] }) {
|
||||
if (isDemoMode()) {
|
||||
const includesDemoEndpoint =
|
||||
endpointIds === undefined || endpointIds.length === 0 || endpointIds.includes("demo");
|
||||
if (!includesDemoEndpoint) return { containers: [], endpoints: [] };
|
||||
|
||||
return {
|
||||
containers: mockContainers.map((container) => ({
|
||||
...container,
|
||||
@@ -205,21 +220,10 @@ export const dockerContainersRequestHandler = createWidgetRequestHandler({
|
||||
endpointName: "Demo Docker",
|
||||
resourceId: `demo:${container.id}`,
|
||||
})),
|
||||
endpoints: [
|
||||
{
|
||||
id: "demo",
|
||||
name: "Demo Docker",
|
||||
status: "available",
|
||||
kind: "docker",
|
||||
transport: "socket",
|
||||
capabilities: ["inventory"],
|
||||
source: "default",
|
||||
scope: "admin",
|
||||
},
|
||||
] satisfies DockerEndpointStatus[],
|
||||
endpoints: [demoDockerEndpoint],
|
||||
};
|
||||
}
|
||||
return await getContainersWithStatsAsync();
|
||||
return await getContainersWithStatsAsync(dockerWidgetEndpointTimeoutMs, endpointIds);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -304,6 +308,8 @@ export const streamContainerLogsAsync = async (
|
||||
};
|
||||
|
||||
export const getDockerEndpointsAsync = (): DockerEndpointStatus[] => {
|
||||
if (isDemoMode()) return [demoDockerEndpoint];
|
||||
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
const initializationFailures = DockerSingleton.getInitializationFailures();
|
||||
|
||||
@@ -331,9 +337,18 @@ export const getDockerEndpointsAsync = (): DockerEndpointStatus[] => {
|
||||
];
|
||||
};
|
||||
|
||||
export async function getContainersWithStatsAsync(timeoutMs = dockerWidgetEndpointTimeoutMs) {
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
const initializationFailures = DockerSingleton.getInitializationFailures();
|
||||
export async function getContainersWithStatsAsync(
|
||||
timeoutMs = dockerWidgetEndpointTimeoutMs,
|
||||
endpointIds: string[] = [],
|
||||
) {
|
||||
const selectedEndpointIds = new Set(endpointIds);
|
||||
const includesAllEndpoints = selectedEndpointIds.size === 0;
|
||||
const dockerInstances = DockerSingleton.getInstances().filter(
|
||||
({ endpointId }) => includesAllEndpoints || selectedEndpointIds.has(endpointId),
|
||||
);
|
||||
const initializationFailures = DockerSingleton.getInitializationFailures().filter(
|
||||
({ descriptor }) => includesAllEndpoints || selectedEndpointIds.has(descriptor.id),
|
||||
);
|
||||
const results = await Promise.allSettled(
|
||||
dockerInstances.map(async ({ instance, host, endpointId, endpointName }) => {
|
||||
const instanceContainers = await withDockerTimeoutAsync(
|
||||
|
||||
@@ -7,6 +7,8 @@ import { DockerSingleton } from "@homarr/docker";
|
||||
import {
|
||||
calculateCpuUsage,
|
||||
calculateMemoryUsage,
|
||||
dockerContainersRequestHandler,
|
||||
getDockerEndpointsAsync,
|
||||
getContainersWithStatsAsync,
|
||||
hasDockerEndpointCapability,
|
||||
} from "../docker";
|
||||
@@ -127,6 +129,31 @@ describe("calculateMemoryUsage", () => {
|
||||
});
|
||||
|
||||
describe("getContainersWithStatsAsync", () => {
|
||||
test("queries only selected Docker endpoints and leaves an empty selection as all", async () => {
|
||||
const firstListContainers = vi.fn(async () => []);
|
||||
const secondListContainers = vi.fn(async () => []);
|
||||
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([
|
||||
createDockerInstance("first", firstListContainers),
|
||||
createDockerInstance("second", secondListContainers),
|
||||
] as never);
|
||||
vi.spyOn(DockerSingleton, "getInitializationFailures").mockReturnValue([]);
|
||||
|
||||
try {
|
||||
const selectedResult = await getContainersWithStatsAsync(50, ["second"]);
|
||||
|
||||
expect(firstListContainers).not.toHaveBeenCalled();
|
||||
expect(secondListContainers).toHaveBeenCalledOnce();
|
||||
expect(selectedResult.endpoints.map(({ id }) => id)).toEqual(["second"]);
|
||||
|
||||
await getContainersWithStatsAsync(50, []);
|
||||
|
||||
expect(firstListContainers).toHaveBeenCalledOnce();
|
||||
expect(secondListContainers).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
test("marks a timed-out endpoint unavailable without blocking healthy endpoints", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -211,14 +238,26 @@ describe("getContainersWithStatsAsync", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("demo capabilities match the advertised inventory-only endpoint", () => {
|
||||
test("demo mode advertises and filters the same inventory-only endpoint", async () => {
|
||||
const previousDemoMode = process.env.DEMO_MODE;
|
||||
process.env.DEMO_MODE = "true";
|
||||
try {
|
||||
expect(getDockerEndpointsAsync()).toEqual([
|
||||
expect.objectContaining({ id: "demo", name: "Demo Docker", capabilities: ["inventory"] }),
|
||||
]);
|
||||
|
||||
const selected = await dockerContainersRequestHandler.handler({ endpointIds: ["demo"] }).getDataAsync();
|
||||
expect(selected.data.endpoints).toEqual([expect.objectContaining({ id: "demo" })]);
|
||||
expect(selected.data.containers.length).toBeGreaterThan(0);
|
||||
|
||||
const excluded = await dockerContainersRequestHandler.handler({ endpointIds: ["other"] }).getDataAsync();
|
||||
expect(excluded.data).toEqual({ containers: [], endpoints: [] });
|
||||
|
||||
expect(hasDockerEndpointCapability("demo", "inventory")).toBe(true);
|
||||
expect(hasDockerEndpointCapability("demo", "logs")).toBe(false);
|
||||
expect(hasDockerEndpointCapability("other", "inventory")).toBe(false);
|
||||
} finally {
|
||||
dockerContainersRequestHandler.invalidateCache();
|
||||
if (previousDemoMode === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = previousDemoMode;
|
||||
}
|
||||
|
||||
@@ -66,15 +66,10 @@
|
||||
"themeAuto": "System",
|
||||
"themeLight": "Light",
|
||||
"themeDark": "Dark",
|
||||
"serverTitle": "Usual server address",
|
||||
"serverOriginHelp": "Homarr uses this address to prefill detected service URLs. You can still edit every service individually.",
|
||||
"serverDescription": "This is a shortcut, not a restriction. Every detected service remains editable.",
|
||||
"serverOrigin": "Domain, IP, or reverse-proxy path",
|
||||
"serverOriginDescription": "Examples: home.lan, 192.168.1.10, or https://home.example.com/services. Choose how each service is appended below.",
|
||||
"urlModeLabel": "Service address pattern",
|
||||
"hostPort": "Same host, different ports",
|
||||
"subdomain": "One subdomain per service",
|
||||
"path": "One path per service",
|
||||
"analytics": "Share anonymous product analytics",
|
||||
"analyticsDescription": "Helps Homarr improve feature reliability. No service credentials or dashboard content are sent."
|
||||
},
|
||||
@@ -2137,6 +2132,13 @@
|
||||
"undo": "Undo"
|
||||
},
|
||||
"here": "here",
|
||||
"serviceUrlTemplate": {
|
||||
"serverTitle": "Usual server address",
|
||||
"urlModeLabel": "Service address pattern",
|
||||
"hostPort": "Same host, different ports",
|
||||
"subdomain": "One subdomain per service",
|
||||
"path": "One path per service"
|
||||
},
|
||||
"iconPicker": {
|
||||
"label": "Icon URL",
|
||||
"description": "Search the library, paste an image URL, or upload a file.",
|
||||
@@ -3737,6 +3739,11 @@
|
||||
"name": "Docker stats",
|
||||
"description": "Stats of your containers (This widget can only be added with administrator privileges)",
|
||||
"option": {
|
||||
"endpointIds": {
|
||||
"label": "Docker environments",
|
||||
"description": "Leave empty to show containers from every Docker environment",
|
||||
"placeholder": "All Docker environments"
|
||||
},
|
||||
"columns": {
|
||||
"label": "Columns to show"
|
||||
},
|
||||
@@ -5685,6 +5692,7 @@
|
||||
},
|
||||
"dynamicSelect": {
|
||||
"noResults": "No results found",
|
||||
"loading": "Loading...",
|
||||
"loadError": "Failed to load options"
|
||||
},
|
||||
"assistant": {
|
||||
@@ -7072,23 +7080,18 @@
|
||||
"docker": {
|
||||
"title": "Containers",
|
||||
"reconciliation": {
|
||||
"title": "Service setup",
|
||||
"description": "Docker services are compared with your apps and integrations. Review every suggestion before Homarr changes anything.",
|
||||
"title": "Assisted Docker setup",
|
||||
"suggestions": "{count, plural, =0 {No suggestions} one {# suggestion} other {# suggestions}}",
|
||||
"attention": "{count} need attention",
|
||||
"endpointUnavailable": "Some services could not be checked because these endpoints are unavailable: {names}",
|
||||
"empty": "No visible Docker services were found.",
|
||||
"emptyFilter": "No services match this view.",
|
||||
"ambiguous": "More than one existing item could match this service. Homarr will not choose one automatically.",
|
||||
"match": "{confidence} confidence match",
|
||||
"filter": {
|
||||
"attention": "Needs attention",
|
||||
"represented": "Already set up",
|
||||
"all": "All"
|
||||
},
|
||||
"confidence": {
|
||||
"high": "High",
|
||||
"medium": "Medium"
|
||||
},
|
||||
"state": {
|
||||
"newRecognized": "Ready to connect",
|
||||
"newApp": "New service",
|
||||
@@ -7097,19 +7100,9 @@
|
||||
"moved": "Address changed"
|
||||
},
|
||||
"url": {
|
||||
"label": "Service address",
|
||||
"inputLabel": "Service URL",
|
||||
"manual": "Enter an address manually",
|
||||
"invalid": "Enter a valid HTTP or HTTPS URL"
|
||||
},
|
||||
"reason": {
|
||||
"publishedAddress": "Published address reported by Docker",
|
||||
"dockerEndpointHost": "Docker endpoint host combined with the published port",
|
||||
"sharedContainerNetwork": "Server-only address for services sharing the container network",
|
||||
"manualHostRequired": "Enter the address that your browser or Homarr server can reach"
|
||||
"manual": "Enter an address manually"
|
||||
},
|
||||
"health": {
|
||||
"title": "Observed service layers",
|
||||
"layer": {
|
||||
"docker": "Docker",
|
||||
"integrationConfiguration": "Integration",
|
||||
@@ -7131,17 +7124,15 @@
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"refresh": "Refresh",
|
||||
"retry": "Try again",
|
||||
"review": "Review",
|
||||
"dismiss": "Dismiss",
|
||||
"restoreDismissed": "Restore dismissed ({count})",
|
||||
"setupIntegration": "Set up integration",
|
||||
"createApp": "Create app",
|
||||
"reviewIntegration": "Review integration",
|
||||
"viewRepresentation": "View setup"
|
||||
},
|
||||
"loadError": {
|
||||
"title": "Service setup could not be loaded",
|
||||
"title": "Docker setup assistant could not be loaded",
|
||||
"message": "Docker service information is unavailable. Try again without leaving this page."
|
||||
},
|
||||
"refreshError": {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { WidgetAppInput } from "./widget-app-input";
|
||||
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
|
||||
import { WidgetCustomWidgetConfigurationInput } from "./widget-custom-widget-configuration-input";
|
||||
import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
|
||||
import { WidgetDynamicMultiSelectInput } from "./widget-dynamic-multiselect-input";
|
||||
import { WidgetLocationInput } from "./widget-location-input";
|
||||
import { WidgetMultiTextInput } from "./widget-multi-text-input";
|
||||
import { WidgetMultiReleasesRepositoriesInput } from "./widget-multiReleasesRepositories-input";
|
||||
@@ -36,6 +37,7 @@ const mapping = {
|
||||
sortableItemList: WidgetSortedItemListInput,
|
||||
multiReleasesRepositories: WidgetMultiReleasesRepositoriesInput,
|
||||
dynamicSelect: WidgetDynamicSelectInput,
|
||||
dynamicMultiSelect: WidgetDynamicMultiSelectInput,
|
||||
umamiEventName: WidgetUmamiEventInput,
|
||||
umamiEventNames: WidgetUmamiEventNamesInput,
|
||||
umamiWebsite: WidgetUmamiWebsiteInput,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { Loader, MultiSelect } from "@mantine/core";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetDynamicMultiSelectInput = ({
|
||||
property,
|
||||
kind,
|
||||
options,
|
||||
}: CommonWidgetInputProps<"dynamicMultiSelect">) => {
|
||||
const t = useScopedI18n("widget.dynamicSelect");
|
||||
const tInput = useWidgetInputTranslation(kind, property);
|
||||
const form = useFormContext();
|
||||
const inputProps = form.getInputProps(`options.${property}`);
|
||||
const selectedValues = (inputProps.value as string[] | undefined) ?? [];
|
||||
const { data: selectData, isPending, isError } = options.useOptions();
|
||||
const data = [
|
||||
...selectData,
|
||||
...selectedValues
|
||||
.filter((selectedValue) => !selectData.some((option) => option.value === selectedValue))
|
||||
.map((selectedValue) => ({ value: selectedValue, label: selectedValue })),
|
||||
];
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
{...inputProps}
|
||||
label={tInput("label")}
|
||||
description={options.withDescription ? tInput("description") : undefined}
|
||||
placeholder={isPending ? t("loading") : tInput("placeholder")}
|
||||
leftSection={isPending ? <Loader size="xs" /> : undefined}
|
||||
searchable
|
||||
clearable
|
||||
maxValues={options.maxValues}
|
||||
nothingFoundMessage={t("noResults")}
|
||||
data={data}
|
||||
disabled={isError}
|
||||
error={isError ? t("loadError") : inputProps.error}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -74,6 +74,11 @@ const createContainerLogsPath = (container: Pick<DockerContainer, "endpointId" |
|
||||
|
||||
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 t = useScopedI18n("docker.field.state.option");
|
||||
|
||||
@@ -248,7 +253,7 @@ export default function DockerWidget({
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
const containersQuery = clientApi.docker.getContainers.useQuery();
|
||||
const containersQuery = clientApi.docker.getContainers.useQuery(getContainersQueryInput(options.endpointIds));
|
||||
const data = getUsableWidgetQueryData(containersQuery);
|
||||
const { isFetching } = containersQuery;
|
||||
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IconBrandDocker, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
@@ -30,6 +31,19 @@ export const { definition, componentLoader } = createWidgetDefinition("dockerCon
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(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({
|
||||
defaultValue: [...allColumnsList],
|
||||
options: allColumnsList.map((value) => ({
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
|
||||
.tableViewport :global(th) {
|
||||
background-color: var(--background-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sortButton {
|
||||
|
||||
@@ -9,7 +9,12 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { StreamSession } from "@homarr/integrations";
|
||||
|
||||
import { getMediaServerColumnVisibility, getSeasonEpisodeParams, SessionDetailsPopover } from "./component";
|
||||
import {
|
||||
getMediaServerColumnVisibility,
|
||||
getResolutionLabel,
|
||||
getSeasonEpisodeParams,
|
||||
SessionDetailsPopover,
|
||||
} from "./component";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
@@ -104,7 +109,8 @@ describe("getMediaServerColumnVisibility", () => {
|
||||
test("keeps a stable responsive column model from the zero-sized first render", () => {
|
||||
expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false });
|
||||
expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false });
|
||||
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: true });
|
||||
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: false });
|
||||
expect(getMediaServerColumnVisibility(540, false)).toEqual({ user: true, status: true });
|
||||
expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true });
|
||||
});
|
||||
});
|
||||
@@ -124,3 +130,30 @@ describe("getSeasonEpisodeParams", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,9 @@ type SortState = { column: SortColumn; descending: boolean } | null;
|
||||
|
||||
export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({
|
||||
user: isAdvanced || width >= 300,
|
||||
status: isAdvanced || width >= 420,
|
||||
// The user (200px) and status (190px) columns together need a floor here, otherwise the
|
||||
// currentlyPlaying column - the primary content - gets squeezed to almost nothing.
|
||||
status: isAdvanced || width >= 540,
|
||||
});
|
||||
|
||||
function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus {
|
||||
@@ -77,6 +79,21 @@ function formatBitrate(bitrateKbps: number | null | undefined): string | null {
|
||||
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,
|
||||
@@ -117,27 +134,33 @@ function StreamTableHeader({
|
||||
sortable,
|
||||
sort,
|
||||
onSort,
|
||||
width,
|
||||
}: {
|
||||
column: SortColumn;
|
||||
label: string;
|
||||
sortable: boolean;
|
||||
sort: SortState;
|
||||
onSort: (column: SortColumn) => void;
|
||||
width?: number;
|
||||
}) {
|
||||
const active = sort?.column === column;
|
||||
const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp;
|
||||
|
||||
return (
|
||||
<Table.Th aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}>
|
||||
<Table.Th w={width} aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}>
|
||||
{sortable ? (
|
||||
<UnstyledButton className={classes.sortButton} onClick={() => onSort(column)}>
|
||||
<Text component="span" size="xs" fw={600} truncate>
|
||||
<Text component="span" size="xs" fw={600} c="dimmed" style={{ letterSpacing: "0.02em" }} truncate>
|
||||
{label}
|
||||
</Text>
|
||||
<SortIcon size="var(--mantine-font-size-xs)" aria-hidden />
|
||||
<SortIcon
|
||||
size="var(--mantine-font-size-xs)"
|
||||
aria-hidden
|
||||
color={active ? undefined : "var(--mantine-color-dimmed)"}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
) : (
|
||||
<Text size="xs" fw={600} truncate>
|
||||
<Text size="xs" fw={600} c="dimmed" style={{ letterSpacing: "0.02em" }} truncate>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
@@ -195,12 +218,14 @@ export default function MediaServerWidget({
|
||||
current?.column === column ? { column, descending: !current.descending } : { column, descending: false },
|
||||
);
|
||||
|
||||
const uniqueIntegrations = currentStreams.map((stream) => ({
|
||||
integrationId: stream.integrationId,
|
||||
integrationKind: stream.integrationKind,
|
||||
integrationIcon: getIconUrl(stream.integrationKind),
|
||||
integrationName: stream.integrationName,
|
||||
}));
|
||||
const uniqueIntegrations = currentStreams
|
||||
.filter((stream) => stream.sessions.length > 0)
|
||||
.map((stream) => ({
|
||||
integrationId: stream.integrationId,
|
||||
integrationKind: stream.integrationKind,
|
||||
integrationIcon: getIconUrl(stream.integrationKind),
|
||||
integrationName: stream.integrationName,
|
||||
}));
|
||||
|
||||
const playingCount = flatSessions.filter(
|
||||
(session) => session.currentlyPlaying && session.currentlyPlaying.playback?.state !== "paused",
|
||||
@@ -263,6 +288,7 @@ export default function MediaServerWidget({
|
||||
sortable={isAdvanced}
|
||||
sort={sort}
|
||||
onSort={toggleSort}
|
||||
width={200}
|
||||
/>
|
||||
)}
|
||||
<StreamTableHeader
|
||||
@@ -279,6 +305,7 @@ export default function MediaServerWidget({
|
||||
sortable={isAdvanced}
|
||||
sort={sort}
|
||||
onSort={toggleSort}
|
||||
width={190}
|
||||
/>
|
||||
)}
|
||||
</Table.Tr>
|
||||
@@ -290,6 +317,12 @@ export default function MediaServerWidget({
|
||||
const status = getPlaybackStatus(currentlyPlaying?.metadata?.transcoding);
|
||||
const location = showLocation ? currentlyPlaying?.location : 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));
|
||||
|
||||
return (
|
||||
@@ -315,7 +348,7 @@ export default function MediaServerWidget({
|
||||
{columnVisibility.user && (
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap" w="100%">
|
||||
<Avatar size={28} src={session.user?.profilePictureUrl} />
|
||||
<Avatar size={28} src={session.user?.profilePictureUrl} style={{ flexShrink: 0 }} />
|
||||
<Stack gap={2} className={classes.cellContent}>
|
||||
<Text size="xs" truncate>
|
||||
{session.user?.username ?? t("items.unknownUser")}
|
||||
@@ -340,24 +373,30 @@ export default function MediaServerWidget({
|
||||
<Table.Td>
|
||||
{currentlyPlaying && (
|
||||
<Stack gap={4} align="flex-start" w="100%" className={classes.cellContent}>
|
||||
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
|
||||
{t(`items.${status}` as never)}
|
||||
</Badge>
|
||||
{(location ?? 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%">
|
||||
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
|
||||
{t(`items.${status}` as never)}
|
||||
</Badge>
|
||||
{location && (
|
||||
<Group gap={4} align="center" wrap="nowrap">
|
||||
{location &&
|
||||
(location === "lan" ? (
|
||||
<IconWifi size="var(--mantine-font-size-xs)" />
|
||||
) : (
|
||||
<IconWorld size="var(--mantine-font-size-xs)" />
|
||||
))}
|
||||
{location && (
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
{location}
|
||||
</Text>
|
||||
{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%">
|
||||
{resolutionLabel && (
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{resolutionLabel}
|
||||
</Text>
|
||||
)}
|
||||
{bitrateLabel && (
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{bitrateLabel}
|
||||
@@ -386,25 +425,42 @@ export default function MediaServerWidget({
|
||||
borderTop: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
|
||||
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
|
||||
count: flatSessions.length,
|
||||
})}
|
||||
</Text>
|
||||
{totalBitrateLabel && (isAdvanced || width >= 300) && (
|
||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
|
||||
</Text>
|
||||
<>
|
||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
•
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Group gap={6}>
|
||||
{uniqueIntegrations.map((integration) => (
|
||||
<Group key={integration.integrationId} gap="xs" align="center">
|
||||
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
|
||||
<Group
|
||||
key={integration.integrationId}
|
||||
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) && (
|
||||
<Text className="media-server-name" size="sm" truncate="end">
|
||||
<Text className="media-server-name" size="xs" fw={500} truncate="end">
|
||||
{integration.integrationName}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import type { ComponentType, FormEvent } from "react";
|
||||
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { schemaResolver } from "@mantine/form";
|
||||
import { IconPencil } from "@tabler/icons-react";
|
||||
import { IconArrowLeft, IconPencil } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
@@ -35,6 +35,16 @@ export interface WidgetEditModalState {
|
||||
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> {
|
||||
kind: TSort;
|
||||
definition: WidgetDefinition;
|
||||
@@ -46,7 +56,7 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
|
||||
itemId?: string;
|
||||
boardId?: string;
|
||||
appId?: string;
|
||||
onEditIntegration?: (integrationId: string) => void;
|
||||
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
|
||||
onIntegrationSaved?: () => void;
|
||||
}
|
||||
|
||||
@@ -61,6 +71,12 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
const { data: session } = useSession();
|
||||
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
|
||||
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);
|
||||
|
||||
z.config({
|
||||
@@ -118,7 +134,24 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
|
||||
const canModifyAllIntegrations = session?.user.permissions.includes("integration-full-all") ?? false;
|
||||
const selectedIntegrations = getSelectedWidgetIntegrations(innerProps.integrationData, form.values.integrationIds);
|
||||
const showIntegrationTab = Boolean(innerProps.onEditIntegration) && selectedIntegrations.length > 0;
|
||||
const editableIntegrationIds = selectedIntegrations
|
||||
.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 [activeTab, setActiveTab] = useState<string | null>("widget");
|
||||
|
||||
@@ -127,15 +160,31 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
if (activeTab === "app" && !showAppTab) setActiveTab("widget");
|
||||
}, [activeTab, showAppTab, showIntegrationTab]);
|
||||
|
||||
const handleTabChange = (value: string | null) => {
|
||||
if (value === "integration" && selectedIntegrations.length === 1) {
|
||||
const integration = selectedIntegrations[0];
|
||||
if (integration) {
|
||||
innerProps.onEditIntegration?.(integration.id);
|
||||
return;
|
||||
}
|
||||
const getIntegrationEditRef = (integrationId: string) => {
|
||||
const existingCallback = integrationEditRefCallbacks.current.get(integrationId);
|
||||
if (existingCallback) {
|
||||
return existingCallback;
|
||||
}
|
||||
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) => {
|
||||
@@ -147,6 +196,13 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
return;
|
||||
}
|
||||
|
||||
for (const integrationEditHandle of integrationEditHandles.current.values()) {
|
||||
const integrationSaved = await integrationEditHandle.submitIfDirty();
|
||||
if (!integrationSaved) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
innerProps.onSuccessfulEdit({
|
||||
...values,
|
||||
advancedOptions,
|
||||
@@ -240,7 +296,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
<FormProvider form={form}>
|
||||
{showResourceTabs ? (
|
||||
<Stack>
|
||||
<Tabs value={activeTab} onChange={handleTabChange}>
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
|
||||
{showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>}
|
||||
@@ -257,42 +313,68 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
{showIntegrationTab && (
|
||||
<Tabs.Panel value="integration" pt="md">
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("item.edit.integration.description")}
|
||||
</Text>
|
||||
{selectedIntegrations.map((integration) => {
|
||||
const canEdit = canModifyAllIntegrations || integration.permissions?.hasFullAccess === true;
|
||||
return (
|
||||
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{integration.url}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={() => innerProps.onEditIntegration?.(integration.id)}
|
||||
aria-label={t("item.edit.integration.editLabel", { name: integration.name })}
|
||||
>
|
||||
{t("item.edit.integration.action")}
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="end">
|
||||
{t("item.edit.integration.fullAccessRequired")}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{displayedIntegrationId === null && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("item.edit.integration.description")}
|
||||
</Text>
|
||||
{selectedIntegrations.map((integration) => {
|
||||
const canEdit = editableIntegrationIds.includes(integration.id);
|
||||
return (
|
||||
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{integration.url}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={() => beginEditingIntegration(integration.id)}
|
||||
aria-label={t("item.edit.integration.editLabel", { name: integration.name })}
|
||||
>
|
||||
{t("item.edit.integration.action")}
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="end">
|
||||
{t("item.edit.integration.fullAccessRequired")}
|
||||
</Text>
|
||||
)}
|
||||
</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>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
|
||||
@@ -69,6 +69,15 @@ 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> {
|
||||
clearable?: boolean;
|
||||
searchable?: boolean;
|
||||
@@ -141,6 +150,13 @@ const optionsFactory = {
|
||||
useOptions: input.useOptions,
|
||||
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>) => ({
|
||||
type: "select" as const,
|
||||
defaultValue: (input.defaultValue ?? input.options[0]) as inferSelectOptionValue<TOptions[number]>,
|
||||
|
||||
Reference in new issue
Block a user