feat(releases-widget): add import from docker functionality (#3130)

This commit is contained in:
Andre Silva
2025-06-06 18:59:46 +01:00
committed by GitHub
parent c7a21b1a5f
commit 7071d76c52
37 changed files with 386 additions and 69 deletions

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "Repositories",
"addRRepository": {
"addRepository": {
"label": "Tilføj repository"
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "Repositories",
"addRRepository": {
"addRepository": {
"label": "Repository hinzufügen"
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,9 +2223,18 @@
},
"repositories": {
"label": "Repositories",
"addRRepository": {
"addRepository": {
"label": "Add repository"
},
"importRepositories": {
"label": "Import from docker",
"loading": "Loading docker images",
"noImagesFound": "No docker images found",
"listFoundImages": "List of found images",
"listAlreadyImportedImages": "List of already imported images",
"allImagesAlreadyImported": "All images already imported",
"onlyAdminCanImport": "Only administrators can import from docker"
},
"provider": {
"label": "Provider"
},
@@ -2266,6 +2275,9 @@
"label": "Confirm"
}
},
"importForm": {
"title": "Import from Docker"
},
"example": {
"label": "Example"
},

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "מאגרים",
"addRRepository": {
"addRepository": {
"label": "הוסף מאגר"
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "Depolar",
"addRRepository": {
"addRepository": {
"label": "Depo Ekle"
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "",
"addRRepository": {
"addRepository": {
"label": ""
},
"provider": {

View File

@@ -2223,7 +2223,7 @@
},
"repositories": {
"label": "儲存庫",
"addRRepository": {
"addRepository": {
"label": "新增儲存庫"
},
"provider": {

View File

@@ -1,19 +1,46 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ActionIcon, Button, Divider, Fieldset, Group, Select, Stack, Text, TextInput } from "@mantine/core";
import {
Accordion,
ActionIcon,
Button,
Checkbox,
Code,
Divider,
Fieldset,
Group,
Image,
Loader,
Select,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import type { CheckboxProps } from "@mantine/core";
import type { FormErrors } from "@mantine/form";
import { useDebouncedValue } from "@mantine/hooks";
import { IconEdit, IconTrash, IconTriangleFilled } from "@tabler/icons-react";
import {
IconBrandDocker,
IconEdit,
IconPlus,
IconSquare,
IconSquareCheck,
IconTrash,
IconTriangleFilled,
} from "@tabler/icons-react";
import { escapeForRegEx } from "@tiptap/react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { findBestIconMatch, IconPicker } from "@homarr/forms-collection";
import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { MaskedImage } from "@homarr/ui";
import { Providers } from "../releases/releases-providers";
import { isProviderKey, Providers } from "../releases/releases-providers";
import type { ReleasesRepository, ReleasesVersionFilter } from "../releases/releases-repository";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
@@ -32,11 +59,14 @@ export const WidgetMultiReleasesRepositoriesInput = ({
const tRepository = useScopedI18n("widget.releases.option.repositories");
const form = useFormContext();
const repositories = form.values.options[property] as ReleasesRepository[];
const { openModal } = useModalAction(ReleaseEditModal);
const { openModal: openEditModal } = useModalAction(RepositoryEditModal);
const { openModal: openImportModal } = useModalAction(RepositoryImportModal);
const versionFilterPrecisionOptions = useMemo(
() => [tRepository("versionFilter.precision.options.none"), "#", "#.#", "#.#.#", "#.#.#.#", "#.#.#.#.#"],
[tRepository],
);
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const onRepositorySave = useCallback(
(repository: ReleasesRepository, index: number): FormValidation => {
@@ -62,8 +92,8 @@ export const WidgetMultiReleasesRepositoriesInput = ({
[form, property],
);
const addNewItem = () => {
const item = {
const addNewRepository = () => {
const repository: ReleasesRepository = {
providerKey: "DockerHub",
identifier: "",
};
@@ -74,16 +104,16 @@ export const WidgetMultiReleasesRepositoriesInput = ({
...previous,
options: {
...previous.options,
[property]: [...previousValues, item],
[property]: [...previousValues, repository],
},
};
});
const index = repositories.length;
openModal({
openEditModal({
fieldPath: `options.${property}.${index}`,
repository: item,
repository,
onRepositorySave: (saved) => onRepositorySave(saved, index),
onRepositoryCancel: () => onRepositoryRemove(index),
versionFilterPrecisionOptions,
@@ -106,24 +136,56 @@ export const WidgetMultiReleasesRepositoriesInput = ({
return (
<Fieldset legend={t("label")}>
<Stack gap="5">
<Button onClick={addNewItem}>{tRepository("addRRepository.label")}</Button>
<Group grow>
<Button leftSection={<IconPlus />} onClick={addNewRepository}>
{tRepository("addRepository.label")}
</Button>
<Tooltip label={tRepository("importRepositories.onlyAdminCanImport")} disabled={isAdmin} withArrow>
<Button
disabled={!isAdmin}
leftSection={<IconBrandDocker stroke={1.25} />}
onClick={() =>
openImportModal({
repositories,
versionFilterPrecisionOptions,
onConfirm: (selectedRepositories) => {
if (!selectedRepositories.length) return;
form.setValues((previous) => {
const previousValues = previous.options?.[property] as ReleasesRepository[];
return {
...previous,
options: {
...previous.options,
[property]: [...previousValues, ...selectedRepositories],
},
};
});
},
isAdmin,
})
}
>
{tRepository("importRepositories.label")}
</Button>
</Tooltip>
</Group>
<Divider my="sm" />
{repositories.map((repository, index) => {
return (
<Stack key={`${repository.providerKey}.${repository.identifier}`} gap={5}>
<Group align="center" gap="xs">
<MaskedOrNormalImage
hasColor={false}
imageUrl={repository.iconUrl ?? Providers[repository.providerKey]?.iconUrl}
<Image
src={repository.iconUrl ?? Providers[repository.providerKey].iconUrl}
style={{
height: "1em",
width: "1em",
height: "1.2em",
width: "1.2em",
}}
/>
<Text c="dimmed" fw={100} size="xs">
{Providers[repository.providerKey]?.name}
{Providers[repository.providerKey].name}
</Text>
<Group justify="space-between" align="center" style={{ flex: 1 }} gap={5}>
@@ -135,7 +197,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
<Button
onClick={() =>
openModal({
openEditModal({
fieldPath: `options.${property}.${index}`,
repository,
onRepositorySave: (saved) => onRepositorySave(saved, index),
@@ -185,7 +247,7 @@ const formatIdentifierName = (identifier: string) => {
return unformattedName?.replace(/[-_]/g, " ").replace(/(?:^\w|[A-Z]|\b\w)/g, (char) => char.toUpperCase()) ?? "";
};
interface ReleaseEditProps {
interface RepositoryEditProps {
fieldPath: string;
repository: ReleasesRepository;
onRepositorySave: (repository: ReleasesRepository) => FormValidation;
@@ -193,7 +255,7 @@ interface ReleaseEditProps {
versionFilterPrecisionOptions: string[];
}
const ReleaseEditModal = createModal<ReleaseEditProps>(({ innerProps, actions }) => {
const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, actions }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const [loading, setLoading] = useState(false);
const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository }));
@@ -262,7 +324,7 @@ const ReleaseEditModal = createModal<ReleaseEditProps>(({ innerProps, actions })
value={tempRepository.providerKey}
error={formErrors[`${innerProps.fieldPath}.providerKey`]}
onChange={(value) => {
if (value && Providers[value]) {
if (value && isProviderKey(value)) {
handleChange({ providerKey: value });
}
}}
@@ -386,7 +448,7 @@ const ReleaseEditModal = createModal<ReleaseEditProps>(({ innerProps, actions })
{tRepository("editForm.cancel.label")}
</Button>
<Button data-autofocus onClick={handleConfirm} color="red.9" loading={loading}>
<Button data-autofocus onClick={handleConfirm} loading={loading}>
{tRepository("editForm.confirm.label")}
</Button>
</Group>
@@ -398,3 +460,247 @@ const ReleaseEditModal = createModal<ReleaseEditProps>(({ innerProps, actions })
},
size: "xl",
});
interface ReleasesRepositoryImport extends ReleasesRepository {
alreadyImported: boolean;
}
interface ContainerImageSelectorProps {
containerImage: ReleasesRepositoryImport;
versionFilterPrecisionOptions: string[];
onImageSelectionChanged?: (isSelected: boolean) => void;
}
const ContainerImageSelector = ({
containerImage,
versionFilterPrecisionOptions,
onImageSelectionChanged,
}: ContainerImageSelectorProps) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const checkBoxProps: CheckboxProps = !onImageSelectionChanged
? {
disabled: true,
checked: true,
}
: {
onChange: (event) => onImageSelectionChanged(event.currentTarget.checked),
};
return (
<Group
key={`${Providers[containerImage.providerKey].name}/${containerImage.identifier}`}
gap="xl"
justify="space-between"
>
<Group gap="md">
<Checkbox
label={
<Group>
<Image
src={containerImage.iconUrl}
style={{
height: "1.2em",
width: "1.2em",
}}
/>
<Text>{containerImage.identifier}</Text>
</Group>
}
{...checkBoxProps}
/>
{containerImage.versionFilter && (
<Group gap={5}>
<Text c="dimmed" size="xs">
{tRepository("versionFilter.label")}:
</Text>
<Code>{containerImage.versionFilter.prefix && containerImage.versionFilter.prefix}</Code>
<Code color="var(--mantine-primary-color-light)" fw={700}>
{versionFilterPrecisionOptions[containerImage.versionFilter.precision]}
</Code>
<Code>{containerImage.versionFilter.suffix && containerImage.versionFilter.suffix}</Code>
</Group>
)}
</Group>
<Group>
<MaskedImage
color="dimmed"
imageUrl={Providers[containerImage.providerKey].iconUrl}
style={{
height: "1em",
width: "1em",
}}
/>
<Text ff="monospace" c="dimmed" size="sm">
{Providers[containerImage.providerKey].name}
</Text>
</Group>
</Group>
);
};
interface RepositoryImportProps {
repositories: ReleasesRepository[];
versionFilterPrecisionOptions: string[];
onConfirm: (selectedRepositories: ReleasesRepositoryImport[]) => void;
isAdmin: boolean;
}
const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps, actions }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const [loading, setLoading] = useState(false);
const [selectedImages, setSelectedImages] = useState([] as ReleasesRepositoryImport[]);
const docker = clientApi.docker.getContainers.useQuery(undefined, {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
enabled: innerProps.isAdmin,
});
const containersImages: ReleasesRepositoryImport[] = useMemo(
() =>
docker.data?.containers.reduce<ReleasesRepositoryImport[]>((acc, containerImage) => {
const providerKey = containerImage.image.startsWith("ghcr.io/") ? "Github" : "DockerHub";
const [identifier, version] = containerImage.image.replace(/^(ghcr\.io\/|docker\.io\/)/, "").split(":");
if (!identifier) return acc;
if (acc.some((item) => item.providerKey === providerKey && item.identifier === identifier)) return acc;
acc.push({
providerKey,
identifier,
iconUrl: containerImage.iconUrl ?? undefined,
name: formatIdentifierName(identifier),
versionFilter: version ? parseImageVersionToVersionFilter(version) : undefined,
alreadyImported: innerProps.repositories.some(
(item) => item.providerKey === providerKey && item.identifier === identifier,
),
});
return acc;
}, []) ?? [],
[docker.data, innerProps.repositories],
);
const handleConfirm = useCallback(() => {
setLoading(true);
innerProps.onConfirm(selectedImages);
setLoading(false);
actions.closeModal();
}, [innerProps, selectedImages, actions]);
const allImagesImported = useMemo(
() => containersImages.every((containerImage) => containerImage.alreadyImported),
[containersImages],
);
const anyImagesImported = useMemo(
() => containersImages.some((containerImage) => containerImage.alreadyImported),
[containersImages],
);
return (
<Stack>
{docker.isPending ? (
<Stack justify="center" align="center">
<Loader size="xl" />
<Title order={3}>{tRepository("importRepositories.loading")}</Title>
</Stack>
) : containersImages.length === 0 ? (
<Stack justify="center" align="center">
<IconBrandDocker stroke={1} size={128} />
<Title order={3}>{tRepository("importRepositories.noImagesFound")}</Title>
</Stack>
) : (
<Stack>
<Accordion defaultValue={!allImagesImported ? "foundImages" : anyImagesImported ? "alreadyImported" : ""}>
<Accordion.Item value="foundImages">
<Accordion.Control disabled={allImagesImported} icon={<IconSquare stroke={1.25} />}>
<Group>
{tRepository("importRepositories.listFoundImages")}
{allImagesImported && (
<Text c="dimmed" size="sm">
{tRepository("importRepositories.allImagesAlreadyImported")}
</Text>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
{!allImagesImported &&
containersImages
.filter((containerImage) => !containerImage.alreadyImported)
.map((containerImage) => {
return (
<ContainerImageSelector
key={`${containerImage.providerKey}/${containerImage.identifier}`}
containerImage={containerImage}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
onImageSelectionChanged={(isSelected) =>
isSelected
? setSelectedImages([...selectedImages, containerImage])
: setSelectedImages(selectedImages.filter((img) => img !== containerImage))
}
/>
);
})}
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="alreadyImported">
<Accordion.Control disabled={!anyImagesImported} icon={<IconSquareCheck stroke={1.25} />}>
{tRepository("importRepositories.listAlreadyImportedImages")}
</Accordion.Control>
<Accordion.Panel>
{anyImagesImported &&
containersImages
.filter((containerImage) => containerImage.alreadyImported)
.map((containerImage) => {
return (
<ContainerImageSelector
key={`${containerImage.providerKey}/${containerImage.identifier}`}
containerImage={containerImage}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
/>
);
})}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
)}
<Group justify="flex-end">
<Button variant="default" onClick={actions.closeModal} color="gray.5">
{tRepository("editForm.cancel.label")}
</Button>
<Button onClick={handleConfirm} loading={loading} disabled={selectedImages.length === 0}>
{tRepository("editForm.confirm.label")}
</Button>
</Group>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("widget.releases.option.repositories.importForm.title");
},
size: "xl",
});
const parseImageVersionToVersionFilter = (imageVersion: string): ReleasesVersionFilter | undefined => {
const version = /(?<=\D|^)\d+(?:\.\d+)*(?![\d.])/.exec(imageVersion)?.[0];
if (!version) return undefined;
const [prefix, suffix] = imageVersion.split(version);
return {
prefix,
precision: version.split(".").length,
suffix,
};
};

View File

@@ -156,7 +156,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
>
<MaskedOrNormalImage
className="releases-repository-header-icon"
imageUrl={repository.iconUrl ?? Providers[repository.providerKey]?.iconUrl}
imageUrl={repository.iconUrl ?? Providers[repository.providerKey].iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
@@ -474,7 +474,7 @@ const ExpandedDisplay = ({ repository, hasIconColor }: ExtendedDisplayProps) =>
<Group className="releases-repository-expanded-header-provider-wrapper" gap={5} align="center">
<MaskedOrNormalImage
className="releases-repository-expanded-header-provider-icon"
imageUrl={Providers[repository.providerKey]?.iconUrl}
imageUrl={Providers[repository.providerKey].iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
@@ -482,7 +482,7 @@ const ExpandedDisplay = ({ repository, hasIconColor }: ExtendedDisplayProps) =>
}}
/>
<Text className="releases-repository-expanded-header-provider-name" size="xs" c="iconColor" ff="monospace">
{Providers[repository.providerKey]?.name}
{Providers[repository.providerKey].name}
</Text>
</Group>
</Group>

View File

@@ -3,16 +3,7 @@ export interface ReleasesProvider {
iconUrl: string;
}
interface ProvidersProps {
[key: string]: ReleasesProvider;
DockerHub: ReleasesProvider;
Github: ReleasesProvider;
Gitlab: ReleasesProvider;
Npm: ReleasesProvider;
Codeberg: ReleasesProvider;
}
export const Providers: ProvidersProps = {
export const Providers = {
DockerHub: {
name: "Docker Hub",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/docker.svg",
@@ -33,4 +24,10 @@ export const Providers: ProvidersProps = {
name: "Codeberg",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/codeberg.svg",
},
} as const satisfies Record<string, ReleasesProvider>;
export type ProviderKey = keyof typeof Providers;
export const isProviderKey = (key: string): key is ProviderKey => {
return key in Providers;
};

View File

@@ -1,3 +1,5 @@
import type { ProviderKey } from "./releases-providers";
export interface ReleasesVersionFilter {
prefix?: string;
precision: number;
@@ -5,7 +7,7 @@ export interface ReleasesVersionFilter {
}
export interface ReleasesRepository {
providerKey: string;
providerKey: ProviderKey;
identifier: string;
name?: string;
versionFilter?: ReleasesVersionFilter;