feat(docker): add endpoint filtering and streamline setup assistant (#6625)

* feat(docker): add endpoint filtering and setup assistant

* fix(docker): simplify environment setup

* test(docker): cover demo endpoint filtering

* refactor(docker): use accordion for setup suggestions

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

* refactor(docker): simplify assisted service setup

* refactor(docker): reuse shared translations

* fix(docker): address review feedback
This commit is contained in:
Thomas authored and GitHub committed 2026-08-19 08:45:07 +02:00
1 parent 315245e048
commit 150d38be62
24 files changed
+772 -409

No files matched your search

+2
View File
@@ -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}
/>
);
};
+6 -1
View File
@@ -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({
+14
View File
@@ -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) => ({
+16
View File
@@ -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]>,