From ec82c8a5660b4be1ca97df3347405b9283d66475 Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sat, 3 Oct 2026 07:42:03 -0400 Subject: [PATCH 01/12] Add RPM remote and distribution APIs, fill out RPM repository API --- src/api/index.ts | 4 +- src/api/rpm-distribution.ts | 11 +++++ src/api/rpm-remote.ts | 44 ++++++++++++++++++++ src/api/rpm-repository.ts | 18 ++++++++ src/utilities/plugin-repository-base-path.ts | 6 ++- 5 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 src/api/rpm-distribution.ts 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-distribution.ts b/src/api/rpm-distribution.ts new file mode 100644 index 00000000..5490e37f --- /dev/null +++ b/src/api/rpm-distribution.ts @@ -0,0 +1,11 @@ +import { PulpAPI } from './pulp'; + +const base = new PulpAPI(); + +export const RPMDistributionAPI = { + create: (data) => base.http.post(`distributions/rpm/rpm/`, data), + + delete: (id) => base.http.delete(`distributions/rpm/rpm/${id}/`), + + list: (params?) => base.list(`distributions/rpm/rpm/`, params), +}; \ No newline at end of file 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 763ebd11..143b482a 100644 --- a/src/utilities/plugin-repository-base-path.ts +++ b/src/utilities/plugin-repository-base-path.ts @@ -9,6 +9,8 @@ import { FileDistributionAPI } from '../api/file-distribution'; import { FileRemoteAPI } from '../api/file-remote'; import { FileRepositoryAPI } from '../api/file-repository'; import { RPMRepositoryAPI } from '../api/rpm-repository'; +import { RPMDistributionAPI } from '../api/rpm-distribution'; +import { RPMRemoteAPI } from '../api/rpm-remote'; // returns the preferred distribution base_path given a repo name // if there is a distribution with the same name as the repository, it will be used (as long as it's connected to the right repo too) @@ -38,8 +40,8 @@ export function plugin2api(plugin) { }; case 'rpm': return { - // FIXME: DistributionAPI: RPMDistributionAPI, - // FIXME: RemoteAPI: RPMRemoteAPI, + DistributionAPI: RPMDistributionAPI, + RemoteAPI: RPMRemoteAPI, RepositoryAPI: RPMRepositoryAPI, }; default: From 87c2c753952cb9362328981b4420db0e7728f575 Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sat, 3 Oct 2026 08:19:15 -0400 Subject: [PATCH 02/12] Add RPM repository detail page with details, versions and distributions tabs --- 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 552578fe..9f47c48d 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -51,6 +51,9 @@ import { Partners, PulpStatus, RPMPackageList, + RPMRepositoryList, + RPMRepositoryEdit, + RPMRepositoryDetail, RoleCreate, RoleList, Search, @@ -344,6 +347,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 5727d5d1..4f823dbe 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 6a4de802c92ea6a0f992630acea2945aeb4f4ad4 Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sat, 3 Oct 2026 08:24:04 -0400 Subject: [PATCH 03/12] Add Packages tab to RPM repository detail --- 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 36bfea9d12846d99850e8f6df9e475ecf659f7e9 Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sat, 3 Oct 2026 08:30:23 -0400 Subject: [PATCH 04/12] Add RPM repository delete action with distribution cleanup --- 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 b031f503b473096abc96783539fe27e65d241254 Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sat, 3 Oct 2026 08:32:38 -0400 Subject: [PATCH 05/12] Default new RPM repositories to autopublish --- 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 8dbc84c201c118c1eccf861f00b0ae8ce5207f59 Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sat, 3 Oct 2026 08:46:39 -0400 Subject: [PATCH 06/12] Add autopublish and retained package versions to RPM repository form; hide Ansible-only private option --- 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 62025ed6..414e04f2 100644 --- a/src/components/repository-form.tsx +++ b/src/components/repository-form.tsx @@ -148,6 +148,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', @@ -191,8 +213,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 7e5eb65b44245a4cf0fdab6bb2c258c8a2c3f79a Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sat, 3 Oct 2026 09:09:17 -0400 Subject: [PATCH 07/12] Add RPM repository sync with sync_policy selection --- 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 db2fc62c4d637ce6727e4210c707d3f5293a6d4d Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sun, 4 Oct 2026 09:49:57 -0400 Subject: [PATCH 08/12] Add RPM remote pages and download policy selection --- 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 ed815ce1b1708e2d0edddb2183b18681a56da6c1 Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sun, 4 Oct 2026 09:50:05 -0400 Subject: [PATCH 09/12] Wire up RPM remotes; add RPM sync policy, repository form fields and review fixes --- src/actions/index.ts | 3 ++ src/actions/rpm-repository-sync.tsx | 34 ++++++++--------- src/api/index.ts | 2 +- src/api/rpm-distribution.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 +++ src/utilities/plugin-repository-base-path.ts | 2 +- 18 files changed, 140 insertions(+), 66 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-distribution.ts b/src/api/rpm-distribution.ts index 5490e37f..668ab4cb 100644 --- a/src/api/rpm-distribution.ts +++ b/src/api/rpm-distribution.ts @@ -8,4 +8,4 @@ export const RPMDistributionAPI = { delete: (id) => base.http.delete(`distributions/rpm/rpm/${id}/`), list: (params?) => base.list(`distributions/rpm/rpm/`, params), -}; \ No newline at end of file +}; 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 9f47c48d..d0267186 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -51,9 +51,12 @@ import { Partners, PulpStatus, RPMPackageList, - RPMRepositoryList, - RPMRepositoryEdit, + RPMRemoteDetail, + RPMRemoteEdit, + RPMRemoteList, RPMRepositoryDetail, + RPMRepositoryEdit, + RPMRepositoryList, RoleCreate, RoleList, Search, @@ -355,9 +358,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 414e04f2..1ddb82e5 100644 --- a/src/components/repository-form.tsx +++ b/src/components/repository-form.tsx @@ -213,24 +213,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 4f823dbe..e113cfae 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', diff --git a/src/utilities/plugin-repository-base-path.ts b/src/utilities/plugin-repository-base-path.ts index 143b482a..798d62c6 100644 --- a/src/utilities/plugin-repository-base-path.ts +++ b/src/utilities/plugin-repository-base-path.ts @@ -8,9 +8,9 @@ import { DebRepositoryAPI } from '../api/deb-repository'; import { FileDistributionAPI } from '../api/file-distribution'; import { FileRemoteAPI } from '../api/file-remote'; import { FileRepositoryAPI } from '../api/file-repository'; -import { RPMRepositoryAPI } from '../api/rpm-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 // if there is a distribution with the same name as the repository, it will be used (as long as it's connected to the right repo too) From 7aaf0916a128cbd9fc46a7b77f2c058a7bdf5550 Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sun, 4 Oct 2026 09:52:55 -0400 Subject: [PATCH 10/12] Add smoke tests for the RPM repository and remote pages --- cypress/e2e/smoke.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/cypress/e2e/smoke.js b/cypress/e2e/smoke.js index 04a5bcf8..fe6be96b 100644 --- a/cypress/e2e/smoke.js +++ b/cypress/e2e/smoke.js @@ -86,6 +86,31 @@ describe('UI smoke tests', () => { // TODO }); + it('RPM repositories', () => { + cy.ui('rpm/repositories'); + cy.assertTitle('Repositories'); + + cy.contains('No repositories yet'); + + // pulp_rpm repositories carry publishing and retention settings the other + // plugins have no use for + cy.contains('button', 'Add repository').click(); + cy.get('#autopublish'); + cy.get('#retain_package_versions'); + }); + + it('RPM remotes', () => { + cy.ui('rpm/remotes'); + cy.assertTitle('Remotes'); + + cy.contains('No remotes yet'); + + // the download policy decides whether packages are fetched during sync or + // on first request, so an RPM remote offers it in the form + cy.contains('button', 'Add remote').click(); + cy.get('#policy'); + }); + it('RPMs', () => { cy.ui('rpm/rpms'); cy.assertTitle('Packages'); From 264705af96a7732928b76fcc6b21448b4f44226f Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sun, 4 Oct 2026 09:55:03 -0400 Subject: [PATCH 11/12] README: list RPM repositories and remotes as supported --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 12106d32..a3cc5905 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,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 c27d50b2bc1542514063ef7d5b778970a5364c4d Mon Sep 17 00:00:00 2001 From: "nicholas.parente" Date: Sun, 4 Oct 2026 10:07:06 -0400 Subject: [PATCH 12/12] Treat an empty retained package versions field as keep all --- 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 1ddb82e5..e3c19228 100644 --- a/src/components/repository-form.tsx +++ b/src/components/repository-form.tsx @@ -153,7 +153,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;