From 9c36ec9cdde48688e0bb4099905647329c894686 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/10] Add Python repository, remote and distribution APIs --- 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 e39f6c98..44f3b63a 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 { RPMRepositoryAPI } from './rpm-repository'; export { SignCollectionAPI } from './sign-collections'; 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 763ebd11..771cabf4 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 { RPMRepositoryAPI } from '../api/rpm-repository'; // returns the preferred distribution base_path given a repo name @@ -36,6 +39,12 @@ export function plugin2api(plugin) { RemoteAPI: FileRemoteAPI, RepositoryAPI: FileRepositoryAPI, }; + case 'python': + return { + DistributionAPI: PythonDistributionAPI, + RemoteAPI: PythonRemoteAPI, + RepositoryAPI: PythonRepositoryAPI, + }; case 'rpm': return { // FIXME: DistributionAPI: RPMDistributionAPI, From 3cbb89ed5a59a9e9d08c117762f0b342884102df 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/10] Add Python repository and remote pages --- 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-distributions.tsx | 2 +- 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 + 26 files changed, 1592 insertions(+), 4 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 8fac49b5..ee04a700 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -27,3 +27,10 @@ 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'; 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 552578fe..dd2aabf2 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -50,6 +50,12 @@ import { NamespaceDetail, Partners, PulpStatus, + PythonRemoteDetail, + PythonRemoteEdit, + PythonRemoteList, + PythonRepositoryDetail, + PythonRepositoryEdit, + PythonRepositoryList, RPMPackageList, RoleCreate, RoleList, @@ -339,6 +345,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-distributions.tsx b/src/components/lazy-distributions.tsx index 68eefd9d..ffefa7a7 100644 --- a/src/components/lazy-distributions.tsx +++ b/src/components/lazy-distributions.tsx @@ -11,7 +11,7 @@ export const LazyDistributions = ({ repositoryHref, }: { emptyText?: string; - plugin: 'ansible' | 'deb' | 'file' | 'rpm'; + plugin: 'ansible' | 'deb' | 'file' | 'python' | 'rpm'; repositoryHref: string; }) => { const [distributions, setDistributions] = useState([]); 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 45a37736..f0919188 100644 --- a/src/components/remote-form.tsx +++ b/src/components/remote-form.tsx @@ -37,7 +37,7 @@ interface IProps { allowEditName?: boolean; closeModal: () => void; errorMessages: ErrorMessagesType; - plugin: 'ansible' | 'container' | 'deb' | 'file'; + plugin: 'ansible' | 'container' | 'deb' | 'file' | 'python'; remote: RemoteType; saveRemote: () => void; showMain?: boolean; @@ -191,6 +191,7 @@ export class RemoteForm extends Component { case 'container': case 'deb': case 'file': + case 'python': disabledFields = disabledFields.concat([ 'auth_url', 'token', diff --git a/src/components/repository-form.tsx b/src/components/repository-form.tsx index 62025ed6..23de1eeb 100644 --- a/src/components/repository-form.tsx +++ b/src/components/repository-form.tsx @@ -29,7 +29,7 @@ interface IProps { errorMessages: ErrorMessagesType; onCancel: () => void; onSave: ({ createDistribution }) => void; - 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 cc1dd5e5..a5a49ec4 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 MultiSearch } from './search/multi-search'; export { default as Search } from './search/search'; 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 5727d5d1..0681bd3a 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`RPMs`, { url: formatPath(Paths.rpm.package.list), diff --git a/src/paths.ts b/src/paths.ts index 89d0a3f0..c384ae64 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: { package: { list: '/rpm/rpms' }, }, From 141d3dae37ea3a89f595f6b64609e4faedbfea43 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/10] Add Python package substitution settings to the repository form --- 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 23de1eeb..aeb2600e 100644 --- a/src/components/repository-form.tsx +++ b/src/components/repository-form.tsx @@ -149,6 +149,40 @@ export const RepositoryForm = ({ t`In order to retain all versions, leave this field blank.`, )} + {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 51e44749f41a9b72da72b242b3885d8304910da1 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/10] Add Python remote fields to the remote form --- src/components/remote-form.tsx | 177 ++++++++++++++++++++++++++ src/containers/python-remote/edit.tsx | 8 ++ 2 files changed, 185 insertions(+) diff --git a/src/components/remote-form.tsx b/src/components/remote-form.tsx index f0919188..bb9cb741 100644 --- a/src/components/remote-form.tsx +++ b/src/components/remote-form.tsx @@ -13,6 +13,7 @@ import { InputGroupItem, Modal, Switch, + TextArea, TextInput, } from '@patternfly/react-core'; import DownloadIcon from '@patternfly/react-icons/dist/esm/icons/download-icon'; @@ -339,6 +340,182 @@ 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.` + } + /> + } + > +