From 328c885c6c8fc6cd900768ef8b518f2c25521976 Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sat, 3 Oct 2026 07:42:03 -0400 Subject: [PATCH 01/16] Add RPM remote and distribution APIs, fill out RPM repository API (cherry picked from commit ec82c8a5660b4be1ca97df3347405b9283d66475) --- src/api/index.ts | 4 +- src/api/rpm-remote.ts | 44 ++++++++++++++++++++ src/api/rpm-repository.ts | 18 ++++++++ src/utilities/plugin-repository-base-path.ts | 3 +- 4 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 src/api/rpm-remote.ts diff --git a/src/api/index.ts b/src/api/index.ts index e39f6c98..a7bfe43a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -78,7 +78,9 @@ export { } from './response-types/user'; export { RoleAPI } from './role'; export { RPMPackageAPI } from './rpm-package'; -export { RPMRepositoryAPI } from './rpm-repository'; +export { RPMDistributionAPI } from './rpm-distribution'; +export { RPMRemoteAPI, type RPMRemoteType } from './rpm-remote'; +export { RPMRepositoryAPI, type RPMRepositoryType,} from './rpm-repository'; export { SignCollectionAPI } from './sign-collections'; export { SignContainersAPI } from './sign-containers'; export { SigningServiceAPI, type SigningServiceType } from './signing-service'; diff --git a/src/api/rpm-remote.ts b/src/api/rpm-remote.ts new file mode 100644 index 00000000..17b9b28f --- /dev/null +++ b/src/api/rpm-remote.ts @@ -0,0 +1,44 @@ +import type { GenericRemote } from './common'; +import { PulpAPI } from './pulp'; + +/** + * RPM Remote Type. + * + * @see https://github.com/pulp/pulp_rpm/blob/main/pulp_rpm/app/serializers/repository.py + */ +interface RPMRemoteType extends GenericRemote { + sles_auth_token?: string | null; +} + +// as in deb-remote / file-remote +function smartUpdate(remote: RPMRemoteType, unmodifiedRemote: RPMRemoteType) { + for (const field of Object.keys(remote)) { + if (remote[field] === '') { + remote[field] = null; + } + + // API returns headers:null but doesn't accept it .. and we don't edit headers + if (remote[field] === null && unmodifiedRemote[field] === null) { + delete remote[field]; + } + } + + return remote; +} + +const base = new PulpAPI(); + +export const RPMRemoteAPI = { + create: (data) => base.http.post(`remotes/rpm/rpm/`, data), + + delete: (id) => base.http.delete(`remotes/rpm/rpm/${id}/`), + + get: (id) => base.http.get(`remotes/rpm/rpm/${id}/`), + + list: (params?) => base.list(`remotes/rpm/rpm/`, params), + + smartUpdate: (id, newValue: RPMRemoteType, oldValue: RPMRemoteType) => + base.http.put(`remotes/rpm/rpm/${id}/`, smartUpdate(newValue, oldValue)), +}; + +export type { RPMRemoteType }; \ No newline at end of file diff --git a/src/api/rpm-repository.ts b/src/api/rpm-repository.ts index 2dd8f24d..5bb685de 100644 --- a/src/api/rpm-repository.ts +++ b/src/api/rpm-repository.ts @@ -34,7 +34,25 @@ interface RPMRepositoryType extends GenericRepository { const base = new PulpAPI(); export const RPMRepositoryAPI = { + create: (data) => base.http.post(`repositories/rpm/rpm/`, data), + + delete: (id) => base.http.delete(`repositories/rpm/rpm/${id}/`), + list: (params?) => base.list(`repositories/rpm/rpm/`, params), + + listVersions: (id: string, params?) => + base.list(`repositories/rpm/rpm/${id}/versions/`, params), + + revert: (id: string, version_href) => + base.http.post(`repositories/rpm/rpm/${id}/modify/`, { + base_version: version_href, + }), + + sync: (id: string, body = {}) => + base.http.post(`repositories/rpm/rpm/${id}/sync/`, body), + + update: (id: string, data) => + base.http.put(`repositories/rpm/rpm/${id}/`, data), }; export type { RPMRepositoryType }; diff --git a/src/utilities/plugin-repository-base-path.ts b/src/utilities/plugin-repository-base-path.ts index 65fbadf2..798d62c6 100644 --- a/src/utilities/plugin-repository-base-path.ts +++ b/src/utilities/plugin-repository-base-path.ts @@ -9,6 +9,7 @@ import { FileDistributionAPI } from '../api/file-distribution'; import { FileRemoteAPI } from '../api/file-remote'; import { FileRepositoryAPI } from '../api/file-repository'; import { RPMDistributionAPI } from '../api/rpm-distribution'; +import { RPMRemoteAPI } from '../api/rpm-remote'; import { RPMRepositoryAPI } from '../api/rpm-repository'; // returns the preferred distribution base_path given a repo name @@ -40,7 +41,7 @@ export function plugin2api(plugin) { case 'rpm': return { DistributionAPI: RPMDistributionAPI, - // FIXME: RemoteAPI: RPMRemoteAPI, + RemoteAPI: RPMRemoteAPI, RepositoryAPI: RPMRepositoryAPI, }; default: From cbad4edb5ebaf69c8145781e987e1fcc5f654f00 Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sat, 3 Oct 2026 08:19:15 -0400 Subject: [PATCH 02/16] Add RPM repository detail page with details, versions and distributions tabs (cherry picked from commit 87c2c753952cb9362328981b4420db0e7728f575) --- src/actions/index.ts | 2 + src/actions/rpm-repository-create.tsx | 9 + src/actions/rpm-repository-edit.tsx | 9 + src/app-routes.tsx | 15 ++ src/containers/index.ts | 3 + src/containers/rpm-repository/detail.tsx | 141 ++++++++++ src/containers/rpm-repository/edit.tsx | 167 ++++++++++++ src/containers/rpm-repository/list.tsx | 82 ++++++ src/containers/rpm-repository/tab-details.tsx | 57 +++++ .../rpm-repository/tab-distributions.tsx | 127 +++++++++ .../tab-repository-versions.tsx | 241 ++++++++++++++++++ src/menu.tsx | 3 + src/paths.ts | 5 + 13 files changed, 861 insertions(+) create mode 100644 src/actions/rpm-repository-create.tsx create mode 100644 src/actions/rpm-repository-edit.tsx create mode 100644 src/containers/rpm-repository/detail.tsx create mode 100644 src/containers/rpm-repository/edit.tsx create mode 100644 src/containers/rpm-repository/list.tsx create mode 100644 src/containers/rpm-repository/tab-details.tsx create mode 100644 src/containers/rpm-repository/tab-distributions.tsx create mode 100644 src/containers/rpm-repository/tab-repository-versions.tsx diff --git a/src/actions/index.ts b/src/actions/index.ts index 8fac49b5..50973ce2 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -27,3 +27,5 @@ export { fileRepositoryCreateAction } from './file-repository-create'; export { fileRepositoryDeleteAction } from './file-repository-delete'; export { fileRepositoryEditAction } from './file-repository-edit'; export { fileRepositorySyncAction } from './file-repository-sync'; +export { rpmRepositoryCreateAction } from './rpm-repository-create'; +export { rpmRepositoryEditAction } from './rpm-repository-edit'; \ No newline at end of file diff --git a/src/actions/rpm-repository-create.tsx b/src/actions/rpm-repository-create.tsx new file mode 100644 index 00000000..28080a37 --- /dev/null +++ b/src/actions/rpm-repository-create.tsx @@ -0,0 +1,9 @@ +import { msg } from '@lingui/core/macro'; +import { Paths, formatPath } from 'src/paths'; +import { Action } from './action'; + +export const rpmRepositoryCreateAction = Action({ + title: msg`Add repository`, + onClick: (item, { navigate }) => + navigate(formatPath(Paths.rpm.repository.edit, { name: '_' })), +}); diff --git a/src/actions/rpm-repository-edit.tsx b/src/actions/rpm-repository-edit.tsx new file mode 100644 index 00000000..99274cd1 --- /dev/null +++ b/src/actions/rpm-repository-edit.tsx @@ -0,0 +1,9 @@ +import { msg } from '@lingui/core/macro'; +import { Paths, formatPath } from 'src/paths'; +import { Action } from './action'; + +export const rpmRepositoryEditAction = Action({ + title: msg`Edit`, + onClick: ({ name }, { navigate }) => + navigate(formatPath(Paths.rpm.repository.edit, { name })), +}); diff --git a/src/app-routes.tsx b/src/app-routes.tsx index 820c8081..f2a74646 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -56,6 +56,9 @@ import { Partners, PulpStatus, RPMPackageList, + RPMRepositoryList, + RPMRepositoryEdit, + RPMRepositoryDetail, RoleCreate, RoleList, Search, @@ -349,6 +352,18 @@ const routes: IRouteConfig[] = [ path: Paths.rpm.package.list, beta: true, }, + { + component: RPMRepositoryList, + path: Paths.rpm.repository.list, + }, + { + component: RPMRepositoryEdit, + path: Paths.rpm.repository.edit, + }, + { + component: RPMRepositoryDetail, + path: Paths.rpm.repository.detail + }, ]; const AuthHandler = ({ diff --git a/src/containers/index.ts b/src/containers/index.ts index cc1dd5e5..62da2d3a 100644 --- a/src/containers/index.ts +++ b/src/containers/index.ts @@ -44,6 +44,9 @@ export { default as RoleCreate } from './role-management/role-create'; export { default as EditRole } from './role-management/role-edit'; export { default as RoleList } from './role-management/role-list'; export { default as RPMPackageList } from './rpm/package-list'; +export { default as RPMRepositoryList } from './rpm-repository/list'; +export { default as RPMRepositoryEdit } from './rpm-repository/edit'; +export { default as RPMRepositoryDetail } from './rpm-repository/detail'; export { default as MultiSearch } from './search/multi-search'; export { default as Search } from './search/search'; export { default as UserProfile } from './settings/user-profile'; diff --git a/src/containers/rpm-repository/detail.tsx b/src/containers/rpm-repository/detail.tsx new file mode 100644 index 00000000..3a4a6e03 --- /dev/null +++ b/src/containers/rpm-repository/detail.tsx @@ -0,0 +1,141 @@ +import { msg, t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { + rpmRepositoryEditAction, +} from 'src/actions'; +import { + RPMRemoteAPI, + type RPMRemoteType, + RPMRepositoryAPI, + type RPMRepositoryType, +} from 'src/api'; +import { PageWithTabs } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { + lastSyncStatus, + lastSynced, + parsePulpIDFromURL, + pluginRepositoryBasePath, +} from 'src/utilities'; +import { DetailsTab } from './tab-details'; +import { DistributionsTab } from './tab-distributions'; +import { RepositoryVersionsTab } from './tab-repository-versions'; + +const RPMRepositoryDetail = PageWithTabs< + RPMRepositoryType & { remote?: RPMRemoteType } +>({ + breadcrumbs: ({ name, tab, params: { repositoryVersion } }) => { + const crumbs = [ + { url: formatPath(Paths.rpm.repository.list), name: t`Repositories` }, + { url: formatPath(Paths.rpm.repository.detail, { name }), name }, + ]; + + if (tab !== 'repository-versions') { + return crumbs; + } + + // Looking at a single version keeps a link back to the list of them. + return repositoryVersion + ? [ + ...crumbs, + { + url: formatPath(Paths.rpm.repository.detail, { name }, { tab }), + name: t`Versions`, + }, + { name: t`Version ${repositoryVersion}` }, + ] + : [...crumbs, { name: t`Versions` }]; + }, + displayName: 'RPMRepositoryDetail', + errorTitle: msg`Repository could not be displayed.`, + headerActions: [ + rpmRepositoryEditAction, + ], + headerDetails: (item) => ( + <> + {item?.last_sync_task && ( +

+ Last updated from registry {lastSynced(item)}{' '} + {lastSyncStatus(item)} +

+ )} + + ), + listUrl: formatPath(Paths.rpm.repository.list), + query: ({ name }) => + RPMRepositoryAPI.list({ name, page_size: 1 }) + .then(({ data }) => data?.results?.[0]) + .then((repository) => { + // There is no detail endpoint keyed by name, so a name matching nothing + // answers 200 with an empty list. Turn that into the 404 the page already + // knows how to render, instead of resolving with undefined. + if (!repository) { + return Promise.reject({ response: { status: 404 } }); + } + + const err = (val) => (e) => { + console.error(e); + return val; + }; + + return Promise.all([ + // the plugin-aware variant, so the deb distribution endpoint is the + // one consulted + pluginRepositoryBasePath( + 'rpm', + repository.name, + repository.pulp_href, + ).catch(err(null)), + repository.remote + ? RPMRemoteAPI.get(parsePulpIDFromURL(repository.remote)) + .then(({ data }) => data) + .catch(() => null) + : null, + ]).then(([distroBasePath, remote]) => ({ + ...repository, + distroBasePath, + remote, + })); + }), + renderTab: (tab, item, actionContext) => + ({ + details: , + 'repository-versions': ( + + ), + distributions: ( + + ), + })[tab], + tabs: (tab, name) => [ + { + active: tab === 'details', + title: t`Details`, + link: formatPath( + Paths.rpm.repository.detail, + { name }, + { tab: 'details' }, + ), + }, + { + active: tab === 'repository-versions', + title: t`Versions`, + link: formatPath( + Paths.rpm.repository.detail, + { name }, + { tab: 'repository-versions' }, + ), + }, + { + active: tab === 'distributions', + title: t`Distributions`, + link: formatPath( + Paths.rpm.repository.detail, + { name }, + { tab: 'distributions' }, + ), + }, + ], +}); + +export default RPMRepositoryDetail; diff --git a/src/containers/rpm-repository/edit.tsx b/src/containers/rpm-repository/edit.tsx new file mode 100644 index 00000000..ffcbff80 --- /dev/null +++ b/src/containers/rpm-repository/edit.tsx @@ -0,0 +1,167 @@ +import { msg, t } from '@lingui/core/macro'; +import { + RPMDistributionAPI, + RPMRepositoryAPI, + type RPMRepositoryType, +} from 'src/api'; +import { Page, RepositoryForm } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL, taskAlert } from 'src/utilities'; + +const initialRepository: RPMRepositoryType = { + name: '', + description: '', + retain_repo_versions: 1, + pulp_labels: {}, + remote: null, +}; + +const RPMRepositoryEdit = Page({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.rpm.repository.list), name: t`Repositories` }, + name && { name }, + name ? { name: t`Edit` } : { name: t`Add` }, + ].filter(Boolean), + + displayName: 'RPMRepositoryEdit', + errorTitle: msg`Repository could not be displayed.`, + listUrl: formatPath(Paths.rpm.repository.list), + query: ({ name }) => + RPMRepositoryAPI.list({ name }).then(({ data: { results } }) => results[0]), + title: ({ name }) => name || t`Add new repository`, + transformParams: ({ name, ...rest }) => ({ + ...rest, + name: name !== '_' ? name : null, + }), + + render: (item, { navigate, queueAlert, state, setState }) => { + if (!state.repositoryToEdit) { + const repositoryToEdit = { + ...initialRepository, + ...item, + }; + setState({ repositoryToEdit, errorMessages: {} }); + } + + const { repositoryToEdit, errorMessages } = state; + if (!repositoryToEdit) { + return null; + } + + const saveRepository = ({ createDistribution }) => { + const { repositoryToEdit } = state; + + const data = { ...repositoryToEdit }; + + // prevent "This field may not be blank." for nullable fields + Object.keys(data).forEach((k) => { + if (data[k] === '') { + data[k] = null; + } + }); + + if (item) { + delete data.last_sync_task; + delete data.last_synced_metadata_time; + delete data.latest_version_href; + delete data.pulp_created; + delete data.pulp_href; + delete data.versions_href; + } + + data.pulp_labels ||= {}; + + let promise = !item + ? RPMRepositoryAPI.create(data).then(({ data: newData }) => { + queueAlert({ + variant: 'success', + title: t`Successfully created repository ${data.name}`, + }); + + return newData.pulp_href; + }) + : RPMRepositoryAPI.update( + parsePulpIDFromURL(item.pulp_href), + data, + ).then(({ data: task }) => { + queueAlert( + taskAlert(task, t`Update started for repository ${data.name}`), + ); + + return item.pulp_href; + }); + + if (createDistribution) { + // only alphanumerics, slashes, underscores and dashes are allowed in base_path, transform anything else to _ + const basePathTransform = (name) => + name.replaceAll(/[^-a-zA-Z0-9_/]/g, '_'); + let distributionName = data.name; + + promise = promise + .then((pulp_href) => + RPMDistributionAPI.create({ + name: distributionName, + base_path: basePathTransform(distributionName), + repository: pulp_href, + }).catch(() => { + // if distribution already exists, try a numeric suffix to name & base_path + distributionName = + data.name + Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); + return RPMDistributionAPI.create({ + name: distributionName, + base_path: basePathTransform(distributionName), + repository: pulp_href, + }); + }), + ) + .then(({ data: task }) => + queueAlert( + taskAlert( + task, + t`Creation started for distribution ${distributionName}`, + ), + ), + ); + } + + promise + .then(() => { + setState({ + errorMessages: {}, + repositoryToEdit: undefined, + }); + + navigate(formatPath(Paths.rpm.repository.list)); + + }) + .catch(({ response: { data } }) => + setState({ + errorMessages: { + __nofield: data.non_field_errors || data.detail, + ...data, + }, + }), + ); + }; + + const closeModal = () => { + setState({ errorMessages: {}, repositoryToEdit: undefined }); + navigate(formatPath(Paths.rpm.repository.list)); + }; + + return ( + setState({ repositoryToEdit: r })} + /> + ); + }, +}); + +export default RPMRepositoryEdit; diff --git a/src/containers/rpm-repository/list.tsx b/src/containers/rpm-repository/list.tsx new file mode 100644 index 00000000..0e9f3231 --- /dev/null +++ b/src/containers/rpm-repository/list.tsx @@ -0,0 +1,82 @@ +import { msg, t } from '@lingui/core/macro'; +import { Td, Tr } from '@patternfly/react-table'; +import { RPMRemoteAPI, RPMRepositoryAPI, type RPMRepositoryType } from 'src/api'; +import { rpmRepositoryCreateAction, rpmRepositoryEditAction } from 'src/actions'; +import { DateComponent, ListItemActions, ListPage, PulpLabels } from 'src/components'; +import { parsePulpIDFromURL } from 'src/utilities'; +import { Link } from 'react-router'; +import { Paths, formatPath } from 'src/paths'; + +const listItemActions = [rpmRepositoryEditAction]; +const typeaheadQuery = ({ inputText, selectedFilter, setState }) => { + if (selectedFilter !== 'remote') { + return; + } + + return RPMRemoteAPI.list({ name__icontains: inputText }) + .then(({ data: { results } }) => + results.map(({ name, pulp_href }) => ({ id: pulp_href, title: name })), + ) + .then((remotes) => setState({ remotes })); +}; + +const RPMRepositoryList = ListPage({ + defaultPageSize: 10, + defaultSort: '-pulp_created', + displayName: 'RPMRepositoryList', + errorTitle: msg`Repositories could not be displayed.`, + filterConfig: ({ state: { remotes } }) => [ + { + id: 'name__icontains', + title: t`Repository name`, + }, + { + id: 'pulp_label_select', + title: t`Pulp Label`, + }, + { + id: 'remote', + title: t`Remote`, + inputType: 'typeahead', + options: [{ id: 'null', title: t`None` }, ...(remotes || [])], + }, + ], + noDataDescription: msg`Repositories will appear once created.`, + headerActions: [rpmRepositoryCreateAction], + listItemActions, + noDataButton: rpmRepositoryCreateAction.button, + noDataTitle: msg`No repositories yet`, + query: ({ params }) => RPMRepositoryAPI.list(params), + typeaheadQuery, + renderTableRow(item: RPMRepositoryType, index: number, actionContext) { + const { name, pulp_created, pulp_href, pulp_labels } = item; + const id = parsePulpIDFromURL(pulp_href); + + const kebabItems = listItemActions.map((action) => + action.dropdownItem({ ...item, id }, actionContext), + ); + + return ( + + + {name} + + + + + + + + + + ); + }, + sortHeaders: [ + { title: msg`Repository name`, type: 'alpha', id: 'name' }, + { title: msg`Labels`, type: 'none', id: 'pulp_labels' }, + { title: msg`Created date`, type: 'numeric', id: 'pulp_created' }, + ], + title: msg`Repositories`, +}); + +export default RPMRepositoryList; \ No newline at end of file diff --git a/src/containers/rpm-repository/tab-details.tsx b/src/containers/rpm-repository/tab-details.tsx new file mode 100644 index 00000000..a8ea5d22 --- /dev/null +++ b/src/containers/rpm-repository/tab-details.tsx @@ -0,0 +1,57 @@ +import { t } from '@lingui/core/macro'; +import { Link } from 'react-router'; +import { type RPMRemoteType, type RPMRepositoryType } from 'src/api'; +import { CopyURL, Details, PulpLabels } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { getRepoURL } from 'src/utilities'; + +interface TabProps { + item: RPMRepositoryType & { + distroBasePath?: string; + remote?: RPMRemoteType; + }; + actionContext: { addAlert: (alert) => void; state: { params } }; +} + +export const DetailsTab = ({ item }: TabProps) => { + return ( +
+ ) : ( + '---' + ), + }, + { + label: t`Labels`, + value: , + }, + { + label: t`Remote`, + value: item?.remote ? item.remote.name : t`None`, + }, + { + label: t`Autopublish`, + value: item?.autopublish ? t`Enabled` : t`Disabled`, + }, + { + label: t`Autopublish`, + value: item?.autopublish ? t`Enabled` : t`Disabled`, + }, + { + label: t`Retained package versions`, + value: item?.retain_package_versions || t`All`, + }, + ]} + /> + ); +}; diff --git a/src/containers/rpm-repository/tab-distributions.tsx b/src/containers/rpm-repository/tab-distributions.tsx new file mode 100644 index 00000000..4839118e --- /dev/null +++ b/src/containers/rpm-repository/tab-distributions.tsx @@ -0,0 +1,127 @@ +import { t } from '@lingui/core/macro'; +import { Td, Tr } from '@patternfly/react-table'; +import { RPMDistributionAPI, type RPMRepositoryType } from 'src/api'; +import { ClipboardCopy, DateComponent, DetailList } from 'src/components'; +import { getRepoURL } from 'src/utilities'; + +interface TabProps { + item: RPMRepositoryType; + actionContext: { + addAlert: (alert) => void; + state: { params }; + hasPermission; + }; +} + +interface Distribution { + base_path: string; + client_url: string; + content_guard: string; + name: string; + pulp_created: string; + pulp_href: string; + pulp_labels: Record; + repository: string; + repository_version: string; +} + +export const DistributionsTab = ({ + item, + actionContext: { addAlert, hasPermission }, +}: TabProps) => { + const query = ({ params } = { params: null }) => { + const newParams = { ...params }; + newParams.ordering = newParams.sort; + delete newParams.sort; + + return RPMDistributionAPI.list({ + repository: item.pulp_href, + ...newParams, + }); + }; + + // An RPM remote only needs a URL to consume this distribution. + const cliConfig = (base_path) => + `pulp rpm remote create --name "${item.name}" --url "${getRepoURL(base_path)}"`; + + const renderTableRow = ( + item: Distribution, + index: number, + _actionContext, + ) => { + const { name, base_path, pulp_created } = item; + + return ( + + {name} + {base_path} + + + + + + {cliConfig(base_path)} + + + + ); + }; + + return ( + + actionContext={{ + addAlert, + query, + hasPermission, + // No hasObjectPermission: an apt repository carries no my_permissions to + // check, and nothing in this list is gated on one. Whoever adds an action + // that is wants a real check here, not a stub that always agrees. + }} + defaultPageSize={10} + defaultSort={'name'} + errorTitle={t`Distributions could not be displayed.`} + filterConfig={[ + { + id: 'name__icontains', + title: t`Name`, + }, + { + id: 'base_path__icontains', + title: t`Base path`, + }, + ]} + noDataDescription={t`You can edit this repository to create a distribution.`} + noDataTitle={t`No distributions created`} + query={query} + renderTableRow={renderTableRow} + sortHeaders={[ + { + title: t`Name`, + type: 'alpha', + id: 'name', + }, + { + title: t`Base path`, + type: 'alpha', + id: 'base_path', + }, + { + title: t`Created`, + type: 'alpha', + id: 'pulp_created', + }, + { + title: t`CLI configuration`, + type: 'none', + id: '', + }, + ]} + title={t`Distributions`} + /> + ); +}; diff --git a/src/containers/rpm-repository/tab-repository-versions.tsx b/src/containers/rpm-repository/tab-repository-versions.tsx new file mode 100644 index 00000000..b8cf410b --- /dev/null +++ b/src/containers/rpm-repository/tab-repository-versions.tsx @@ -0,0 +1,241 @@ +import { t } from '@lingui/core/macro'; +import { Table, Td, Th, Tr } from '@patternfly/react-table'; +import { useEffect, useState } from 'react'; +import { Link } from 'react-router'; +import { RPMRepositoryAPI } from 'src/api'; +import { + DateComponent, + DetailList, + Details, + ListItemActions, + Spinner, +} from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL } from 'src/utilities'; + +interface TabProps { + item; + actionContext: { + addAlert: (alert) => void; + state: { params }; + hasPermission: (string) => boolean; + hasObjectPermission: (string) => boolean; + }; +} + +type ContentSummary = Record< + string, + { + count: number; + href: string; + } +>; + +interface RPMRepositoryVersionType { + pulp_href: string; + pulp_created: string; + number: number; + repository: string; + base_version: null; + content_summary: { + added: ContentSummary; + removed: ContentSummary; + present: ContentSummary; + }; +} + +const ContentSummary = ({ data }: { data: object }) => { + if (!Object.keys(data).length) { + return <>{t`None`}; + } + + return ( + + + + + + {Object.entries(data).map(([k, v]) => ( + + + + + ))} +
{t`Count`}{t`Pulp type`}
{v['count']}{k}
+ ); +}; + +const BaseVersion = ({ + repositoryName, + data, +}: { + repositoryName: string; + data?: string; +}) => { + if (!data) { + return <>{t`None`}; + } + + const number = data.split('/').at(-2); + return ( + + {number} + + ); +}; + +export const RepositoryVersionsTab = ({ + item, + actionContext: { addAlert, state, hasPermission, hasObjectPermission }, +}: TabProps) => { + const pulpId = parsePulpIDFromURL(item.pulp_href); + const latest_href = item.latest_version_href; + const repositoryName = item.name; + const queryList = ({ params }) => + RPMRepositoryAPI.listVersions(pulpId, params); + const queryDetail = ({ number }) => + RPMRepositoryAPI.listVersions(pulpId, { number }); + const [modalState, setModalState] = useState({}); + const [version, setVersion] = useState(null); + + useEffect(() => { + if (state.params.repositoryVersion) { + queryDetail({ number: state.params.repositoryVersion }).then( + ({ data }) => { + if (!data?.results?.[0]) { + addAlert({ + variant: 'danger', + title: t`Failed to find repository version`, + }); + } + setVersion(data.results[0]); + }, + ); + } else { + setVersion(null); + } + }, [state.params.repositoryVersion]); + + const renderTableRow = ( + item: RPMRepositoryVersionType, + index: number, + actionContext, + listItemActions, + ) => { + const { number, pulp_created, pulp_href } = item; + + const isLatest = latest_href === pulp_href; + + const kebabItems = listItemActions.map((action) => + action.dropdownItem({ ...item, isLatest, repositoryName }, actionContext), + ); + + return ( + + + + {number} + + {isLatest ? ' ' + t`(latest)` : null} + + + + + + + ); + }; + + return state.params.repositoryVersion ? ( + version ? ( +
, + }, + { + label: t`Content added`, + value: , + }, + { + label: t`Content removed`, + value: , + }, + { + label: t`Current content`, + value: , + }, + { + label: t`Base version`, + value: ( + + ), + }, + ]} + /> + ) : ( + + ) + ) : ( + + actionContext={{ + addAlert, + state: modalState, + setState: setModalState, + query: queryList, + hasPermission, + hasObjectPermission, // needs item=repository, not repository version + }} + defaultPageSize={10} + defaultSort={'-pulp_created'} + errorTitle={t`Repository versions could not be displayed.`} + filterConfig={null} + listItemActions={[]} + noDataButton={null} + noDataDescription={t`Repository versions will appear once the repository is modified.`} + noDataTitle={t`No repository versions yet`} + query={queryList} + renderTableRow={renderTableRow} + sortHeaders={[ + { + title: t`Version number`, + type: 'numeric', + id: 'number', + }, + { + title: t`Created date`, + type: 'numeric', + id: 'pulp_created', + }, + ]} + title={t`Repository versions`} + /> + ); +}; diff --git a/src/menu.tsx b/src/menu.tsx index 61adaa01..8b4fa2b6 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -116,6 +116,9 @@ function standaloneMenu() { }), ]), menuSection('Pulp RPM', { condition: and(loggedIn, hasPlugin('rpm')) }, [ + menuItem(t`Repositories`, { + url: formatPath(Paths.rpm.repository.list), + }), menuItem(t`RPMs`, { url: formatPath(Paths.rpm.package.list), }), diff --git a/src/paths.ts b/src/paths.ts index 89d0a3f0..fc44646f 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -152,6 +152,11 @@ export const Paths = { search: '/search', }, rpm: { + repository: { + detail: '/rpm/repositories/detail/:name', + edit: '/rpm/repositories/edit/:name', + list: '/rpm/repositories', + }, package: { list: '/rpm/rpms' }, }, }; From 0fde8c56fbfd3f05e8be106a07e02c7617303724 Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sat, 3 Oct 2026 08:24:04 -0400 Subject: [PATCH 03/16] Add Packages tab to RPM repository detail (cherry picked from commit 6a4de802c92ea6a0f992630acea2945aeb4f4ad4) --- src/containers/rpm-repository/detail.tsx | 11 +++ .../rpm-repository/tab-packages.tsx | 82 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/containers/rpm-repository/tab-packages.tsx diff --git a/src/containers/rpm-repository/detail.tsx b/src/containers/rpm-repository/detail.tsx index 3a4a6e03..a3af23e9 100644 --- a/src/containers/rpm-repository/detail.tsx +++ b/src/containers/rpm-repository/detail.tsx @@ -20,6 +20,7 @@ import { import { DetailsTab } from './tab-details'; import { DistributionsTab } from './tab-distributions'; import { RepositoryVersionsTab } from './tab-repository-versions'; +import { PackagesTab } from './tab-packages'; const RPMRepositoryDetail = PageWithTabs< RPMRepositoryType & { remote?: RPMRemoteType } @@ -106,6 +107,7 @@ const RPMRepositoryDetail = PageWithTabs< distributions: ( ), + packages: , })[tab], tabs: (tab, name) => [ { @@ -135,6 +137,15 @@ const RPMRepositoryDetail = PageWithTabs< { tab: 'distributions' }, ), }, + { + active: tab === 'packages', + title: t`Packages`, + link: formatPath( + Paths.rpm.repository.detail, + { name }, + { tab: 'packages' }, + ), + }, ], }); diff --git a/src/containers/rpm-repository/tab-packages.tsx b/src/containers/rpm-repository/tab-packages.tsx new file mode 100644 index 00000000..a9d757d5 --- /dev/null +++ b/src/containers/rpm-repository/tab-packages.tsx @@ -0,0 +1,82 @@ +import { t } from '@lingui/core/macro'; +import { Td, Tr } from '@patternfly/react-table'; +import { RPMPackageAPI, type RPMRepositoryType } from 'src/api'; +import { DetailList } from 'src/components'; + +interface TabProps { + item: RPMRepositoryType; + actionContext: { + addAlert: (alert) => void; + state: { params }; + hasPermission; + }; +} + +interface RPMPackage { + name: string; + epoch: string; + version: string; + release: string; + arch: string; + summary: string; + pulp_href: string; +} + +// epoch:version-release, with epoch omitted when 0, as rpm/dnf display it +const evr = ({ epoch, version, release }: RPMPackage) => + `${epoch && epoch !== '0' ? `${epoch}:` : ''}${version}-${release}`; + +export const PackagesTab = ({ + item, + actionContext: { addAlert, hasPermission }, +}: TabProps) => { + const query = ({ params } = { params: null }) => { + // without repository_version, Pulp would list every package on the server + if (!item.latest_version_href) { + return Promise.resolve({ data: { count: 0, results: [] } }); + } + + const newParams = { ...params }; + newParams.ordering = newParams.sort; + delete newParams.sort; + + return RPMPackageAPI.list({ + repository_version: item.latest_version_href, + fields: 'name,epoch,version,release,arch,summary,pulp_href', + ...newParams, + }); + }; + + const renderTableRow = (pkg: RPMPackage, index: number, _actionContext) => ( + + {pkg.name} + {evr(pkg)} + {pkg.arch} + {pkg.summary} + + ); + + return ( + + actionContext={{ addAlert, query, hasPermission }} + defaultPageSize={20} + defaultSort={'name'} + errorTitle={t`Packages could not be displayed.`} + filterConfig={[ + { id: 'name__contains', title: t`Package name` }, + { id: 'arch', title: t`Arch` }, + ]} + noDataDescription={t`Sync this repository or add packages to it.`} + noDataTitle={t`No packages in the latest version`} + query={query} + renderTableRow={renderTableRow} + sortHeaders={[ + { title: t`Name`, type: 'alpha', id: 'name' }, + { title: t`Version`, type: 'none', id: 'version' }, + { title: t`Arch`, type: 'none', id: 'arch' }, + { title: t`Summary`, type: 'none', id: 'summary' }, + ]} + title={t`Packages`} + /> + ); +}; \ No newline at end of file From cbb86fe9d51438e213ff5f48994b5ede6d5507c3 Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sat, 3 Oct 2026 08:30:23 -0400 Subject: [PATCH 04/16] Add RPM repository delete action with distribution cleanup (cherry picked from commit 36bfea9d12846d99850e8f6df9e475ecf659f7e9) --- src/actions/index.ts | 3 +- src/actions/rpm-repository-delete.tsx | 123 +++++++++++++++++++++++++ src/containers/rpm-repository/list.tsx | 4 +- 3 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 src/actions/rpm-repository-delete.tsx diff --git a/src/actions/index.ts b/src/actions/index.ts index 50973ce2..9f48ddab 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -28,4 +28,5 @@ export { fileRepositoryDeleteAction } from './file-repository-delete'; export { fileRepositoryEditAction } from './file-repository-edit'; export { fileRepositorySyncAction } from './file-repository-sync'; export { rpmRepositoryCreateAction } from './rpm-repository-create'; -export { rpmRepositoryEditAction } from './rpm-repository-edit'; \ No newline at end of file +export { rpmRepositoryEditAction } from './rpm-repository-edit'; +export { rpmRepositoryDeleteAction } from './rpm-repository-delete'; \ No newline at end of file diff --git a/src/actions/rpm-repository-delete.tsx b/src/actions/rpm-repository-delete.tsx new file mode 100644 index 00000000..85308f7a --- /dev/null +++ b/src/actions/rpm-repository-delete.tsx @@ -0,0 +1,123 @@ +import { msg, t } from '@lingui/core/macro'; +import { RPMDistributionAPI, RPMRepositoryAPI } from 'src/api'; +import { DeleteRepositoryModal } from 'src/components'; +import { + handleHttpError, + parsePulpIDFromURL, + taskAlert, + waitForTaskUrl, +} from 'src/utilities'; +import { Action } from './action'; + +export const rpmRepositoryDeleteAction = Action({ + title: msg`Delete`, + modal: ({ addAlert, listQuery, setState, state }) => + state.deleteModalOpen ? ( + setState({ deleteModalOpen: null })} + deleteAction={() => + deleteRepository(state.deleteModalOpen, { + addAlert, + listQuery, + setState, + }) + } + name={state.deleteModalOpen.name} + /> + ) : null, + onClick: ( + { name, id, pulp_href }: { name: string; id?: string; pulp_href?: string }, + { setState }, + ) => + setState({ + deleteModalOpen: { + pulpId: id || parsePulpIDFromURL(pulp_href), + name, + pulp_href, + }, + }), +}); + +const DISTRIBUTION_PAGE_SIZE = 100; + +// A repository can be serving more distributions than a single page holds, and +// any the lookup misses are left pointing at a repository that no longer exists. +async function listDistributions(repository) { + const distributions = []; + let page = 1; + let count = Infinity; + + while (distributions.length < count) { + const { data } = await RPMDistributionAPI.list({ + repository, + page, + page_size: DISTRIBUTION_PAGE_SIZE, + }); + + // Also stops the loop should count ever disagree with what the pages return. + if (!data.results?.length) { + break; + } + + distributions.push(...data.results); + count = data.count; + page++; + } + + return distributions; +} + +async function deleteRepository( + { name, pulp_href, pulpId }, + { addAlert, setState, listQuery }, +) { + const distributionsToDelete = await listDistributions(pulp_href).catch( + (e) => { + handleHttpError( + t`Failed to list distributions, removing only the repository.`, + () => null, + addAlert, + )(e); + return []; + }, + ); + + const deleteRepo = RPMRepositoryAPI.delete(pulpId) + .then(({ data }) => { + addAlert(taskAlert(data.task, t`Removal started for repository ${name}`)); + return waitForTaskUrl(data.task); + }) + .catch( + handleHttpError( + t`Failed to remove repository ${name}`, + () => setState({ deleteModalOpen: null }), + addAlert, + ), + ); + + const deleteDistribution = ({ name, pulp_href }) => { + const distribution_id = parsePulpIDFromURL(pulp_href); + return RPMDistributionAPI.delete(distribution_id) + .then(({ data }) => { + addAlert( + taskAlert(data.task, t`Removal started for distribution ${name}`), + ); + return waitForTaskUrl(data.task); + }) + .catch( + handleHttpError( + t`Failed to remove distribution ${name}`, + () => null, + addAlert, + ), + ); + }; + + return Promise.all([ + deleteRepo, + ...distributionsToDelete.map(deleteDistribution), + ]).then(() => { + setState({ deleteModalOpen: null }); + listQuery(); + }); +} diff --git a/src/containers/rpm-repository/list.tsx b/src/containers/rpm-repository/list.tsx index 0e9f3231..cff4d2e6 100644 --- a/src/containers/rpm-repository/list.tsx +++ b/src/containers/rpm-repository/list.tsx @@ -1,13 +1,13 @@ import { msg, t } from '@lingui/core/macro'; import { Td, Tr } from '@patternfly/react-table'; import { RPMRemoteAPI, RPMRepositoryAPI, type RPMRepositoryType } from 'src/api'; -import { rpmRepositoryCreateAction, rpmRepositoryEditAction } from 'src/actions'; +import { rpmRepositoryCreateAction, rpmRepositoryEditAction, rpmRepositoryDeleteAction } from 'src/actions'; import { DateComponent, ListItemActions, ListPage, PulpLabels } from 'src/components'; import { parsePulpIDFromURL } from 'src/utilities'; import { Link } from 'react-router'; import { Paths, formatPath } from 'src/paths'; -const listItemActions = [rpmRepositoryEditAction]; +const listItemActions = [rpmRepositoryEditAction, rpmRepositoryDeleteAction]; const typeaheadQuery = ({ inputText, selectedFilter, setState }) => { if (selectedFilter !== 'remote') { return; From 9fc194777227d8776c1ee9cc7d15556b6c2f347d Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sat, 3 Oct 2026 08:32:38 -0400 Subject: [PATCH 05/16] Default new RPM repositories to autopublish (cherry picked from commit b031f503b473096abc96783539fe27e65d241254) --- src/containers/rpm-repository/edit.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/containers/rpm-repository/edit.tsx b/src/containers/rpm-repository/edit.tsx index ffcbff80..cd629917 100644 --- a/src/containers/rpm-repository/edit.tsx +++ b/src/containers/rpm-repository/edit.tsx @@ -14,6 +14,7 @@ const initialRepository: RPMRepositoryType = { retain_repo_versions: 1, pulp_labels: {}, remote: null, + autopublish: true, }; const RPMRepositoryEdit = Page({ From 30d63f5e1d4c62b0e7b0617b8df354dc949e1d81 Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sat, 3 Oct 2026 08:46:39 -0400 Subject: [PATCH 06/16] Add autopublish and retained package versions to RPM repository form; hide Ansible-only private option (cherry picked from commit 8dbc84c201c118c1eccf861f00b0ae8ce5207f59) --- src/components/repository-form.tsx | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/components/repository-form.tsx b/src/components/repository-form.tsx index 12dae8b1..e5e6858d 100644 --- a/src/components/repository-form.tsx +++ b/src/components/repository-form.tsx @@ -199,6 +199,28 @@ export const RepositoryForm = ({ t`Retained number of versions`, t`In order to retain all versions, leave this field blank.`, )} + {plugin === 'rpm' && ( + <> + {numericField( + 'retain_package_versions', + t`Versions kept per package`, + t`How many versions of each package to keep; older ones are dropped on sync. Set to 0 to keep all versions.`, + )} + {formGroup( + 'autopublish', + t`Autopublish`, + t`Create a publication for every new repository version, so distributions pointing at this repository always serve the latest content.`, + + updateRepository({ ...repository, autopublish: value }) + } + label={t`Publish new versions automatically`} + />, + )} + + )} {formGroup( 'distributions', @@ -269,8 +291,12 @@ export const RepositoryForm = ({ , )} - - {formGroup( + + {plugin !== 'rpm' && formGroup( + // putting the plugin check here because private repositories + // aren't an RPM concept. It's worth noting that (to my knowledge) + // Ansible Galaxy is the only thing that would need the private flag + // NP - 2026 'private', t`Make private`, t`Make the repository private.`, From 5533180040649d08ff5d34a838949ff8b5efe338 Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sat, 3 Oct 2026 09:09:17 -0400 Subject: [PATCH 07/16] Add RPM repository sync with sync_policy selection (cherry picked from commit 7e5eb65b44245a4cf0fdab6bb2c258c8a2c3f79a) --- src/actions/index.ts | 3 +- src/actions/rpm-repository-sync.tsx | 72 ++++++++++++++++++++++++ src/components/sync-modal.tsx | 68 +++++++++++++++------- src/containers/rpm-repository/detail.tsx | 4 ++ src/containers/rpm-repository/list.tsx | 13 ++++- 5 files changed, 138 insertions(+), 22 deletions(-) create mode 100644 src/actions/rpm-repository-sync.tsx diff --git a/src/actions/index.ts b/src/actions/index.ts index 9f48ddab..56c39f4e 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -29,4 +29,5 @@ export { fileRepositoryEditAction } from './file-repository-edit'; export { fileRepositorySyncAction } from './file-repository-sync'; export { rpmRepositoryCreateAction } from './rpm-repository-create'; export { rpmRepositoryEditAction } from './rpm-repository-edit'; -export { rpmRepositoryDeleteAction } from './rpm-repository-delete'; \ No newline at end of file +export { rpmRepositoryDeleteAction } from './rpm-repository-delete'; +export { rpmRepositorySyncAction } from './rpm-repository-sync'; diff --git a/src/actions/rpm-repository-sync.tsx b/src/actions/rpm-repository-sync.tsx new file mode 100644 index 00000000..287df558 --- /dev/null +++ b/src/actions/rpm-repository-sync.tsx @@ -0,0 +1,72 @@ +import { msg, t } from '@lingui/core/macro'; +import { RPMRepositoryAPI } from 'src/api'; +import { SyncModal } from 'src/components'; +import { handleHttpError, parsePulpIDFromURL, taskAlert } from 'src/utilities'; +import { Action } from './action'; + + // pulp_rpm's own API default, which never removes content. Mirroring is + // opted into, never defaulted to. + const DEFAULT_SYNC_POLICY = 'additive'; + + const syncPolicies = () => [ + { id: 'additive', label: t`Additive: only add new content` }, + { id: 'mirror_content_only', label: t`Mirror content only` }, + { id: 'mirror_complete', label: t`Mirror complete (exact copy incl. metadata)` }, + ]; + +export const rpmRepositorySyncAction = Action({ + title: msg`Sync`, + modal: ({ addAlert, query, setState, state }) => + state.syncModalOpen ? ( + setState({ syncModalOpen: null })} + defaultSyncPolicy={DEFAULT_SYNC_POLICY} + syncPolicies={syncPolicies()} + syncAction={(syncParams) => + syncRepository(state.syncModalOpen, { addAlert, query }, syncParams) + } + name={state.syncModalOpen.name} + /> + ) : null, + onClick: ({ name, pulp_href }, { setState }) => + setState({ + syncModalOpen: { name, pulp_href }, + }), + visible: (_item, { hasPermission }) => + hasPermission('rpm.change_rpmrepository'), + disabled: ({ remote, last_sync_task }) => { + if (!remote) { + return t`There are no remotes associated with this repository.`; + } + + if ( + last_sync_task && + ['running', 'waiting'].includes(last_sync_task.state) + ) { + return t`Sync task is already queued.`; + } + }, +}); + +function syncRepository({ name, pulp_href }, { addAlert, query }, syncParams) { + const pulpId = parsePulpIDFromURL(pulp_href); + return RPMRepositoryAPI.sync( + pulpId, + { + sync_policy: syncParams?.sync_policy || DEFAULT_SYNC_POLICY, + optimize: syncParams?.optimize ?? true, + }, + ) + .then(({ data }) => { + addAlert(taskAlert(data.task, t`Sync started for repository "${name}".`)); + + query(); + }) + .catch( + handleHttpError( + t`Failed to sync repository "${name}"`, + () => null, + addAlert, + ), + ); +} diff --git a/src/components/sync-modal.tsx b/src/components/sync-modal.tsx index bd999e47..a3509cbb 100644 --- a/src/components/sync-modal.tsx +++ b/src/components/sync-modal.tsx @@ -1,5 +1,5 @@ import { t } from '@lingui/core/macro'; -import { Button, FormGroup, Modal, Switch } from '@patternfly/react-core'; +import { Button, FormGroup, FormSelect, FormSelectOption, Modal, Switch } from '@patternfly/react-core'; import { useEffect, useState } from 'react'; import { HelpButton, Spinner } from 'src/components'; @@ -10,11 +10,16 @@ interface IProps { defaultMirror?: boolean; syncAction: (syncParams) => Promise; name: string; + // When given, a sync policy dropdown replaces the mirror switch (pulp_rpm). + syncPolicies?: { id: string; label: string }[]; + defaultSyncPolicy?: string; } export const SyncModal = ({ closeAction, defaultMirror = true, + defaultSyncPolicy, + syncPolicies, syncAction, name, }: IProps) => { @@ -22,12 +27,13 @@ export const SyncModal = ({ const [syncParams, setSyncParams] = useState({ mirror: defaultMirror, optimize: true, + sync_policy: defaultSyncPolicy, }); useEffect(() => { setPending(false); - setSyncParams({ mirror: defaultMirror, optimize: true }); - }, [name, defaultMirror]); + setSyncParams({ mirror: defaultMirror, optimize: true, sync_policy: defaultSyncPolicy }); }, + [name, defaultMirror, defaultSyncPolicy]); if (!name) { return null; @@ -61,23 +67,47 @@ export const SyncModal = ({ title={t`Sync repository "${name}"`} variant='medium' > - - } - > - - setSyncParams({ ...syncParams, mirror }) + {syncPolicies ? ( + } - label={t`Content not present in remote repository will be removed from the local repository`} - labelOff={t`Sync will only add missing content`} - /> - + > + + setSyncParams({ ...syncParams, sync_policy }) + } + > + {syncPolicies.map(({ id, label }) => ( + + ))} + + + ) : ( + + } + > + + setSyncParams({ ...syncParams, mirror }) + } + label={t`Content not present in remote repository will be removed from the local repository`} + labelOff={t`Sync will only add missing content`} + /> + + )}
( <> diff --git a/src/containers/rpm-repository/list.tsx b/src/containers/rpm-repository/list.tsx index cff4d2e6..2aadcebe 100644 --- a/src/containers/rpm-repository/list.tsx +++ b/src/containers/rpm-repository/list.tsx @@ -1,13 +1,22 @@ import { msg, t } from '@lingui/core/macro'; import { Td, Tr } from '@patternfly/react-table'; import { RPMRemoteAPI, RPMRepositoryAPI, type RPMRepositoryType } from 'src/api'; -import { rpmRepositoryCreateAction, rpmRepositoryEditAction, rpmRepositoryDeleteAction } from 'src/actions'; +import { + rpmRepositoryCreateAction, + rpmRepositoryDeleteAction, + rpmRepositoryEditAction, + rpmRepositorySyncAction, +} from 'src/actions'; import { DateComponent, ListItemActions, ListPage, PulpLabels } from 'src/components'; import { parsePulpIDFromURL } from 'src/utilities'; import { Link } from 'react-router'; import { Paths, formatPath } from 'src/paths'; -const listItemActions = [rpmRepositoryEditAction, rpmRepositoryDeleteAction]; +const listItemActions = [ + rpmRepositoryEditAction, + rpmRepositorySyncAction, + rpmRepositoryDeleteAction, +]; const typeaheadQuery = ({ inputText, selectedFilter, setState }) => { if (selectedFilter !== 'remote') { return; From ad1aa3f40461de9fd265940b8aa15e91b78ac8eb Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sun, 4 Oct 2026 09:49:57 -0400 Subject: [PATCH 08/16] Add RPM remote pages and download policy selection (cherry picked from commit db2fc62c4d637ce6727e4210c707d3f5293a6d4d) --- src/actions/rpm-remote-create.tsx | 9 ++ src/actions/rpm-remote-delete.tsx | 44 +++++++ src/actions/rpm-remote-edit.tsx | 9 ++ src/components/remote-form.tsx | 37 +++++- src/containers/rpm-remote/detail.tsx | 44 +++++++ src/containers/rpm-remote/edit.tsx | 149 ++++++++++++++++++++++ src/containers/rpm-remote/list.tsx | 79 ++++++++++++ src/containers/rpm-remote/tab-details.tsx | 61 +++++++++ 8 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 src/actions/rpm-remote-create.tsx create mode 100644 src/actions/rpm-remote-delete.tsx create mode 100644 src/actions/rpm-remote-edit.tsx create mode 100644 src/containers/rpm-remote/detail.tsx create mode 100644 src/containers/rpm-remote/edit.tsx create mode 100644 src/containers/rpm-remote/list.tsx create mode 100644 src/containers/rpm-remote/tab-details.tsx diff --git a/src/actions/rpm-remote-create.tsx b/src/actions/rpm-remote-create.tsx new file mode 100644 index 00000000..7344dd41 --- /dev/null +++ b/src/actions/rpm-remote-create.tsx @@ -0,0 +1,9 @@ +import { msg } from '@lingui/core/macro'; +import { Paths, formatPath } from 'src/paths'; +import { Action } from './action'; + +export const rpmRemoteCreateAction = Action({ + title: msg`Add remote`, + onClick: (item, { navigate }) => + navigate(formatPath(Paths.rpm.remote.edit, { name: '_' })), +}); diff --git a/src/actions/rpm-remote-delete.tsx b/src/actions/rpm-remote-delete.tsx new file mode 100644 index 00000000..1822872b --- /dev/null +++ b/src/actions/rpm-remote-delete.tsx @@ -0,0 +1,44 @@ +import { msg, t } from '@lingui/core/macro'; +import { RPMRemoteAPI } from 'src/api'; +import { DeleteRemoteModal } from 'src/components'; +import { + handleHttpError, + parsePulpIDFromURL, + taskAlert, + waitForTaskUrl, +} from 'src/utilities'; +import { Action } from './action'; + +export const rpmRemoteDeleteAction = Action({ + title: msg`Delete`, + modal: ({ addAlert, listQuery, setState, state }) => + state.deleteModalOpen ? ( + setState({ deleteModalOpen: null })} + deleteAction={() => + deleteRemote(state.deleteModalOpen, { addAlert, setState, listQuery }) + } + name={state.deleteModalOpen.name} + /> + ) : null, + onClick: ( + { name, id, pulp_href }: { name: string; id?: string; pulp_href?: string }, + { setState }, + ) => + setState({ + deleteModalOpen: { pulpId: id || parsePulpIDFromURL(pulp_href), name }, + }), +}); + +function deleteRemote({ name, pulpId }, { addAlert, setState, listQuery }) { + return RPMRemoteAPI.delete(pulpId) + .then(({ data }) => { + addAlert(taskAlert(data.task, t`Removal started for remote ${name}`)); + setState({ deleteModalOpen: null }); + return waitForTaskUrl(data.task); + }) + .then(() => listQuery()) + .catch( + handleHttpError(t`Failed to remove remote ${name}`, () => null, addAlert), + ); +} diff --git a/src/actions/rpm-remote-edit.tsx b/src/actions/rpm-remote-edit.tsx new file mode 100644 index 00000000..e2518287 --- /dev/null +++ b/src/actions/rpm-remote-edit.tsx @@ -0,0 +1,9 @@ +import { msg } from '@lingui/core/macro'; +import { Paths, formatPath } from 'src/paths'; +import { Action } from './action'; + +export const rpmRemoteEditAction = Action({ + title: msg`Edit`, + onClick: ({ name }, { navigate }) => + navigate(formatPath(Paths.rpm.remote.edit, { name })), +}); diff --git a/src/components/remote-form.tsx b/src/components/remote-form.tsx index 45a37736..9cde8cac 100644 --- a/src/components/remote-form.tsx +++ b/src/components/remote-form.tsx @@ -9,6 +9,8 @@ import { FlexItem, Form, FormGroup, + FormSelect, + FormSelectOption, InputGroup, InputGroupItem, Modal, @@ -33,11 +35,15 @@ import { validateURLHelper, } from 'src/utilities'; +// Plugins whose remotes offer a download policy in this form. pulp_file and +// pulp_deb accept the same three policies; add them here once tested. +const PLUGINS_WITH_POLICY = ['rpm']; + interface IProps { allowEditName?: boolean; closeModal: () => void; errorMessages: ErrorMessagesType; - plugin: 'ansible' | 'container' | 'deb' | 'file'; + plugin: 'ansible' | 'container' | 'deb' | 'file' | 'rpm'; remote: RemoteType; saveRemote: () => void; showMain?: boolean; @@ -191,6 +197,7 @@ export class RemoteForm extends Component { case 'container': case 'deb': case 'file': + case 'rpm': disabledFields = disabledFields.concat([ 'auth_url', 'token', @@ -337,7 +344,33 @@ export class RemoteForm extends Component { {...validateURLHelper(errorMessages['url'], remote.url)} /> - + {PLUGINS_WITH_POLICY.includes(plugin) && ( + + } + isRequired={requiredFields.includes('policy')} + > + this.updateRemote(value, 'policy')} + > + + + + + + {errorMessages['policy']} + + + )} {plugin === 'deb' ? ( <> ({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.rpm.remote.list), name: t`Remotes` }, + { url: formatPath(Paths.rpm.remote.detail, { name }), name }, + ].filter(Boolean), + displayName: 'RPMRemoteDetail', + errorTitle: msg`Remote could not be displayed.`, + headerActions: [rpmRemoteEditAction, rpmRemoteDeleteAction], + listUrl: formatPath(Paths.rpm.remote.list), + query: ({ name }) => + RPMRemoteAPI.list({ name, page_size: 1 }).then(({ data }) => { + const remote = data?.results?.[0]; + + // There is no detail endpoint keyed by name, so a name matching nothing + // answers 200 with an empty list. Turn that into the 404 the page already + // knows how to render, instead of resolving with undefined. + if (!remote) { + return Promise.reject({ response: { status: 404 } }); + } + + return remote; + }), + renderTab: (tab, item, actionContext) => + ({ + details: , + })[tab], + tabs: (tab, name) => [ + { + active: tab === 'details', + title: t`Details`, + link: formatPath(Paths.rpm.remote.detail, { name }, { tab: 'details' }), + }, + ], +}); + +export default RPMRemoteDetail; diff --git a/src/containers/rpm-remote/edit.tsx b/src/containers/rpm-remote/edit.tsx new file mode 100644 index 00000000..38886f82 --- /dev/null +++ b/src/containers/rpm-remote/edit.tsx @@ -0,0 +1,149 @@ +import { msg, t } from '@lingui/core/macro'; +import { RPMRemoteAPI, type RPMRemoteType } from 'src/api'; +import { Page, RemoteForm } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL, taskAlert } from 'src/utilities'; + +const initialRemote: RPMRemoteType = { + name: '', + url: '', + ca_cert: null, + client_cert: null, + tls_validation: true, + proxy_url: null, + download_concurrency: null, + rate_limit: null, + policy: 'immediate', + hidden_fields: [ + 'client_key', + 'proxy_username', + 'proxy_password', + 'username', + 'password', + ].map((name) => ({ name, is_set: false })), +}; + +const RPMRemoteEdit = Page({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.rpm.remote.list), name: t`Remotes` }, + name && { url: formatPath(Paths.rpm.remote.detail, { name }), name }, + name ? { name: t`Edit` } : { name: t`Add` }, + ].filter(Boolean), + + displayName: 'RPMRemoteEdit', + errorTitle: msg`Remote could not be displayed.`, + listUrl: formatPath(Paths.rpm.remote.list), + query: ({ name }) => + RPMRemoteAPI.list({ name }).then(({ data: { results } }) => results[0]), + title: ({ name }) => name || t`Add new remote`, + transformParams: ({ name, ...rest }) => ({ + ...rest, + name: name !== '_' ? name : null, + }), + + render: (item, { navigate, queueAlert, state, setState }) => { + if (!state.remoteToEdit) { + const remoteToEdit = { + ...initialRemote, + ...item, + }; + setState({ remoteToEdit, errorMessages: {} }); + } + + const { remoteToEdit, errorMessages } = state; + if (!remoteToEdit) { + return null; + } + + const saveRemote = () => { + const { remoteToEdit } = state; + + const data = { ...remoteToEdit }; + + if (!item) { + // prevent "This field may not be blank." when writing in and then deleting username/password/etc + // only when creating, edit diffs with item + Object.keys(data).forEach((k) => { + if (data[k] === '' || data[k] == null) { + delete data[k]; + } + }); + + delete data.hidden_fields; + } + + delete data.my_permissions; + + // api requires traling slash, fix the trivial case + if (data.url && !data.url.includes('?') && !data.url.endsWith('/')) { + data.url += '/'; + } + + const promise = !item + ? RPMRemoteAPI.create(data) + : RPMRemoteAPI.smartUpdate( + parsePulpIDFromURL(item.pulp_href), + data, + item, + ); + + promise + .then(({ data: task }) => { + setState({ + errorMessages: {}, + remoteToEdit: undefined, + }); + + queueAlert( + item + ? taskAlert(task, t`Update started for remote ${data.name}`) + : { + variant: 'success', + title: t`Successfully created remote ${data.name}`, + }, + ); + + navigate( + formatPath(Paths.rpm.remote.detail, { + name: data.name, + }), + ); + }) + .catch(({ response: { data } }) => + setState({ + errorMessages: { + __nofield: data.non_field_errors || data.detail, + ...data, + }, + }), + ); + }; + + const closeModal = () => { + setState({ errorMessages: {}, remoteToEdit: undefined }); + navigate( + item + ? formatPath(Paths.rpm.remote.detail, { + name: item.name, + }) + : formatPath(Paths.rpm.remote.list), + ); + }; + + return ( + setState({ remoteToEdit: r })} + /> + ); + }, +}); + +export default RPMRemoteEdit; diff --git a/src/containers/rpm-remote/list.tsx b/src/containers/rpm-remote/list.tsx new file mode 100644 index 00000000..eaae744b --- /dev/null +++ b/src/containers/rpm-remote/list.tsx @@ -0,0 +1,79 @@ +import { msg, t } from '@lingui/core/macro'; +import { Td, Tr } from '@patternfly/react-table'; +import { Link } from 'react-router'; +import { + rpmRemoteCreateAction, + rpmRemoteDeleteAction, + rpmRemoteEditAction, +} from 'src/actions'; +import { RPMRemoteAPI, type RPMRemoteType } from 'src/api'; +import { CopyURL, ListItemActions, ListPage } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL } from 'src/utilities'; + +const listItemActions = [ + // Edit + rpmRemoteEditAction, + // Delete + rpmRemoteDeleteAction, +]; + +const RPMRemoteList = ListPage({ + defaultPageSize: 10, + defaultSort: '-pulp_created', + displayName: 'RPMRemoteList', + errorTitle: msg`Remotes could not be displayed.`, + filterConfig: () => [ + { + id: 'name__icontains', + title: t`Remote name`, + }, + ], + headerActions: [rpmRemoteCreateAction], // Add remote + listItemActions, + noDataButton: rpmRemoteCreateAction.button, + noDataDescription: msg`Remotes will appear once created.`, + noDataTitle: msg`No remotes yet`, + query: ({ params }) => RPMRemoteAPI.list(params), + renderTableRow(item: RPMRemoteType, index: number, actionContext) { + const { name, policy, pulp_href, url } = item; + const id = parsePulpIDFromURL(pulp_href); + + const kebabItems = listItemActions.map((action) => + action.dropdownItem({ ...item, id }, actionContext), + ); + + return ( + + + {name} + + + + + {policy || '---'} + + + ); + }, + sortHeaders: [ + { + title: msg`Remote name`, + type: 'alpha', + id: 'name', + }, + { + title: msg`URL`, + type: 'alpha', + id: 'url', + }, + { + title: msg`Download policy`, + type: 'none', + id: 'policy', + }, + ], + title: msg`Remotes`, +}); + +export default RPMRemoteList; diff --git a/src/containers/rpm-remote/tab-details.tsx b/src/containers/rpm-remote/tab-details.tsx new file mode 100644 index 00000000..fffa1f98 --- /dev/null +++ b/src/containers/rpm-remote/tab-details.tsx @@ -0,0 +1,61 @@ +import { t } from '@lingui/core/macro'; +import { type RPMRemoteType } from 'src/api'; +import { + CopyURL, + Details, + LazyRepositories, + PulpCodeBlock, +} from 'src/components'; + +interface TabProps { + item: RPMRemoteType; + actionContext: object; +} + +const MaybeCode = ({ code, filename }: { code: string; filename: string }) => + code ? : <>{t`None`}; + +export const DetailsTab = ({ item }: TabProps) => ( +
, + }, + { label: t`Download policy`, value: item?.policy || t`immediate` }, + { + label: t`Proxy URL`, + value: , + }, + { + label: t`TLS validation`, + value: item?.tls_validation ? t`Enabled` : t`Disabled`, + }, + { + label: t`Client certificate`, + value: ( + + ), + }, + { + label: t`CA certificate`, + value: ( + + ), + }, + { + label: t`Download concurrency`, + value: item?.download_concurrency ?? t`None`, + }, + { label: t`Rate limit`, value: item?.rate_limit ?? t`None` }, + { + label: t`Repositories`, + value: , + }, + ]} + /> +); From 5872f579e9eb04eb8837bd2cd33b573f6bf45d81 Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sun, 4 Oct 2026 09:50:05 -0400 Subject: [PATCH 09/16] Wire up RPM remotes; add RPM sync policy, repository form fields and review fixes (cherry picked from commit ed815ce1b1708e2d0edddb2183b18681a56da6c1) --- src/actions/index.ts | 3 ++ src/actions/rpm-repository-sync.tsx | 34 ++++++++--------- src/api/index.ts | 2 +- src/api/rpm-remote.ts | 2 +- src/app-routes.tsx | 25 ++++++++++--- src/components/repository-form.tsx | 37 ++++++++++--------- src/components/sync-modal.tsx | 17 +++++++-- src/containers/index.ts | 3 ++ src/containers/rpm-repository/detail.tsx | 6 +-- src/containers/rpm-repository/edit.tsx | 20 ++++++++-- src/containers/rpm-repository/list.tsx | 23 +++++++++--- src/containers/rpm-repository/tab-details.tsx | 12 +++++- .../rpm-repository/tab-distributions.tsx | 6 +-- .../rpm-repository/tab-packages.tsx | 2 +- src/menu.tsx | 5 ++- src/paths.ts | 5 +++ 16 files changed, 138 insertions(+), 64 deletions(-) diff --git a/src/actions/index.ts b/src/actions/index.ts index 56c39f4e..3c949376 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -27,6 +27,9 @@ export { fileRepositoryCreateAction } from './file-repository-create'; export { fileRepositoryDeleteAction } from './file-repository-delete'; export { fileRepositoryEditAction } from './file-repository-edit'; export { fileRepositorySyncAction } from './file-repository-sync'; +export { rpmRemoteCreateAction } from './rpm-remote-create'; +export { rpmRemoteDeleteAction } from './rpm-remote-delete'; +export { rpmRemoteEditAction } from './rpm-remote-edit'; export { rpmRepositoryCreateAction } from './rpm-repository-create'; export { rpmRepositoryEditAction } from './rpm-repository-edit'; export { rpmRepositoryDeleteAction } from './rpm-repository-delete'; diff --git a/src/actions/rpm-repository-sync.tsx b/src/actions/rpm-repository-sync.tsx index 287df558..818acce5 100644 --- a/src/actions/rpm-repository-sync.tsx +++ b/src/actions/rpm-repository-sync.tsx @@ -4,15 +4,18 @@ import { SyncModal } from 'src/components'; import { handleHttpError, parsePulpIDFromURL, taskAlert } from 'src/utilities'; import { Action } from './action'; - // pulp_rpm's own API default, which never removes content. Mirroring is - // opted into, never defaulted to. - const DEFAULT_SYNC_POLICY = 'additive'; +// pulp_rpm's own API default, which never removes content. Mirroring is +// opted into, never defaulted to. +const DEFAULT_SYNC_POLICY = 'additive'; - const syncPolicies = () => [ - { id: 'additive', label: t`Additive: only add new content` }, - { id: 'mirror_content_only', label: t`Mirror content only` }, - { id: 'mirror_complete', label: t`Mirror complete (exact copy incl. metadata)` }, - ]; +const syncPolicies = () => [ + { id: 'additive', label: t`Additive: only add new content` }, + { id: 'mirror_content_only', label: t`Mirror content only` }, + { + id: 'mirror_complete', + label: t`Mirror complete (exact copy incl. metadata)`, + }, +]; export const rpmRepositorySyncAction = Action({ title: msg`Sync`, @@ -21,8 +24,8 @@ export const rpmRepositorySyncAction = Action({ setState({ syncModalOpen: null })} defaultSyncPolicy={DEFAULT_SYNC_POLICY} - syncPolicies={syncPolicies()} - syncAction={(syncParams) => + syncPolicies={syncPolicies()} + syncAction={(syncParams) => syncRepository(state.syncModalOpen, { addAlert, query }, syncParams) } name={state.syncModalOpen.name} @@ -50,13 +53,10 @@ export const rpmRepositorySyncAction = Action({ function syncRepository({ name, pulp_href }, { addAlert, query }, syncParams) { const pulpId = parsePulpIDFromURL(pulp_href); - return RPMRepositoryAPI.sync( - pulpId, - { - sync_policy: syncParams?.sync_policy || DEFAULT_SYNC_POLICY, - optimize: syncParams?.optimize ?? true, - }, - ) + return RPMRepositoryAPI.sync(pulpId, { + sync_policy: syncParams?.sync_policy || DEFAULT_SYNC_POLICY, + optimize: syncParams?.optimize ?? true, + }) .then(({ data }) => { addAlert(taskAlert(data.task, t`Sync started for repository "${name}".`)); diff --git a/src/api/index.ts b/src/api/index.ts index a7bfe43a..9e7b2efd 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -80,7 +80,7 @@ export { RoleAPI } from './role'; export { RPMPackageAPI } from './rpm-package'; export { RPMDistributionAPI } from './rpm-distribution'; export { RPMRemoteAPI, type RPMRemoteType } from './rpm-remote'; -export { RPMRepositoryAPI, type RPMRepositoryType,} from './rpm-repository'; +export { RPMRepositoryAPI, type RPMRepositoryType } from './rpm-repository'; export { SignCollectionAPI } from './sign-collections'; export { SignContainersAPI } from './sign-containers'; export { SigningServiceAPI, type SigningServiceType } from './signing-service'; diff --git a/src/api/rpm-remote.ts b/src/api/rpm-remote.ts index 17b9b28f..317257e7 100644 --- a/src/api/rpm-remote.ts +++ b/src/api/rpm-remote.ts @@ -41,4 +41,4 @@ export const RPMRemoteAPI = { base.http.put(`remotes/rpm/rpm/${id}/`, smartUpdate(newValue, oldValue)), }; -export type { RPMRemoteType }; \ No newline at end of file +export type { RPMRemoteType }; diff --git a/src/app-routes.tsx b/src/app-routes.tsx index f2a74646..986a5cf7 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -56,9 +56,12 @@ import { Partners, PulpStatus, RPMPackageList, - RPMRepositoryList, - RPMRepositoryEdit, + RPMRemoteDetail, + RPMRemoteEdit, + RPMRemoteList, RPMRepositoryDetail, + RPMRepositoryEdit, + RPMRepositoryList, RoleCreate, RoleList, Search, @@ -360,9 +363,21 @@ const routes: IRouteConfig[] = [ component: RPMRepositoryEdit, path: Paths.rpm.repository.edit, }, - { - component: RPMRepositoryDetail, - path: Paths.rpm.repository.detail + { + component: RPMRepositoryDetail, + path: Paths.rpm.repository.detail, + }, + { + component: RPMRemoteDetail, + path: Paths.rpm.remote.detail, + }, + { + component: RPMRemoteEdit, + path: Paths.rpm.remote.edit, + }, + { + component: RPMRemoteList, + path: Paths.rpm.remote.list, }, ]; diff --git a/src/components/repository-form.tsx b/src/components/repository-form.tsx index e5e6858d..34a17352 100644 --- a/src/components/repository-form.tsx +++ b/src/components/repository-form.tsx @@ -291,24 +291,25 @@ export const RepositoryForm = ({ , )} - - {plugin !== 'rpm' && formGroup( - // putting the plugin check here because private repositories - // aren't an RPM concept. It's worth noting that (to my knowledge) - // Ansible Galaxy is the only thing that would need the private flag - // NP - 2026 - 'private', - t`Make private`, - t`Make the repository private.`, - - updateRepository({ ...repository, private: value }) - } - />, - )} + + {plugin !== 'rpm' && + formGroup( + // putting the plugin check here because private repositories + // aren't an RPM concept. It's worth noting that (to my knowledge) + // Ansible Galaxy is the only thing that would need the private flag + // NP - 2026 + 'private', + t`Make private`, + t`Make the repository private.`, + + updateRepository({ ...repository, private: value }) + } + />, + )} {formGroup( 'remote', diff --git a/src/components/sync-modal.tsx b/src/components/sync-modal.tsx index a3509cbb..180f9578 100644 --- a/src/components/sync-modal.tsx +++ b/src/components/sync-modal.tsx @@ -1,5 +1,12 @@ import { t } from '@lingui/core/macro'; -import { Button, FormGroup, FormSelect, FormSelectOption, Modal, Switch } from '@patternfly/react-core'; +import { + Button, + FormGroup, + FormSelect, + FormSelectOption, + Modal, + Switch, +} from '@patternfly/react-core'; import { useEffect, useState } from 'react'; import { HelpButton, Spinner } from 'src/components'; @@ -32,8 +39,12 @@ export const SyncModal = ({ useEffect(() => { setPending(false); - setSyncParams({ mirror: defaultMirror, optimize: true, sync_policy: defaultSyncPolicy }); }, - [name, defaultMirror, defaultSyncPolicy]); + setSyncParams({ + mirror: defaultMirror, + optimize: true, + sync_policy: defaultSyncPolicy, + }); + }, [name, defaultMirror, defaultSyncPolicy]); if (!name) { return null; diff --git a/src/containers/index.ts b/src/containers/index.ts index 62da2d3a..e29d45b6 100644 --- a/src/containers/index.ts +++ b/src/containers/index.ts @@ -44,6 +44,9 @@ export { default as RoleCreate } from './role-management/role-create'; export { default as EditRole } from './role-management/role-edit'; export { default as RoleList } from './role-management/role-list'; export { default as RPMPackageList } from './rpm/package-list'; +export { default as RPMRemoteDetail } from './rpm-remote/detail'; +export { default as RPMRemoteEdit } from './rpm-remote/edit'; +export { default as RPMRemoteList } from './rpm-remote/list'; export { default as RPMRepositoryList } from './rpm-repository/list'; export { default as RPMRepositoryEdit } from './rpm-repository/edit'; export { default as RPMRepositoryDetail } from './rpm-repository/detail'; diff --git a/src/containers/rpm-repository/detail.tsx b/src/containers/rpm-repository/detail.tsx index b9d194d0..c55f8fb1 100644 --- a/src/containers/rpm-repository/detail.tsx +++ b/src/containers/rpm-repository/detail.tsx @@ -1,8 +1,8 @@ import { msg, t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; import { - rpmRepositoryEditAction, rpmRepositoryDeleteAction, + rpmRepositoryEditAction, rpmRepositorySyncAction, } from 'src/actions'; import { @@ -21,8 +21,8 @@ import { } from 'src/utilities'; import { DetailsTab } from './tab-details'; import { DistributionsTab } from './tab-distributions'; -import { RepositoryVersionsTab } from './tab-repository-versions'; import { PackagesTab } from './tab-packages'; +import { RepositoryVersionsTab } from './tab-repository-versions'; const RPMRepositoryDetail = PageWithTabs< RPMRepositoryType & { remote?: RPMRemoteType } @@ -84,7 +84,7 @@ const RPMRepositoryDetail = PageWithTabs< }; return Promise.all([ - // the plugin-aware variant, so the deb distribution endpoint is the + // the plugin-aware variant, so the RPM distribution endpoint is the // one consulted pluginRepositoryBasePath( 'rpm', diff --git a/src/containers/rpm-repository/edit.tsx b/src/containers/rpm-repository/edit.tsx index cd629917..9a07f770 100644 --- a/src/containers/rpm-repository/edit.tsx +++ b/src/containers/rpm-repository/edit.tsx @@ -21,7 +21,10 @@ const RPMRepositoryEdit = Page({ breadcrumbs: ({ name }) => [ { url: formatPath(Paths.rpm.repository.list), name: t`Repositories` }, - name && { name }, + name && { + url: formatPath(Paths.rpm.repository.detail, { name }), + name, + }, name ? { name: t`Edit` } : { name: t`Add` }, ].filter(Boolean), @@ -133,8 +136,11 @@ const RPMRepositoryEdit = Page({ repositoryToEdit: undefined, }); - navigate(formatPath(Paths.rpm.repository.list)); - + navigate( + formatPath(Paths.rpm.repository.detail, { + name: data.name, + }), + ); }) .catch(({ response: { data } }) => setState({ @@ -148,7 +154,13 @@ const RPMRepositoryEdit = Page({ const closeModal = () => { setState({ errorMessages: {}, repositoryToEdit: undefined }); - navigate(formatPath(Paths.rpm.repository.list)); + navigate( + item + ? formatPath(Paths.rpm.repository.detail, { + name: item.name, + }) + : formatPath(Paths.rpm.repository.list), + ); }; return ( diff --git a/src/containers/rpm-repository/list.tsx b/src/containers/rpm-repository/list.tsx index 2aadcebe..e38c1a0c 100644 --- a/src/containers/rpm-repository/list.tsx +++ b/src/containers/rpm-repository/list.tsx @@ -1,16 +1,25 @@ import { msg, t } from '@lingui/core/macro'; import { Td, Tr } from '@patternfly/react-table'; -import { RPMRemoteAPI, RPMRepositoryAPI, type RPMRepositoryType } from 'src/api'; +import { Link } from 'react-router'; import { rpmRepositoryCreateAction, rpmRepositoryDeleteAction, rpmRepositoryEditAction, rpmRepositorySyncAction, } from 'src/actions'; -import { DateComponent, ListItemActions, ListPage, PulpLabels } from 'src/components'; -import { parsePulpIDFromURL } from 'src/utilities'; -import { Link } from 'react-router'; +import { + RPMRemoteAPI, + RPMRepositoryAPI, + type RPMRepositoryType, +} from 'src/api'; +import { + DateComponent, + ListItemActions, + ListPage, + PulpLabels, +} from 'src/components'; import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL } from 'src/utilities'; const listItemActions = [ rpmRepositoryEditAction, @@ -68,7 +77,9 @@ const RPMRepositoryList = ListPage({ return ( - {name} + + {name} + @@ -88,4 +99,4 @@ const RPMRepositoryList = ListPage({ title: msg`Repositories`, }); -export default RPMRepositoryList; \ No newline at end of file +export default RPMRepositoryList; diff --git a/src/containers/rpm-repository/tab-details.tsx b/src/containers/rpm-repository/tab-details.tsx index a8ea5d22..30796f6e 100644 --- a/src/containers/rpm-repository/tab-details.tsx +++ b/src/containers/rpm-repository/tab-details.tsx @@ -37,7 +37,17 @@ export const DetailsTab = ({ item }: TabProps) => { }, { label: t`Remote`, - value: item?.remote ? item.remote.name : t`None`, + value: item?.remote ? ( + + {item.remote.name} + + ) : ( + t`None` + ), }, { label: t`Autopublish`, diff --git a/src/containers/rpm-repository/tab-distributions.tsx b/src/containers/rpm-repository/tab-distributions.tsx index 4839118e..0fd41749 100644 --- a/src/containers/rpm-repository/tab-distributions.tsx +++ b/src/containers/rpm-repository/tab-distributions.tsx @@ -42,8 +42,8 @@ export const DistributionsTab = ({ // An RPM remote only needs a URL to consume this distribution. const cliConfig = (base_path) => - `pulp rpm remote create --name "${item.name}" --url "${getRepoURL(base_path)}"`; - + `pulp rpm remote create --name "${item.name}" --url "${getRepoURL(base_path)}"`; + const renderTableRow = ( item: Distribution, index: number, @@ -78,7 +78,7 @@ export const DistributionsTab = ({ addAlert, query, hasPermission, - // No hasObjectPermission: an apt repository carries no my_permissions to + // No hasObjectPermission: an RPM repository carries no my_permissions to // check, and nothing in this list is gated on one. Whoever adds an action // that is wants a real check here, not a stub that always agrees. }} diff --git a/src/containers/rpm-repository/tab-packages.tsx b/src/containers/rpm-repository/tab-packages.tsx index a9d757d5..74b60f29 100644 --- a/src/containers/rpm-repository/tab-packages.tsx +++ b/src/containers/rpm-repository/tab-packages.tsx @@ -79,4 +79,4 @@ export const PackagesTab = ({ title={t`Packages`} /> ); -}; \ No newline at end of file +}; diff --git a/src/menu.tsx b/src/menu.tsx index 8b4fa2b6..36422478 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -117,7 +117,10 @@ function standaloneMenu() { ]), menuSection('Pulp RPM', { condition: and(loggedIn, hasPlugin('rpm')) }, [ menuItem(t`Repositories`, { - url: formatPath(Paths.rpm.repository.list), + url: formatPath(Paths.rpm.repository.list), + }), + menuItem(t`Remotes`, { + url: formatPath(Paths.rpm.remote.list), }), menuItem(t`RPMs`, { url: formatPath(Paths.rpm.package.list), diff --git a/src/paths.ts b/src/paths.ts index fc44646f..23437e74 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -152,6 +152,11 @@ export const Paths = { search: '/search', }, rpm: { + remote: { + detail: '/rpm/remotes/detail/:name', + edit: '/rpm/remotes/edit/:name', + list: '/rpm/remotes', + }, repository: { detail: '/rpm/repositories/detail/:name', edit: '/rpm/repositories/edit/:name', From 1d6b2e6cad8291d797c77358cb0ed78bd154d0ec Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sun, 4 Oct 2026 09:55:03 -0400 Subject: [PATCH 10/16] README: list RPM repositories and remotes as supported (cherry picked from commit 264705af96a7732928b76fcc6b21448b4f44226f) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index df64e681..cc87916b 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ PulpUI is a community driven single-page application that talks to a running [pu ### Plugins - Ansible (repositories, remotes, collections, namespaces, approvals, imports) - File (repositories, remotes) -- RPM (content) +- RPM (repositories, remotes, content) - Container (execution environments, tags, manifests) ## Requirements From edd4649d8411713eb706c0c2d21a648beac490e6 Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sun, 4 Oct 2026 10:07:06 -0400 Subject: [PATCH 11/16] Treat an empty retained package versions field as keep all (cherry picked from commit c27d50b2bc1542514063ef7d5b778970a5364c4d) --- src/components/repository-form.tsx | 2 +- src/containers/rpm-repository/edit.tsx | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/repository-form.tsx b/src/components/repository-form.tsx index 34a17352..f27fab7e 100644 --- a/src/components/repository-form.tsx +++ b/src/components/repository-form.tsx @@ -204,7 +204,7 @@ export const RepositoryForm = ({ {numericField( 'retain_package_versions', t`Versions kept per package`, - t`How many versions of each package to keep; older ones are dropped on sync. Set to 0 to keep all versions.`, + t`How many versions of each package to keep; older ones are dropped on sync. Leave blank to keep all versions.`, )} {formGroup( 'autopublish', diff --git a/src/containers/rpm-repository/edit.tsx b/src/containers/rpm-repository/edit.tsx index 9a07f770..5dd98439 100644 --- a/src/containers/rpm-repository/edit.tsx +++ b/src/containers/rpm-repository/edit.tsx @@ -65,6 +65,10 @@ const RPMRepositoryEdit = Page({ } }); + // retain_package_versions is not nullable in pulp_rpm; an empty field + // means keep every version, which the API spells 0 + data.retain_package_versions ??= 0; + if (item) { delete data.last_sync_task; delete data.last_synced_metadata_time; From bca8b2ef3e35c0b72ae44ef3a2446949b66580e1 Mon Sep 17 00:00:00 2001 From: Danilo Lakovic Date: Mon, 2 Nov 2026 12:16:05 +0100 Subject: [PATCH 12/16] Create RPM repositories through the shared create flow The RPM add page used its own save path: it published new repositories by default, kept a single repository version, and on any distribution error retried with a random numeric suffix, so a failed or conflicting create could leave an unexpected, differently named distribution behind. It now uses the same createRepository flow as the file and deb pages. A new repository keeps every version and is not autopublished (the form says what ticking the box does). A distribution is created only when the box is ticked, after the name and base path were checked, and a refused distribution is reported once and never retried under another name. A blank "versions kept per package" is sent as 0, which keeps all versions. --- src/components/repository-form.tsx | 21 +- src/containers/rpm-repository/edit.test.tsx | 346 ++++++++++++++++++++ src/containers/rpm-repository/edit.tsx | 184 +++++------ 3 files changed, 443 insertions(+), 108 deletions(-) create mode 100644 src/containers/rpm-repository/edit.test.tsx diff --git a/src/components/repository-form.tsx b/src/components/repository-form.tsx index f27fab7e..70cb20c5 100644 --- a/src/components/repository-form.tsx +++ b/src/components/repository-form.tsx @@ -210,14 +210,19 @@ export const RepositoryForm = ({ 'autopublish', t`Autopublish`, t`Create a publication for every new repository version, so distributions pointing at this repository always serve the latest content.`, - - updateRepository({ ...repository, autopublish: value }) - } - label={t`Publish new versions automatically`} - />, + <> + + updateRepository({ ...repository, autopublish: value }) + } + label={t`Publish new versions automatically`} + /> + + {t`A ticked box publishes every new version, so any distribution serving this repository offers it.`} + + , )} )} diff --git a/src/containers/rpm-repository/edit.test.tsx b/src/containers/rpm-repository/edit.test.tsx new file mode 100644 index 00000000..d1036d80 --- /dev/null +++ b/src/containers/rpm-repository/edit.test.tsx @@ -0,0 +1,346 @@ +import { screen, waitFor } from '@testing-library/react'; +import { HttpResponse, http } from 'msw'; +import { Route, Routes, useLocation } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AppContextProvider, useAppContext } from 'src/app-context'; +import { Paths } from 'src/paths'; +import { distribution, listOf, repository } from 'src/test-utils/pulp'; +import { renderWithProviders } from 'src/test-utils/render'; +import { server } from 'src/test-utils/server'; +import RPMRepositoryEdit from './edit'; + +const API = '/pulp/api/v3'; +const REPOSITORY_HREF = `${API}/repositories/rpm/rpm/00000000-0000-4000-8000-0000000000aa/`; +const TASK_HREF = `${API}/tasks/00000000-0000-4000-8000-0000000000cc/`; + +interface Seen { + method: string; + path: string; +} + +let seen: Seen[]; +let repositoryBodies: Record[]; +let distributionBodies: Record[]; + +const requests = () => seen.map((r) => `${r.method} ${r.path}`); +const writes = () => + seen.filter((r) => r.method !== 'GET').map((r) => `${r.method} ${r.path}`); + +// Records every request the page makes, in order. +beforeEach(() => { + seen = []; + repositoryBodies = []; + distributionBodies = []; + server.events.on('request:start', ({ request }) => { + seen.push({ method: request.method, path: new URL(request.url).pathname }); + }); +}); + +afterEach(() => { + server.events.removeAllListeners(); +}); + +// Shows where the application ended up after a save and the alerts it raised. +function Where() { + const { pathname } = useLocation(); + const { alerts } = useAppContext(); + return ( + <> +

{pathname}

+
    + {alerts.map(({ title }, index) => ( +
  • {String(title)}
  • + ))} +
+ + ); +} + +function renderEdit(route: string) { + return renderWithProviders( + + + } + /> + } /> + + , + { route }, + ); +} + +const nameInput = () => screen.findByRole('textbox', { name: /^Name/ }); +const packageVersionsInput = () => + screen.getByRole('spinbutton', { name: /Versions kept per package/ }); +const repositoryVersionsInput = () => + screen.getByRole('spinbutton', { name: /Retained number of versions/ }); +const autopublish = () => screen.getByRole('checkbox', { name: /Publish new/ }); +const createDistributionBox = () => + screen.getByRole('checkbox', { name: /Create a distribution/ }); +const save = () => screen.getByRole('button', { name: 'Save' }); + +function serveRepositoryCreate() { + server.use( + http.post(`*${API}/repositories/rpm/rpm/`, async ({ request }) => { + repositoryBodies.push((await request.json()) as Record); + return HttpResponse.json({ pulp_href: REPOSITORY_HREF }, { status: 201 }); + }), + ); +} + +function serveDistributionCreate(answer: () => Response) { + server.use( + http.post(`*${API}/distributions/rpm/rpm/`, async ({ request }) => { + distributionBodies.push( + (await request.json()) as Record, + ); + return answer(); + }), + http.get(`*${API}/tasks/:id/`, () => + HttpResponse.json({ pulp_href: TASK_HREF, state: 'completed' }), + ), + ); +} + +function serveSupportingLists() { + server.use( + listOf(`${API}/remotes/rpm/rpm/`, []).handler, + listOf(`${API}/distributions/rpm/rpm/`, []).handler, + listOf(`${API}/publications/rpm/rpm/`, []).handler, + // the conflict checks read the generic list of all plugins + listOf(`${API}/distributions/`, []).handler, + http.get(`*${API}/status/`, () => + HttpResponse.json({ + content_settings: { + content_origin: 'https://pulp.example', + content_path_prefix: '/pulp/content/', + }, + }), + ), + ); +} + +describe('creating an RPM repository', () => { + beforeEach(serveSupportingLists); + + it('offers autopublish unticked and says what ticking it does', async () => { + renderEdit('/rpm/repositories/edit/_'); + + await nameInput(); + expect(autopublish()).not.toBeChecked(); + expect( + screen.getByText(/publishes every new version/i), + ).toBeInTheDocument(); + expect(createDistributionBox()).not.toBeChecked(); + }); + + it('sends one repository POST and no distribution request when the distribution box stays unticked', async () => { + serveRepositoryCreate(); + const { user } = renderEdit('/rpm/repositories/edit/_'); + + await user.type(await nameInput(), 'zz-test-rpm'); + await user.click(save()); + + await waitFor(() => + expect(screen.getByTestId('where')).toHaveTextContent( + '/rpm/repositories/detail/zz-test-rpm', + ), + ); + expect(repositoryBodies).toHaveLength(1); + expect(repositoryBodies[0]).toMatchObject({ + name: 'zz-test-rpm', + autopublish: false, + retain_repo_versions: null, + retain_package_versions: 0, + }); + expect(writes()).toStrictEqual([`POST ${API}/repositories/rpm/rpm/`]); + // the name and base path are only checked when a distribution is wanted + expect(requests()).not.toContain(`GET ${API}/distributions/`); + }); + + it('sends the autopublish choice the user made', async () => { + serveRepositoryCreate(); + const { user } = renderEdit('/rpm/repositories/edit/_'); + + await user.type(await nameInput(), 'zz-test-rpm'); + await user.click(autopublish()); + await user.click(save()); + + await waitFor(() => + expect(screen.getByTestId('where')).toBeInTheDocument(), + ); + expect(repositoryBodies[0]).toMatchObject({ autopublish: true }); + }); + + it('sends 0 for a blank package versions field and the number typed otherwise', async () => { + serveRepositoryCreate(); + const first = renderEdit('/rpm/repositories/edit/_'); + + await first.user.type(await nameInput(), 'zz-test-rpm'); + expect(packageVersionsInput()).toHaveValue(null); + expect(repositoryVersionsInput()).toHaveValue(null); + await first.user.click(save()); + await waitFor(() => + expect(screen.getByTestId('where')).toBeInTheDocument(), + ); + first.unmount(); + + const second = renderEdit('/rpm/repositories/edit/_'); + await second.user.type(await nameInput(), 'zz-test-rpm'); + await second.user.type(packageVersionsInput(), '3'); + await second.user.click(save()); + await waitFor(() => + expect(screen.getByTestId('where')).toBeInTheDocument(), + ); + + expect(repositoryBodies.map((b) => b['retain_package_versions'])).toEqual([ + 0, 3, + ]); + }); + + it('checks the name and base path, then creates the repository, then one distribution with the typed base path', async () => { + serveRepositoryCreate(); + serveDistributionCreate(() => + HttpResponse.json({ task: TASK_HREF }, { status: 202 }), + ); + const { user } = renderEdit('/rpm/repositories/edit/_'); + + await user.type(await nameInput(), 'zz-test-rpm'); + await user.click(createDistributionBox()); + await waitFor(() => + expect(screen.getByRole('textbox', { name: /Base path/ })).toHaveValue( + 'zz-test-rpm', + ), + ); + await user.click(save()); + + await waitFor(() => + expect(screen.getByTestId('where')).toHaveTextContent( + '/rpm/repositories/detail/zz-test-rpm', + ), + ); + expect(distributionBodies).toStrictEqual([ + { + name: 'zz-test-rpm', + base_path: 'zz-test-rpm', + repository: REPOSITORY_HREF, + }, + ]); + const posted = writes(); + expect(posted).toStrictEqual([ + `POST ${API}/repositories/rpm/rpm/`, + `POST ${API}/distributions/rpm/rpm/`, + ]); + const firstCheck = requests().indexOf(`GET ${API}/distributions/`); + expect(firstCheck).toBeGreaterThanOrEqual(0); + expect(firstCheck).toBeLessThan( + requests().indexOf(`POST ${API}/repositories/rpm/rpm/`), + ); + }); + + it('reports a refused distribution once, never retries it under another name, and keeps the repository', async () => { + serveRepositoryCreate(); + serveDistributionCreate(() => + HttpResponse.json( + { base_path: ['Overlaps with an existing distribution.'] }, + { status: 400 }, + ), + ); + const { user } = renderEdit('/rpm/repositories/edit/_'); + + await user.type(await nameInput(), 'zz-test-rpm'); + await user.click(createDistributionBox()); + await user.click(save()); + + const where = await screen.findByTestId('where'); + expect(where).toHaveTextContent('/rpm/repositories/detail/zz-test-rpm'); + expect( + await screen.findByText(/was not created.*Overlaps with an existing/), + ).toBeInTheDocument(); + expect(distributionBodies).toHaveLength(1); + expect(distributionBodies[0]).toMatchObject({ name: 'zz-test-rpm' }); + const names = [...repositoryBodies, ...distributionBodies].map( + (b) => b['name'], + ); + expect(new Set(names)).toStrictEqual(new Set(['zz-test-rpm'])); + expect(writes()).toStrictEqual([ + `POST ${API}/repositories/rpm/rpm/`, + `POST ${API}/distributions/rpm/rpm/`, + ]); + }); + + it('is blocked before any write when a distribution already uses the name', async () => { + serveRepositoryCreate(); + server.use( + listOf( + `${API}/distributions/`, + [distribution({ name: 'zz-test-rpm', base_path: 'elsewhere' })], + { + matching: (row, query) => + query.has('name') ? row.name === query.get('name') : false, + }, + ).handler, + ); + const { user } = renderEdit('/rpm/repositories/edit/_'); + + await user.type(await nameInput(), 'zz-test-rpm'); + await user.click(createDistributionBox()); + await user.click(save()); + + expect( + await screen.findByText(/A distribution named "zz-test-rpm" already/), + ).toBeInTheDocument(); + expect(writes()).toStrictEqual([]); + expect(screen.queryByTestId('where')).toBeNull(); + }); +}); + +describe('editing an RPM repository', () => { + beforeEach(serveSupportingLists); + + it('sends one PUT to the repository and offers no distribution', async () => { + const existing = repository({ + name: 'existing', + pulp_href: REPOSITORY_HREF, + retain_repo_versions: null, + retain_package_versions: 2, + autopublish: true, + }); + server.use( + listOf(`${API}/repositories/rpm/rpm/`, [existing], { + matching: (row, query) => row.name === query.get('name'), + }).handler, + http.put(`*${API}/repositories/rpm/rpm/:id/`, async ({ request }) => { + repositoryBodies.push( + (await request.json()) as Record, + ); + return HttpResponse.json({ task: TASK_HREF }, { status: 202 }); + }), + ); + const { user } = renderEdit('/rpm/repositories/edit/existing'); + + await screen.findByDisplayValue('existing'); + expect( + screen.queryByRole('checkbox', { name: /Create a distribution/ }), + ).toBeNull(); + expect(autopublish()).toBeChecked(); + expect(packageVersionsInput()).toHaveValue(2); + await user.click(save()); + + await waitFor(() => + expect(screen.getByTestId('where')).toHaveTextContent( + '/rpm/repositories/detail/existing', + ), + ); + expect(writes()).toStrictEqual([ + `PUT ${API}/repositories/rpm/rpm/00000000-0000-4000-8000-0000000000aa/`, + ]); + expect(repositoryBodies[0]).toMatchObject({ + autopublish: true, + retain_package_versions: 2, + }); + expect(screen.queryByText(/Created distribution/)).toBeNull(); + }); +}); diff --git a/src/containers/rpm-repository/edit.tsx b/src/containers/rpm-repository/edit.tsx index 5dd98439..caac182c 100644 --- a/src/containers/rpm-repository/edit.tsx +++ b/src/containers/rpm-repository/edit.tsx @@ -1,20 +1,27 @@ import { msg, t } from '@lingui/core/macro'; +import { RPMDistributionAPI } from 'src/api/rpm-distribution'; import { - RPMDistributionAPI, RPMRepositoryAPI, type RPMRepositoryType, -} from 'src/api'; +} from 'src/api/rpm-repository'; import { Page, RepositoryForm } from 'src/components'; import { Paths, formatPath } from 'src/paths'; -import { parsePulpIDFromURL, taskAlert } from 'src/utilities'; - +import { + createRepository, + saveErrorMessages, +} from 'src/utilities/create-repository'; +import { parsePulpIDFromURL } from 'src/utilities/parse-pulp-id'; +import { taskAlert } from 'src/utilities/task-alert'; + +// retain_repo_versions null keeps every version of a new repository, and a new +// repository publishes nothing until the user asks for it const initialRepository: RPMRepositoryType = { name: '', description: '', - retain_repo_versions: 1, + retain_repo_versions: null, pulp_labels: {}, remote: null, - autopublish: true, + autopublish: false, }; const RPMRepositoryEdit = Page({ @@ -40,21 +47,15 @@ const RPMRepositoryEdit = Page({ }), render: (item, { navigate, queueAlert, state, setState }) => { - if (!state.repositoryToEdit) { - const repositoryToEdit = { - ...initialRepository, - ...item, - }; - setState({ repositoryToEdit, errorMessages: {} }); - } - - const { repositoryToEdit, errorMessages } = state; - if (!repositoryToEdit) { - return null; - } + // derived while rendering; the edited copy is stored on the first change + const repositoryToEdit = state.repositoryToEdit ?? { + ...initialRepository, + ...item, + }; + const errorMessages = state.errorMessages ?? {}; - const saveRepository = ({ createDistribution }) => { - const { repositoryToEdit } = state; + const saveRepository = async ({ distribution }) => { + setState({ errorMessages: {} }); const data = { ...repositoryToEdit }; @@ -65,95 +66,77 @@ const RPMRepositoryEdit = Page({ } }); - // retain_package_versions is not nullable in pulp_rpm; an empty field - // means keep every version, which the API spells 0 - data.retain_package_versions ??= 0; - - if (item) { - delete data.last_sync_task; - delete data.last_synced_metadata_time; - delete data.latest_version_href; - delete data.pulp_created; - delete data.pulp_href; - delete data.versions_href; - } + // retain_package_versions is not nullable on the server and 0 keeps + // every version, so a blank field is sent as 0 + data.retain_package_versions = + data.retain_package_versions == null + ? 0 + : Number(data.retain_package_versions); data.pulp_labels ||= {}; - let promise = !item - ? RPMRepositoryAPI.create(data).then(({ data: newData }) => { - queueAlert({ - variant: 'success', - title: t`Successfully created repository ${data.name}`, - }); + const goToRepository = () => { + setState({ errorMessages: {}, repositoryToEdit: undefined }); + navigate(formatPath(Paths.rpm.repository.detail, { name: data.name })); + }; - return newData.pulp_href; - }) - : RPMRepositoryAPI.update( + try { + if (item) { + // editing never creates a distribution + delete data.last_sync_task; + delete data.last_synced_metadata_time; + delete data.latest_version_href; + delete data.pulp_created; + delete data.pulp_href; + delete data.versions_href; + + const { data: task } = await RPMRepositoryAPI.update( parsePulpIDFromURL(item.pulp_href), data, - ).then(({ data: task }) => { - queueAlert( - taskAlert(task, t`Update started for repository ${data.name}`), - ); - - return item.pulp_href; - }); - - if (createDistribution) { - // only alphanumerics, slashes, underscores and dashes are allowed in base_path, transform anything else to _ - const basePathTransform = (name) => - name.replaceAll(/[^-a-zA-Z0-9_/]/g, '_'); - let distributionName = data.name; - - promise = promise - .then((pulp_href) => - RPMDistributionAPI.create({ - name: distributionName, - base_path: basePathTransform(distributionName), - repository: pulp_href, - }).catch(() => { - // if distribution already exists, try a numeric suffix to name & base_path - distributionName = - data.name + Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); - return RPMDistributionAPI.create({ - name: distributionName, - base_path: basePathTransform(distributionName), - repository: pulp_href, - }); - }), - ) - .then(({ data: task }) => - queueAlert( - taskAlert( - task, - t`Creation started for distribution ${distributionName}`, - ), - ), ); - } + // the server answers with the repository itself, not a task, when + // the update changes nothing + queueAlert( + task?.task + ? taskAlert(task, t`Update started for repository ${data.name}`) + : { + variant: 'success', + title: t`Updated repository ${data.name}`, + }, + ); + goToRepository(); + return; + } - promise - .then(() => { - setState({ - errorMessages: {}, - repositoryToEdit: undefined, + const { distributionError } = await createRepository({ + RepositoryAPI: RPMRepositoryAPI, + DistributionAPI: RPMDistributionAPI, + data, + distribution, + }); + + if (distributionError) { + queueAlert({ + variant: 'danger', + title: t`Repository "${data.name}" was created. The distribution "${data.name}" was not created: ${distributionError}`, }); - - navigate( - formatPath(Paths.rpm.repository.detail, { - name: data.name, - }), - ); - }) - .catch(({ response: { data } }) => - setState({ - errorMessages: { - __nofield: data.non_field_errors || data.detail, - ...data, - }, - }), - ); + } else { + queueAlert({ + variant: 'success', + title: t`Successfully created repository ${data.name}`, + }); + if (distribution) { + const { basePath } = distribution; + queueAlert({ + variant: 'success', + title: t`Created distribution "${data.name}" at base path ${basePath}.`, + }); + } + } + goToRepository(); + } catch (e) { + setState({ errorMessages: saveErrorMessages(e) }); + } }; const closeModal = () => { @@ -171,6 +154,7 @@ const RPMRepositoryEdit = Page({ Date: Mon, 2 Nov 2026 12:17:31 +0100 Subject: [PATCH 13/16] Offer the private flag only for ansible repositories The repository form showed "Make private" for file, deb and rpm repositories too, although only the ansible serializer has that field. Ticking it there sent a value the server ignores or refuses. The checkbox is now rendered for ansible only. The tests also cover the RPM-only autopublish checkbox starting unticked. --- src/components/repository-form.test.tsx | 44 ++++++++++++++++++++++++- src/components/repository-form.tsx | 36 ++++++++++---------- 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/src/components/repository-form.test.tsx b/src/components/repository-form.test.tsx index 29000377..0f1fa974 100644 --- a/src/components/repository-form.test.tsx +++ b/src/components/repository-form.test.tsx @@ -30,6 +30,9 @@ beforeEach(() => { settings.reservedPrefixes = []; server.use( listOf(`${API}/remotes/file/file/`, []).handler, + listOf(`${API}/remotes/deb/apt/`, []).handler, + listOf(`${API}/remotes/rpm/rpm/`, []).handler, + listOf(`${API}/remotes/ansible/collection/`, []).handler, listOf(`${API}/distributions/file/file/`, []).handler, listOf(`${API}/publications/file/file/`, []).handler, http.get(`*${API}/status/`, () => @@ -52,11 +55,13 @@ function Harness({ onSave, errorMessages = {}, initial = {}, + plugin = 'file', }: { isNew: boolean; onSave: Save; errorMessages?: Record; initial?: Record; + plugin?: 'ansible' | 'deb' | 'file' | 'rpm'; }) { const [repository, setRepository] = useState({ name: isNew ? '' : 'existing', @@ -77,7 +82,7 @@ function Harness({ isNew={isNew} onCancel={() => undefined} onSave={onSave as never} - plugin='file' + plugin={plugin} repository={repository as never} updateRepository={setRepository as never} /> @@ -296,3 +301,40 @@ describe('repository form, saving', () => { expect(onSave).toHaveBeenCalledTimes(1); }); }); + +describe('repository form, plugin specific fields', () => { + it.each(['file', 'deb', 'rpm'] as const)( + 'offers no private checkbox for a %s repository', + async (plugin) => { + renderWithProviders(); + + await screen.findByRole('textbox', { name: /Name/ }); + + expect( + screen.queryByRole('checkbox', { name: /Make private/ }), + ).toBeNull(); + }, + ); + + it('offers the private checkbox for an ansible repository', async () => { + renderWithProviders(); + + expect( + await screen.findByRole('checkbox', { name: /Make private/ }), + ).toBeInTheDocument(); + }); + + it('offers autopublish unticked on the RPM add form and not on the file one', async () => { + const rpm = renderWithProviders( + , + ); + + const box = await screen.findByRole('checkbox', { name: /Publish new/ }); + expect(box).not.toBeChecked(); + rpm.unmount(); + + renderWithProviders(); + await screen.findByRole('textbox', { name: /Name/ }); + expect(screen.queryByRole('checkbox', { name: /Publish new/ })).toBeNull(); + }); +}); diff --git a/src/components/repository-form.tsx b/src/components/repository-form.tsx index 70cb20c5..18ca9bbc 100644 --- a/src/components/repository-form.tsx +++ b/src/components/repository-form.tsx @@ -297,24 +297,24 @@ export const RepositoryForm = ({ , )} - {plugin !== 'rpm' && - formGroup( - // putting the plugin check here because private repositories - // aren't an RPM concept. It's worth noting that (to my knowledge) - // Ansible Galaxy is the only thing that would need the private flag - // NP - 2026 - 'private', - t`Make private`, - t`Make the repository private.`, - - updateRepository({ ...repository, private: value }) - } - />, - )} + { + // only ansible repositories have a private flag; the other plugins + // would send a field their serializer does not have + plugin === 'ansible' && + formGroup( + 'private', + t`Make private`, + t`Make the repository private.`, + + updateRepository({ ...repository, private: value }) + } + />, + ) + } {formGroup( 'remote', From 1b2c8b95f970585a04f6263bdd4f3117a9d43302 Mon Sep 17 00:00:00 2001 From: Danilo Lakovic Date: Mon, 2 Nov 2026 12:19:00 +0100 Subject: [PATCH 14/16] Show served URLs on the RPM repository pages The RPM repository page built its URL from a distribution's base path and showed a single guess, and the Distributions tab built every command line the same way, so the page could show a URL the server does not serve. The Details tab listed the Autopublish row twice. The page now loads every distribution that serves the repository (bound to it, to one of its publications or to one of its versions) and shows the base_url the server reports for each. A distribution with nothing to serve is marked "Not served", a failed load is shown instead of "None", the Distributions tab lists the loaded distributions with a command line that uses the server URL, and Details has one Autopublish row. --- src/containers/rpm-repository/detail.test.tsx | 208 ++++++++++++++++++ src/containers/rpm-repository/detail.tsx | 45 ++-- src/containers/rpm-repository/tab-details.tsx | 21 +- .../rpm-repository/tab-distributions.tsx | 74 +++---- 4 files changed, 280 insertions(+), 68 deletions(-) create mode 100644 src/containers/rpm-repository/detail.test.tsx diff --git a/src/containers/rpm-repository/detail.test.tsx b/src/containers/rpm-repository/detail.test.tsx new file mode 100644 index 00000000..37cf0407 --- /dev/null +++ b/src/containers/rpm-repository/detail.test.tsx @@ -0,0 +1,208 @@ +import { screen, within } from '@testing-library/react'; +import { HttpResponse, http } from 'msw'; +import { Route, Routes } from 'react-router'; +import { describe, expect, it } from 'vitest'; +import { AppContextProvider } from 'src/app-context'; +import { Paths } from 'src/paths'; +import { + distribution, + listOf, + publication, + repository, +} from 'src/test-utils/pulp'; +import { renderWithProviders } from 'src/test-utils/render'; +import { server } from 'src/test-utils/server'; +import RPMRepositoryDetail from './detail'; + +const API = '/pulp/api/v3'; +const REPOSITORY_HREF = `${API}/repositories/rpm/rpm/00000000-0000-4000-8000-0000000000aa/`; +const OTHER_REPOSITORY_HREF = `${API}/repositories/rpm/rpm/00000000-0000-4000-8000-0000000000bb/`; + +const tools = repository({ + name: 'zz-test-rpm', + pulp_href: REPOSITORY_HREF, + retain_repo_versions: null, + retain_package_versions: 0, + autopublish: true, + remote: null, +}); + +// The distribution lists the page reads: the ones bound to the repository are +// asked for with a repository filter, every distribution of the plugin is read +// once without it. +function serveDistributions(rows: ReturnType[]) { + server.use( + listOf(`${API}/distributions/rpm/rpm/`, rows, { + matching: (row, query) => + query.has('repository') + ? row.repository === query.get('repository') + : true, + }).handler, + ); +} + +function servePublications(rows: ReturnType[]) { + server.use( + listOf(`${API}/publications/rpm/rpm/`, rows, { + matching: (row, query) => + query.has('repository') + ? row.repository === query.get('repository') + : true, + }).handler, + ); +} + +function serveRepository(row = tools) { + server.use( + listOf(`${API}/repositories/rpm/rpm/`, [row], { + matching: (r, query) => r.name === query.get('name'), + }).handler, + ); +} + +function renderDetail(tab: 'details' | 'distributions' = 'details') { + return renderWithProviders( + + + } + /> + + , + { route: `/rpm/repositories/detail/zz-test-rpm?tab=${tab}` }, + ); +} + +describe('RPM repository details', () => { + it('shows the base_url the server reports for each distribution, never a URL built from the base path', async () => { + const first = distribution({ + name: 'zz-first', + base_path: 'zz-first', + base_url: 'https://pulp.example/mirror-one/zz-first/', + repository: REPOSITORY_HREF, + }); + const second = distribution({ + name: 'zz-second', + base_path: 'zz-second', + base_url: 'https://pulp.example/mirror-two/other/', + repository: REPOSITORY_HREF, + }); + serveRepository(); + serveDistributions([first, second]); + servePublications([publication({ repository: REPOSITORY_HREF })]); + renderDetail(); + + expect( + await screen.findByText('https://pulp.example/mirror-one/zz-first/'), + ).toBeInTheDocument(); + expect( + screen.getByText('https://pulp.example/mirror-two/other/'), + ).toBeInTheDocument(); + expect(screen.queryByText(/\/pulp\/content\/zz-/)).toBeNull(); + expect(screen.queryByText(/Not served/)).toBeNull(); + }); + + it('says "Not served" next to a distribution whose repository has no publication', async () => { + serveRepository(); + serveDistributions([ + distribution({ + name: 'zz-bare', + base_path: 'zz-bare', + repository: REPOSITORY_HREF, + }), + ]); + servePublications([]); + renderDetail(); + + expect(await screen.findByText(/Not served/)).toBeInTheDocument(); + }); + + it('shows exactly one Autopublish row', async () => { + serveRepository(); + serveDistributions([]); + servePublications([]); + renderDetail(); + + await screen.findByText('Retained package versions'); + + expect(screen.getAllByText('Autopublish')).toHaveLength(1); + expect(screen.getByText('Enabled')).toBeInTheDocument(); + }); + + it('shows why the distributions are missing when they could not be loaded, instead of "None"', async () => { + serveRepository(); + server.use( + http.get(`*${API}/distributions/rpm/rpm/`, () => + HttpResponse.json({ detail: 'broken' }, { status: 500 }), + ), + ); + servePublications([]); + renderDetail(); + + expect( + await screen.findByText(/Distributions could not be loaded/), + ).toBeInTheDocument(); + }); +}); + +describe('RPM repository Distributions tab', () => { + it('lists distributions bound to the repository, to its publication and to one of its versions, with the server URL and a CLI line using it', async () => { + const publicationRow = publication({ repository: REPOSITORY_HREF }); + serveRepository(); + serveDistributions([ + distribution({ + name: 'zz-bound', + base_path: 'bp-bound', + base_url: 'https://pulp.example/served/zz-bound/', + repository: REPOSITORY_HREF, + }), + distribution({ + name: 'zz-published', + base_path: 'bp-published', + base_url: 'https://pulp.example/served/zz-published/', + publication: publicationRow.pulp_href, + }), + distribution({ + name: 'zz-versioned', + base_path: 'bp-versioned', + base_url: 'https://pulp.example/served/zz-versioned/', + repository_version: `${REPOSITORY_HREF}versions/0/`, + }), + distribution({ + name: 'zz-foreign', + base_path: 'zz-foreign', + repository: OTHER_REPOSITORY_HREF, + }), + ]); + servePublications([publicationRow]); + renderDetail('distributions'); + + for (const name of ['zz-bound', 'zz-published', 'zz-versioned']) { + const row = (await screen.findByText(name)).closest('tr'); + expect(row).not.toBeNull(); + const line = within(row as HTMLElement).getByText( + /pulp rpm remote create/, + ); + expect(line.textContent).toBe( + `pulp rpm remote create --name "zz-test-rpm" --url "https://pulp.example/served/${name}/"`, + ); + } + expect(screen.queryByText('zz-foreign')).toBeNull(); + }); + + it('shows the list error when the distributions could not be loaded', async () => { + serveRepository(); + server.use( + http.get(`*${API}/distributions/rpm/rpm/`, () => + HttpResponse.json({ detail: 'broken' }, { status: 500 }), + ), + ); + servePublications([]); + renderDetail('distributions'); + + expect( + await screen.findByText(/Distributions could not be displayed/), + ).toBeInTheDocument(); + }); +}); diff --git a/src/containers/rpm-repository/detail.tsx b/src/containers/rpm-repository/detail.tsx index c55f8fb1..c4a54ee1 100644 --- a/src/containers/rpm-repository/detail.tsx +++ b/src/containers/rpm-repository/detail.tsx @@ -13,19 +13,23 @@ import { } from 'src/api'; import { PageWithTabs } from 'src/components'; import { Paths, formatPath } from 'src/paths'; +import { lastSyncStatus, lastSynced, parsePulpIDFromURL } from 'src/utilities'; import { - lastSyncStatus, - lastSynced, - parsePulpIDFromURL, - pluginRepositoryBasePath, -} from 'src/utilities'; + type RepositoryDistribution, + distributionsLoadError, + repositoryDistributions, +} from 'src/utilities/repository-distributions'; import { DetailsTab } from './tab-details'; import { DistributionsTab } from './tab-distributions'; import { PackagesTab } from './tab-packages'; import { RepositoryVersionsTab } from './tab-repository-versions'; const RPMRepositoryDetail = PageWithTabs< - RPMRepositoryType & { remote?: RPMRemoteType } + RPMRepositoryType & { + distributions?: RepositoryDistribution[]; + distributionsError?: string | null; + remote?: RPMRemoteType; + } >({ breadcrumbs: ({ name, tab, params: { repositoryVersion } }) => { const crumbs = [ @@ -78,27 +82,28 @@ const RPMRepositoryDetail = PageWithTabs< return Promise.reject({ response: { status: 404 } }); } - const err = (val) => (e) => { - console.error(e); - return val; - }; - return Promise.all([ - // the plugin-aware variant, so the RPM distribution endpoint is the - // one consulted - pluginRepositoryBasePath( - 'rpm', - repository.name, - repository.pulp_href, - ).catch(err(null)), + // a failed load reaches the page as a visible message, never as an + // empty list + repositoryDistributions('rpm', repository.pulp_href).then( + (distributions) => ({ distributions, distributionsError: null }), + (e) => { + console.error(e); + return { + distributions: [] as RepositoryDistribution[], + distributionsError: distributionsLoadError(e), + }; + }, + ), repository.remote ? RPMRemoteAPI.get(parsePulpIDFromURL(repository.remote)) .then(({ data }) => data) .catch(() => null) : null, - ]).then(([distroBasePath, remote]) => ({ + ]).then(([{ distributions, distributionsError }, remote]) => ({ ...repository, - distroBasePath, + distributions, + distributionsError, remote, })); }), diff --git a/src/containers/rpm-repository/tab-details.tsx b/src/containers/rpm-repository/tab-details.tsx index 30796f6e..01db624f 100644 --- a/src/containers/rpm-repository/tab-details.tsx +++ b/src/containers/rpm-repository/tab-details.tsx @@ -1,13 +1,15 @@ import { t } from '@lingui/core/macro'; import { Link } from 'react-router'; import { type RPMRemoteType, type RPMRepositoryType } from 'src/api'; -import { CopyURL, Details, PulpLabels } from 'src/components'; +import { Details, PulpLabels } from 'src/components'; +import { DistributionURLs } from 'src/components/distribution-url'; import { Paths, formatPath } from 'src/paths'; -import { getRepoURL } from 'src/utilities'; +import { type RepositoryDistribution } from 'src/utilities/repository-distributions'; interface TabProps { item: RPMRepositoryType & { - distroBasePath?: string; + distributions?: RepositoryDistribution[]; + distributionsError?: string | null; remote?: RPMRemoteType; }; actionContext: { addAlert: (alert) => void; state: { params } }; @@ -25,10 +27,11 @@ export const DetailsTab = ({ item }: TabProps) => { }, { label: t`Repository URL`, - value: item?.distroBasePath ? ( - - ) : ( - '---' + value: ( + ), }, { @@ -53,10 +56,6 @@ export const DetailsTab = ({ item }: TabProps) => { label: t`Autopublish`, value: item?.autopublish ? t`Enabled` : t`Disabled`, }, - { - label: t`Autopublish`, - value: item?.autopublish ? t`Enabled` : t`Disabled`, - }, { label: t`Retained package versions`, value: item?.retain_package_versions || t`All`, diff --git a/src/containers/rpm-repository/tab-distributions.tsx b/src/containers/rpm-repository/tab-distributions.tsx index 0fd41749..ba9dc5ef 100644 --- a/src/containers/rpm-repository/tab-distributions.tsx +++ b/src/containers/rpm-repository/tab-distributions.tsx @@ -1,11 +1,18 @@ import { t } from '@lingui/core/macro'; import { Td, Tr } from '@patternfly/react-table'; -import { RPMDistributionAPI, type RPMRepositoryType } from 'src/api'; +import { type RPMRepositoryType } from 'src/api'; import { ClipboardCopy, DateComponent, DetailList } from 'src/components'; -import { getRepoURL } from 'src/utilities'; +import { DistributionURL } from 'src/components/distribution-url'; +import { + type RepositoryDistribution, + listLoadedDistributions, +} from 'src/utilities/repository-distributions'; interface TabProps { - item: RPMRepositoryType; + item: RPMRepositoryType & { + distributions?: RepositoryDistribution[]; + distributionsError?: string | null; + }; actionContext: { addAlert: (alert) => void; state: { params }; @@ -13,48 +20,38 @@ interface TabProps { }; } -interface Distribution { - base_path: string; - client_url: string; - content_guard: string; - name: string; - pulp_created: string; - pulp_href: string; - pulp_labels: Record; - repository: string; - repository_version: string; -} - export const DistributionsTab = ({ item, actionContext: { addAlert, hasPermission }, }: TabProps) => { - const query = ({ params } = { params: null }) => { - const newParams = { ...params }; - newParams.ordering = newParams.sort; - delete newParams.sort; - - return RPMDistributionAPI.list({ - repository: item.pulp_href, - ...newParams, - }); - }; + // The page loaded every distribution serving the repository (bound to it, + // to one of its publications or to one of its versions), so the list is cut + // from those; a failed load is shown as the list's error. + const query = ({ params } = { params: null }) => + item.distributionsError + ? Promise.reject(item.distributionsError) + : Promise.resolve(listLoadedDistributions(item.distributions, params)); - // An RPM remote only needs a URL to consume this distribution. - const cliConfig = (base_path) => - `pulp rpm remote create --name "${item.name}" --url "${getRepoURL(base_path)}"`; + // An RPM remote only needs the URL the server reports for the distribution. + const cliConfig = (url: string | null) => + url + ? `pulp rpm remote create --name "${item.name}" --url "${url}"` + : t`None`; const renderTableRow = ( - item: Distribution, + item: RepositoryDistribution, index: number, _actionContext, ) => { - const { name, base_path, pulp_created } = item; + const { name, base_path, pulp_created, url } = item; return ( {name} {base_path} + + + @@ -65,7 +62,7 @@ export const DistributionsTab = ({ variant={'inline-compact'} key={index} > - {cliConfig(base_path)} + {cliConfig(url)} @@ -73,14 +70,12 @@ export const DistributionsTab = ({ }; return ( - + actionContext={{ addAlert, query, hasPermission, - // No hasObjectPermission: an RPM repository carries no my_permissions to - // check, and nothing in this list is gated on one. Whoever adds an action - // that is wants a real check here, not a stub that always agrees. + hasObjectPermission: (_p: string): boolean => true, }} defaultPageSize={10} defaultSort={'name'} @@ -95,8 +90,8 @@ export const DistributionsTab = ({ title: t`Base path`, }, ]} - noDataDescription={t`You can edit this repository to create a distribution.`} - noDataTitle={t`No distributions created`} + noDataDescription={t`Clients cannot download its content until a distribution serves it.`} + noDataTitle={t`No distribution serves this repository`} query={query} renderTableRow={renderTableRow} sortHeaders={[ @@ -110,6 +105,11 @@ export const DistributionsTab = ({ type: 'alpha', id: 'base_path', }, + { + title: t`URL`, + type: 'none', + id: 'url', + }, { title: t`Created`, type: 'alpha', From cb3e3c1e42d2e1e61f3458504990f6d9327696e0 Mon Sep 17 00:00:00 2001 From: Danilo Lakovic Date: Mon, 2 Nov 2026 12:19:00 +0100 Subject: [PATCH 15/16] Check the RPM pages in the smoke run Open the RPM repository and remote pages against the throwaway Pulp and look for the fields that only RPM has: autopublish (unticked on a new repository), the retained package versions and the download policy. --- e2e/smoke/pages.spec.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/e2e/smoke/pages.spec.ts b/e2e/smoke/pages.spec.ts index 7fa9bd18..c51b7975 100644 --- a/e2e/smoke/pages.spec.ts +++ b/e2e/smoke/pages.spec.ts @@ -55,6 +55,29 @@ test.describe('pages of a signed-in session', () => { await expect(page.locator('#architectures')).toBeVisible(); }); + test('RPM repositories', async ({ page }) => { + await page.goto('rpm/repositories'); + await expect(title(page, 'Repositories')).toBeVisible(); + await expect(page.getByText('No repositories yet')).toBeVisible(); + + // pulp_rpm repositories carry publishing and retention settings the other + // plugins have no use for, and a new one must not publish until asked to + await page.getByRole('button', { name: 'Add repository' }).click(); + await expect(page.locator('#autopublish')).not.toBeChecked(); + await expect(page.locator('#retain_package_versions')).toBeVisible(); + }); + + test('RPM remotes', async ({ page }) => { + await page.goto('rpm/remotes'); + await expect(title(page, 'Remotes')).toBeVisible(); + await expect(page.getByText('No remotes yet')).toBeVisible(); + + // the download policy decides whether packages are fetched during sync or + // on first request, so an RPM remote offers it in the form + await page.getByRole('button', { name: 'Add remote' }).click(); + await expect(page.locator('#policy')).toBeVisible(); + }); + test('RPM packages', async ({ page }) => { await page.goto('rpm/rpms'); await expect(title(page, 'Packages')).toBeVisible(); From 9de7c542f75c35557b54ea31bf60e7a88be4087f Mon Sep 17 00:00:00 2001 From: Danilo Lakovic Date: Mon, 2 Nov 2026 12:20:05 +0100 Subject: [PATCH 16/16] Import RPM repository page code from specific files The detail page and its Details and Distributions tabs imported through the api, actions and utilities barrels, which the lint rules warn about because a barrel pulls in unrelated modules. Import from the specific files, as the file repository pages do. --- src/containers/rpm-repository/detail.tsx | 16 +++++++--------- src/containers/rpm-repository/tab-details.tsx | 3 ++- .../rpm-repository/tab-distributions.tsx | 2 +- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/containers/rpm-repository/detail.tsx b/src/containers/rpm-repository/detail.tsx index c4a54ee1..a127b499 100644 --- a/src/containers/rpm-repository/detail.tsx +++ b/src/containers/rpm-repository/detail.tsx @@ -1,19 +1,17 @@ import { msg, t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; +import { rpmRepositoryDeleteAction } from 'src/actions/rpm-repository-delete'; +import { rpmRepositoryEditAction } from 'src/actions/rpm-repository-edit'; +import { rpmRepositorySyncAction } from 'src/actions/rpm-repository-sync'; +import { RPMRemoteAPI, type RPMRemoteType } from 'src/api/rpm-remote'; import { - rpmRepositoryDeleteAction, - rpmRepositoryEditAction, - rpmRepositorySyncAction, -} from 'src/actions'; -import { - RPMRemoteAPI, - type RPMRemoteType, RPMRepositoryAPI, type RPMRepositoryType, -} from 'src/api'; +} from 'src/api/rpm-repository'; import { PageWithTabs } from 'src/components'; import { Paths, formatPath } from 'src/paths'; -import { lastSyncStatus, lastSynced, parsePulpIDFromURL } from 'src/utilities'; +import { lastSyncStatus, lastSynced } from 'src/utilities/last-sync-task'; +import { parsePulpIDFromURL } from 'src/utilities/parse-pulp-id'; import { type RepositoryDistribution, distributionsLoadError, diff --git a/src/containers/rpm-repository/tab-details.tsx b/src/containers/rpm-repository/tab-details.tsx index 01db624f..cbfbdf0e 100644 --- a/src/containers/rpm-repository/tab-details.tsx +++ b/src/containers/rpm-repository/tab-details.tsx @@ -1,6 +1,7 @@ import { t } from '@lingui/core/macro'; import { Link } from 'react-router'; -import { type RPMRemoteType, type RPMRepositoryType } from 'src/api'; +import { type RPMRemoteType } from 'src/api/rpm-remote'; +import { type RPMRepositoryType } from 'src/api/rpm-repository'; import { Details, PulpLabels } from 'src/components'; import { DistributionURLs } from 'src/components/distribution-url'; import { Paths, formatPath } from 'src/paths'; diff --git a/src/containers/rpm-repository/tab-distributions.tsx b/src/containers/rpm-repository/tab-distributions.tsx index ba9dc5ef..cc58289e 100644 --- a/src/containers/rpm-repository/tab-distributions.tsx +++ b/src/containers/rpm-repository/tab-distributions.tsx @@ -1,6 +1,6 @@ import { t } from '@lingui/core/macro'; import { Td, Tr } from '@patternfly/react-table'; -import { type RPMRepositoryType } from 'src/api'; +import { type RPMRepositoryType } from 'src/api/rpm-repository'; import { ClipboardCopy, DateComponent, DetailList } from 'src/components'; import { DistributionURL } from 'src/components/distribution-url'; import {