From 1a57bf8a71b34e72cb4913e016d4e0e0dd83bdd1 Mon Sep 17 00:00:00 2001 From: Nick Parente <17182181+Nparente27@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:01:53 -0400 Subject: [PATCH 01/15] Add Python repository, remote and distribution APIs (cherry picked from commit 9c36ec9cdde48688e0bb4099905647329c894686) --- src/api/index.ts | 11 ++++ src/api/python-distribution.ts | 11 ++++ src/api/python-remote.ts | 67 ++++++++++++++++++++ src/api/python-repository.ts | 40 ++++++++++++ src/utilities/plugin-repository-base-path.ts | 9 +++ 5 files changed, 138 insertions(+) create mode 100644 src/api/python-distribution.ts create mode 100644 src/api/python-remote.ts create mode 100644 src/api/python-repository.ts diff --git a/src/api/index.ts b/src/api/index.ts index 9e7b2efd..5926648b 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -77,6 +77,17 @@ export { type UserType, } from './response-types/user'; export { RoleAPI } from './role'; +export { PythonDistributionAPI } from './python-distribution'; +export { + PythonRemoteAPI, + type PythonPackageType, + type PythonPlatform, + type PythonRemoteType, +} from './python-remote'; +export { + PythonRepositoryAPI, + type PythonRepositoryType, +} from './python-repository'; export { RPMPackageAPI } from './rpm-package'; export { RPMDistributionAPI } from './rpm-distribution'; export { RPMRemoteAPI, type RPMRemoteType } from './rpm-remote'; diff --git a/src/api/python-distribution.ts b/src/api/python-distribution.ts new file mode 100644 index 00000000..f200e930 --- /dev/null +++ b/src/api/python-distribution.ts @@ -0,0 +1,11 @@ +import { PulpAPI } from './pulp'; + +const base = new PulpAPI(); + +export const PythonDistributionAPI = { + create: (data) => base.http.post(`distributions/python/pypi/`, data), + + delete: (id) => base.http.delete(`distributions/python/pypi/${id}/`), + + list: (params?) => base.list(`distributions/python/pypi/`, params), +}; diff --git a/src/api/python-remote.ts b/src/api/python-remote.ts new file mode 100644 index 00000000..312b6152 --- /dev/null +++ b/src/api/python-remote.ts @@ -0,0 +1,67 @@ +import type { GenericRemote } from './common'; +import { PulpAPI } from './pulp'; + +type PythonPackageType = + | 'bdist_dmg' + | 'bdist_dumb' + | 'bdist_egg' + | 'bdist_msi' + | 'bdist_rpm' + | 'bdist_wheel' + | 'bdist_wininst' + | 'sdist'; +type PythonPlatform = 'windows' | 'macos' | 'freebsd' | 'linux'; + +/** + * Python Remote Type. + * + * @see https://github.com/pulp/pulp_python/blob/main/pulp_python/app/serializers.py + */ +interface PythonRemoteType extends GenericRemote { + includes?: string[]; + excludes?: string[]; + prereleases?: boolean; + package_types?: PythonPackageType[]; + keep_latest_packages?: number; + exclude_platforms?: PythonPlatform[]; + provenance?: boolean; +} + +// as in deb-remote / file-remote +function smartUpdate( + remote: PythonRemoteType, + unmodifiedRemote: PythonRemoteType, +) { + 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 PythonRemoteAPI = { + create: (data) => base.http.post(`remotes/python/python/`, data), + + delete: (id) => base.http.delete(`remotes/python/python/${id}/`), + + get: (id) => base.http.get(`remotes/python/python/${id}/`), + + list: (params?) => base.list(`remotes/python/python/`, params), + + smartUpdate: (id, newValue: PythonRemoteType, oldValue: PythonRemoteType) => + base.http.put( + `remotes/python/python/${id}/`, + smartUpdate(newValue, oldValue), + ), +}; + +export type { PythonPackageType, PythonPlatform, PythonRemoteType }; diff --git a/src/api/python-repository.ts b/src/api/python-repository.ts new file mode 100644 index 00000000..d434bc2b --- /dev/null +++ b/src/api/python-repository.ts @@ -0,0 +1,40 @@ +import type { GenericRepository } from './common'; +import { PulpAPI } from './pulp'; + +/** + * Python Repository Type. + * + * @see https://github.com/pulp/pulp_python/blob/main/pulp_python/app/serializers.py + */ +interface PythonRepositoryType extends GenericRepository { + /** @deprecated by pulp_python; not offered in the UI */ + autopublish?: boolean; + allow_package_substitution?: boolean; + error_on_reject?: boolean; +} + +const base = new PulpAPI(); + +export const PythonRepositoryAPI = { + create: (data) => base.http.post(`repositories/python/python/`, data), + + delete: (id) => base.http.delete(`repositories/python/python/${id}/`), + + list: (params?) => base.list(`repositories/python/python/`, params), + + listVersions: (id: string, params?) => + base.list(`repositories/python/python/${id}/versions/`, params), + + revert: (id: string, version_href) => + base.http.post(`repositories/python/python/${id}/modify/`, { + base_version: version_href, + }), + + sync: (id: string, body = {}) => + base.http.post(`repositories/python/python/${id}/sync/`, body), + + update: (id: string, data) => + base.http.put(`repositories/python/python/${id}/`, data), +}; + +export type { PythonRepositoryType }; diff --git a/src/utilities/plugin-repository-base-path.ts b/src/utilities/plugin-repository-base-path.ts index 798d62c6..45e91cd9 100644 --- a/src/utilities/plugin-repository-base-path.ts +++ b/src/utilities/plugin-repository-base-path.ts @@ -8,6 +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 { PythonDistributionAPI } from '../api/python-distribution'; +import { PythonRemoteAPI } from '../api/python-remote'; +import { PythonRepositoryAPI } from '../api/python-repository'; import { RPMDistributionAPI } from '../api/rpm-distribution'; import { RPMRemoteAPI } from '../api/rpm-remote'; import { RPMRepositoryAPI } from '../api/rpm-repository'; @@ -38,6 +41,12 @@ export function plugin2api(plugin) { RemoteAPI: FileRemoteAPI, RepositoryAPI: FileRepositoryAPI, }; + case 'python': + return { + DistributionAPI: PythonDistributionAPI, + RemoteAPI: PythonRemoteAPI, + RepositoryAPI: PythonRepositoryAPI, + }; case 'rpm': return { DistributionAPI: RPMDistributionAPI, From 2a9618fcc6e08a9d7f79d4524fe3776d22a38f57 Mon Sep 17 00:00:00 2001 From: Nick Parente <17182181+Nparente27@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:28:34 -0400 Subject: [PATCH 02/15] Add Python repository and remote pages (cherry picked from commit 3cbb89ed5a59a9e9d08c117762f0b342884102df) --- src/actions/index.ts | 7 + src/actions/python-remote-create.tsx | 9 + src/actions/python-remote-delete.tsx | 44 ++++ src/actions/python-remote-edit.tsx | 9 + src/actions/python-repository-create.tsx | 9 + src/actions/python-repository-delete.tsx | 123 +++++++++ src/actions/python-repository-edit.tsx | 9 + src/actions/python-repository-sync.tsx | 63 +++++ src/app-routes.tsx | 30 +++ src/components/lazy-repositories.tsx | 2 +- src/components/remote-form.tsx | 3 +- src/components/repository-form.tsx | 2 +- src/containers/index.ts | 6 + src/containers/python-remote/detail.tsx | 48 ++++ src/containers/python-remote/edit.tsx | 149 +++++++++++ src/containers/python-remote/list.tsx | 81 ++++++ src/containers/python-remote/tab-details.tsx | 97 +++++++ src/containers/python-repository/detail.tsx | 145 +++++++++++ src/containers/python-repository/edit.tsx | 181 +++++++++++++ src/containers/python-repository/list.tsx | 124 +++++++++ .../python-repository/tab-details.tsx | 60 +++++ .../python-repository/tab-distributions.tsx | 128 ++++++++++ .../tab-repository-versions.tsx | 241 ++++++++++++++++++ src/menu.tsx | 12 + src/paths.ts | 12 + 25 files changed, 1591 insertions(+), 3 deletions(-) create mode 100644 src/actions/python-remote-create.tsx create mode 100644 src/actions/python-remote-delete.tsx create mode 100644 src/actions/python-remote-edit.tsx create mode 100644 src/actions/python-repository-create.tsx create mode 100644 src/actions/python-repository-delete.tsx create mode 100644 src/actions/python-repository-edit.tsx create mode 100644 src/actions/python-repository-sync.tsx create mode 100644 src/containers/python-remote/detail.tsx create mode 100644 src/containers/python-remote/edit.tsx create mode 100644 src/containers/python-remote/list.tsx create mode 100644 src/containers/python-remote/tab-details.tsx create mode 100644 src/containers/python-repository/detail.tsx create mode 100644 src/containers/python-repository/edit.tsx create mode 100644 src/containers/python-repository/list.tsx create mode 100644 src/containers/python-repository/tab-details.tsx create mode 100644 src/containers/python-repository/tab-distributions.tsx create mode 100644 src/containers/python-repository/tab-repository-versions.tsx diff --git a/src/actions/index.ts b/src/actions/index.ts index 3c949376..d20bd577 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -27,6 +27,13 @@ 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 { pythonRemoteCreateAction } from './python-remote-create'; +export { pythonRemoteDeleteAction } from './python-remote-delete'; +export { pythonRemoteEditAction } from './python-remote-edit'; +export { pythonRepositoryCreateAction } from './python-repository-create'; +export { pythonRepositoryDeleteAction } from './python-repository-delete'; +export { pythonRepositoryEditAction } from './python-repository-edit'; +export { pythonRepositorySyncAction } from './python-repository-sync'; export { rpmRemoteCreateAction } from './rpm-remote-create'; export { rpmRemoteDeleteAction } from './rpm-remote-delete'; export { rpmRemoteEditAction } from './rpm-remote-edit'; diff --git a/src/actions/python-remote-create.tsx b/src/actions/python-remote-create.tsx new file mode 100644 index 00000000..37dc42cc --- /dev/null +++ b/src/actions/python-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 pythonRemoteCreateAction = Action({ + title: msg`Add remote`, + onClick: (item, { navigate }) => + navigate(formatPath(Paths.python.remote.edit, { name: '_' })), +}); diff --git a/src/actions/python-remote-delete.tsx b/src/actions/python-remote-delete.tsx new file mode 100644 index 00000000..ce78c167 --- /dev/null +++ b/src/actions/python-remote-delete.tsx @@ -0,0 +1,44 @@ +import { msg, t } from '@lingui/core/macro'; +import { PythonRemoteAPI } from 'src/api'; +import { DeleteRemoteModal } from 'src/components'; +import { + handleHttpError, + parsePulpIDFromURL, + taskAlert, + waitForTaskUrl, +} from 'src/utilities'; +import { Action } from './action'; + +export const pythonRemoteDeleteAction = 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 PythonRemoteAPI.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/python-remote-edit.tsx b/src/actions/python-remote-edit.tsx new file mode 100644 index 00000000..00a16de0 --- /dev/null +++ b/src/actions/python-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 pythonRemoteEditAction = Action({ + title: msg`Edit`, + onClick: ({ name }, { navigate }) => + navigate(formatPath(Paths.python.remote.edit, { name })), +}); diff --git a/src/actions/python-repository-create.tsx b/src/actions/python-repository-create.tsx new file mode 100644 index 00000000..8c18cc61 --- /dev/null +++ b/src/actions/python-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 pythonRepositoryCreateAction = Action({ + title: msg`Add repository`, + onClick: (item, { navigate }) => + navigate(formatPath(Paths.python.repository.edit, { name: '_' })), +}); diff --git a/src/actions/python-repository-delete.tsx b/src/actions/python-repository-delete.tsx new file mode 100644 index 00000000..d7fe52d9 --- /dev/null +++ b/src/actions/python-repository-delete.tsx @@ -0,0 +1,123 @@ +import { msg, t } from '@lingui/core/macro'; +import { PythonDistributionAPI, PythonRepositoryAPI } from 'src/api'; +import { DeleteRepositoryModal } from 'src/components'; +import { + handleHttpError, + parsePulpIDFromURL, + taskAlert, + waitForTaskUrl, +} from 'src/utilities'; +import { Action } from './action'; + +export const pythonRepositoryDeleteAction = 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 PythonDistributionAPI.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 = PythonRepositoryAPI.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 PythonDistributionAPI.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/actions/python-repository-edit.tsx b/src/actions/python-repository-edit.tsx new file mode 100644 index 00000000..ee8c7adc --- /dev/null +++ b/src/actions/python-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 pythonRepositoryEditAction = Action({ + title: msg`Edit`, + onClick: ({ name }, { navigate }) => + navigate(formatPath(Paths.python.repository.edit, { name })), +}); diff --git a/src/actions/python-repository-sync.tsx b/src/actions/python-repository-sync.tsx new file mode 100644 index 00000000..bb9cf439 --- /dev/null +++ b/src/actions/python-repository-sync.tsx @@ -0,0 +1,63 @@ +import { msg, t } from '@lingui/core/macro'; +import { PythonRepositoryAPI } from 'src/api'; +import { SyncModal } from 'src/components'; +import { handleHttpError, parsePulpIDFromURL, taskAlert } from 'src/utilities'; +import { Action } from './action'; + +// pulp_python syncs additively unless mirror is requested. Mirroring deletes +// local content the remote no longer has, so it is the direction to opt into +// rather than out of, as for deb. +const MIRROR_BY_DEFAULT = false; + +export const pythonRepositorySyncAction = Action({ + title: msg`Sync`, + modal: ({ addAlert, query, setState, state }) => + state.syncModalOpen ? ( + setState({ syncModalOpen: null })} + defaultMirror={MIRROR_BY_DEFAULT} + 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('python.change_pythonrepository'), + 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 PythonRepositoryAPI.sync( + pulpId, + syncParams || { mirror: MIRROR_BY_DEFAULT }, + ) + .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/app-routes.tsx b/src/app-routes.tsx index 986a5cf7..9d82edcd 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -55,6 +55,12 @@ import { NamespaceDetail, Partners, PulpStatus, + PythonRemoteDetail, + PythonRemoteEdit, + PythonRemoteList, + PythonRepositoryDetail, + PythonRepositoryEdit, + PythonRepositoryList, RPMPackageList, RPMRemoteDetail, RPMRemoteEdit, @@ -350,6 +356,30 @@ const routes: IRouteConfig[] = [ path: Paths.meta.about, noAuth: true, }, + { + component: PythonRemoteDetail, + path: Paths.python.remote.detail, + }, + { + component: PythonRemoteEdit, + path: Paths.python.remote.edit, + }, + { + component: PythonRemoteList, + path: Paths.python.remote.list, + }, + { + component: PythonRepositoryDetail, + path: Paths.python.repository.detail, + }, + { + component: PythonRepositoryEdit, + path: Paths.python.repository.edit, + }, + { + component: PythonRepositoryList, + path: Paths.python.repository.list, + }, { component: RPMPackageList, path: Paths.rpm.package.list, diff --git a/src/components/lazy-repositories.tsx b/src/components/lazy-repositories.tsx index b3aaf150..c444b08b 100644 --- a/src/components/lazy-repositories.tsx +++ b/src/components/lazy-repositories.tsx @@ -14,7 +14,7 @@ export const LazyRepositories = ({ }: { content_href?: string; emptyText?: string; - plugin: 'ansible' | 'deb' | 'file' | 'rpm'; + plugin: 'ansible' | 'deb' | 'file' | 'python' | 'rpm'; remote_href?: string; }) => { const [repositories, setRepositories] = useState([]); diff --git a/src/components/remote-form.tsx b/src/components/remote-form.tsx index 9cde8cac..d4b50107 100644 --- a/src/components/remote-form.tsx +++ b/src/components/remote-form.tsx @@ -43,7 +43,7 @@ interface IProps { allowEditName?: boolean; closeModal: () => void; errorMessages: ErrorMessagesType; - plugin: 'ansible' | 'container' | 'deb' | 'file' | 'rpm'; + plugin: 'ansible' | 'container' | 'deb' | 'file' | 'python' | 'rpm'; remote: RemoteType; saveRemote: () => void; showMain?: boolean; @@ -197,6 +197,7 @@ export class RemoteForm extends Component { case 'container': case 'deb': case 'file': + case 'python': case 'rpm': disabledFields = disabledFields.concat([ 'auth_url', diff --git a/src/components/repository-form.tsx b/src/components/repository-form.tsx index 18ca9bbc..33cdb56f 100644 --- a/src/components/repository-form.tsx +++ b/src/components/repository-form.tsx @@ -41,7 +41,7 @@ interface IProps { onSave: (options: { distribution: { basePath: string } | null; }) => void | Promise; - plugin: 'ansible' | 'deb' | 'file' | 'rpm'; + plugin: 'ansible' | 'deb' | 'file' | 'python' | 'rpm'; repository: AnsibleRepositoryType; updateRepository: (r) => void; } diff --git a/src/containers/index.ts b/src/containers/index.ts index e29d45b6..b3cb67e9 100644 --- a/src/containers/index.ts +++ b/src/containers/index.ts @@ -43,6 +43,12 @@ export { default as PulpStatus } from './pulp-status'; 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 PythonRemoteDetail } from './python-remote/detail'; +export { default as PythonRemoteEdit } from './python-remote/edit'; +export { default as PythonRemoteList } from './python-remote/list'; +export { default as PythonRepositoryDetail } from './python-repository/detail'; +export { default as PythonRepositoryEdit } from './python-repository/edit'; +export { default as PythonRepositoryList } from './python-repository/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'; diff --git a/src/containers/python-remote/detail.tsx b/src/containers/python-remote/detail.tsx new file mode 100644 index 00000000..38d29164 --- /dev/null +++ b/src/containers/python-remote/detail.tsx @@ -0,0 +1,48 @@ +import { msg, t } from '@lingui/core/macro'; +import { pythonRemoteDeleteAction, pythonRemoteEditAction } from 'src/actions'; +import { PythonRemoteAPI, type PythonRemoteType } from 'src/api'; +import { PageWithTabs } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { DetailsTab } from './tab-details'; + +const PythonRemoteDetail = PageWithTabs({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.python.remote.list), name: t`Remotes` }, + { url: formatPath(Paths.python.remote.detail, { name }), name }, + ].filter(Boolean), + displayName: 'PythonRemoteDetail', + errorTitle: msg`Remote could not be displayed.`, + headerActions: [pythonRemoteEditAction, pythonRemoteDeleteAction], + listUrl: formatPath(Paths.python.remote.list), + query: ({ name }) => + PythonRemoteAPI.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.python.remote.detail, + { name }, + { tab: 'details' }, + ), + }, + ], +}); + +export default PythonRemoteDetail; diff --git a/src/containers/python-remote/edit.tsx b/src/containers/python-remote/edit.tsx new file mode 100644 index 00000000..28228a1d --- /dev/null +++ b/src/containers/python-remote/edit.tsx @@ -0,0 +1,149 @@ +import { msg, t } from '@lingui/core/macro'; +import { PythonRemoteAPI, type PythonRemoteType } from 'src/api'; +import { Page, RemoteForm } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL, taskAlert } from 'src/utilities'; + +const initialRemote: PythonRemoteType = { + name: '', + url: '', + ca_cert: null, + client_cert: null, + tls_validation: true, + proxy_url: null, + download_concurrency: null, + rate_limit: null, + + hidden_fields: [ + 'client_key', + 'proxy_username', + 'proxy_password', + 'username', + 'password', + ].map((name) => ({ name, is_set: false })), +}; + +const PythonRemoteEdit = Page({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.python.remote.list), name: t`Remotes` }, + name && { url: formatPath(Paths.python.remote.detail, { name }), name }, + name ? { name: t`Edit` } : { name: t`Add` }, + ].filter(Boolean), + + displayName: 'PythonRemoteEdit', + errorTitle: msg`Remote could not be displayed.`, + listUrl: formatPath(Paths.python.remote.list), + query: ({ name }) => + PythonRemoteAPI.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 + ? PythonRemoteAPI.create(data) + : PythonRemoteAPI.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.python.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.python.remote.detail, { + name: item.name, + }) + : formatPath(Paths.python.remote.list), + ); + }; + + return ( + setState({ remoteToEdit: r })} + /> + ); + }, +}); + +export default PythonRemoteEdit; diff --git a/src/containers/python-remote/list.tsx b/src/containers/python-remote/list.tsx new file mode 100644 index 00000000..160066d9 --- /dev/null +++ b/src/containers/python-remote/list.tsx @@ -0,0 +1,81 @@ +import { msg, t } from '@lingui/core/macro'; +import { Td, Tr } from '@patternfly/react-table'; +import { Link } from 'react-router'; +import { + pythonRemoteCreateAction, + pythonRemoteDeleteAction, + pythonRemoteEditAction, +} from 'src/actions'; +import { PythonRemoteAPI, type PythonRemoteType } from 'src/api'; +import { CopyURL, ListItemActions, ListPage } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL } from 'src/utilities'; + +const listItemActions = [ + // Edit + pythonRemoteEditAction, + // Delete + pythonRemoteDeleteAction, +]; + +const PythonRemoteList = ListPage({ + defaultPageSize: 10, + defaultSort: '-pulp_created', + displayName: 'PythonRemoteList', + errorTitle: msg`Remotes could not be displayed.`, + filterConfig: () => [ + { + id: 'name__icontains', + title: t`Remote name`, + }, + ], + headerActions: [pythonRemoteCreateAction], // Add remote + listItemActions, + noDataButton: pythonRemoteCreateAction.button, + noDataDescription: msg`Remotes will appear once created.`, + noDataTitle: msg`No remotes yet`, + query: ({ params }) => PythonRemoteAPI.list(params), + renderTableRow(item: PythonRemoteType, 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 PythonRemoteList; diff --git a/src/containers/python-remote/tab-details.tsx b/src/containers/python-remote/tab-details.tsx new file mode 100644 index 00000000..30070133 --- /dev/null +++ b/src/containers/python-remote/tab-details.tsx @@ -0,0 +1,97 @@ +import { t } from '@lingui/core/macro'; +import { type PythonRemoteType } from 'src/api'; +import { + CopyURL, + Details, + LazyRepositories, + PulpCodeBlock, +} from 'src/components'; + +interface TabProps { + item: PythonRemoteType; + 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 }, + { + label: t`Includes`, + value: item?.includes?.length + ? item.includes.join(', ') + : t`All projects`, + }, + { + label: t`Excludes`, + value: item?.excludes?.length ? item.excludes.join(', ') : t`None`, + }, + { + label: t`Pre-releases`, + value: item?.prereleases ? t`Included` : t`Excluded`, + }, + { + label: t`Package types`, + value: item?.package_types?.length + ? item.package_types.join(', ') + : t`All`, + }, + { + label: t`Latest versions kept per package`, + value: item?.keep_latest_packages || t`All`, + }, + { + label: t`Excluded platforms`, + value: item?.exclude_platforms?.length + ? item.exclude_platforms.join(', ') + : t`None`, + }, + { + label: t`Provenance`, + value: item?.provenance ? t`Synced` : t`Not synced`, + }, + { + 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: ( + + ), + }, + ]} + /> +); diff --git a/src/containers/python-repository/detail.tsx b/src/containers/python-repository/detail.tsx new file mode 100644 index 00000000..033c5882 --- /dev/null +++ b/src/containers/python-repository/detail.tsx @@ -0,0 +1,145 @@ +import { msg, t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { + pythonRepositoryDeleteAction, + pythonRepositoryEditAction, + pythonRepositorySyncAction, +} from 'src/actions'; +import { + PythonRemoteAPI, + type PythonRemoteType, + PythonRepositoryAPI, + type PythonRepositoryType, +} 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 PythonRepositoryDetail = PageWithTabs< + PythonRepositoryType & { remote?: PythonRemoteType } +>({ + breadcrumbs: ({ name, tab, params: { repositoryVersion } }) => { + const crumbs = [ + { url: formatPath(Paths.python.repository.list), name: t`Repositories` }, + { url: formatPath(Paths.python.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.python.repository.detail, { name }, { tab }), + name: t`Versions`, + }, + { name: t`Version ${repositoryVersion}` }, + ] + : [...crumbs, { name: t`Versions` }]; + }, + displayName: 'PythonRepositoryDetail', + errorTitle: msg`Repository could not be displayed.`, + headerActions: [ + pythonRepositoryEditAction, + pythonRepositorySyncAction, + pythonRepositoryDeleteAction, + ], + headerDetails: (item) => ( + <> + {item?.last_sync_task && ( +

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

+ )} + + ), + listUrl: formatPath(Paths.python.repository.list), + query: ({ name }) => + PythonRepositoryAPI.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 Python distribution endpoint is the + // one consulted + pluginRepositoryBasePath( + 'python', + repository.name, + repository.pulp_href, + ).catch(err(null)), + repository.remote + ? PythonRemoteAPI.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.python.repository.detail, + { name }, + { tab: 'details' }, + ), + }, + { + active: tab === 'repository-versions', + title: t`Versions`, + link: formatPath( + Paths.python.repository.detail, + { name }, + { tab: 'repository-versions' }, + ), + }, + { + active: tab === 'distributions', + title: t`Distributions`, + link: formatPath( + Paths.python.repository.detail, + { name }, + { tab: 'distributions' }, + ), + }, + ], +}); + +export default PythonRepositoryDetail; diff --git a/src/containers/python-repository/edit.tsx b/src/containers/python-repository/edit.tsx new file mode 100644 index 00000000..e0734074 --- /dev/null +++ b/src/containers/python-repository/edit.tsx @@ -0,0 +1,181 @@ +import { msg, t } from '@lingui/core/macro'; +import { + PythonDistributionAPI, + PythonRepositoryAPI, + type PythonRepositoryType, +} from 'src/api'; +import { Page, RepositoryForm } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL, taskAlert } from 'src/utilities'; + +const initialRepository: PythonRepositoryType = { + name: '', + description: '', + retain_repo_versions: 1, + pulp_labels: {}, + remote: null, +}; + +const PythonRepositoryEdit = Page({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.python.repository.list), name: t`Repositories` }, + name && { + url: formatPath(Paths.python.repository.detail, { name }), + name, + }, + name ? { name: t`Edit` } : { name: t`Add` }, + ].filter(Boolean), + + displayName: 'PythonRepositoryEdit', + errorTitle: msg`Repository could not be displayed.`, + listUrl: formatPath(Paths.python.repository.list), + query: ({ name }) => + PythonRepositoryAPI.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 + ? PythonRepositoryAPI.create(data).then(({ data: newData }) => { + queueAlert({ + variant: 'success', + title: t`Successfully created repository ${data.name}`, + }); + + return newData.pulp_href; + }) + : PythonRepositoryAPI.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) => + PythonDistributionAPI.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 PythonDistributionAPI.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.python.repository.detail, { + name: data.name, + }), + ); + }) + .catch(({ response: { data } }) => + setState({ + errorMessages: { + __nofield: data.non_field_errors || data.detail, + ...data, + }, + }), + ); + }; + + const closeModal = () => { + setState({ errorMessages: {}, repositoryToEdit: undefined }); + navigate( + item + ? formatPath(Paths.python.repository.detail, { + name: item.name, + }) + : formatPath(Paths.python.repository.list), + ); + }; + + return ( + setState({ repositoryToEdit: r })} + /> + ); + }, +}); + +export default PythonRepositoryEdit; diff --git a/src/containers/python-repository/list.tsx b/src/containers/python-repository/list.tsx new file mode 100644 index 00000000..7dc81215 --- /dev/null +++ b/src/containers/python-repository/list.tsx @@ -0,0 +1,124 @@ +import { msg, t } from '@lingui/core/macro'; +import { Td, Tr } from '@patternfly/react-table'; +import { Link } from 'react-router'; +import { + pythonRepositoryCreateAction, + pythonRepositoryDeleteAction, + pythonRepositoryEditAction, + pythonRepositorySyncAction, +} from 'src/actions'; +import { + PythonRemoteAPI, + PythonRepositoryAPI, + type PythonRepositoryType, +} from 'src/api'; +import { + DateComponent, + ListItemActions, + ListPage, + PulpLabels, +} from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL } from 'src/utilities'; + +const listItemActions = [ + // Edit + pythonRepositoryEditAction, + // Sync + pythonRepositorySyncAction, + // Delete + pythonRepositoryDeleteAction, +]; + +const typeaheadQuery = ({ inputText, selectedFilter, setState }) => { + if (selectedFilter !== 'remote') { + return; + } + + return PythonRemoteAPI.list({ name__icontains: inputText }) + .then(({ data: { results } }) => + results.map(({ name, pulp_href }) => ({ id: pulp_href, title: name })), + ) + .then((remotes) => setState({ remotes })); +}; + +const PythonRepositoryList = ListPage({ + defaultPageSize: 10, + defaultSort: '-pulp_created', + displayName: 'PythonRepositoryList', + 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 || []), + ], + }, + ], + headerActions: [pythonRepositoryCreateAction], // Add repository + listItemActions, + noDataButton: pythonRepositoryCreateAction.button, + noDataDescription: msg`Repositories will appear once created.`, + noDataTitle: msg`No repositories yet`, + query: ({ params }) => PythonRepositoryAPI.list(params), + typeaheadQuery, + renderTableRow(item: PythonRepositoryType, 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 PythonRepositoryList; diff --git a/src/containers/python-repository/tab-details.tsx b/src/containers/python-repository/tab-details.tsx new file mode 100644 index 00000000..7397d4c0 --- /dev/null +++ b/src/containers/python-repository/tab-details.tsx @@ -0,0 +1,60 @@ +import { t } from '@lingui/core/macro'; +import { Link } from 'react-router'; +import { type PythonRemoteType, type PythonRepositoryType } from 'src/api'; +import { Details, PulpLabels } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; + +interface TabProps { + item: PythonRepositoryType & { + distroBasePath?: string; + remote?: PythonRemoteType; + }; + actionContext: { addAlert: (alert) => void; state: { params } }; +} + +export const DetailsTab = ({ item }: TabProps) => { + return ( +
, + }, + { + label: t`Remote`, + value: item?.remote ? ( + + {item?.remote.name} + + ) : ( + t`None` + ), + }, + { + label: t`Package substitution`, + value: + item?.allow_package_substitution === false + ? t`Not allowed` + : t`Allowed`, + }, + { + label: t`When packages are rejected`, + value: + item?.error_on_reject === false + ? t`Skip them` + : t`Fail the whole version`, + }, + ]} + /> + ); +}; diff --git a/src/containers/python-repository/tab-distributions.tsx b/src/containers/python-repository/tab-distributions.tsx new file mode 100644 index 00000000..5297682a --- /dev/null +++ b/src/containers/python-repository/tab-distributions.tsx @@ -0,0 +1,128 @@ +import { t } from '@lingui/core/macro'; +import { Td, Tr } from '@patternfly/react-table'; +import { PythonDistributionAPI, type PythonRepositoryType } from 'src/api'; +import { ClipboardCopy, DateComponent, DetailList } from 'src/components'; + +interface TabProps { + item: PythonRepositoryType; + actionContext: { + addAlert: (alert) => void; + state: { params }; + hasPermission; + }; +} + +interface Distribution { + base_path: string; + base_url: 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 PythonDistributionAPI.list({ + repository: item.pulp_href, + ...newParams, + }); + }; + + // pulp_python reports where pip should look as the distribution's base_url; + // the simple index lives under it. + const cliConfig = (base_url) => + `pip install --index-url ${base_url}simple/ `; + + const renderTableRow = ( + item: Distribution, + index: number, + _actionContext, + ) => { + const { name, base_path, base_url, pulp_created } = item; + + return ( + + {name} + {base_path} + + + + + + {cliConfig(base_url)} + + + + ); + }; + + return ( + + actionContext={{ + addAlert, + query, + hasPermission, + // No hasObjectPermission: nothing in this list is gated on one. Python + // repositories do carry my_permissions, so an action added here later + // can check it properly rather than stubbing it. + }} + 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`Install with pip`, + type: 'none', + id: '', + }, + ]} + title={t`Distributions`} + /> + ); +}; diff --git a/src/containers/python-repository/tab-repository-versions.tsx b/src/containers/python-repository/tab-repository-versions.tsx new file mode 100644 index 00000000..a49452cd --- /dev/null +++ b/src/containers/python-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 { PythonRepositoryAPI } 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 PythonRepositoryVersionType { + 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 }) => + PythonRepositoryAPI.listVersions(pulpId, params); + const queryDetail = ({ number }) => + PythonRepositoryAPI.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: PythonRepositoryVersionType, + 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 36422478..c2aacba4 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -115,6 +115,18 @@ function standaloneMenu() { url: formatPath(Paths.file.remote.list), }), ]), + menuSection( + 'Pulp Python', + { condition: and(loggedIn, hasPlugin('python')) }, + [ + menuItem(t`Repositories`, { + url: formatPath(Paths.python.repository.list), + }), + menuItem(t`Remotes`, { + url: formatPath(Paths.python.remote.list), + }), + ], + ), menuSection('Pulp RPM', { condition: and(loggedIn, hasPlugin('rpm')) }, [ menuItem(t`Repositories`, { url: formatPath(Paths.rpm.repository.list), diff --git a/src/paths.ts b/src/paths.ts index 23437e74..5827313f 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -151,6 +151,18 @@ export const Paths = { login: '/login', search: '/search', }, + python: { + remote: { + detail: '/python/remotes/detail/:name', + edit: '/python/remotes/edit/:name', + list: '/python/remotes', + }, + repository: { + detail: '/python/repositories/detail/:name', + edit: '/python/repositories/edit/:name', + list: '/python/repositories', + }, + }, rpm: { remote: { detail: '/rpm/remotes/detail/:name', From 445fd37d2a0dcad935ad7542793916aaeb75b9c8 Mon Sep 17 00:00:00 2001 From: Nick Parente <17182181+Nparente27@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:34:10 -0400 Subject: [PATCH 03/15] Add Python package substitution settings to the repository form (cherry picked from commit 141d3dae37ea3a89f595f6b64609e4faedbfea43) --- src/components/repository-form.tsx | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/components/repository-form.tsx b/src/components/repository-form.tsx index 33cdb56f..fe5c5323 100644 --- a/src/components/repository-form.tsx +++ b/src/components/repository-form.tsx @@ -227,6 +227,40 @@ export const RepositoryForm = ({ )} + {plugin === 'python' && ( + <> + {formGroup( + 'allow_package_substitution', + t`Allow package substitution`, + t`Allow replacing an existing package with one that has the same filename but a different checksum. When off, any new repository version that would cause such a substitution is rejected, whether from sync, upload or modify.`, + + updateRepository({ + ...repository, + allow_package_substitution: value, + }) + } + label={t`Allow same-filename packages with different contents`} + />, + )} + {formGroup( + 'error_on_reject', + t`Fail on rejected packages`, + t`When on, a rejected package fails the whole repository version and nothing is added. When off, rejected packages are skipped, the rest are added, and the skipped ones are listed in the task's progress report.`, + + updateRepository({ ...repository, error_on_reject: value }) + } + label={t`Fail the whole version if any package is rejected`} + />, + )} + + )} + {formGroup( 'distributions', t`Distributions`, From 1c1168060c6b5d4946488dc8f3b21ffb342f9087 Mon Sep 17 00:00:00 2001 From: Nick Parente <17182181+Nparente27@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:48:37 -0400 Subject: [PATCH 04/15] Add Python remote fields to the remote form (cherry picked from commit 51e44749f41a9b72da72b242b3885d8304910da1) --- src/components/remote-form.tsx | 178 ++++++++++++++++++++++++++ src/containers/python-remote/edit.tsx | 8 ++ 2 files changed, 186 insertions(+) diff --git a/src/components/remote-form.tsx b/src/components/remote-form.tsx index d4b50107..899e9415 100644 --- a/src/components/remote-form.tsx +++ b/src/components/remote-form.tsx @@ -15,6 +15,7 @@ import { InputGroupItem, Modal, Switch, + TextArea, TextInput, } from '@patternfly/react-core'; import DownloadIcon from '@patternfly/react-icons/dist/esm/icons/download-icon'; @@ -372,6 +373,183 @@ export class RemoteForm extends Component { )} + + {plugin === 'python' ? ( + <> + {(['includes', 'excludes'] as const).map((field) => ( + =4.2". Leave empty to sync every project the remote offers.` + : t`Projects to skip, one specifier per line, for example "django<4". Applied after includes.` + } + /> + } + > +