refactor(docker): simplify assisted service setup

This commit is contained in:
ajnart committed 2026-08-18 20:37:07 +02:00
1 parent 5e631b2f80
commit 65b725e1bb
12 files changed
+312 -250

No files matched your search

@@ -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,
})),
);
+2
View File
@@ -1,3 +1,5 @@
export { OnboardingAuthShell } from "./onboarding-auth-shell";
export { OnboardingStudio } from "./onboarding-studio";
export { normalizeServiceUrl } from "./discovery-selection";
export { ServiceUrlTemplate } from "./service-url-template";
export type { OnboardingEnvironment, OnboardingStudioProps } from "./types";
@@ -358,6 +358,7 @@
display: flex;
width: max-content;
min-width: 100%;
margin: 0;
padding: 0.2rem;
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-default);
@@ -0,0 +1,106 @@
"use client";
import type { ReactNode } from "react";
import { useRef, useState } from "react";
import { FloatingIndicator, Stack, TextInput, UnstyledButton } from "@mantine/core";
import type { UrlTemplateMode } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useOnboardingSounds } from "./use-onboarding-sounds";
import classes from "./onboarding-studio.module.css";
interface FloatingControlProps<T extends string> {
value: T;
onChange: (value: T) => void;
options: readonly { value: T; label: ReactNode }[];
ariaLabel: string;
}
export const OnboardingFloatingControl = <T extends string>({
value,
onChange,
options,
ariaLabel,
}: FloatingControlProps<T>) => {
const [rootRef, setRootRef] = useState<HTMLFieldSetElement | null>(null);
const controlRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const sounds = useOnboardingSounds();
return (
<div className={classes.floatingControlScroller}>
<fieldset ref={setRootRef} className={classes.floatingControlRoot} aria-label={ariaLabel}>
<FloatingIndicator
target={controlRefs.current[value] ?? null}
parent={rootRef}
className={classes.floatingControlIndicator}
/>
{options.map((option) => {
const selected = value === option.value;
return (
<UnstyledButton
key={option.value}
ref={(node) => {
controlRefs.current[option.value] = node;
}}
type="button"
className={classes.floatingControl}
data-active={selected}
aria-pressed={selected}
onClick={() => {
sounds.click();
onChange(option.value);
}}
>
{option.label}
</UnstyledButton>
);
})}
</fieldset>
</div>
);
};
interface ServiceUrlTemplateProps {
serverOrigin: string;
onServerOriginChange: (value: string) => void;
mode: UrlTemplateMode;
onModeChange: (value: UrlTemplateMode) => void;
readOnly?: boolean;
required?: boolean;
}
export const ServiceUrlTemplate = ({
serverOrigin,
onServerOriginChange,
mode,
onModeChange,
readOnly,
required,
}: ServiceUrlTemplateProps) => {
const t = useScopedI18n("init.studio.essentials");
return (
<Stack gap="sm">
<TextInput
label={t("serverTitle")}
placeholder="home.lan · 192.168.1.10 · https://homarr.example.com"
value={serverOrigin}
onChange={(event) => onServerOriginChange(event.currentTarget.value)}
readOnly={readOnly}
required={required}
withAsterisk={required}
/>
<OnboardingFloatingControl
ariaLabel={t("urlModeLabel")}
value={mode}
onChange={onModeChange}
options={[
{ value: "hostPort", label: t("hostPort") },
{ value: "subdomain", label: t("subdomain") },
{ value: "path", label: t("path") },
]}
/>
</Stack>
);
};
+6 -68
View File
@@ -15,7 +15,6 @@ import {
ColorSwatch,
CopyButton,
Fieldset,
FloatingIndicator,
Group,
Paper,
PasswordInput,
@@ -99,6 +98,7 @@ import type { OnboardingStudioProps } from "./types";
import { getBoardValidationErrors } from "./board-validation";
import { OnboardingBackdrop } from "./onboarding-backdrop";
import { OnboardingWordmark } from "./onboarding-wordmark";
import { OnboardingFloatingControl, ServiceUrlTemplate } from "./service-url-template";
import { useOnboardingSounds } from "./use-onboarding-sounds";
import {
normalizeServiceUrl,
@@ -152,57 +152,6 @@ const initialSecondaryColor = "#fd7e14";
const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = [];
const emptyDiscoveredApps: DockerDiscoveryData["apps"] = [];
interface OnboardingFloatingControlProps<T extends string> {
value: T;
onChange: (value: T) => void;
options: readonly { value: T; label: ReactNode }[];
ariaLabel: string;
}
const OnboardingFloatingControl = <T extends string>({
value,
onChange,
options,
ariaLabel,
}: OnboardingFloatingControlProps<T>) => {
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
const controlRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const sounds = useOnboardingSounds();
return (
<div className={classes.floatingControlScroller}>
<div ref={setRootRef} className={classes.floatingControlRoot} role="group" aria-label={ariaLabel}>
<FloatingIndicator
target={controlRefs.current[value] ?? null}
parent={rootRef}
className={classes.floatingControlIndicator}
/>
{options.map((option) => {
const selected = value === option.value;
return (
<UnstyledButton
key={option.value}
ref={(node) => {
controlRefs.current[option.value] = node;
}}
type="button"
className={classes.floatingControl}
data-active={selected}
aria-pressed={selected}
onClick={() => {
sounds.click();
onChange(option.value);
}}
>
{option.label}
</UnstyledButton>
);
})}
</div>
</div>
);
};
export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingStudioProps) => {
const t = useScopedI18n("init.studio");
const tCommon = useScopedI18n("common.action");
@@ -924,24 +873,13 @@ const Essentials = (props: StudioSectionProps) => {
<Alert variant="light" icon={<IconInfoCircle size={18} />} my="xs">
{t("serverOriginHelp")}
</Alert>
<TextInput
label={t("serverTitle")}
placeholder="home.lan · 192.168.1.10 · https://homarr.example.com"
value={props.serverOrigin}
onChange={(event) => props.setServerOrigin(event.currentTarget.value)}
<ServiceUrlTemplate
serverOrigin={props.serverOrigin}
onServerOriginChange={props.setServerOrigin}
mode={props.urlMode}
onModeChange={props.setUrlMode}
readOnly={!isHydrated}
required
withAsterisk
/>
<OnboardingFloatingControl
ariaLabel={t("urlModeLabel")}
value={props.urlMode}
onChange={props.setUrlMode}
options={[
{ value: "hostPort", label: t("hostPort") },
{ value: "subdomain", label: t("subdomain") },
{ value: "path", label: t("path") },
]}
/>
{props.serverOrigin ? (
<Code block>{buildIntegrationUrl("sonarr", props.serverOrigin, props.urlMode)}</Code>
+1 -17
View File
@@ -7079,23 +7079,17 @@
"title": "Containers",
"reconciliation": {
"title": "Assisted Docker setup",
"description": "Optional suggestions to connect Docker services with Homarr apps and integrations.",
"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",
@@ -7104,19 +7098,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",