"use client"; import React, { useCallback, useMemo, useState } from "react"; import { Accordion, ActionIcon, Button, Checkbox, Code, Divider, Fieldset, Group, Image, Loader, PasswordInput, Select, Stack, Text, TextInput, Title, Tooltip, } from "@mantine/core"; import type { FormErrors } from "@mantine/form"; import { IconAlertTriangleFilled, IconBrandDocker, IconCopy, IconCopyCheckFilled, IconEdit, IconKey, IconPackageImport, IconPlus, IconTrash, IconTriangleFilled, IconZoomScan, } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { createId } from "@homarr/common"; import type { ReleaseProviderKind } from "@homarr/definitions"; import { getReleaseProviderDefaultUrl, getReleaseProviderIconUrl, getReleaseProviderName, normalizeReleaseProviderIdentifier, releaseProviderKinds, } from "@homarr/definitions"; import { IconPicker } from "@homarr/forms-collection"; import { createModal, useModalAction } from "@homarr/modals"; import { showErrorNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; import { MaskedImage } from "@homarr/ui"; import type { ReleasesRepository, ReleasesVersionFilter } from "../releases/releases-repository"; import type { CommonWidgetInputProps } from "./common"; import { useWidgetInputTranslation } from "./common"; import { useFormContext } from "./form"; interface FormValidation { hasErrors: boolean; errors: FormErrors; } const escapeForRegEx = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); export const WidgetMultiReleasesRepositoriesInput = ({ property, kind, itemId, }: CommonWidgetInputProps<"multiReleasesRepositories">) => { const t = useWidgetInputTranslation(kind, property); const tRepository = useI18n("widget.releases.option.repositories"); const actionT = useI18n("common.action"); const form = useFormContext(); const repositories = form.values.options[property] as ReleasesRepository[]; 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 providerOptions = useMemo( () => releaseProviderKinds.map((provider) => ({ value: provider, label: getReleaseProviderName(provider) })), [], ); const onRepositorySave = useCallback( (repository: ReleasesRepository, index: number): FormValidation => { form.setFieldValue(`options.${property}.${index}.provider`, repository.provider); form.setFieldValue(`options.${property}.${index}.identifier`, repository.identifier); form.setFieldValue(`options.${property}.${index}.name`, repository.name); form.setFieldValue(`options.${property}.${index}.versionFilter`, repository.versionFilter); form.setFieldValue(`options.${property}.${index}.iconUrl`, repository.iconUrl); form.setFieldValue(`options.${property}.${index}.providerUrl`, repository.providerUrl); const formValidation = form.validate(); const fieldErrors: FormErrors = Object.entries(formValidation.errors).reduce((acc, [key, value]) => { if (key.startsWith(`options.${property}.${index}.`)) { acc[key] = value; } return acc; }, {} as FormErrors); return { hasErrors: Object.keys(fieldErrors).length > 0, errors: fieldErrors, }; }, [form, property], ); const addNewRepository = () => { const repository: ReleasesRepository = { id: createId(), identifier: "", }; form.setValues((previous) => { const previousValues = previous.options?.[property] as ReleasesRepository[]; return { ...previous, options: { ...previous.options, [property]: [...previousValues, repository], }, }; }); const index = repositories.length; openEditModal({ fieldPath: `options.${property}.${index}`, repository, onRepositorySave: (saved) => onRepositorySave(saved, index), onRepositoryCancel: () => onRepositoryRemove(index), versionFilterPrecisionOptions, providerOptions, }); }; const onRepositoryRemove = (index: number) => { form.setValues((previous) => { const previousValues = previous.options?.[property] as ReleasesRepository[]; return { ...previous, options: { ...previous.options, [property]: previousValues.filter((_, i) => i !== index), }, }; }); }; return (
{repositories.map((repository, index) => { const providerName = repository.provider ? getReleaseProviderName(repository.provider) : ""; return ( {providerName} {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} {repository.name || repository.identifier} onRepositoryRemove(index)}> {Object.keys(form.errors).filter((key) => key.startsWith(`options.${property}.${index}.`)).length > 0 && ( {tRepository("invalid")} )} ); })} {itemId && }
); }; const providersWithAuth: ReleaseProviderKind[] = [ "github", "gitHubContainerRegistry", "dockerHub", "gitlab", "npm", "codeberg", ]; const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repositories: ReleasesRepository[] }) => { const tRepository = useI18n("widget.releases.option.repositories"); const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId }); const setSecret = clientApi.widget.secrets.setSecret.useMutation({ onSuccess: () => refetch(), onError: () => { showErrorNotification({ title: tRepository("tokens.label"), message: tRepository("tokens.notConfigured"), }); }, }); const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({ onSuccess: () => refetch(), onError: () => { showErrorNotification({ title: tRepository("tokens.label"), message: tRepository("tokens.notConfigured"), }); }, }); const authProviders = useMemo(() => { const usedProviders = repositories.map((r) => r.provider).filter((p): p is ReleaseProviderKind => p !== undefined); const configuredProviders = configuredKinds.filter((k): k is ReleaseProviderKind => providersWithAuth.includes(k as ReleaseProviderKind), ); return [...new Set([...usedProviders, ...configuredProviders])].filter((p) => providersWithAuth.includes(p)); }, [repositories, configuredKinds]); if (authProviders.length === 0) return null; return ( <> {authProviders.map((provider) => ( { await setSecret.mutateAsync({ itemId, kind: provider, value }); }} onDelete={async () => { await deleteSecret.mutateAsync({ itemId, kind: provider }); }} /> ))} ); }; const ProviderTokenInput = ({ provider, hasToken, onSave, onDelete, }: { provider: ReleaseProviderKind; hasToken: boolean; onSave: (value: string) => Promise; onDelete: () => Promise; }) => { const tRepository = useI18n("widget.releases.option.repositories"); const actionT = useI18n("common.action"); const [value, setValue] = useState(""); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); const handleSave = async () => { if (!value.trim()) return; setSaving(true); try { await onSave(value.trim()); setValue(""); setEditing(false); } finally { setSaving(false); } }; const handleDelete = async () => { setSaving(true); try { await onDelete(); setValue(""); setEditing(false); } finally { setSaving(false); } }; return ( { setValue(e.currentTarget.value); setEditing(true); }} style={{ flex: 1 }} size="xs" leftSection={} /> {editing && value.trim() && ( )} {hasToken && !editing && ( )} ); }; const formatVersionFilterRegex = (versionFilter: ReleasesVersionFilter | undefined) => { if (!versionFilter) return undefined; const escapedPrefix = versionFilter.prefix ? escapeForRegEx(versionFilter.prefix) : ""; const precision = "[0-9]+\\.".repeat(versionFilter.precision).slice(0, -2); const escapedSuffix = versionFilter.suffix ? escapeForRegEx(versionFilter.suffix) : ""; return `^${escapedPrefix}${precision}${escapedSuffix}$`; }; const formatIdentifierName = (identifier: string) => { const unformattedName = identifier.split("/").pop(); return unformattedName?.replace(/[-_]/g, " ").replace(/(?:^\w|[A-Z]|\b\w)/g, (char) => char.toUpperCase()) ?? ""; }; interface RepositoryEditProps { fieldPath: string; repository: ReleasesRepository; onRepositorySave: (repository: ReleasesRepository) => FormValidation; onRepositoryCancel?: () => void; versionFilterPrecisionOptions: string[]; providerOptions: { value: ReleaseProviderKind; label: string }[]; } const RepositoryEditModal = createModal(({ innerProps, actions }) => { const tRepository = useI18n("widget.releases.option.repositories"); const actionT = useI18n("common.action"); const [loading, setLoading] = useState(false); const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository })); const [formErrors, setFormErrors] = useState({}); const handleConfirm = useCallback(() => { setLoading(true); const validation = innerProps.onRepositorySave(tempRepository); setFormErrors(validation.errors); if (!validation.hasErrors) { actions.closeModal(); } setLoading(false); }, [innerProps, tempRepository, actions]); const handleCancel = useCallback(() => { if (innerProps.onRepositoryCancel) { innerProps.onRepositoryCancel(); } actions.closeModal(); }, [innerProps, actions]); const handleChange = useCallback((changedValue: Partial) => { setTempRepository((prev) => ({ ...prev, ...changedValue })); }, []); return (
({ value: key, label: value, }))} value={tempRepository.versionFilter?.precision.toString() ?? "0"} onChange={(value) => { const precision = value ? parseInt(value) : 0; handleChange({ versionFilter: isNaN(precision) || precision <= 0 ? undefined : { ...tempRepository.versionFilter, precision, }, }); }} error={formErrors[`${innerProps.fieldPath}.versionFilter.precision`]} /> { handleChange({ versionFilter: { ...(tempRepository.versionFilter ?? { precision: 0 }), suffix: event.currentTarget.value, }, }); }} error={formErrors[`${innerProps.fieldPath}.versionFilter.suffix`]} disabled={!tempRepository.versionFilter} /> {tRepository("versionFilter.regex.label")}:{" "} {formatVersionFilterRegex(tempRepository.versionFilter) ?? tRepository("versionFilter.precision.options.none")} ); }).withOptions({ defaultTitle(t) { return t("widget.releases.option.repositories.editForm.title"); }, size: "xl", }); interface ReleasesRepositoryImport extends ReleasesRepository { alreadyImported: boolean; } interface ImportRepositorySelectProps { repository: ReleasesRepositoryImport; checked: boolean; versionFilterPrecisionOptions: string[]; disabled: boolean; onImageSelectionChanged?: (isSelected: boolean) => void; } const ImportRepositorySelect = ({ repository, checked, versionFilterPrecisionOptions, disabled = false, onImageSelectionChanged = undefined, }: ImportRepositorySelectProps) => { const tRepository = useI18n("widget.releases.option.repositories"); const provider = repository.provider; return ( { if (onImageSelectionChanged) { onImageSelectionChanged(!checked); } }} label={ {repository.identifier} } /> {repository.versionFilter && ( {tRepository("versionFilter.label")}: {repository.versionFilter.prefix} {versionFilterPrecisionOptions[repository.versionFilter.precision]} {repository.versionFilter.suffix} )} {provider ? ( ) : ( )} {provider ? getReleaseProviderName(provider) : tRepository("noProvider.label")} ); }; interface RepositoryImportProps { repositories: ReleasesRepository[]; versionFilterPrecisionOptions: string[]; onConfirm: (selectedRepositories: ReleasesRepositoryImport[]) => void; isAdmin: boolean; } const RepositoryImportModal = createModal(({ innerProps, actions }) => { const tRepository = useI18n("widget.releases.option.repositories"); const actionT = useI18n("common.action"); const [loading, setLoading] = useState(false); const [selectedImages, setSelectedImages] = useState([] as ReleasesRepositoryImport[]); const docker = clientApi.docker.getContainers.useQuery(undefined, { refetchOnMount: false, enabled: innerProps.isAdmin, }); const importRepositories: ReleasesRepositoryImport[] = useMemo( () => docker.data?.containers.reduce((acc, container) => { const [maybeSource, maybeIdentifierAndVersion] = container.image.split(/\/(.*)/); const hasSource = maybeSource && maybeSource in containerImageToProviderKind; const source = hasSource ? maybeSource : "docker.io"; const [identifier, version] = hasSource && maybeIdentifierAndVersion ? maybeIdentifierAndVersion.split(":") : container.image.split(":"); if (!identifier) return acc; const provider = containerImageToProviderKind[source] ?? "dockerHub"; const normalizedIdentifier = normalizeReleaseProviderIdentifier(provider, identifier); if (acc.some((item) => item.provider === provider && item.identifier === normalizedIdentifier)) return acc; acc.push({ id: createId(), provider, identifier: normalizedIdentifier, iconUrl: container.iconUrl ?? undefined, name: formatIdentifierName(normalizedIdentifier), versionFilter: version ? parseImageVersionToVersionFilter(version) : undefined, alreadyImported: innerProps.repositories.some( (item) => item.provider === provider && item.identifier === normalizedIdentifier, ), }); return acc; }, []) ?? [], [docker.data, innerProps.repositories], ); const handleConfirm = useCallback(() => { setLoading(true); innerProps.onConfirm(selectedImages); setLoading(false); actions.closeModal(); }, [innerProps, selectedImages, actions]); const allImagesImported = useMemo( () => importRepositories.every((repository) => repository.alreadyImported), [importRepositories], ); const anyImagesImported = useMemo( () => importRepositories.some((repository) => repository.alreadyImported), [importRepositories], ); return ( {docker.isPending ? ( {tRepository("importRepositories.loading")} ) : importRepositories.length === 0 ? ( {tRepository("importRepositories.noImagesFound")} ) : ( }> {tRepository("importRepositories.listFoundImages")} {allImagesImported && ( {tRepository("importRepositories.allImagesAlreadyImported")} )} {!allImagesImported && ( {importRepositories .filter((repository) => !repository.alreadyImported) .map((repository) => ( isSelected ? setSelectedImages([...selectedImages, repository]) : setSelectedImages(selectedImages.filter((img) => img !== repository)) } /> ))} )} }> {tRepository("importRepositories.listAlreadyImportedImages")} {anyImagesImported && ( {importRepositories .filter((repository) => repository.alreadyImported) .map((repository) => ( ))} )} )} ); }).withOptions({ defaultTitle(t) { return t("widget.releases.option.repositories.importForm.title"); }, size: "xl", }); const containerImageToProviderKind: Record = { "ghcr.io": "gitHubContainerRegistry", "docker.io": "dockerHub", "lscr.io": "linuxServerIO", "quay.io": "quay", }; 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, }; };