From bd6c010f6bfeed8df6f9cc104bbe43a633f5dd20 Mon Sep 17 00:00:00 2001 From: Gino Lisignoli Date: Fri, 25 Jul 2025 20:20:18 +1200 Subject: [PATCH 1/7] Rough draft rpm repository management --- src/actions/index.ts | 4 + src/actions/rpm-repository-create.tsx | 9 + src/actions/rpm-repository-delete.tsx | 99 ++++++ src/actions/rpm-repository-edit.tsx | 9 + src/actions/rpm-repository-sync.tsx | 148 +++++++++ src/api/index.ts | 4 +- src/api/rpm-distribution.ts | 11 + src/api/rpm-remote.ts | 62 ++++ src/api/rpm-repository.ts | 38 +++ src/app-routes.tsx | 17 + src/components/index.ts | 1 + src/components/rpm-repository-form.tsx | 291 ++++++++++++++++++ src/containers/index.ts | 3 + src/containers/rpm-remote/detail.tsx | 0 src/containers/rpm-remote/edit.tsx | 0 src/containers/rpm-remote/list.tsx | 0 src/containers/rpm-remote/tab-details.tsx | 0 src/containers/rpm-repository/detail.tsx | 134 ++++++++ src/containers/rpm-repository/edit.tsx | 184 +++++++++++ src/containers/rpm-repository/list.tsx | 76 +++++ src/containers/rpm-repository/tab-details.tsx | 55 ++++ .../rpm-repository/tab-distributions.tsx | 124 ++++++++ .../tab-repository-versions.tsx | 241 +++++++++++++++ src/menu.tsx | 3 + src/paths.ts | 16 +- 25 files changed, 1525 insertions(+), 4 deletions(-) create mode 100644 src/actions/rpm-repository-create.tsx create mode 100644 src/actions/rpm-repository-delete.tsx create mode 100644 src/actions/rpm-repository-edit.tsx create mode 100644 src/actions/rpm-repository-sync.tsx create mode 100644 src/api/rpm-distribution.ts create mode 100644 src/api/rpm-remote.ts create mode 100644 src/components/rpm-repository-form.tsx create mode 100644 src/containers/rpm-remote/detail.tsx create mode 100644 src/containers/rpm-remote/edit.tsx create mode 100644 src/containers/rpm-remote/list.tsx create mode 100644 src/containers/rpm-remote/tab-details.tsx create mode 100644 src/containers/rpm-repository/detail.tsx create mode 100644 src/containers/rpm-repository/edit.tsx create mode 100644 src/containers/rpm-repository/list.tsx create mode 100644 src/containers/rpm-repository/tab-details.tsx create mode 100644 src/containers/rpm-repository/tab-distributions.tsx create mode 100644 src/containers/rpm-repository/tab-repository-versions.tsx diff --git a/src/actions/index.ts b/src/actions/index.ts index a493b36a..199d70b9 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -20,3 +20,7 @@ export { fileRepositoryCreateAction } from './file-repository-create'; export { fileRepositoryDeleteAction } from './file-repository-delete'; export { fileRepositoryEditAction } from './file-repository-edit'; export { fileRepositorySyncAction } from './file-repository-sync'; +export { rpmRepositoryCreateAction } from './rpm-repository-create'; +export { rpmRepositoryDeleteAction } from './rpm-repository-delete'; +export { rpmRepositoryEditAction } from './rpm-repository-edit'; +export { rpmRepositorySyncAction } from './rpm-repository-sync'; diff --git a/src/actions/rpm-repository-create.tsx b/src/actions/rpm-repository-create.tsx new file mode 100644 index 00000000..28080a37 --- /dev/null +++ b/src/actions/rpm-repository-create.tsx @@ -0,0 +1,9 @@ +import { msg } from '@lingui/core/macro'; +import { Paths, formatPath } from 'src/paths'; +import { Action } from './action'; + +export const rpmRepositoryCreateAction = Action({ + title: msg`Add repository`, + onClick: (item, { navigate }) => + navigate(formatPath(Paths.rpm.repository.edit, { name: '_' })), +}); diff --git a/src/actions/rpm-repository-delete.tsx b/src/actions/rpm-repository-delete.tsx new file mode 100644 index 00000000..a3c431dd --- /dev/null +++ b/src/actions/rpm-repository-delete.tsx @@ -0,0 +1,99 @@ +import { msg, t } from '@lingui/core/macro'; +import { RPMDistributionAPI, RPMRepositoryAPI } from 'src/api'; +import { DeleteRepositoryModal } from 'src/components'; +import { + handleHttpError, + parsePulpIDFromURL, + taskAlert, + waitForTaskUrl, +} from 'src/utilities'; +import { Action } from './action'; + +export const rpmRepositoryDeleteAction = Action({ + title: msg`Delete`, + modal: ({ addAlert, listQuery, setState, state }) => + state.deleteModalOpen ? ( + setState({ deleteModalOpen: null })} + deleteAction={() => + deleteRepository(state.deleteModalOpen, { + addAlert, + listQuery, + setState, + }) + } + name={state.deleteModalOpen.name} + /> + ) : null, + onClick: ( + { name, id, pulp_href }: { name: string; id?: string; pulp_href?: string }, + { setState }, + ) => + setState({ + deleteModalOpen: { + pulpId: id || parsePulpIDFromURL(pulp_href), + name, + pulp_href, + }, + }), +}); + +async function deleteRepository( + { name, pulp_href, pulpId }, + { addAlert, setState, listQuery }, +) { + // TODO: handle more pages + const distributionsToDelete = await RPMDistributionAPI.list({ + repository: pulp_href, + page: 1, + page_size: 100, + }) + .then(({ data: { results } }) => results || []) + .catch((e) => { + handleHttpError( + t`Failed to list distributions, removing only the repository.`, + () => null, + addAlert, + )(e); + return []; + }); + + const deleteRepo = RPMRepositoryAPI.delete(pulpId) + .then(({ data }) => { + addAlert(taskAlert(data.task, t`Removal started for repository ${name}`)); + return waitForTaskUrl(data.task); + }) + .catch( + handleHttpError( + t`Failed to remove repository ${name}`, + () => setState({ deleteModalOpen: null }), + addAlert, + ), + ); + + const deleteDistribution = ({ name, pulp_href }) => { + const distribution_id = parsePulpIDFromURL(pulp_href); + return RPMDistributionAPI.delete(distribution_id) + .then(({ data }) => { + addAlert( + taskAlert(data.task, t`Removal started for distribution ${name}`), + ); + return waitForTaskUrl(data.task); + }) + .catch( + handleHttpError( + t`Failed to remove distribution ${name}`, + () => null, + addAlert, + ), + ); + }; + + return Promise.all([ + deleteRepo, + ...distributionsToDelete.map(deleteDistribution), + ]).then(() => { + setState({ deleteModalOpen: null }); + listQuery(); + }); +} diff --git a/src/actions/rpm-repository-edit.tsx b/src/actions/rpm-repository-edit.tsx new file mode 100644 index 00000000..99274cd1 --- /dev/null +++ b/src/actions/rpm-repository-edit.tsx @@ -0,0 +1,9 @@ +import { msg } from '@lingui/core/macro'; +import { Paths, formatPath } from 'src/paths'; +import { Action } from './action'; + +export const rpmRepositoryEditAction = Action({ + title: msg`Edit`, + onClick: ({ name }, { navigate }) => + navigate(formatPath(Paths.rpm.repository.edit, { name })), +}); diff --git a/src/actions/rpm-repository-sync.tsx b/src/actions/rpm-repository-sync.tsx new file mode 100644 index 00000000..80ee6a87 --- /dev/null +++ b/src/actions/rpm-repository-sync.tsx @@ -0,0 +1,148 @@ +import { msg, t } from '@lingui/core/macro'; +import { Button, FormGroup, Modal, Switch } from '@patternfly/react-core'; +import { useEffect, useState } from 'react'; +import { RPMRepositoryAPI } from 'src/api'; +import { HelpButton, Spinner } from 'src/components'; +import { handleHttpError, parsePulpIDFromURL, taskAlert } from 'src/utilities'; +import { Action } from './action'; + +const SyncModal = ({ + closeAction, + syncAction, + name, +}: { + closeAction: () => null; + syncAction: (syncParams) => Promise; + name: string; +}) => { + const [pending, setPending] = useState(false); + const [syncParams, setSyncParams] = useState({ + mirror: true, + optimize: true, + }); + + useEffect(() => { + setPending(false); + setSyncParams({ mirror: true, optimize: true }); + }, [name]); + + if (!name) { + return null; + } + + return ( + + + , + , + ]} + isOpen + onClose={closeAction} + title={t`Sync repository "${name}"`} + variant='medium' + > + + } + > + + setSyncParams({ ...syncParams, mirror }) + } + label={t`Content not present in remote repository will be removed from the local repository`} + labelOff={t`Sync will only add missing content`} + /> + +
+ + } + > + + setSyncParams({ ...syncParams, optimize }) + } + label={t`Only perform the sync if changes are reported by the remote server.`} + labelOff={t`Force a sync to happen.`} + /> + +
+
+ ); +}; + +export const rpmRepositorySyncAction = Action({ + title: msg`Sync`, + modal: ({ addAlert, query, setState, state }) => + state.syncModalOpen ? ( + setState({ syncModalOpen: null })} + syncAction={(syncParams) => + syncRepository(state.syncModalOpen, { addAlert, query }, syncParams) + } + name={state.syncModalOpen.name} + /> + ) : null, + onClick: ({ name, pulp_href }, { setState }) => + setState({ + syncModalOpen: { name, pulp_href }, + }), + visible: (_item, { hasPermission }) => + hasPermission('rpm.change_collectionremote'), + disabled: ({ remote, last_sync_task }) => { + if (!remote) { + return t`There are no remotes associated with this repository.`; + } + + if ( + last_sync_task && + ['running', 'waiting'].includes(last_sync_task.state) + ) { + return t`Sync task is already queued.`; + } + }, +}); + +function syncRepository({ name, pulp_href }, { addAlert, query }, syncParams) { + const pulpId = parsePulpIDFromURL(pulp_href); + return RPMRepositoryAPI.sync(pulpId, syncParams || { mirror: true }) + .then(({ data }) => { + addAlert(taskAlert(data.task, t`Sync started for repository "${name}".`)); + + query(); + }) + .catch( + handleHttpError( + t`Failed to sync repository "${name}"`, + () => null, + addAlert, + ), + ); +} diff --git a/src/api/index.ts b/src/api/index.ts index d1499327..a4748eab 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -74,8 +74,10 @@ export { type UserType, } from './response-types/user'; export { RoleAPI } from './role'; +export { RPMDistributionAPI } from './rpm-distribution'; export { RPMPackageAPI } from './rpm-package'; -export { RPMRepositoryAPI } from './rpm-repository'; +export { RPMRepositoryAPI, type RPMRepositoryType } from './rpm-repository'; +export { RPMRemoteAPI, type RPMRemoteType } from './rpm-remote'; export { SignCollectionAPI } from './sign-collections'; export { SignContainersAPI } from './sign-containers'; export { SigningServiceAPI, type SigningServiceType } from './signing-service'; diff --git a/src/api/rpm-distribution.ts b/src/api/rpm-distribution.ts new file mode 100644 index 00000000..668ab4cb --- /dev/null +++ b/src/api/rpm-distribution.ts @@ -0,0 +1,11 @@ +import { PulpAPI } from './pulp'; + +const base = new PulpAPI(); + +export const RPMDistributionAPI = { + create: (data) => base.http.post(`distributions/rpm/rpm/`, data), + + delete: (id) => base.http.delete(`distributions/rpm/rpm/${id}/`), + + list: (params?) => base.list(`distributions/rpm/rpm/`, params), +}; diff --git a/src/api/rpm-remote.ts b/src/api/rpm-remote.ts new file mode 100644 index 00000000..95dd9b91 --- /dev/null +++ b/src/api/rpm-remote.ts @@ -0,0 +1,62 @@ +import { PulpAPI } from './pulp'; + +export class RPMRemoteType { + ca_cert: string; + client_cert: string; + download_concurrency: number; + name: string; + proxy_url: string; + proxy_username: string; + proxy_password: string; + pulp_href?: string; + rate_limit: number; + tls_validation: boolean; + url: string; + username: string; + password: string; + max_retries: number; + policy?: 'immediate' | 'on_demand' | 'streamed'; + pulp_labels?: Record; + total_timeout?: number; + connect_timeout?: number; + sock_connect_timeout?: number; + sock_read_timeout?: number; + headers?: Record; + sles_auth_token?: string; + + hidden_fields: { + is_set: boolean; + name: string; + }[]; +} + +// simplified version of smartUpdate from execution-environment-registry +function smartUpdate(remote: RPMRemoteType, unmodifiedRemote: RPMRemoteType) { + for (const field of Object.keys(remote)) { + if (remote[field] === '') { + remote[field] = null; + } + + // API returns headers:null bull doesn't accept it .. and we don't edit headers + if (remote[field] === null && unmodifiedRemote[field] === null) { + delete remote[field]; + } + } + + return remote; +} + +const base = new PulpAPI(); + +export const RPMRemoteAPI = { + create: (data) => base.http.post(`remotes/rpm/rpm/`, data), + + delete: (id) => base.http.delete(`remotes/rpm/rpm/${id}/`), + + get: (id) => base.http.get(`remotes/rpm/rpm/${id}/`), + + list: (params?) => base.list(`remotes/rpm/rpm/`, params), + + smartUpdate: (id, newValue: RPMRemoteType, oldValue: RPMRemoteType) => + base.http.put(`remotes/rpm/rpm/${id}/`, smartUpdate(newValue, oldValue)), +}; diff --git a/src/api/rpm-repository.ts b/src/api/rpm-repository.ts index 9e32c898..2c0010d0 100644 --- a/src/api/rpm-repository.ts +++ b/src/api/rpm-repository.ts @@ -1,7 +1,45 @@ import { PulpAPI } from './pulp'; +export class RPMRepositoryType { + autopublish?: boolean; + checksum_type?: 'sha1' | 'sha256' | 'sha512'; + description: string | null; + gpgcheck?: 0 | 1 | 2; + latest_version_href?: string; + metadata_signing_service?: string; + name: string; + pulp_created?: string; + pulp_href?: string; + pulp_labels: Record; + pulp_last_updated?: string; + remote: string | null; + repoclosure_verification?: boolean; + repo_gpgcheck?: 0 | 1; + retain_package_versions?: number; + retain_repo_versions: number | null; + versions_href?: string; +} + const base = new PulpAPI(); export const RPMRepositoryAPI = { + create: (data) => base.http.post(`repositories/rpm/rpm/`, data), + + delete: (id) => base.http.delete(`repositories/rpm/rpm/${id}/`), + list: (params?) => base.list(`repositories/rpm/rpm/`, params), + + listVersions: (id: string, params?) => + base.list(`repositories/rpm/rpm/${id}/versions/`, params), + + revert: (id: string, version_href) => + base.http.post(`repositories/rpm/rpm/${id}/modify/`, { + base_version: version_href, + }), + + sync: (id: string, body = {}) => + base.http.post(`repositories/rpm/rpm/${id}/sync/`, body), + + update: (id: string, data) => + base.http.put(`repositories/rpm/rpm/${id}/`, data), }; diff --git a/src/app-routes.tsx b/src/app-routes.tsx index dd21576b..edd2f095 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -45,6 +45,9 @@ import { Partners, PulpStatus, RPMPackageList, + RPMRepositoryDetail, + RPMRepositoryList, + RpmRepositoryEdit, RoleCreate, RoleList, Search, @@ -314,6 +317,20 @@ const routes: IRouteConfig[] = [ path: Paths.rpm.package.list, beta: true, }, + { + component: RPMRepositoryDetail, + path: Paths.rpm.repository.detail, + beta: true, + }, + { + component: RPMRepositoryList, + path: Paths.rpm.repository.list, + beta: true, + }, + { + component: RpmRepositoryEdit, + path: Paths.rpm.repository.edit, + }, ]; const AuthHandler = ({ diff --git a/src/components/index.ts b/src/components/index.ts index d9626b0c..a8419749 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -125,6 +125,7 @@ export { RoleListTable, } from './role-list-table'; export { RolePermissions } from './role-permissions'; +export { RPMRepositoryForm } from './rpm-repository-form'; export { SelectGroup } from './select-group'; export { SelectRoles } from './select-roles'; export { SelectUser } from './select-user'; diff --git a/src/components/rpm-repository-form.tsx b/src/components/rpm-repository-form.tsx new file mode 100644 index 00000000..53325156 --- /dev/null +++ b/src/components/rpm-repository-form.tsx @@ -0,0 +1,291 @@ +import { t } from '@lingui/core/macro'; +import { + ActionGroup, + Button, + Checkbox, + Form, + FormGroup, + TextInput, +} from '@patternfly/react-core'; +import { useEffect, useState } from 'react'; +import { + RPMRepositoryAPI, + type RPMRepositoryType, +} from 'src/api'; +import { + FormFieldHelper, + HelpButton, + LazyDistributions, + PulpLabels, + Spinner, + Typeahead, +} from 'src/components'; +import { + type ErrorMessagesType, + errorMessage, + pluginRepositoryBasePath, +} from 'src/utilities'; + +interface IProps { + allowEditName: boolean; + errorMessages: ErrorMessagesType; + onCancel: () => void; + onSave: ({ createDistribution }) => void; + plugin: 'rpm'; + repository: RPMRepositoryType; + updateRepository: (r) => void; +} + +export const RPMRepositoryForm = ({ + allowEditName, + errorMessages, + onCancel, + onSave, + plugin, + repository, + updateRepository, +}: IProps) => { + const requiredFields = []; + const disabledFields = allowEditName ? [] : ['name']; + + const formGroup = (fieldName, label, helperText, children) => ( + + {label} + + ) : ( + label + ) + } + isRequired={requiredFields.includes(fieldName)} + > + {children} + + {errorMessages[fieldName]} + + + ); + const inputField = (fieldName, label, helperText, props) => + formGroup( + fieldName, + label, + helperText, + + updateRepository({ ...repository, [fieldName]: value }) + } + {...props} + />, + ); + const stringField = (fieldName, label, helperText?) => + inputField(fieldName, label, helperText, { type: 'text' }); + const numericField = (fieldName, label, helperText?) => + inputField(fieldName, label, helperText, { type: 'number' }); + + const isValid = !requiredFields.find((field) => !repository[field]); + + const [createDistribution, setCreateDistribution] = useState(true); + const [disabledDistribution, setDisabledDistribution] = useState(false); + const onDistributionsLoad = (distroBasePath) => { + if (distroBasePath) { + setCreateDistribution(false); + setDisabledDistribution(true); + } else { + setCreateDistribution(true); + setDisabledDistribution(false); + } + }; + + const [remotes, setRemotes] = useState(null); + const [remotesError, setRemotesError] = useState(null); + const loadRemotes = (name?) => { + setRemotesError(null); + // (plugin === 'ansible' + // ? AnsibleRemoteAPI.list({ ...(name ? { name__icontains: name } : {}) }) + // : plugin === 'file' + // ? FileRemoteAPI.list({ ...(name ? { name__icontains: name } : {}) }) + // : Promise.reject(plugin) + // ) + // .then(({ data }) => + // setRemotes(data.results.map((r) => ({ ...r, id: r.pulp_href }))), + // ) + // .catch((e) => { + // const { status, statusText } = e.response; + // setRemotes([]); + // setRemotesError(errorMessage(status, statusText)); + // }); + }; + + useEffect(() => loadRemotes(), []); + + useEffect(() => { + // create + if (!repository || !repository.name) { + onDistributionsLoad(null); + return; + } + + pluginRepositoryBasePath(plugin, repository.name, repository.pulp_href) + .catch(() => null) + .then(onDistributionsLoad); + }, [repository?.pulp_href]); + + const selectedRemote = remotes?.find?.( + ({ pulp_href }) => pulp_href === repository?.remote, + ); + + return ( +
+ {stringField('name', t`Name`)} + {stringField('description', t`Description`)} + {numericField( + 'retain_repo_versions', + t`Retained number of versions`, + t`In order to retain all versions, leave this field blank.`, + )} + + {formGroup( + 'distributions', + t`Distributions`, + t`Content in repositories without a distribution will not be visible to clients for sync, download or search.`, + <> + +
+ setCreateDistribution(value)} + label={t`Create a "${repository.name}" distribution`} + id='create_distribution' + /> + , + )} + + {formGroup( + 'auto_publish', + t`Auto-publish`, + t`Automatically create publications for new repository versions, and update any distributions pointing to this repository.`, + + updateRepository({ ...repository, autopublish: value }) + } + />, + )} + + {formGroup( + 'pulp_labels', + t`Labels`, + t`Pulp labels in the form of 'key:value'.`, + <> +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + + updateRepository({ ...repository, pulp_labels: labels }) + } + /> +
+ , + )} + + {formGroup( + 'remote', + t`Remote`, + t`Setting a remote allows a repository to sync from elsewhere.`, + <> +
+ {remotes ? ( + + updateRepository({ ...repository, remote: null }) + } + onSelect={(_event, value) => + updateRepository({ + ...repository, + remote: remotes.find(({ name }) => name === value) + ?.pulp_href, + }) + } + placeholderText={t`Select a remote`} + results={remotes} + selections={ + selectedRemote + ? [ + { + name: selectedRemote.name, + id: selectedRemote.pulp_href, + }, + ] + : [] + } + /> + ) : null} + {remotesError ? ( + + {t`Failed to load remotes: ${remotesError}`} + + ) : null} + {!remotes && !remotesError ? : null} +
+ , + )} + + {errorMessages['__nofield'] ? ( + + {errorMessages['__nofield']} + + ) : null} + + + + + + + ); +}; diff --git a/src/containers/index.ts b/src/containers/index.ts index c75db44e..48737fca 100644 --- a/src/containers/index.ts +++ b/src/containers/index.ts @@ -38,6 +38,9 @@ export { default as RoleCreate } from './role-management/role-create'; export { default as EditRole } from './role-management/role-edit'; export { default as RoleList } from './role-management/role-list'; export { default as RPMPackageList } from './rpm/package-list'; +export { default as RPMRepositoryDetail } from './rpm-repository/detail'; +export { default as RPMRepositoryList } from './rpm-repository/list'; +export { default as RpmRepositoryEdit } from './rpm-repository/edit'; export { default as MultiSearch } from './search/multi-search'; export { default as Search } from './search/search'; export { default as UserProfile } from './settings/user-profile'; diff --git a/src/containers/rpm-remote/detail.tsx b/src/containers/rpm-remote/detail.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/containers/rpm-remote/edit.tsx b/src/containers/rpm-remote/edit.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/containers/rpm-remote/list.tsx b/src/containers/rpm-remote/list.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/containers/rpm-remote/tab-details.tsx b/src/containers/rpm-remote/tab-details.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/containers/rpm-repository/detail.tsx b/src/containers/rpm-repository/detail.tsx new file mode 100644 index 00000000..8c290453 --- /dev/null +++ b/src/containers/rpm-repository/detail.tsx @@ -0,0 +1,134 @@ +import { msg, t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { + rpmRepositoryDeleteAction, + rpmRepositoryEditAction, + rpmRepositorySyncAction, +} from 'src/actions'; +import { + RPMRemoteAPI, + type RPMRemoteType, + RPMRepositoryAPI, + type RPMRepositoryType, +} from 'src/api'; +import { PageWithTabs } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { + lastSyncStatus, + lastSynced, + parsePulpIDFromURL, + repositoryBasePath, +} from 'src/utilities'; +import { DetailsTab } from './tab-details'; +import { DistributionsTab } from './tab-distributions'; +import { RepositoryVersionsTab } from './tab-repository-versions'; + +const RPMRepositoryDetail = PageWithTabs< + RPMRepositoryType & { remote?: RPMRemoteType } +>({ + breadcrumbs: ({ name, tab, params: { repositoryVersion } }) => + [ + { url: formatPath(Paths.rpm.repository.list), name: t`Repositories` }, + { url: formatPath(Paths.rpm.repository.detail, { name }), name }, + tab === 'repository-versions' && repositoryVersion + ? { + url: formatPath(Paths.rpm.repository.detail, { name }, { tab }), + name: t`Versions`, + } + : null, + tab === 'repository-versions' && repositoryVersion + ? { name: t`Version ${repositoryVersion}` } + : null, + tab === 'repository-versions' && !repositoryVersion + ? { name: t`Versions` } + : null, + ].filter(Boolean), + displayName: 'RPMRepositoryDetail', + errorTitle: msg`Repository could not be displayed.`, + headerActions: [ + rpmRepositoryEditAction, + rpmRepositorySyncAction, + rpmRepositoryDeleteAction, + ], + headerDetails: (item) => ( + <> + {item?.last_sync_task && ( +

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

+ )} + + ), + listUrl: formatPath(Paths.rpm.repository.list), + query: ({ name }) => + RPMRepositoryAPI.list({ name, page_size: 1 }) + .then(({ data: { results } }) => results[0]) + .then((repository) => { + // using the list api, so an empty array is really a 404 + if (!repository) { + return Promise.reject({ response: { status: 404 } }); + } + + const err = (val) => (e) => { + console.error(e); + return val; + }; + + return Promise.all([ + repositoryBasePath(repository.name, repository.pulp_href).catch( + err(null), + ), + repository.remote + ? RPMRemoteAPI.get(parsePulpIDFromURL(repository.remote)) + .then(({ data }) => data) + .catch(() => null) + : null, + ]).then(([distroBasePath, remote]) => ({ + ...repository, + distroBasePath, + remote, + })); + }), + renderTab: (tab, item, actionContext) => + ({ + details: , + 'repository-versions': ( + + ), + distributions: ( + + ), + })[tab], + tabs: (tab, name) => [ + { + active: tab === 'details', + title: t`Details`, + link: formatPath( + Paths.rpm.repository.detail, + { name }, + { tab: 'details' }, + ), + }, + { + active: tab === 'repository-versions', + title: t`Versions`, + link: formatPath( + Paths.rpm.repository.detail, + { name }, + { tab: 'repository-versions' }, + ), + }, + { + active: tab === 'distributions', + title: t`Distributions`, + link: formatPath( + Paths.rpm.repository.detail, + { name }, + { tab: 'distributions' }, + ), + }, + ], +}); + +export default RPMRepositoryDetail; diff --git a/src/containers/rpm-repository/edit.tsx b/src/containers/rpm-repository/edit.tsx new file mode 100644 index 00000000..5ca0bc95 --- /dev/null +++ b/src/containers/rpm-repository/edit.tsx @@ -0,0 +1,184 @@ +import { msg, t } from '@lingui/core/macro'; +import { + RPMDistributionAPI, + RPMRepositoryAPI, + type RPMRepositoryType, +} from 'src/api'; +import { Page, RPMRepositoryForm } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL, taskAlert } from 'src/utilities'; + +const initialRepository: RPMRepositoryType = { + name: '', + description: '', + retain_repo_versions: 1, + pulp_labels: {}, + remote: null, + autopublish: false, + metadata_signing_service: null, + gpgcheck: 0, + repo_gpgcheck: 0, + checksum_type: 'sha256', +}; + +const RpmRepositoryEdit = Page({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.rpm.repository.list), name: t`Repositories` }, + name && { + url: formatPath(Paths.rpm.repository.detail, { name }), + name, + }, + name ? { name: t`Edit` } : { name: t`Add` }, + ].filter(Boolean), + + displayName: 'RpmRepositoryEdit', + errorTitle: msg`Repository could not be displayed.`, + listUrl: formatPath(Paths.rpm.repository.list), + query: ({ name }) => + RPMRepositoryAPI.list({ name }).then( + ({ data: { results } }) => results[0], + ), + title: ({ name }) => name || t`Add new repository`, + transformParams: ({ name, ...rest }) => ({ + ...rest, + name: name !== '_' ? name : null, + }), + + render: (item, { navigate, queueAlert, state, setState }) => { + if (!state.repositoryToEdit) { + const repositoryToEdit = { + ...initialRepository, + ...item, + }; + setState({ repositoryToEdit, errorMessages: {} }); + } + + const { repositoryToEdit, errorMessages } = state; + if (!repositoryToEdit) { + return null; + } + + const saveRepository = ({ createDistribution }) => { + const { repositoryToEdit } = state; + + const data = { ...repositoryToEdit }; + + // prevent "This field may not be blank." for nullable fields + Object.keys(data).forEach((k) => { + if (data[k] === '') { + data[k] = null; + } + }); + + if (item) { + delete data.latest_version_href; + delete data.pulp_created; + delete data.pulp_href; + delete data.versions_href; + } + + data.pulp_labels ||= {}; + + let promise = !item + ? RPMRepositoryAPI.create(data).then(({ data: newData }) => { + queueAlert({ + variant: 'success', + title: t`Successfully created repository ${data.name}`, + }); + + return newData.pulp_href; + }) + : RPMRepositoryAPI.update( + parsePulpIDFromURL(item.pulp_href), + data, + ).then(({ data: task }) => { + queueAlert( + taskAlert(task, t`Update started for repository ${data.name}`), + ); + + return item.pulp_href; + }); + + if (createDistribution) { + // only alphanumerics, slashes, underscores and dashes are allowed in base_path, transform anything else to _ + const basePathTransform = (name) => + name.replaceAll(/[^-a-zA-Z0-9_/]/g, '_'); + let distributionName = data.name; + + promise = promise + .then((pulp_href) => + RPMDistributionAPI.create({ + name: distributionName, + base_path: basePathTransform(distributionName), + repository: pulp_href, + }).catch(() => { + // if distribution already exists, try a numeric suffix to name & base_path + distributionName = + data.name + Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); + return RPMDistributionAPI.create({ + name: distributionName, + base_path: basePathTransform(distributionName), + repository: pulp_href, + }); + }), + ) + .then(({ data: task }) => + queueAlert( + taskAlert( + task, + t`Creation started for distribution ${distributionName}`, + ), + ), + ); + } + + promise + .then(() => { + setState({ + errorMessages: {}, + repositoryToEdit: undefined, + }); + + navigate( + formatPath(Paths.rpm.repository.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.rpm.repository.detail, { + name: item.name, + }) + : formatPath(Paths.rpm.repository.list), + ); + }; + + return ( + setState({ repositoryToEdit: r })} + /> + ); + }, +}); + +export default RpmRepositoryEdit; diff --git a/src/containers/rpm-repository/list.tsx b/src/containers/rpm-repository/list.tsx new file mode 100644 index 00000000..f6897f1f --- /dev/null +++ b/src/containers/rpm-repository/list.tsx @@ -0,0 +1,76 @@ +import { msg, t } from '@lingui/core/macro'; +import { Td, Tr } from '@patternfly/react-table'; +import { Link } from 'react-router'; +import { rpmRepositoryCreateAction } from 'src/actions'; +import { RPMRepositoryAPI, type RPMRepositoryType } from 'src/api'; +import { + DateComponent, + ListItemActions, + ListPage, + PulpLabels, +} from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL } from 'src/utilities'; + +const RPMRepositoryList = ListPage({ + defaultPageSize: 10, + defaultSort: '-pulp_created', + displayName: 'RPMRepositoryList', + errorTitle: msg`Repositories could not be displayed.`, + filterConfig: () => [ + { + id: 'name__icontains', + title: t`Repository name`, + }, + { + id: 'pulp_label_select', + title: t`Pulp Label`, + }, + ], + headerActions: [rpmRepositoryCreateAction], + noDataButton: rpmRepositoryCreateAction.button, + noDataDescription: msg`Repositories will appear once created.`, + noDataTitle: msg`No repositories yet`, + query: ({ params }) => RPMRepositoryAPI.list(params), + renderTableRow(item: RPMRepositoryType, index: number) { + const { name, pulp_created, pulp_href, pulp_labels } = item; + const id = parsePulpIDFromURL(pulp_href); + + return ( + + + + {name} + + + + + + + + + + + ); + }, + sortHeaders: [ + { + title: msg`Repository name`, + type: 'alpha', + id: 'name', + }, + { + title: msg`Labels`, + type: 'none', + id: 'pulp_labels', + }, + { + title: msg`Created date`, + type: 'numeric', + id: 'pulp_created', + }, + ], + title: msg`Repositories`, +}); + +export default RPMRepositoryList; diff --git a/src/containers/rpm-repository/tab-details.tsx b/src/containers/rpm-repository/tab-details.tsx new file mode 100644 index 00000000..29c5bee1 --- /dev/null +++ b/src/containers/rpm-repository/tab-details.tsx @@ -0,0 +1,55 @@ +import { t } from '@lingui/core/macro'; +import { Link } from 'react-router'; +import { type RPMRemoteType, type RPMRepositoryType } from 'src/api'; +import { CopyURL, Details, PulpLabels } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { getRepoURL } from 'src/utilities'; + +interface TabProps { + item: RPMRepositoryType & { + distroBasePath?: string; + remote?: RPMRemoteType; + }; + actionContext: { addAlert: (alert) => void; state: { params } }; +} + +export const DetailsTab = ({ item }: TabProps) => { + return ( +
+ ) : ( + '---' + ), + }, + { + label: t`Labels`, + value: , + }, + { + label: t`Remote`, + value: item?.remote ? ( + + {item?.remote.name} + + ) : ( + t`None` + ), + }, + ]} + /> + ); +}; diff --git a/src/containers/rpm-repository/tab-distributions.tsx b/src/containers/rpm-repository/tab-distributions.tsx new file mode 100644 index 00000000..a798cbcc --- /dev/null +++ b/src/containers/rpm-repository/tab-distributions.tsx @@ -0,0 +1,124 @@ +import { t } from '@lingui/core/macro'; +import { Td, Tr } from '@patternfly/react-table'; +import { RPMDistributionAPI, type RPMRepositoryType } from 'src/api'; +import { ClipboardCopy, DateComponent, DetailList } from 'src/components'; +import { getRepoURL } from 'src/utilities'; + +interface TabProps { + item: RPMRepositoryType; + actionContext: { + addAlert: (alert) => void; + state: { params }; + hasPermission; + }; +} + +interface Distribution { + base_path: string; + client_url: string; + content_guard: string; + name: string; + pulp_created: string; + pulp_href: string; + pulp_labels: Record; + repository: string; + repository_version: string; +} + +export const DistributionsTab = ({ + item, + actionContext: { addAlert, hasPermission }, +}: TabProps) => { + const query = ({ params } = { params: null }) => { + const newParams = { ...params }; + newParams.ordering = newParams.sort; + delete newParams.sort; + + return RPMDistributionAPI.list({ + repository: item.pulp_href, + ...newParams, + }); + }; + + const cliConfig = (base_path) => + `pulp rpm distribution create --name "${item.name}" --base-bath "${base_path}" --repository "${item.name}"`; + + const renderTableRow = ( + item: Distribution, + index: number, + _actionContext, + ) => { + const { name, base_path, pulp_created } = item; + + return ( + + {name} + {base_path} + + + + + + {cliConfig(base_path)} + + + + ); + }; + + return ( + + actionContext={{ + addAlert, + query, + hasPermission, + hasObjectPermission: (_p: string): boolean => true, + }} + defaultPageSize={10} + defaultSort={'name'} + errorTitle={t`Distributions could not be displayed.`} + filterConfig={[ + { + id: 'name__icontains', + title: t`Name`, + }, + { + id: 'base_path__icontains', + title: t`Base path`, + }, + ]} + noDataDescription={t`You can edit this repository to create a distribution.`} + noDataTitle={t`No distributions created`} + query={query} + renderTableRow={renderTableRow} + sortHeaders={[ + { + title: t`Name`, + type: 'alpha', + id: 'name', + }, + { + title: t`Base path`, + type: 'alpha', + id: 'base_path', + }, + { + title: t`Created`, + type: 'alpha', + id: 'pulp_created', + }, + { + title: t`CLI configuration`, + type: 'none', + id: '', + }, + ]} + title={t`Distributions`} + /> + ); +}; diff --git a/src/containers/rpm-repository/tab-repository-versions.tsx b/src/containers/rpm-repository/tab-repository-versions.tsx new file mode 100644 index 00000000..b8cf410b --- /dev/null +++ b/src/containers/rpm-repository/tab-repository-versions.tsx @@ -0,0 +1,241 @@ +import { t } from '@lingui/core/macro'; +import { Table, Td, Th, Tr } from '@patternfly/react-table'; +import { useEffect, useState } from 'react'; +import { Link } from 'react-router'; +import { RPMRepositoryAPI } from 'src/api'; +import { + DateComponent, + DetailList, + Details, + ListItemActions, + Spinner, +} from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL } from 'src/utilities'; + +interface TabProps { + item; + actionContext: { + addAlert: (alert) => void; + state: { params }; + hasPermission: (string) => boolean; + hasObjectPermission: (string) => boolean; + }; +} + +type ContentSummary = Record< + string, + { + count: number; + href: string; + } +>; + +interface RPMRepositoryVersionType { + pulp_href: string; + pulp_created: string; + number: number; + repository: string; + base_version: null; + content_summary: { + added: ContentSummary; + removed: ContentSummary; + present: ContentSummary; + }; +} + +const ContentSummary = ({ data }: { data: object }) => { + if (!Object.keys(data).length) { + return <>{t`None`}; + } + + return ( + + + + + + {Object.entries(data).map(([k, v]) => ( + + + + + ))} +
{t`Count`}{t`Pulp type`}
{v['count']}{k}
+ ); +}; + +const BaseVersion = ({ + repositoryName, + data, +}: { + repositoryName: string; + data?: string; +}) => { + if (!data) { + return <>{t`None`}; + } + + const number = data.split('/').at(-2); + return ( + + {number} + + ); +}; + +export const RepositoryVersionsTab = ({ + item, + actionContext: { addAlert, state, hasPermission, hasObjectPermission }, +}: TabProps) => { + const pulpId = parsePulpIDFromURL(item.pulp_href); + const latest_href = item.latest_version_href; + const repositoryName = item.name; + const queryList = ({ params }) => + RPMRepositoryAPI.listVersions(pulpId, params); + const queryDetail = ({ number }) => + RPMRepositoryAPI.listVersions(pulpId, { number }); + const [modalState, setModalState] = useState({}); + const [version, setVersion] = useState(null); + + useEffect(() => { + if (state.params.repositoryVersion) { + queryDetail({ number: state.params.repositoryVersion }).then( + ({ data }) => { + if (!data?.results?.[0]) { + addAlert({ + variant: 'danger', + title: t`Failed to find repository version`, + }); + } + setVersion(data.results[0]); + }, + ); + } else { + setVersion(null); + } + }, [state.params.repositoryVersion]); + + const renderTableRow = ( + item: RPMRepositoryVersionType, + index: number, + actionContext, + listItemActions, + ) => { + const { number, pulp_created, pulp_href } = item; + + const isLatest = latest_href === pulp_href; + + const kebabItems = listItemActions.map((action) => + action.dropdownItem({ ...item, isLatest, repositoryName }, actionContext), + ); + + return ( + + + + {number} + + {isLatest ? ' ' + t`(latest)` : null} + + + + + + + ); + }; + + return state.params.repositoryVersion ? ( + version ? ( +
, + }, + { + label: t`Content added`, + value: , + }, + { + label: t`Content removed`, + value: , + }, + { + label: t`Current content`, + value: , + }, + { + label: t`Base version`, + value: ( + + ), + }, + ]} + /> + ) : ( + + ) + ) : ( + + actionContext={{ + addAlert, + state: modalState, + setState: setModalState, + query: queryList, + hasPermission, + hasObjectPermission, // needs item=repository, not repository version + }} + defaultPageSize={10} + defaultSort={'-pulp_created'} + errorTitle={t`Repository versions could not be displayed.`} + filterConfig={null} + listItemActions={[]} + noDataButton={null} + noDataDescription={t`Repository versions will appear once the repository is modified.`} + noDataTitle={t`No repository versions yet`} + query={queryList} + renderTableRow={renderTableRow} + sortHeaders={[ + { + title: t`Version number`, + type: 'numeric', + id: 'number', + }, + { + title: t`Created date`, + type: 'numeric', + id: 'pulp_created', + }, + ]} + title={t`Repository versions`} + /> + ); +}; diff --git a/src/menu.tsx b/src/menu.tsx index 614e615b..56bc55bc 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -108,6 +108,9 @@ function standaloneMenu() { }), ]), menuSection('Pulp RPM', { condition: and(loggedIn, hasPlugin('rpm')) }, [ + menuItem(t`Repositories`, { + url: formatPath(Paths.rpm.repository.list), + }), menuItem(t`RPMs`, { url: formatPath(Paths.rpm.package.list), }), diff --git a/src/paths.ts b/src/paths.ts index 02a6aff1..d54a851f 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -98,6 +98,19 @@ export const Paths = { manifest: '/container/containers/manifest/:namespace?/:container/:digest', }, }, + rpm: { + package: { list: '/rpm/rpms' }, + repository: { + detail: '/rpm/repositories/detail/:name', + edit: '/rpm/repositories/edit/:name', + list: '/rpm/repositories', + }, + remote: { + detail: '/rpm/remotes/detail/:name', + edit: '/rpm/remotes/edit/:name', + list: '/rpm/remotes', + } + }, core: { group: { detail: '/groups/detail/:group', @@ -139,7 +152,4 @@ export const Paths = { login: '/login', search: '/search', }, - rpm: { - package: { list: '/rpm/rpms' }, - }, }; From 6b6f2c547a1e9c38c55d124d3b1a11af2370c0af Mon Sep 17 00:00:00 2001 From: Gino Lisignoli Date: Tue, 29 Jul 2025 19:12:24 +1200 Subject: [PATCH 2/7] Add remotes draft --- src/actions/index.ts | 3 + src/actions/rpm-remote-create.tsx | 9 ++ src/actions/rpm-remote-delete.tsx | 44 +++++++ src/actions/rpm-remote-edit.tsx | 9 ++ src/app-routes.tsx | 18 +++ src/containers/index.ts | 3 + src/containers/rpm-remote/detail.tsx | 40 ++++++ src/containers/rpm-remote/edit.tsx | 150 ++++++++++++++++++++++ src/containers/rpm-remote/list.tsx | 75 +++++++++++ src/containers/rpm-remote/tab-details.tsx | 60 +++++++++ src/menu.tsx | 3 + 11 files changed, 414 insertions(+) create mode 100644 src/actions/rpm-remote-create.tsx create mode 100644 src/actions/rpm-remote-delete.tsx create mode 100644 src/actions/rpm-remote-edit.tsx diff --git a/src/actions/index.ts b/src/actions/index.ts index 199d70b9..2e118269 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -24,3 +24,6 @@ export { rpmRepositoryCreateAction } from './rpm-repository-create'; export { rpmRepositoryDeleteAction } from './rpm-repository-delete'; export { rpmRepositoryEditAction } from './rpm-repository-edit'; export { rpmRepositorySyncAction } from './rpm-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/rpm-remote-create.tsx b/src/actions/rpm-remote-create.tsx new file mode 100644 index 00000000..7344dd41 --- /dev/null +++ b/src/actions/rpm-remote-create.tsx @@ -0,0 +1,9 @@ +import { msg } from '@lingui/core/macro'; +import { Paths, formatPath } from 'src/paths'; +import { Action } from './action'; + +export const rpmRemoteCreateAction = Action({ + title: msg`Add remote`, + onClick: (item, { navigate }) => + navigate(formatPath(Paths.rpm.remote.edit, { name: '_' })), +}); diff --git a/src/actions/rpm-remote-delete.tsx b/src/actions/rpm-remote-delete.tsx new file mode 100644 index 00000000..1822872b --- /dev/null +++ b/src/actions/rpm-remote-delete.tsx @@ -0,0 +1,44 @@ +import { msg, t } from '@lingui/core/macro'; +import { RPMRemoteAPI } from 'src/api'; +import { DeleteRemoteModal } from 'src/components'; +import { + handleHttpError, + parsePulpIDFromURL, + taskAlert, + waitForTaskUrl, +} from 'src/utilities'; +import { Action } from './action'; + +export const rpmRemoteDeleteAction = Action({ + title: msg`Delete`, + modal: ({ addAlert, listQuery, setState, state }) => + state.deleteModalOpen ? ( + setState({ deleteModalOpen: null })} + deleteAction={() => + deleteRemote(state.deleteModalOpen, { addAlert, setState, listQuery }) + } + name={state.deleteModalOpen.name} + /> + ) : null, + onClick: ( + { name, id, pulp_href }: { name: string; id?: string; pulp_href?: string }, + { setState }, + ) => + setState({ + deleteModalOpen: { pulpId: id || parsePulpIDFromURL(pulp_href), name }, + }), +}); + +function deleteRemote({ name, pulpId }, { addAlert, setState, listQuery }) { + return RPMRemoteAPI.delete(pulpId) + .then(({ data }) => { + addAlert(taskAlert(data.task, t`Removal started for remote ${name}`)); + setState({ deleteModalOpen: null }); + return waitForTaskUrl(data.task); + }) + .then(() => listQuery()) + .catch( + handleHttpError(t`Failed to remove remote ${name}`, () => null, addAlert), + ); +} diff --git a/src/actions/rpm-remote-edit.tsx b/src/actions/rpm-remote-edit.tsx new file mode 100644 index 00000000..e2518287 --- /dev/null +++ b/src/actions/rpm-remote-edit.tsx @@ -0,0 +1,9 @@ +import { msg } from '@lingui/core/macro'; +import { Paths, formatPath } from 'src/paths'; +import { Action } from './action'; + +export const rpmRemoteEditAction = Action({ + title: msg`Edit`, + onClick: ({ name }, { navigate }) => + navigate(formatPath(Paths.rpm.remote.edit, { name })), +}); diff --git a/src/app-routes.tsx b/src/app-routes.tsx index edd2f095..928ba95b 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -48,6 +48,9 @@ import { RPMRepositoryDetail, RPMRepositoryList, RpmRepositoryEdit, + RPMRemoteDetail, + RPMRemoteEdit, + RPMRemoteList, RoleCreate, RoleList, Search, @@ -331,6 +334,21 @@ const routes: IRouteConfig[] = [ component: RpmRepositoryEdit, path: Paths.rpm.repository.edit, }, + { + component: RPMRemoteDetail, + path: Paths.rpm.remote.detail, + beta: true, + }, + { + component: RPMRemoteEdit, + path: Paths.rpm.remote.edit, + beta: true, + }, + { + component: RPMRemoteList, + path: Paths.rpm.remote.list, + beta: true, + }, ]; const AuthHandler = ({ diff --git a/src/containers/index.ts b/src/containers/index.ts index 48737fca..d1b7b9de 100644 --- a/src/containers/index.ts +++ b/src/containers/index.ts @@ -41,6 +41,9 @@ export { default as RPMPackageList } from './rpm/package-list'; export { default as RPMRepositoryDetail } from './rpm-repository/detail'; export { default as RPMRepositoryList } from './rpm-repository/list'; export { default as RpmRepositoryEdit } from './rpm-repository/edit'; +export { default as RPMRemoteDetail } from './rpm-remote/detail'; +export { default as RPMRemoteEdit } from './rpm-remote/edit'; +export { default as RPMRemoteList } from './rpm-remote/list'; export { default as MultiSearch } from './search/multi-search'; export { default as Search } from './search/search'; export { default as UserProfile } from './settings/user-profile'; diff --git a/src/containers/rpm-remote/detail.tsx b/src/containers/rpm-remote/detail.tsx index e69de29b..be6e2cbb 100644 --- a/src/containers/rpm-remote/detail.tsx +++ b/src/containers/rpm-remote/detail.tsx @@ -0,0 +1,40 @@ +import { msg, t } from '@lingui/core/macro'; +import { rpmRemoteDeleteAction, rpmRemoteEditAction } from 'src/actions'; +import { RPMRemoteAPI, type RPMRemoteType } from 'src/api'; +import { PageWithTabs } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { DetailsTab } from './tab-details'; + +const RPMRemoteDetail = PageWithTabs({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.rpm.remote.list), name: t`Remotes` }, + { url: formatPath(Paths.rpm.remote.detail, { name }), name }, + ].filter(Boolean), + displayName: 'RPMRemoteDetail', + errorTitle: msg`Remote could not be displayed.`, + headerActions: [rpmRemoteEditAction, rpmRemoteDeleteAction], + listUrl: formatPath(Paths.rpm.remote.list), + query: ({ name }) => + RPMRemoteAPI.list({ name }) + .then(({ data: { results } }) => results[0]) + .then( + (remote) => + remote || + // using the list api, so an empty array is really a 404 + Promise.reject({ response: { status: 404 } }), + ), + renderTab: (tab, item, actionContext) => + ({ + details: , + })[tab], + tabs: (tab, name) => [ + { + active: tab === 'details', + title: t`Details`, + link: formatPath(Paths.rpm.remote.detail, { name }, { tab: 'details' }), + }, + ], +}); + +export default RPMRemoteDetail; diff --git a/src/containers/rpm-remote/edit.tsx b/src/containers/rpm-remote/edit.tsx index e69de29b..f921558c 100644 --- a/src/containers/rpm-remote/edit.tsx +++ b/src/containers/rpm-remote/edit.tsx @@ -0,0 +1,150 @@ +import { msg, t } from '@lingui/core/macro'; +import { RPMRemoteAPI, type RPMRemoteType } from 'src/api'; +import { Page, RemoteForm } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL, taskAlert } from 'src/utilities'; + +const initialRemote: RPMRemoteType = { + name: '', + url: '', + ca_cert: null, + client_cert: null, + tls_validation: true, + proxy_url: null, + download_concurrency: null, + rate_limit: null, + + hidden_fields: [ + 'client_key', + 'proxy_username', + 'proxy_password', + 'username', + 'password', + 'token', + ].map((name) => ({ name, is_set: false })), +}; + +const RPMRemoteEdit = Page({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.rpm.remote.list), name: t`Remotes` }, + name && { url: formatPath(Paths.rpm.remote.detail, { name }), name }, + name ? { name: t`Edit` } : { name: t`Add` }, + ].filter(Boolean), + + displayName: 'RPMRemoteEdit', + errorTitle: msg`Remote could not be displayed.`, + listUrl: formatPath(Paths.rpm.remote.list), + query: ({ name }) => + RPMRemoteAPI.list({ name }).then(({ data: { results } }) => results[0]), + title: ({ name }) => name || t`Add new remote`, + transformParams: ({ name, ...rest }) => ({ + ...rest, + name: name !== '_' ? name : null, + }), + + render: (item, { navigate, queueAlert, state, setState }) => { + if (!state.remoteToEdit) { + const remoteToEdit = { + ...initialRemote, + ...item, + }; + setState({ remoteToEdit, errorMessages: {} }); + } + + const { remoteToEdit, errorMessages } = state; + if (!remoteToEdit) { + return null; + } + + const saveRemote = () => { + const { remoteToEdit } = state; + + const data = { ...remoteToEdit }; + + if (!item) { + // prevent "This field may not be blank." when writing in and then deleting username/password/etc + // only when creating, edit diffs with item + Object.keys(data).forEach((k) => { + if (data[k] === '' || data[k] == null) { + delete data[k]; + } + }); + + delete data.hidden_fields; + } + + delete data.my_permissions; + + // api requires traling slash, fix the trivial case + if (data.url && !data.url.includes('?') && !data.url.endsWith('/')) { + data.url += '/'; + } + + const promise = !item + ? RPMRemoteAPI.create(data) + : RPMRemoteAPI.smartUpdate( + parsePulpIDFromURL(item.pulp_href), + data, + item, + ); + + promise + .then(({ data: task }) => { + setState({ + errorMessages: {}, + remoteToEdit: undefined, + }); + + queueAlert( + item + ? taskAlert(task, t`Update started for remote ${data.name}`) + : { + variant: 'success', + title: t`Successfully created remote ${data.name}`, + }, + ); + + navigate( + formatPath(Paths.rpm.remote.detail, { + name: data.name, + }), + ); + }) + .catch(({ response: { data } }) => + setState({ + errorMessages: { + __nofield: data.non_field_errors || data.detail, + ...data, + }, + }), + ); + }; + + const closeModal = () => { + setState({ errorMessages: {}, remoteToEdit: undefined }); + navigate( + item + ? formatPath(Paths.rpm.remote.detail, { + name: item.name, + }) + : formatPath(Paths.rpm.remote.list), + ); + }; + + return ( + setState({ remoteToEdit: r })} + /> + ); + }, +}); + +export default RPMRemoteEdit; diff --git a/src/containers/rpm-remote/list.tsx b/src/containers/rpm-remote/list.tsx index e69de29b..27af8dab 100644 --- a/src/containers/rpm-remote/list.tsx +++ b/src/containers/rpm-remote/list.tsx @@ -0,0 +1,75 @@ +import { msg, t } from '@lingui/core/macro'; +import { Td, Tr } from '@patternfly/react-table'; +import { Link } from 'react-router'; +import { + rpmRemoteCreateAction, + rpmRemoteDeleteAction, + rpmRemoteEditAction, +} from 'src/actions'; +import { RPMRemoteAPI, type RPMRemoteType } from 'src/api'; +import { CopyURL, ListItemActions, ListPage } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL } from 'src/utilities'; + +const listItemActions = [ + // Edit + rpmRemoteEditAction, + // Delete + rpmRemoteDeleteAction, +]; + +const RPMRemoteList = ListPage({ + defaultPageSize: 10, + defaultSort: '-pulp_created', + displayName: 'RPMRemoteList', + errorTitle: msg`Remotes could not be displayed.`, + filterConfig: () => [ + { + id: 'name__icontains', + title: t`Remote name`, + }, + ], + headerActions: [rpmRemoteCreateAction], // Add remote + listItemActions, + noDataButton: rpmRemoteCreateAction.button, + noDataDescription: msg`Remotes will appear once created.`, + noDataTitle: msg`No remotes yet`, + query: ({ params }) => RPMRemoteAPI.list(params), + renderTableRow(item: RPMRemoteType, index: number, actionContext) { + const { name, pulp_href, url } = item; + const id = parsePulpIDFromURL(pulp_href); + + const kebabItems = listItemActions.map((action) => + action.dropdownItem({ ...item, id }, actionContext), + ); + + return ( + + + + {name} + + + + + + + + ); + }, + sortHeaders: [ + { + title: msg`Remote name`, + type: 'alpha', + id: 'name', + }, + { + title: msg`URL`, + type: 'alpha', + id: 'url', + }, + ], + title: msg`Remotes`, +}); + +export default RPMRemoteList; diff --git a/src/containers/rpm-remote/tab-details.tsx b/src/containers/rpm-remote/tab-details.tsx index e69de29b..3a3c709c 100644 --- a/src/containers/rpm-remote/tab-details.tsx +++ b/src/containers/rpm-remote/tab-details.tsx @@ -0,0 +1,60 @@ +import { t } from '@lingui/core/macro'; +import { type RPMRemoteType } from 'src/api'; +import { + CopyURL, + Details, + LazyRepositories, + PulpCodeBlock, +} from 'src/components'; + +interface TabProps { + item: RPMRemoteType; + actionContext: object; +} + +const MaybeCode = ({ code, rpmname }: { code: string; rpmname: string }) => + code ? : <>{t`None`}; + +export const DetailsTab = ({ item }: TabProps) => ( +
, + }, + { + 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/menu.tsx b/src/menu.tsx index 56bc55bc..444ca32f 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -111,6 +111,9 @@ function standaloneMenu() { menuItem(t`Repositories`, { url: formatPath(Paths.rpm.repository.list), }), + menuItem(t`Remotes`, { + url: formatPath(Paths.rpm.remote.list), + }), menuItem(t`RPMs`, { url: formatPath(Paths.rpm.package.list), }), From 5a94a3e9c8881c71073afe4a665bcaad04bbe7e2 Mon Sep 17 00:00:00 2001 From: Gino Lisignoli Date: Tue, 29 Jul 2025 19:28:59 +1200 Subject: [PATCH 3/7] Add distributions menu --- src/menu.tsx | 3 +++ src/paths.ts | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/src/menu.tsx b/src/menu.tsx index 444ca32f..5a17d344 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -108,6 +108,9 @@ function standaloneMenu() { }), ]), menuSection('Pulp RPM', { condition: and(loggedIn, hasPlugin('rpm')) }, [ + menuItem(t`Distributions`, { + url: formatPath(Paths.rpm.distribution.list), + }), menuItem(t`Repositories`, { url: formatPath(Paths.rpm.repository.list), }), diff --git a/src/paths.ts b/src/paths.ts index d54a851f..fbf5e1f9 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -100,6 +100,11 @@ export const Paths = { }, rpm: { package: { list: '/rpm/rpms' }, + distribution: { + detail: '/rpm/distributions/detail/:name', + edit: '/rpm/distributions/edit/:name', + list: '/rpm/distributions', + }, repository: { detail: '/rpm/repositories/detail/:name', edit: '/rpm/repositories/edit/:name', From 9cacd1bfc7ff403cd05b12f71d9c964df47a979a Mon Sep 17 00:00:00 2001 From: Gino Lisignoli Date: Sun, 3 Aug 2025 11:04:18 +1200 Subject: [PATCH 4/7] Add draft distribution management --- src/actions/index.ts | 3 + src/actions/rpm-distribution-create.tsx | 9 + src/actions/rpm-distribution-delete.tsx | 64 ++++++ src/actions/rpm-distribution-edit.tsx | 9 + src/api/index.ts | 2 +- src/api/rpm-distribution.ts | 115 +++++++++++ src/api/rpm-remote.ts | 22 +-- src/app-routes.tsx | 18 ++ src/components/delete-distribution-modal.tsx | 45 +++++ src/components/index.ts | 2 + src/components/remote-form.tsx | 2 +- src/components/rpm-distribution-form.tsx | 184 ++++++++++++++++++ src/containers/index.ts | 3 + src/containers/rpm-distribution/detail.tsx | 40 ++++ src/containers/rpm-distribution/edit.tsx | 136 +++++++++++++ src/containers/rpm-distribution/list.tsx | 77 ++++++++ .../rpm-distribution/tab-details.tsx | 62 ++++++ src/containers/rpm-remote/tab-details.tsx | 2 +- 18 files changed, 781 insertions(+), 14 deletions(-) create mode 100644 src/actions/rpm-distribution-create.tsx create mode 100644 src/actions/rpm-distribution-delete.tsx create mode 100644 src/actions/rpm-distribution-edit.tsx create mode 100644 src/components/delete-distribution-modal.tsx create mode 100644 src/components/rpm-distribution-form.tsx create mode 100644 src/containers/rpm-distribution/detail.tsx create mode 100644 src/containers/rpm-distribution/edit.tsx create mode 100644 src/containers/rpm-distribution/list.tsx create mode 100644 src/containers/rpm-distribution/tab-details.tsx diff --git a/src/actions/index.ts b/src/actions/index.ts index 2e118269..804539a3 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -20,6 +20,9 @@ export { fileRepositoryCreateAction } from './file-repository-create'; export { fileRepositoryDeleteAction } from './file-repository-delete'; export { fileRepositoryEditAction } from './file-repository-edit'; export { fileRepositorySyncAction } from './file-repository-sync'; +export { rpmDistributionCreateAction } from './rpm-distribution-create'; +export { rpmDistributionDeleteAction } from './rpm-distribution-delete'; +export { rpmDistributionEditAction } from './rpm-distribution-edit'; export { rpmRepositoryCreateAction } from './rpm-repository-create'; export { rpmRepositoryDeleteAction } from './rpm-repository-delete'; export { rpmRepositoryEditAction } from './rpm-repository-edit'; diff --git a/src/actions/rpm-distribution-create.tsx b/src/actions/rpm-distribution-create.tsx new file mode 100644 index 00000000..2c291bbd --- /dev/null +++ b/src/actions/rpm-distribution-create.tsx @@ -0,0 +1,9 @@ +import { msg } from '@lingui/core/macro'; +import { Paths, formatPath } from 'src/paths'; +import { Action } from './action'; + +export const rpmDistributionCreateAction = Action({ + title: msg`Create distribution`, + onClick: (item, { navigate }) => + navigate(formatPath(Paths.rpm.distribution.edit, { name: '_' })), +}); diff --git a/src/actions/rpm-distribution-delete.tsx b/src/actions/rpm-distribution-delete.tsx new file mode 100644 index 00000000..3063e802 --- /dev/null +++ b/src/actions/rpm-distribution-delete.tsx @@ -0,0 +1,64 @@ +import { msg, t } from '@lingui/core/macro'; +import { RPMDistributionAPI } from 'src/api'; +import { DeleteDistributionModal } from 'src/components'; +import { + handleHttpError, + parsePulpIDFromURL, + taskAlert, + waitForTaskUrl, +} from 'src/utilities'; +import { Action } from './action'; + +export const rpmDistributionDeleteAction = Action({ + title: msg`Delete`, + modal: ({ addAlert, listQuery, setState, state }) => + state.deleteModalOpen ? ( + setState({ deleteModalOpen: null })} + deleteAction={() => + deleteDistribution(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, + }, + }), +}); + +async function deleteDistribution( + { name, pulpId }, + { addAlert, setState, listQuery }, +) { + const deleteDistribution = RPMDistributionAPI.delete(pulpId) + .then(({ data }) => { + addAlert(taskAlert(data.task, t`Removal started for distribution ${name}`)); + return waitForTaskUrl(data.task); + }) + .catch( + handleHttpError( + t`Failed to remove distribution ${name}`, + () => setState({ deleteModalOpen: null }), + addAlert, + ), + ); + + return Promise.all([ + deleteDistribution, + ]).then(() => { + setState({ deleteModalOpen: null }); + listQuery(); + }); +} diff --git a/src/actions/rpm-distribution-edit.tsx b/src/actions/rpm-distribution-edit.tsx new file mode 100644 index 00000000..babe1c78 --- /dev/null +++ b/src/actions/rpm-distribution-edit.tsx @@ -0,0 +1,9 @@ +import { msg } from '@lingui/core/macro'; +import { Paths, formatPath } from 'src/paths'; +import { Action } from './action'; + +export const rpmDistributionEditAction = Action({ + title: msg`Edit`, + onClick: ({ name }, { navigate }) => + navigate(formatPath(Paths.rpm.distribution.edit, { name })), +}); diff --git a/src/api/index.ts b/src/api/index.ts index a4748eab..8e9f8673 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -74,7 +74,7 @@ export { type UserType, } from './response-types/user'; export { RoleAPI } from './role'; -export { RPMDistributionAPI } from './rpm-distribution'; +export { RPMDistributionAPI, type RPMDistributionType } from './rpm-distribution'; export { RPMPackageAPI } from './rpm-package'; export { RPMRepositoryAPI, type RPMRepositoryType } from './rpm-repository'; export { RPMRemoteAPI, type RPMRemoteType } from './rpm-remote'; diff --git a/src/api/rpm-distribution.ts b/src/api/rpm-distribution.ts index 668ab4cb..cc6bd0ba 100644 --- a/src/api/rpm-distribution.ts +++ b/src/api/rpm-distribution.ts @@ -2,10 +2,125 @@ import { PulpAPI } from './pulp'; const base = new PulpAPI(); + +// rpm.RpmDistributionResponse: +// type: object +// description: Serializer for RPM Distributions. +// properties: +// pulp_href: +// type: string +// format: uri +// readOnly: true +// prn: +// type: string +// readOnly: true +// description: The Pulp Resource Name (PRN). +// pulp_created: +// type: string +// format: date-time +// readOnly: true +// description: Timestamp of creation. +// pulp_last_updated: +// type: string +// format: date-time +// readOnly: true +// description: 'Timestamp of the last time this resource was updated. Note: +// for immutable resources - like content, repository versions, and publication +// - pulp_created and pulp_last_updated dates will be the same.' +// base_path: +// type: string +// description: The base (relative) path component of the published url. Avoid +// paths that overlap with other distribution base paths +// (e.g. "foo" and "foo/bar") +// base_url: +// type: string +// readOnly: true +// description: The URL for accessing the publication as defined by this distribution. +// content_guard: +// type: string +// format: uri +// nullable: true +// description: An optional content-guard. +// no_content_change_since: +// type: string +// readOnly: true +// description: Timestamp since when the distributed content served by this +// distribution has not changed. If equals to `null`, no guarantee is provided +// about content changes. +// hidden: +// type: boolean +// default: false +// description: Whether this distribution should be shown in the content app. +// pulp_labels: +// type: object +// additionalProperties: +// type: string +// nullable: true +// name: +// type: string +// description: A unique name. Ex, `rawhide` and `stable`. +// repository: +// type: string +// format: uri +// nullable: true +// description: The latest RepositoryVersion for this Repository will be served. +// publication: +// type: string +// format: uri +// nullable: true +// description: Publication to be served +// generate_repo_config: +// type: boolean +// default: false +// description: An option specifying whether Pulp should generate *.repo files. +// checkpoint: +// type: boolean +// required: +// - base_path +// - name + +export class RPMDistributionType { + pulp_href?: string; + prn?: string; + pulp_created?: string; + pulp_last_updated?: string; + base_path: string; + base_url?: string; + content_guard?: string; + no_content_change_since?: string; + hidden?: boolean; + pulp_labels?: Record; + name: string; + repository?: string; + publication?: string; + generate_repo_config?: boolean; + checkpoint?: boolean; +} + +// simplified version of smartUpdate from execution-environment-registry +function smartUpdate(remote: RPMDistributionType, unmodifiedRemote: RPMDistributionType) { + for (const field of Object.keys(remote)) { + if (remote[field] === '') { + remote[field] = null; + } + + // API returns headers:null bull doesn't accept it .. and we don't edit headers + if (remote[field] === null && unmodifiedRemote[field] === null) { + delete remote[field]; + } + } + + return remote; +} + + export const RPMDistributionAPI = { create: (data) => base.http.post(`distributions/rpm/rpm/`, data), delete: (id) => base.http.delete(`distributions/rpm/rpm/${id}/`), list: (params?) => base.list(`distributions/rpm/rpm/`, params), + + smartUpdate: (id, newValue: RPMDistributionType, oldValue: RPMDistributionType) => + base.http.put(`distributions/rpm/rpm/${id}/`, smartUpdate(newValue, oldValue)), }; diff --git a/src/api/rpm-remote.ts b/src/api/rpm-remote.ts index 95dd9b91..c86c2795 100644 --- a/src/api/rpm-remote.ts +++ b/src/api/rpm-remote.ts @@ -1,20 +1,20 @@ import { PulpAPI } from './pulp'; export class RPMRemoteType { - ca_cert: string; - client_cert: string; - download_concurrency: number; + ca_cert?: string; + client_cert?: string; + download_concurrency?: number; name: string; - proxy_url: string; - proxy_username: string; - proxy_password: string; + proxy_url?: string; + proxy_username?: string; + proxy_password?: string; pulp_href?: string; - rate_limit: number; - tls_validation: boolean; + rate_limit?: number; + tls_validation?: boolean; url: string; - username: string; - password: string; - max_retries: number; + username?: string; + password?: string; + max_retries?: number; policy?: 'immediate' | 'on_demand' | 'streamed'; pulp_labels?: Record; total_timeout?: number; diff --git a/src/app-routes.tsx b/src/app-routes.tsx index 928ba95b..e28f262c 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -44,6 +44,9 @@ import { NamespaceDetail, Partners, PulpStatus, + RPMDistributionDetail, + RPMDistributionEdit, + RPMDistributionList, RPMPackageList, RPMRepositoryDetail, RPMRepositoryList, @@ -315,6 +318,21 @@ const routes: IRouteConfig[] = [ path: Paths.meta.about, noAuth: true, }, + { + component: RPMDistributionDetail, + path: Paths.rpm.distribution.detail, + beta: true, + }, + { + component: RPMDistributionEdit, + path: Paths.rpm.distribution.edit, + beta: true, + }, + { + component: RPMDistributionList, + path: Paths.rpm.distribution.list, + beta: true, + }, { component: RPMPackageList, path: Paths.rpm.package.list, diff --git a/src/components/delete-distribution-modal.tsx b/src/components/delete-distribution-modal.tsx new file mode 100644 index 00000000..638e6b6e --- /dev/null +++ b/src/components/delete-distribution-modal.tsx @@ -0,0 +1,45 @@ +import { t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { Text } from '@patternfly/react-core'; +import { useState } from 'react'; +import { DeleteModal } from 'src/components'; + +interface IProps { + closeAction: () => void; + deleteAction: () => void; + name: string; +} + +export const DeleteDistributionModal = ({ + closeAction, + deleteAction, + name, +}: IProps) => { + const [pending, setPending] = useState(false); + + if (!name) { + return null; + } + + return ( + { + setPending(false); + closeAction(); + }} + deleteAction={() => { + setPending(false); + deleteAction(); + }} + isDisabled={pending} + title={t`Delete Distribution?`} + > + + + Are you sure you want to delete the distribution {name} + + + + ); +}; diff --git a/src/components/index.ts b/src/components/index.ts index a8419749..07d00a5d 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -25,6 +25,7 @@ export { DarkmodeSwitcher } from './darkmode-switcher'; export { DataForm } from './data-form'; export { DateComponent } from './date-component'; export { DeleteCollectionModal } from './delete-collection-modal'; +export { DeleteDistributionModal } from './delete-distribution-modal'; export { DeleteExecutionEnvironmentModal } from './delete-execution-environment-modal'; export { DeleteGroupModal } from './delete-group-modal'; export { DeleteModal } from './delete-modal'; @@ -125,6 +126,7 @@ export { RoleListTable, } from './role-list-table'; export { RolePermissions } from './role-permissions'; +export { RPMDistributionForm } from './rpm-distribution-form'; export { RPMRepositoryForm } from './rpm-repository-form'; export { SelectGroup } from './select-group'; export { SelectRoles } from './select-roles'; diff --git a/src/components/remote-form.tsx b/src/components/remote-form.tsx index b74df696..5ae9774d 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' | 'file'; + plugin: 'ansible' | 'container' | 'file' | 'rpm'; remote: RemoteType; saveRemote: () => void; showMain?: boolean; diff --git a/src/components/rpm-distribution-form.tsx b/src/components/rpm-distribution-form.tsx new file mode 100644 index 00000000..889fef26 --- /dev/null +++ b/src/components/rpm-distribution-form.tsx @@ -0,0 +1,184 @@ +import { t } from '@lingui/core/macro'; +import { + ActionGroup, + Button, + Checkbox, + Form, + FormGroup, + TextInput, +} from '@patternfly/react-core'; +import { + type RPMDistributionType, +} from 'src/api'; +import { + FormFieldHelper, + HelpButton, + PulpLabels, +} from 'src/components'; +import { + type ErrorMessagesType, +} from 'src/utilities'; + +interface IProps { + allowEditName: boolean; + errorMessages: ErrorMessagesType; + onCancel: () => void; + onSave: ({ createDistribution }) => void; + plugin: 'ansible' | 'file' | 'rpm'; + distribution: RPMDistributionType; + updateDistribution: (d) => void; + closeModal: () => void; + saveDistribution: () => void; +} + +export const RPMDistributionForm = ({ + allowEditName, + errorMessages, + onCancel, + onSave, + distribution, + updateDistribution, + saveDistribution, +}: IProps) => { + const requiredFields = []; + const disabledFields = allowEditName ? [] : ['name']; + + const formGroup = (fieldName, label, helperText, children) => ( + + {label} + + ) : ( + label + ) + } + isRequired={requiredFields.includes(fieldName)} + > + {children} + + {errorMessages[fieldName]} + + + ); + const inputField = (fieldName, label, helperText, props) => + formGroup( + fieldName, + label, + helperText, + + updateDistribution({ ...distribution, [fieldName]: value }) + } + {...props} + />, + ); + const stringField = (fieldName, label, helperText?) => + inputField(fieldName, label, helperText, { type: 'text' }); + const numericField = (fieldName, label, helperText?) => + inputField(fieldName, label, helperText, { type: 'number' }); + + const isValid = !requiredFields.find((field) => !distribution[field]); + + return ( +
+ {stringField('name', t`Name`)} + {stringField('base_path', t`Base Path`)} + + {stringField('content_guard', t`Content Guard`)} + + {formGroup( + 'hidden', + t`Hidden`, + t`Whether this distribution should be shown in the content app.`, + <> + + updateDistribution({ ...distribution, hidden: value }) + } + /> + + )} + + {formGroup( + 'generate_repo_config', + t`Generate Repo Config`, + t`An option specifying whether Pulp should generate *.repo files.`, + <> + + updateDistribution({ + ...distribution, + generate_repo_config: value, + }) + } + /> + + )} + + {formGroup( + 'pulp_labels', + t`Labels`, + t`Pulp labels in the form of 'key:value'.`, + <> +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + + updateDistribution({ ...distribution, pulp_labels: labels }) + } + /> +
+ , + )} + + {errorMessages['__nofield'] ? ( + + {errorMessages['__nofield']} + + ) : null} + + + + + + + ); +}; diff --git a/src/containers/index.ts b/src/containers/index.ts index d1b7b9de..de9396cd 100644 --- a/src/containers/index.ts +++ b/src/containers/index.ts @@ -37,6 +37,9 @@ 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 RPMDistributionDetail } from './rpm-distribution/detail'; +export { default as RPMDistributionEdit } from './rpm-distribution/edit'; +export { default as RPMDistributionList } from './rpm-distribution/list'; export { default as RPMPackageList } from './rpm/package-list'; export { default as RPMRepositoryDetail } from './rpm-repository/detail'; export { default as RPMRepositoryList } from './rpm-repository/list'; diff --git a/src/containers/rpm-distribution/detail.tsx b/src/containers/rpm-distribution/detail.tsx new file mode 100644 index 00000000..e54e4ec2 --- /dev/null +++ b/src/containers/rpm-distribution/detail.tsx @@ -0,0 +1,40 @@ +import { msg, t } from '@lingui/core/macro'; +import { rpmDistributionDeleteAction, rpmDistributionEditAction } from 'src/actions'; +import { RPMDistributionAPI, type RPMDistributionType } from 'src/api'; +import { PageWithTabs } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { DetailsTab } from './tab-details'; + +const RPMDistributionDetail = PageWithTabs({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.rpm.distribution.list), name: t`Distributions` }, + { url: formatPath(Paths.rpm.distribution.detail, { name }), name }, + ].filter(Boolean), + displayName: 'RPMDistributionDetail', + errorTitle: msg`Distribution could not be displayed.`, + headerActions: [rpmDistributionEditAction, rpmDistributionDeleteAction], + listUrl: formatPath(Paths.rpm.distribution.list), + query: ({ name }) => + RPMDistributionAPI.list({ name }) + .then(({ data: { results } }) => results[0]) + .then( + (distribution) => + distribution || + // using the list api, so an empty array is really a 404 + Promise.reject({ response: { status: 404 } }), + ), + renderTab: (tab, item, actionContext) => + ({ + details: , + })[tab], + tabs: (tab, name) => [ + { + active: tab === 'details', + title: t`Details`, + link: formatPath(Paths.rpm.distribution.detail, { name }, { tab: 'details' }), + }, + ], +}); + +export default RPMDistributionDetail; diff --git a/src/containers/rpm-distribution/edit.tsx b/src/containers/rpm-distribution/edit.tsx new file mode 100644 index 00000000..1f5db33b --- /dev/null +++ b/src/containers/rpm-distribution/edit.tsx @@ -0,0 +1,136 @@ +import { msg, t } from '@lingui/core/macro'; +import { RPMDistributionAPI, type RPMDistributionType } from 'src/api'; +import { Page, RPMDistributionForm } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL, taskAlert } from 'src/utilities'; + +const initialDistribution: RPMDistributionType = { + name: '', + base_path: '', +}; + +const RPMDistributionEdit = Page({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.rpm.distribution.list), name: t`Distributions` }, + name && { url: formatPath(Paths.rpm.distribution.detail, { name }), name }, + name ? { name: t`Edit` } : { name: t`Add` }, + ].filter(Boolean), + + displayName: 'RPMDistributionEdit', + errorTitle: msg`Distribution could not be displayed.`, + listUrl: formatPath(Paths.rpm.distribution.list), + query: ({ name }) => + RPMDistributionAPI.list({ name }).then(({ data: { results } }) => results[0]), + title: ({ name }) => name || t`Add new distribution`, + transformParams: ({ name, ...rest }) => ({ + ...rest, + name: name !== '_' ? name : null, + }), + + render: (item, { navigate, queueAlert, state, setState }) => { + if (!state.distributionToEdit) { + const distributionToEdit = { + ...initialDistribution, + ...item, + }; + setState({ distributionToEdit, errorMessages: {} }); + } + + const { distributionToEdit, errorMessages } = state; + if (!distributionToEdit) { + return null; + } + + const saveDistribution = () => { + const { distributionToEdit } = state; + + const data = { ...distributionToEdit }; + + 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 + ? RPMDistributionAPI.create(data) + : RPMDistributionAPI.smartUpdate( + parsePulpIDFromURL(item.pulp_href), + data, + item, + ); + + promise + .then(({ data: task }) => { + setState({ + errorMessages: {}, + distributionToEdit: undefined, + }); + + queueAlert( + item + ? taskAlert(task, t`Update started for distribution ${data.name}`) + : { + variant: 'success', + title: t`Successfully created distribution ${data.name}`, + }, + ); + + navigate( + formatPath(Paths.rpm.distribution.detail, { + name: data.name, + }), + ); + }) + .catch(({ response: { data } }) => + setState({ + errorMessages: { + __nofield: data.non_field_errors || data.detail, + ...data, + }, + }), + ); + }; + + const closeModal = () => { + setState({ errorMessages: {}, distributionToEdit: undefined }); + navigate( + item + ? formatPath(Paths.rpm.distribution.detail, { + name: item.name, + }) + : formatPath(Paths.rpm.distribution.list), + ); + }; + + return ( + setState({ distributionToEdit: r })} + onCancel={closeModal} + onSave={() => saveDistribution()} + /> + ); + }, +}); + +export default RPMDistributionEdit; diff --git a/src/containers/rpm-distribution/list.tsx b/src/containers/rpm-distribution/list.tsx new file mode 100644 index 00000000..17d4d4fc --- /dev/null +++ b/src/containers/rpm-distribution/list.tsx @@ -0,0 +1,77 @@ +import { msg, t } from '@lingui/core/macro'; +import { Td, Tr } from '@patternfly/react-table'; +import { Link } from 'react-router'; +import { + rpmDistributionCreateAction, + rpmDistributionDeleteAction, + rpmDistributionEditAction, +} from 'src/actions'; +import { RPMDistributionAPI, type RPMDistributionType } from 'src/api'; +import { CopyURL, ListItemActions, ListPage } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL } from 'src/utilities'; + +const listItemActions = [ + // Edit + rpmDistributionEditAction, + // Delete + rpmDistributionDeleteAction, +]; + +const RPMDistributionList = ListPage({ + defaultPageSize: 10, + defaultSort: '-pulp_created', + displayName: 'RPMDistributionList', + errorTitle: msg`Distributions could not be displayed.`, + filterConfig: () => [ + { + id: 'name__icontains', + title: t`Distribution name`, + }, + ], + headerActions: [rpmDistributionCreateAction], // Add distribution + listItemActions, + noDataButton: rpmDistributionCreateAction.button, + noDataDescription: msg`Distributions will appear once created.`, + noDataTitle: msg`No distributions yet`, + query: ({ params }) => RPMDistributionAPI.list(params), + renderTableRow(item: RPMDistributionType, index: number, actionContext) { + const { name, pulp_href } = item; + const id = parsePulpIDFromURL(pulp_href); + + const kebabItems = listItemActions.map((action) => + action.dropdownItem({ ...item, id }, actionContext), + ); + + return ( + + + + {name} + + + + + + + + ); + }, + sortHeaders: [ + { + title: msg`Distribution name`, + type: 'alpha', + id: 'name', + }, + { + title: msg`Base path`, + type: 'alpha', + id: 'url', + }, + ], + title: msg`Distributions`, +}); + +export default RPMDistributionList; diff --git a/src/containers/rpm-distribution/tab-details.tsx b/src/containers/rpm-distribution/tab-details.tsx new file mode 100644 index 00000000..8266edb4 --- /dev/null +++ b/src/containers/rpm-distribution/tab-details.tsx @@ -0,0 +1,62 @@ +import { t } from '@lingui/core/macro'; +import { type RPMDistributionType } from 'src/api'; +import { + CopyURL, + Details, + LazyRepositories, + PulpCodeBlock, +} from 'src/components'; + +interface TabProps { + item: RPMDistributionType; + actionContext: object; +} + +const MaybeCode = ({ code, rpmname }: { code: string; rpmname: string }) => + code ? : <>{t`None`}; + +export const DetailsTab = ({ item }: TabProps) => ( +
, + }, + { + label: t`Content guard`, + value: , + }, + { + label: t`hidden`, + value: item?.hidden ? t`Yes` : t`No`, + }, + { + label: t`Labels`, + value: item?.pulp_labels + ? Object.entries(item.pulp_labels).map(([key, value]) => ( +
+ {key}: {value} +
+ )) + : t`None`, + }, + { + label: t`Repository`, + value: , + }, + { + label: t`Publication`, + value: , + }, + { + label: t`Generate repo config`, + value: item?.generate_repo_config ? t`Yes` : t`No`, + }, + { + label: t`Checkpoint`, + value: item?.checkpoint ? t`Yes` : t`No`, + }, + ]} + /> +); diff --git a/src/containers/rpm-remote/tab-details.tsx b/src/containers/rpm-remote/tab-details.tsx index 3a3c709c..e5d0dabc 100644 --- a/src/containers/rpm-remote/tab-details.tsx +++ b/src/containers/rpm-remote/tab-details.tsx @@ -13,7 +13,7 @@ interface TabProps { } const MaybeCode = ({ code, rpmname }: { code: string; rpmname: string }) => - code ? : <>{t`None`}; + code ? : <>{t`None`}; export const DetailsTab = ({ item }: TabProps) => (
Date: Mon, 4 Aug 2025 09:34:15 +1200 Subject: [PATCH 5/7] Added all rpm details --- src/api/rpm-repository.ts | 9 ++++++--- src/containers/rpm-repository/edit.tsx | 5 +++-- src/containers/rpm-repository/tab-details.tsx | 16 ++++++++++++---- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/api/rpm-repository.ts b/src/api/rpm-repository.ts index 2c0010d0..cd089122 100644 --- a/src/api/rpm-repository.ts +++ b/src/api/rpm-repository.ts @@ -2,11 +2,12 @@ import { PulpAPI } from './pulp'; export class RPMRepositoryType { autopublish?: boolean; - checksum_type?: 'sha1' | 'sha256' | 'sha512'; description: string | null; - gpgcheck?: 0 | 1 | 2; latest_version_href?: string; metadata_signing_service?: string; + package_signing_service?: string; + package_signing_fingerprint?: string; + checksum_type?: 'unknown' | 'md5' | 'sha1' | 'sha224' |'sha256' | 'sha384' | 'sha512'; name: string; pulp_created?: string; pulp_href?: string; @@ -14,10 +15,12 @@ export class RPMRepositoryType { pulp_last_updated?: string; remote: string | null; repoclosure_verification?: boolean; - repo_gpgcheck?: 0 | 1; retain_package_versions?: number; retain_repo_versions: number | null; versions_href?: string; + compression_type?: 'zstd' | 'gz'; + repo_config?: object; + layout?: 'nested_alphabetically' | 'flat'; } const base = new PulpAPI(); diff --git a/src/containers/rpm-repository/edit.tsx b/src/containers/rpm-repository/edit.tsx index 5ca0bc95..b0e6ee59 100644 --- a/src/containers/rpm-repository/edit.tsx +++ b/src/containers/rpm-repository/edit.tsx @@ -16,9 +16,10 @@ const initialRepository: RPMRepositoryType = { remote: null, autopublish: false, metadata_signing_service: null, - gpgcheck: 0, - repo_gpgcheck: 0, checksum_type: 'sha256', + compression_type: 'zstd', + repo_config: {}, + layout: 'nested_alphabetically', }; const RpmRepositoryEdit = Page({ diff --git a/src/containers/rpm-repository/tab-details.tsx b/src/containers/rpm-repository/tab-details.tsx index 29c5bee1..dbb81c46 100644 --- a/src/containers/rpm-repository/tab-details.tsx +++ b/src/containers/rpm-repository/tab-details.tsx @@ -19,10 +19,10 @@ export const DetailsTab = ({ item }: TabProps) => { fields={[ { label: t`Repository name`, value: item?.name }, { label: t`Description`, value: item?.description || t`None` }, - { - label: t`Retained version count`, - value: item?.retain_repo_versions ?? t`All`, - }, + { label: t`Created`, value: item?.pulp_created }, + { label: t`Last updated`, value: item?.pulp_last_updated }, + { label: t`Retained version count`, value: item?.retain_repo_versions ?? t`All` }, + { label: t`Retained package versions`, value: item?.retain_package_versions ?? t`All` }, { label: t`Repository URL`, value: item?.distroBasePath ? ( @@ -31,6 +31,14 @@ export const DetailsTab = ({ item }: TabProps) => { '---' ), }, + { label: t`Autopublish`, value: item?.autopublish ? t`Yes` : t`No` }, + { label: t`Metadata signing service`, value: item?.metadata_signing_service || t`None` }, + { label: t`Package signing service`, value: item?.package_signing_service || t`None` }, + { label: t`Package signing fingerprint`, value: item?.package_signing_fingerprint || t`None` }, + { label: t`Checksum type`, value: item?.checksum_type || t`Unknown` }, + { label: t`Compression type`, value: item?.compression_type || t`None` }, + { label: t`Repo config`, value: JSON.stringify(item?.repo_config) }, + { label: t`Layout`, value: item?.layout }, { label: t`Labels`, value: , From fc239eaeb7c7eaa58d62316f0ccd93933795db8c Mon Sep 17 00:00:00 2001 From: Gino Lisignoli Date: Mon, 4 Aug 2025 18:24:09 +1200 Subject: [PATCH 6/7] Added draft rpm tests, added model to api for rpm repository --- cypress/e2e/rpm.js | 43 ++++++++++++++ cypress/e2e/smoke.js | 7 --- src/api/rpm-distribution.ts | 77 -------------------------- src/api/rpm-repository.ts | 6 +- src/components/rpm-repository-form.tsx | 32 ++++------- src/containers/rpm-repository/edit.tsx | 9 --- src/utilities/model-to-api.ts | 2 + 7 files changed, 60 insertions(+), 116 deletions(-) create mode 100644 cypress/e2e/rpm.js diff --git a/cypress/e2e/rpm.js b/cypress/e2e/rpm.js new file mode 100644 index 00000000..def2bffc --- /dev/null +++ b/cypress/e2e/rpm.js @@ -0,0 +1,43 @@ +describe('RPM plugin tests', () => { + beforeEach(() => cy.login()); + + it ('RPM Empty Packages', () => { + cy.ui('rpm/rpms'); + cy.assertTitle('Packages'); + + cy.contains('No packages yet'); + }); + + it ('RPM Empty Repositories', () => { + cy.ui('rpm/repositories'); + cy.assertTitle('Repositories'); + + cy.contains('No repositories yet'); + }); + + it ('RPM Add Repository', () => { + cy.ui('rpm/repositories'); + cy.contains('button', 'Add repository').click(); + + cy.assertTitle('Add new repository'); + + cy.get('#name').type('Test Repository'); + cy.get('#description').type('This is a test repository'); + + cy.contains('button', 'Save').click(); + cy.assertTitle('Test Repository'); + cy.contains('Test Repository'); + }); + + it ('RPM Remove Repository', () => { + cy.ui('rpm/repositories'); + cy.contains('Test Repository').click(); + + cy.contains('button', 'Delete').click(); + cy.contains('Are you sure you want to delete the repository Test Repository?'); + + cy.get('[role="dialog"]').contains('button', 'Delete').click(); + cy.assertTitle('Repositories'); + cy.contains('No repositories yet'); + }); +}); diff --git a/cypress/e2e/smoke.js b/cypress/e2e/smoke.js index edeb1806..d16d9db8 100644 --- a/cypress/e2e/smoke.js +++ b/cypress/e2e/smoke.js @@ -63,13 +63,6 @@ describe('UI smoke tests', () => { // TODO }); - it('RPMs', () => { - cy.ui('rpm/rpms'); - cy.assertTitle('Packages'); - - cy.contains('No packages yet'); - }); - it('Task management', () => { cy.ui('tasks'); cy.assertTitle('Task management'); diff --git a/src/api/rpm-distribution.ts b/src/api/rpm-distribution.ts index cc6bd0ba..cd519b1b 100644 --- a/src/api/rpm-distribution.ts +++ b/src/api/rpm-distribution.ts @@ -2,83 +2,6 @@ import { PulpAPI } from './pulp'; const base = new PulpAPI(); - -// rpm.RpmDistributionResponse: -// type: object -// description: Serializer for RPM Distributions. -// properties: -// pulp_href: -// type: string -// format: uri -// readOnly: true -// prn: -// type: string -// readOnly: true -// description: The Pulp Resource Name (PRN). -// pulp_created: -// type: string -// format: date-time -// readOnly: true -// description: Timestamp of creation. -// pulp_last_updated: -// type: string -// format: date-time -// readOnly: true -// description: 'Timestamp of the last time this resource was updated. Note: -// for immutable resources - like content, repository versions, and publication -// - pulp_created and pulp_last_updated dates will be the same.' -// base_path: -// type: string -// description: The base (relative) path component of the published url. Avoid -// paths that overlap with other distribution base paths -// (e.g. "foo" and "foo/bar") -// base_url: -// type: string -// readOnly: true -// description: The URL for accessing the publication as defined by this distribution. -// content_guard: -// type: string -// format: uri -// nullable: true -// description: An optional content-guard. -// no_content_change_since: -// type: string -// readOnly: true -// description: Timestamp since when the distributed content served by this -// distribution has not changed. If equals to `null`, no guarantee is provided -// about content changes. -// hidden: -// type: boolean -// default: false -// description: Whether this distribution should be shown in the content app. -// pulp_labels: -// type: object -// additionalProperties: -// type: string -// nullable: true -// name: -// type: string -// description: A unique name. Ex, `rawhide` and `stable`. -// repository: -// type: string -// format: uri -// nullable: true -// description: The latest RepositoryVersion for this Repository will be served. -// publication: -// type: string -// format: uri -// nullable: true -// description: Publication to be served -// generate_repo_config: -// type: boolean -// default: false -// description: An option specifying whether Pulp should generate *.repo files. -// checkpoint: -// type: boolean -// required: -// - base_path -// - name - export class RPMDistributionType { pulp_href?: string; prn?: string; diff --git a/src/api/rpm-repository.ts b/src/api/rpm-repository.ts index cd089122..6b00ee47 100644 --- a/src/api/rpm-repository.ts +++ b/src/api/rpm-repository.ts @@ -11,12 +11,12 @@ export class RPMRepositoryType { name: string; pulp_created?: string; pulp_href?: string; - pulp_labels: Record; + pulp_labels?: Record; pulp_last_updated?: string; - remote: string | null; + remote?: string | null; repoclosure_verification?: boolean; retain_package_versions?: number; - retain_repo_versions: number | null; + retain_repo_versions?: number | null; versions_href?: string; compression_type?: 'zstd' | 'gz'; repo_config?: object; diff --git a/src/components/rpm-repository-form.tsx b/src/components/rpm-repository-form.tsx index 53325156..c2582301 100644 --- a/src/components/rpm-repository-form.tsx +++ b/src/components/rpm-repository-form.tsx @@ -6,10 +6,12 @@ import { Form, FormGroup, TextInput, + Select, } from '@patternfly/react-core'; import { useEffect, useState } from 'react'; import { RPMRepositoryAPI, + RPMRemoteAPI, type RPMRepositoryType, } from 'src/api'; import { @@ -111,20 +113,15 @@ export const RPMRepositoryForm = ({ const [remotesError, setRemotesError] = useState(null); const loadRemotes = (name?) => { setRemotesError(null); - // (plugin === 'ansible' - // ? AnsibleRemoteAPI.list({ ...(name ? { name__icontains: name } : {}) }) - // : plugin === 'file' - // ? FileRemoteAPI.list({ ...(name ? { name__icontains: name } : {}) }) - // : Promise.reject(plugin) - // ) - // .then(({ data }) => - // setRemotes(data.results.map((r) => ({ ...r, id: r.pulp_href }))), - // ) - // .catch((e) => { - // const { status, statusText } = e.response; - // setRemotes([]); - // setRemotesError(errorMessage(status, statusText)); - // }); + RPMRemoteAPI.list({ ...(name ? { name__icontains: name } : {}) }) + .then(({ data }) => + setRemotes(data.results.map((r) => ({ ...r, id: r.pulp_href }))), + ) + .catch((e) => { + const { status, statusText } = e.response; + setRemotes([]); + setRemotesError(errorMessage(status, statusText)); + }); }; useEffect(() => loadRemotes(), []); @@ -149,12 +146,7 @@ export const RPMRepositoryForm = ({
{stringField('name', t`Name`)} {stringField('description', t`Description`)} - {numericField( - 'retain_repo_versions', - t`Retained number of versions`, - t`In order to retain all versions, leave this field blank.`, - )} - + {numericField('retain_repo_versions', t`Retained number of versions`, t`In order to retain all versions, leave this field blank.`,)} {formGroup( 'distributions', t`Distributions`, diff --git a/src/containers/rpm-repository/edit.tsx b/src/containers/rpm-repository/edit.tsx index b0e6ee59..71ca403b 100644 --- a/src/containers/rpm-repository/edit.tsx +++ b/src/containers/rpm-repository/edit.tsx @@ -11,15 +11,6 @@ import { parsePulpIDFromURL, taskAlert } from 'src/utilities'; const initialRepository: RPMRepositoryType = { name: '', description: '', - retain_repo_versions: 1, - pulp_labels: {}, - remote: null, - autopublish: false, - metadata_signing_service: null, - checksum_type: 'sha256', - compression_type: 'zstd', - repo_config: {}, - layout: 'nested_alphabetically', }; const RpmRepositoryEdit = Page({ diff --git a/src/utilities/model-to-api.ts b/src/utilities/model-to-api.ts index d3c0e8c4..0847b0f2 100644 --- a/src/utilities/model-to-api.ts +++ b/src/utilities/model-to-api.ts @@ -4,6 +4,7 @@ import { AnsibleRepositoryAPI, ContainerDistributionAPI, ContainerPullThroughDistributionAPI, + RPMRepositoryAPI, } from 'src/api'; export const ModelToApi = { @@ -13,4 +14,5 @@ export const ModelToApi = { ansibledistribution: AnsibleDistributionAPI, containerdistribution: ContainerDistributionAPI, containerpullthroughdistribution: ContainerPullThroughDistributionAPI, + rpmrepository: RPMRepositoryAPI }; From 1794ee72a65cd0d03d4b6ec5e15ef24b338dd7a7 Mon Sep 17 00:00:00 2001 From: Gino Lisignoli Date: Sun, 10 Aug 2025 13:05:35 +1200 Subject: [PATCH 7/7] Added remote support and some tests --- cypress/e2e/rpm.js | 147 ++++++++++++++++++- cypress/support/e2e.js | 4 + src/components/delete-distribution-modal.tsx | 2 +- src/components/remote-form.tsx | 2 + src/containers/rpm-remote/edit.tsx | 2 +- src/containers/rpm-repository/edit.tsx | 4 + src/utilities/plugin-repository-base-path.ts | 2 + 7 files changed, 157 insertions(+), 6 deletions(-) diff --git a/cypress/e2e/rpm.js b/cypress/e2e/rpm.js index def2bffc..29fc1185 100644 --- a/cypress/e2e/rpm.js +++ b/cypress/e2e/rpm.js @@ -1,21 +1,69 @@ describe('RPM plugin tests', () => { beforeEach(() => cy.login()); - it ('RPM Empty Packages', () => { + afterEach(function () { + if (this.currentTest.state === 'failed') { + Cypress.stop() + } + }) + + it('Empty distributions', () => { + cy.ui('rpm/distributions'); + cy.assertTitle('Distributions'); + + cy.contains('No distributions yet'); + }); + + it('Empty packages', () => { cy.ui('rpm/rpms'); cy.assertTitle('Packages'); cy.contains('No packages yet'); }); - it ('RPM Empty Repositories', () => { + it('Empty repositories', () => { cy.ui('rpm/repositories'); cy.assertTitle('Repositories'); cy.contains('No repositories yet'); }); - it ('RPM Add Repository', () => { + it('Empty remotes', () => { + cy.ui('rpm/remotes'); + cy.assertTitle('Remotes'); + + cy.contains('No remotes yet'); + }); + + it('Add distribution', () => { + cy.ui('rpm/distributions'); + cy.contains('button', 'Create distribution').click(); + + cy.assertTitle('Add new distribution'); + + cy.get('#name').type('Bare Test Distribution'); + cy.get('#base_path').type('bare-test-distribution'); + + cy.contains('button', 'Save').click(); + cy.assertTitle('Bare Test Distribution'); + cy.contains('Bare Test Distribution'); + }) + + it('Add remote', () => { + cy.ui('rpm/remotes'); + cy.contains('button', 'Add remote').click(); + + cy.assertTitle('Add new remote'); + + cy.get('#name').type('Test Remote'); + cy.get('#url').type('https://fixtures.pulpproject.org/rpm-repo-metadata/'); + + cy.contains('button', 'Save').click(); + cy.assertTitle('Test Remote'); + cy.contains('Test Remote'); + }); + + it('Add repository with a remote and distribution', () => { cy.ui('rpm/repositories'); cy.contains('button', 'Add repository').click(); @@ -24,12 +72,91 @@ describe('RPM plugin tests', () => { cy.get('#name').type('Test Repository'); cy.get('#description').type('This is a test repository'); + cy.get('input[id="create_distribution"]').should('be.checked'); + cy.get('label[for="create_distribution"]').contains('Test Repository'); + + cy.get('input[placeholder="Select a remote"]').click(); + cy.contains('Test Remote').click(); + cy.contains('button', 'Save').click(); cy.assertTitle('Test Repository'); cy.contains('Test Repository'); }); - it ('RPM Remove Repository', () => { + it('Sync a repository', () => { + cy.ui('rpm/repositories'); + cy.contains('Test Repository').click(); + + cy.contains('button', 'Sync').click(); + + let packagesSynced = false; + for (let i = 0; i < 30; i++) { + cy.ui('rpm/rpms'); + packagesSynced = cy.contains('No packages yet'); + if (packagesSynced) { + break; + } + cy.wait(1000); + } + packagesSynced; + }); + + it ('Edit remote', () => { + cy.ui('rpm/remotes'); + cy.contains('Test Remote').click(); + + cy.contains('button', 'Edit').click(); + cy.assertTitle('Test Remote'); + + cy.get('#url').clear().type('https://fixtures.pulpproject.org/rpm-repo-metadata-changed/'); + + cy.contains('button', 'Save').click(); + cy.assertTitle('Test Remote'); + cy.assertListText('https://fixtures.pulpproject.org/rpm-repo-metadata-changed/'); + }) + + + it ('Edit repository', () => { + cy.ui('rpm/repositories'); + cy.contains('Test Repository').click(); + + cy.contains('button', 'Edit').click(); + cy.assertTitle('Test Repository'); + + cy.get('#description').clear().type('This is an updated test repository'); + + cy.contains('button', 'Save').click(); + cy.assertTitle('Test Repository'); + cy.assertListText('This is an updated test repository'); + }) + + it ('Edit distribution', () => { + cy.ui('rpm/distributions'); + cy.contains('Test Distribution').click(); + + cy.contains('button', 'Edit').click(); + cy.assertTitle('Test Distribution'); + + cy.get('#base_path').clear().type('updated-base-path'); + + cy.contains('button', 'Save').click(); + cy.assertTitle('Test Distribution'); + cy.assertListText('updated-base-path'); + }) + + it('Remove distribution', () => { + cy.ui('rpm/distributions'); + cy.contains('Bare Test Distribution').click(); + + cy.contains('button', 'Delete').click(); + cy.contains('Are you sure you want to delete the distribution Bare Test Distribution?'); + + cy.get('[role="dialog"]').contains('button', 'Delete').click(); + cy.assertTitle('Distributions'); + cy.contains('Bare Test Distribution').should('not.exist'); + }) + + it('Remove last repository', () => { cy.ui('rpm/repositories'); cy.contains('Test Repository').click(); @@ -40,4 +167,16 @@ describe('RPM plugin tests', () => { cy.assertTitle('Repositories'); cy.contains('No repositories yet'); }); + + it('Remove last remote', () => { + cy.ui('rpm/remotes'); + cy.contains('Test Remote').click(); + + cy.contains('button', 'Delete').click(); + cy.contains('Are you sure you want to delete the remote Test Remote?'); + + cy.get('[role="dialog"]').contains('button', 'Delete').click(); + cy.assertTitle('Remotes'); + cy.contains('No remotes yet'); + }); }); diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js index a4479bdf..320f419e 100644 --- a/cypress/support/e2e.js +++ b/cypress/support/e2e.js @@ -7,6 +7,10 @@ Cypress.Commands.add('assertTitle', {}, (title) => { cy.contains('.pf-v5-c-title', title); }); +Cypress.Commands.add('assertListText', {}, (text) => { + cy.contains('.pf-v5-c-description-list__text', text); +}); + Cypress.Commands.add('ui', {}, (path = '') => { cy.visit(ui + path); }); diff --git a/src/components/delete-distribution-modal.tsx b/src/components/delete-distribution-modal.tsx index 638e6b6e..5a660708 100644 --- a/src/components/delete-distribution-modal.tsx +++ b/src/components/delete-distribution-modal.tsx @@ -37,7 +37,7 @@ export const DeleteDistributionModal = ({ > - Are you sure you want to delete the distribution {name} + Are you sure you want to delete the distribution {name}? diff --git a/src/components/remote-form.tsx b/src/components/remote-form.tsx index 5ae9774d..15732488 100644 --- a/src/components/remote-form.tsx +++ b/src/components/remote-form.tsx @@ -182,6 +182,7 @@ export class RemoteForm extends Component { case 'container': case 'file': + case 'rpm': disabledFields = disabledFields.concat([ 'auth_url', 'token', @@ -190,6 +191,7 @@ export class RemoteForm extends Component { 'sync_dependencies', ]); break; + } const save = ( diff --git a/src/containers/rpm-remote/edit.tsx b/src/containers/rpm-remote/edit.tsx index f921558c..d3046090 100644 --- a/src/containers/rpm-remote/edit.tsx +++ b/src/containers/rpm-remote/edit.tsx @@ -136,8 +136,8 @@ const RPMRemoteEdit = Page({ ({ @@ -58,6 +59,9 @@ const RpmRepositoryEdit = Page({ // prevent "This field may not be blank." for nullable fields Object.keys(data).forEach((k) => { + if (k === 'package_signing_fingerprint') { + return; + } if (data[k] === '') { data[k] = null; } diff --git a/src/utilities/plugin-repository-base-path.ts b/src/utilities/plugin-repository-base-path.ts index b11237d4..91d11bcd 100644 --- a/src/utilities/plugin-repository-base-path.ts +++ b/src/utilities/plugin-repository-base-path.ts @@ -4,6 +4,7 @@ import { AnsibleRepositoryAPI, FileDistributionAPI, FileRepositoryAPI, + RPMDistributionAPI, RPMRepositoryAPI, } from 'src/api'; @@ -29,6 +30,7 @@ export function plugin2api(plugin) { return { // FIXME: DistributionAPI: RPMDistributionAPI, RepositoryAPI: RPMRepositoryAPI, + DistributionAPI: RPMDistributionAPI }; default: return {};