From 7a73093fb1be5bbf10d4d63ab6f7809f2f27e956 Mon Sep 17 00:00:00 2001 From: Martin Hradil Date: Mon, 28 Oct 2024 22:07:17 +0000 Subject: [PATCH] pulp_file: Repositories & Remotes --- src/actions/file-remote-create.tsx | 9 + src/actions/file-remote-delete.tsx | 44 ++++ src/actions/file-remote-edit.tsx | 9 + src/actions/file-repository-create.tsx | 9 + src/actions/file-repository-delete.tsx | 99 +++++++ src/actions/file-repository-edit.tsx | 9 + src/actions/file-repository-sync.tsx | 148 +++++++++++ src/actions/index.ts | 7 + src/api/ansible-remote.ts | 35 ++- src/api/ansible-repository.ts | 20 ++ src/api/index.ts | 9 +- src/api/response-types/ansible-remote.ts | 33 --- src/api/response-types/ansible-repository.ts | 20 -- src/app-routes.tsx | 36 +++ src/components/remote-form.tsx | 24 +- src/components/repository-form.tsx | 19 +- src/containers/ansible-remote/edit.tsx | 10 +- src/containers/ansible-repository/edit.tsx | 1 + .../execution-environment/registry-list.tsx | 22 +- src/containers/file-remote/detail.tsx | 40 +++ src/containers/file-remote/edit.tsx | 150 +++++++++++ src/containers/file-remote/list.tsx | 75 ++++++ src/containers/file-remote/tab-details.tsx | 69 +++++ src/containers/file-repository/detail.tsx | 134 ++++++++++ src/containers/file-repository/edit.tsx | 181 +++++++++++++ src/containers/file-repository/list.tsx | 124 +++++++++ .../file-repository/tab-details.tsx | 55 ++++ .../file-repository/tab-distributions.tsx | 126 +++++++++ .../tab-repository-versions.tsx | 241 ++++++++++++++++++ src/containers/index.ts | 6 + src/menu.tsx | 8 + src/utilities/index.ts | 1 + src/utilities/plugin-repository-base-path.ts | 71 ++++++ 33 files changed, 1754 insertions(+), 90 deletions(-) create mode 100644 src/actions/file-remote-create.tsx create mode 100644 src/actions/file-remote-delete.tsx create mode 100644 src/actions/file-remote-edit.tsx create mode 100644 src/actions/file-repository-create.tsx create mode 100644 src/actions/file-repository-delete.tsx create mode 100644 src/actions/file-repository-edit.tsx create mode 100644 src/actions/file-repository-sync.tsx delete mode 100644 src/api/response-types/ansible-remote.ts delete mode 100644 src/api/response-types/ansible-repository.ts create mode 100644 src/containers/file-remote/detail.tsx create mode 100644 src/containers/file-remote/edit.tsx create mode 100644 src/containers/file-remote/list.tsx create mode 100644 src/containers/file-remote/tab-details.tsx create mode 100644 src/containers/file-repository/detail.tsx create mode 100644 src/containers/file-repository/edit.tsx create mode 100644 src/containers/file-repository/list.tsx create mode 100644 src/containers/file-repository/tab-details.tsx create mode 100644 src/containers/file-repository/tab-distributions.tsx create mode 100644 src/containers/file-repository/tab-repository-versions.tsx create mode 100644 src/utilities/plugin-repository-base-path.ts diff --git a/src/actions/file-remote-create.tsx b/src/actions/file-remote-create.tsx new file mode 100644 index 00000000..b9c64890 --- /dev/null +++ b/src/actions/file-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 fileRemoteCreateAction = Action({ + title: msg`Add remote`, + onClick: (item, { navigate }) => + navigate(formatPath(Paths.file.remote.edit, { name: '_' })), +}); diff --git a/src/actions/file-remote-delete.tsx b/src/actions/file-remote-delete.tsx new file mode 100644 index 00000000..f037500e --- /dev/null +++ b/src/actions/file-remote-delete.tsx @@ -0,0 +1,44 @@ +import { msg, t } from '@lingui/core/macro'; +import { FileRemoteAPI } from 'src/api'; +import { DeleteRemoteModal } from 'src/components'; +import { + handleHttpError, + parsePulpIDFromURL, + taskAlert, + waitForTaskUrl, +} from 'src/utilities'; +import { Action } from './action'; + +export const fileRemoteDeleteAction = 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 FileRemoteAPI.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/file-remote-edit.tsx b/src/actions/file-remote-edit.tsx new file mode 100644 index 00000000..19102419 --- /dev/null +++ b/src/actions/file-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 fileRemoteEditAction = Action({ + title: msg`Edit`, + onClick: ({ name }, { navigate }) => + navigate(formatPath(Paths.file.remote.edit, { name })), +}); diff --git a/src/actions/file-repository-create.tsx b/src/actions/file-repository-create.tsx new file mode 100644 index 00000000..f03f33e5 --- /dev/null +++ b/src/actions/file-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 fileRepositoryCreateAction = Action({ + title: msg`Add repository`, + onClick: (item, { navigate }) => + navigate(formatPath(Paths.file.repository.edit, { name: '_' })), +}); diff --git a/src/actions/file-repository-delete.tsx b/src/actions/file-repository-delete.tsx new file mode 100644 index 00000000..58653798 --- /dev/null +++ b/src/actions/file-repository-delete.tsx @@ -0,0 +1,99 @@ +import { msg, t } from '@lingui/core/macro'; +import { FileDistributionAPI, FileRepositoryAPI } from 'src/api'; +import { DeleteRepositoryModal } from 'src/components'; +import { + handleHttpError, + parsePulpIDFromURL, + taskAlert, + waitForTaskUrl, +} from 'src/utilities'; +import { Action } from './action'; + +export const fileRepositoryDeleteAction = 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 FileDistributionAPI.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 = FileRepositoryAPI.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 FileDistributionAPI.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/file-repository-edit.tsx b/src/actions/file-repository-edit.tsx new file mode 100644 index 00000000..ecff9346 --- /dev/null +++ b/src/actions/file-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 fileRepositoryEditAction = Action({ + title: msg`Edit`, + onClick: ({ name }, { navigate }) => + navigate(formatPath(Paths.file.repository.edit, { name })), +}); diff --git a/src/actions/file-repository-sync.tsx b/src/actions/file-repository-sync.tsx new file mode 100644 index 00000000..775a7fbf --- /dev/null +++ b/src/actions/file-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 { FileRepositoryAPI } 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 fileRepositorySyncAction = 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('file.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 FileRepositoryAPI.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/actions/index.ts b/src/actions/index.ts index dec174ae..a493b36a 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -13,3 +13,10 @@ export { ansibleRepositoryDeleteAction } from './ansible-repository-delete'; export { ansibleRepositoryEditAction } from './ansible-repository-edit'; export { ansibleRepositorySyncAction } from './ansible-repository-sync'; export { ansibleRepositoryVersionRevertAction } from './ansible-repository-version-revert'; +export { fileRemoteCreateAction } from './file-remote-create'; +export { fileRemoteDeleteAction } from './file-remote-delete'; +export { fileRemoteEditAction } from './file-remote-edit'; +export { fileRepositoryCreateAction } from './file-repository-create'; +export { fileRepositoryDeleteAction } from './file-repository-delete'; +export { fileRepositoryEditAction } from './file-repository-edit'; +export { fileRepositorySyncAction } from './file-repository-sync'; diff --git a/src/api/ansible-remote.ts b/src/api/ansible-remote.ts index 767e14ab..98c1ecdc 100644 --- a/src/api/ansible-remote.ts +++ b/src/api/ansible-remote.ts @@ -1,5 +1,38 @@ import { PulpAPI } from './pulp'; -import { type AnsibleRemoteType } from './response-types/ansible-remote'; + +export class AnsibleRemoteType { + auth_url: string; + ca_cert: string; + client_cert: string; + download_concurrency: number; + name: string; + proxy_url: string; + pulp_href?: string; + rate_limit: number; + requirements_file: string; + tls_validation: boolean; + url: string; + signed_only: boolean; + sync_dependencies?: boolean; + + // connect_timeout + // headers + // max_retries + // policy + // pulp_created + // pulp_labels + // pulp_last_updated + // sock_connect_timeout + // sock_read_timeout + // total_timeout + + hidden_fields: { + is_set: boolean; + name: string; + }[]; + + my_permissions?: string[]; +} // simplified version of smartUpdate from execution-environment-registry function smartUpdate( diff --git a/src/api/ansible-repository.ts b/src/api/ansible-repository.ts index 24bf1819..7a0758a1 100644 --- a/src/api/ansible-repository.ts +++ b/src/api/ansible-repository.ts @@ -1,4 +1,24 @@ import { PulpAPI } from './pulp'; +import { type LastSyncType } from './response-types/remote'; + +export class AnsibleRepositoryType { + description: string; + last_sync_task?: LastSyncType; + latest_version_href?: string; + name: string; + private?: boolean; + pulp_created?: string; + pulp_href?: string; + pulp_labels?: Record; + remote?: string; + retain_repo_versions: number; + + // gpgkey + // last_synced_metadata_time + // versions_href + + my_permissions?: string[]; +} const base = new PulpAPI(); diff --git a/src/api/index.ts b/src/api/index.ts index db9a2c3f..cd7304bd 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,7 +1,10 @@ export { ActivitiesAPI } from './activities'; export { AnsibleDistributionAPI } from './ansible-distribution'; -export { AnsibleRemoteAPI } from './ansible-remote'; -export { AnsibleRepositoryAPI } from './ansible-repository'; +export { AnsibleRemoteAPI, type AnsibleRemoteType } from './ansible-remote'; +export { + AnsibleRepositoryAPI, + type AnsibleRepositoryType, +} from './ansible-repository'; export { CertificateUploadAPI } from './certificate-upload'; export { CollectionAPI } from './collection'; export { CollectionVersionAPI } from './collection-version'; @@ -37,8 +40,6 @@ export { OrphanCleanupAPI } from './orphan-cleanup'; export { PulpLoginAPI } from './pulp-login'; export { PulpStatusAPI } from './pulp-status'; export { RepairAPI } from './repair'; -export { type AnsibleRemoteType } from './response-types/ansible-remote'; -export { type AnsibleRepositoryType } from './response-types/ansible-repository'; export { type CollectionDetailType, type CollectionUploadType, diff --git a/src/api/response-types/ansible-remote.ts b/src/api/response-types/ansible-remote.ts deleted file mode 100644 index 88c60a83..00000000 --- a/src/api/response-types/ansible-remote.ts +++ /dev/null @@ -1,33 +0,0 @@ -export class AnsibleRemoteType { - auth_url: string; - ca_cert: string; - client_cert: string; - download_concurrency: number; - name: string; - proxy_url: string; - pulp_href?: string; - rate_limit: number; - requirements_file: string; - tls_validation: boolean; - url: string; - signed_only: boolean; - sync_dependencies?: boolean; - - // connect_timeout - // headers - // max_retries - // policy - // pulp_created - // pulp_labels - // pulp_last_updated - // sock_connect_timeout - // sock_read_timeout - // total_timeout - - hidden_fields: { - is_set: boolean; - name: string; - }[]; - - my_permissions?: string[]; -} diff --git a/src/api/response-types/ansible-repository.ts b/src/api/response-types/ansible-repository.ts deleted file mode 100644 index aafd0bee..00000000 --- a/src/api/response-types/ansible-repository.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { type LastSyncType } from './remote'; - -export class AnsibleRepositoryType { - description: string; - last_sync_task?: LastSyncType; - latest_version_href?: string; - name: string; - private?: boolean; - pulp_created?: string; - pulp_href?: string; - pulp_labels?: Record; - remote?: string; - retain_repo_versions: number; - - // gpgkey - // last_synced_metadata_time - // versions_href - - my_permissions?: string[]; -} diff --git a/src/app-routes.tsx b/src/app-routes.tsx index 9617058f..610ad0e3 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -29,6 +29,12 @@ import { ExecutionEnvironmentList, ExecutionEnvironmentManifest, ExecutionEnvironmentRegistryList, + FileRemoteDetail, + FileRemoteEdit, + FileRemoteList, + FileRepositoryDetail, + FileRepositoryEdit, + FileRepositoryList, GroupDetail, GroupList, LoginPage, @@ -161,6 +167,36 @@ const routes: IRouteConfig[] = [ path: Paths.ansible.repository.list, beta: true, }, + { + component: FileRemoteDetail, + path: Paths.file.remote.detail, + beta: true, + }, + { + component: FileRemoteEdit, + path: Paths.file.remote.edit, + beta: true, + }, + { + component: FileRemoteList, + path: Paths.file.remote.list, + beta: true, + }, + { + component: FileRepositoryDetail, + path: Paths.file.repository.detail, + beta: true, + }, + { + component: FileRepositoryEdit, + path: Paths.file.repository.edit, + beta: true, + }, + { + component: FileRepositoryList, + path: Paths.file.repository.list, + beta: true, + }, { component: UserProfile, path: Paths.core.user.profile, diff --git a/src/components/remote-form.tsx b/src/components/remote-form.tsx index 75b54082..e47a1088 100644 --- a/src/components/remote-form.tsx +++ b/src/components/remote-form.tsx @@ -37,11 +37,11 @@ interface IProps { allowEditName?: boolean; closeModal: () => void; errorMessages: ErrorMessagesType; + plugin: 'ansible' | 'container' | 'file'; remote: RemoteType; - remoteType: 'registry' | 'ansible-remote'; saveRemote: () => void; - showModal?: boolean; showMain?: boolean; + showModal?: boolean; title?: string; updateRemote: (remote) => void; } @@ -146,7 +146,7 @@ export class RemoteForm extends Component { // Shim in a default concurrency value to pass form validation if ( - this.props.remoteType !== 'registry' && + this.props.plugin !== 'container' && this.props.remote.download_concurrency === null ) { this.updateRemote(10, 'download_concurrency'); @@ -157,11 +157,11 @@ export class RemoteForm extends Component { const { allowEditName, closeModal, + plugin, remote, saveRemote, showMain, showModal, - remoteType, title, } = this.props; @@ -173,15 +173,15 @@ export class RemoteForm extends Component { let disabledFields = allowEditName ? [] : ['name']; const isCommunityRemote = - remoteType === 'ansible-remote' && - remote?.url === 'https://galaxy.ansible.com/api/'; + plugin === 'ansible' && remote?.url === 'https://galaxy.ansible.com/api/'; - switch (remoteType) { - case 'ansible-remote': - // require only name, url; nothing disabled + switch (plugin) { + case 'ansible': + // nothing disabled break; - case 'registry': + case 'container': + case 'file': disabledFields = disabledFields.concat([ 'auth_url', 'token', @@ -930,7 +930,7 @@ export class RemoteForm extends Component { } private isValid(requiredFields) { - const { remote, remoteType } = this.props; + const { plugin, remote } = this.props; for (const field of requiredFields) { if (!remote[field] || remote[field] === '') { @@ -938,7 +938,7 @@ export class RemoteForm extends Component { } } - if (remoteType === 'ansible-remote') { + if (plugin !== 'container') { // only required in remotes, not registries if (remote.download_concurrency < 1) { return false; diff --git a/src/components/repository-form.tsx b/src/components/repository-form.tsx index 77576904..a85691b4 100644 --- a/src/components/repository-form.tsx +++ b/src/components/repository-form.tsx @@ -8,7 +8,11 @@ import { TextInput, } from '@patternfly/react-core'; import { useEffect, useState } from 'react'; -import { AnsibleRemoteAPI, type AnsibleRepositoryType } from 'src/api'; +import { + AnsibleRemoteAPI, + type AnsibleRepositoryType, + FileRemoteAPI, +} from 'src/api'; import { FormFieldHelper, HelpButton, @@ -20,7 +24,7 @@ import { import { type ErrorMessagesType, errorMessage, - repositoryBasePath, + pluginRepositoryBasePath, } from 'src/utilities'; interface IProps { @@ -28,6 +32,7 @@ interface IProps { errorMessages: ErrorMessagesType; onCancel: () => void; onSave: ({ createDistribution }) => void; + plugin: 'ansible' | 'file'; repository: AnsibleRepositoryType; updateRepository: (r) => void; } @@ -37,6 +42,7 @@ export const RepositoryForm = ({ errorMessages, onCancel, onSave, + plugin, repository, updateRepository, }: IProps) => { @@ -106,7 +112,12 @@ export const RepositoryForm = ({ const [remotesError, setRemotesError] = useState(null); const loadRemotes = (name?) => { setRemotesError(null); - AnsibleRemoteAPI.list({ ...(name ? { name__icontains: name } : {}) }) + (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 }))), ) @@ -126,7 +137,7 @@ export const RepositoryForm = ({ return; } - repositoryBasePath(repository.name, repository.pulp_href) + pluginRepositoryBasePath(plugin, repository.name, repository.pulp_href) .catch(() => null) .then(onDistributionsLoad); }, [repository?.pulp_href]); diff --git a/src/containers/ansible-remote/edit.tsx b/src/containers/ansible-remote/edit.tsx index 41c573df..1e967bef 100644 --- a/src/containers/ansible-remote/edit.tsx +++ b/src/containers/ansible-remote/edit.tsx @@ -152,13 +152,13 @@ const AnsibleRemoteEdit = Page({ return ( setState({ remoteToEdit: r })} - remoteType='ansible-remote' - showMain saveRemote={saveRemote} - errorMessages={errorMessages} - closeModal={closeModal} + showMain + updateRemote={(r) => setState({ remoteToEdit: r })} /> ); }, diff --git a/src/containers/ansible-repository/edit.tsx b/src/containers/ansible-repository/edit.tsx index 23207cce..3638c7ec 100644 --- a/src/containers/ansible-repository/edit.tsx +++ b/src/containers/ansible-repository/edit.tsx @@ -184,6 +184,7 @@ const AnsibleRepositoryEdit = Page({ errorMessages={errorMessages} onCancel={closeModal} onSave={saveRepository} + plugin='ansible' repository={repositoryToEdit} updateRepository={(r) => setState({ repositoryToEdit: r })} /> diff --git a/src/containers/execution-environment/registry-list.tsx b/src/containers/execution-environment/registry-list.tsx index a8776c23..6e451f20 100644 --- a/src/containers/execution-environment/registry-list.tsx +++ b/src/containers/execution-environment/registry-list.tsx @@ -160,9 +160,17 @@ class ExecutionEnvironmentRegistryList extends Component { /> {showRemoteFormModal && ( + this.setState({ + remoteToEdit: null, + remoteUnmodified: null, + showRemoteFormModal: false, + }) + } + errorMessages={remoteFormErrors} + plugin='container' remote={remoteToEdit} - remoteType='registry' - updateRemote={(r: RemoteType) => this.setState({ remoteToEdit: r })} saveRemote={() => { const { remoteFormNew, remoteToEdit } = this.state; const newRemote = { ...remoteToEdit }; @@ -200,19 +208,11 @@ class ExecutionEnvironmentRegistryList extends Component { this.setState({ remoteFormErrors: mapErrorMessages(err) }), ); }} - errorMessages={remoteFormErrors} showModal={showRemoteFormModal} - closeModal={() => - this.setState({ - remoteToEdit: null, - remoteUnmodified: null, - showRemoteFormModal: false, - }) - } - allowEditName={remoteFormNew} title={ remoteFormNew ? t`Add remote registry` : t`Edit remote registry` } + updateRemote={(r: RemoteType) => this.setState({ remoteToEdit: r })} /> )} {showDeleteModal && remoteToEdit && ( diff --git a/src/containers/file-remote/detail.tsx b/src/containers/file-remote/detail.tsx new file mode 100644 index 00000000..74c8c967 --- /dev/null +++ b/src/containers/file-remote/detail.tsx @@ -0,0 +1,40 @@ +import { msg, t } from '@lingui/core/macro'; +import { fileRemoteDeleteAction, fileRemoteEditAction } from 'src/actions'; +import { FileRemoteAPI, type FileRemoteType } from 'src/api'; +import { PageWithTabs } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { DetailsTab } from './tab-details'; + +const FileRemoteDetail = PageWithTabs({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.file.remote.list), name: t`Remotes` }, + { url: formatPath(Paths.file.remote.detail, { name }), name }, + ].filter(Boolean), + displayName: 'FileRemoteDetail', + errorTitle: msg`Remote could not be displayed.`, + headerActions: [fileRemoteEditAction, fileRemoteDeleteAction], + listUrl: formatPath(Paths.file.remote.list), + query: ({ name }) => + FileRemoteAPI.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.file.remote.detail, { name }, { tab: 'details' }), + }, + ], +}); + +export default FileRemoteDetail; diff --git a/src/containers/file-remote/edit.tsx b/src/containers/file-remote/edit.tsx new file mode 100644 index 00000000..43255f70 --- /dev/null +++ b/src/containers/file-remote/edit.tsx @@ -0,0 +1,150 @@ +import { msg, t } from '@lingui/core/macro'; +import { FileRemoteAPI, type FileRemoteType } from 'src/api'; +import { Page, RemoteForm } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL, taskAlert } from 'src/utilities'; + +const initialRemote: FileRemoteType = { + name: '', + url: '', + ca_cert: null, + client_cert: null, + tls_validation: true, + proxy_url: null, + download_concurrency: null, + rate_limit: null, + requirements_file: null, + auth_url: null, + signed_only: false, + + hidden_fields: [ + 'client_key', + 'proxy_username', + 'proxy_password', + 'username', + 'password', + 'token', + ].map((name) => ({ name, is_set: false })), +}; + +const FileRemoteEdit = Page({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.file.remote.list), name: t`Remotes` }, + name && { url: formatPath(Paths.file.remote.detail, { name }), name }, + name ? { name: t`Edit` } : { name: t`Add` }, + ].filter(Boolean), + + displayName: 'FileRemoteEdit', + errorTitle: msg`Remote could not be displayed.`, + listUrl: formatPath(Paths.file.remote.list), + query: ({ name }) => + FileRemoteAPI.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; + delete data.signed_only; + + // api requires traling slash, fix the trivial case + if (data.url && !data.url.includes('?') && !data.url.endsWith('/')) { + data.url += '/'; + } + + const promise = !item + ? FileRemoteAPI.create(data) + : FileRemoteAPI.patch(parsePulpIDFromURL(item.pulp_href), data); + + 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.file.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.file.remote.detail, { + name: item.name, + }) + : formatPath(Paths.file.remote.list), + ); + }; + + return ( + setState({ remoteToEdit: r })} + /> + ); + }, +}); + +export default FileRemoteEdit; diff --git a/src/containers/file-remote/list.tsx b/src/containers/file-remote/list.tsx new file mode 100644 index 00000000..c7e622d5 --- /dev/null +++ b/src/containers/file-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 { + fileRemoteCreateAction, + fileRemoteDeleteAction, + fileRemoteEditAction, +} from 'src/actions'; +import { FileRemoteAPI, type FileRemoteType } from 'src/api'; +import { CopyURL, ListItemActions, ListPage } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL } from 'src/utilities'; + +const listItemActions = [ + // Edit + fileRemoteEditAction, + // Delete + fileRemoteDeleteAction, +]; + +const FileRemoteList = ListPage({ + defaultPageSize: 10, + defaultSort: '-pulp_created', + displayName: 'FileRemoteList', + errorTitle: msg`Remotes could not be displayed.`, + filterConfig: () => [ + { + id: 'name__icontains', + title: t`Remote name`, + }, + ], + headerActions: [fileRemoteCreateAction], // Add remote + listItemActions, + noDataButton: fileRemoteCreateAction.button, + noDataDescription: msg`Remotes will appear once created.`, + noDataTitle: msg`No remotes yet`, + query: ({ params }) => FileRemoteAPI.list(params), + renderTableRow(item: FileRemoteType, 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 FileRemoteList; diff --git a/src/containers/file-remote/tab-details.tsx b/src/containers/file-remote/tab-details.tsx new file mode 100644 index 00000000..bad002dd --- /dev/null +++ b/src/containers/file-remote/tab-details.tsx @@ -0,0 +1,69 @@ +import { t } from '@lingui/core/macro'; +import { type FileRemoteType } from 'src/api'; +import { + CopyURL, + Details, + LazyRepositories, + PulpCodeBlock, +} from 'src/components'; + +interface TabProps { + item: FileRemoteType; + actionContext: object; +} + +const MaybeCode = ({ code, filename }: { code: string; filename: 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: , + }, + { + label: t`YAML requirements`, + value: ( + + ), + }, + ]} + /> +); diff --git a/src/containers/file-repository/detail.tsx b/src/containers/file-repository/detail.tsx new file mode 100644 index 00000000..e6a4c9e4 --- /dev/null +++ b/src/containers/file-repository/detail.tsx @@ -0,0 +1,134 @@ +import { msg, t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { + fileRepositoryDeleteAction, + fileRepositoryEditAction, + fileRepositorySyncAction, +} from 'src/actions'; +import { + FileRemoteAPI, + type FileRemoteType, + FileRepositoryAPI, + type FileRepositoryType, +} 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 FileRepositoryDetail = PageWithTabs< + FileRepositoryType & { remote?: FileRemoteType } +>({ + breadcrumbs: ({ name, tab, params: { repositoryVersion } }) => + [ + { url: formatPath(Paths.file.repository.list), name: t`Repositories` }, + { url: formatPath(Paths.file.repository.detail, { name }), name }, + tab === 'repository-versions' && repositoryVersion + ? { + url: formatPath(Paths.file.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: 'FileRepositoryDetail', + errorTitle: msg`Repository could not be displayed.`, + headerActions: [ + fileRepositoryEditAction, + fileRepositorySyncAction, + fileRepositoryDeleteAction, + ], + headerDetails: (item) => ( + <> + {item?.last_sync_task && ( +

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

+ )} + + ), + listUrl: formatPath(Paths.file.repository.list), + query: ({ name }) => + FileRepositoryAPI.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 + ? FileRemoteAPI.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.file.repository.detail, + { name }, + { tab: 'details' }, + ), + }, + { + active: tab === 'repository-versions', + title: t`Versions`, + link: formatPath( + Paths.file.repository.detail, + { name }, + { tab: 'repository-versions' }, + ), + }, + { + active: tab === 'distributions', + title: t`Distributions`, + link: formatPath( + Paths.file.repository.detail, + { name }, + { tab: 'distributions' }, + ), + }, + ], +}); + +export default FileRepositoryDetail; diff --git a/src/containers/file-repository/edit.tsx b/src/containers/file-repository/edit.tsx new file mode 100644 index 00000000..4d6d00bf --- /dev/null +++ b/src/containers/file-repository/edit.tsx @@ -0,0 +1,181 @@ +import { msg, t } from '@lingui/core/macro'; +import { + FileDistributionAPI, + FileRepositoryAPI, + type FileRepositoryType, +} from 'src/api'; +import { Page, RepositoryForm } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL, taskAlert } from 'src/utilities'; + +const initialRepository: FileRepositoryType = { + name: '', + description: '', + retain_repo_versions: 1, + pulp_labels: {}, + remote: null, +}; + +const FileRepositoryEdit = Page({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.file.repository.list), name: t`Repositories` }, + name && { + url: formatPath(Paths.file.repository.detail, { name }), + name, + }, + name ? { name: t`Edit` } : { name: t`Add` }, + ].filter(Boolean), + + displayName: 'FileRepositoryEdit', + errorTitle: msg`Repository could not be displayed.`, + listUrl: formatPath(Paths.file.repository.list), + query: ({ name }) => + FileRepositoryAPI.list({ name }).then( + ({ data: { results } }) => results[0], + ), + title: ({ name }) => name || t`Add new repository`, + transformParams: ({ name, ...rest }) => ({ + ...rest, + name: name !== '_' ? name : null, + }), + + render: (item, { navigate, queueAlert, state, setState }) => { + if (!state.repositoryToEdit) { + const repositoryToEdit = { + ...initialRepository, + ...item, + }; + setState({ repositoryToEdit, errorMessages: {} }); + } + + const { repositoryToEdit, errorMessages } = state; + if (!repositoryToEdit) { + return null; + } + + const saveRepository = ({ createDistribution }) => { + const { repositoryToEdit } = state; + + const data = { ...repositoryToEdit }; + + // prevent "This field may not be blank." for nullable fields + Object.keys(data).forEach((k) => { + if (data[k] === '') { + data[k] = null; + } + }); + + if (item) { + delete data.last_sync_task; + delete data.last_synced_metadata_time; + delete data.latest_version_href; + delete data.pulp_created; + delete data.pulp_href; + delete data.versions_href; + } + + data.pulp_labels ||= {}; + + let promise = !item + ? FileRepositoryAPI.create(data).then(({ data: newData }) => { + queueAlert({ + variant: 'success', + title: t`Successfully created repository ${data.name}`, + }); + + return newData.pulp_href; + }) + : FileRepositoryAPI.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) => + FileDistributionAPI.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 FileDistributionAPI.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.file.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.file.repository.detail, { + name: item.name, + }) + : formatPath(Paths.file.repository.list), + ); + }; + + return ( + setState({ repositoryToEdit: r })} + /> + ); + }, +}); + +export default FileRepositoryEdit; diff --git a/src/containers/file-repository/list.tsx b/src/containers/file-repository/list.tsx new file mode 100644 index 00000000..33fa6e9f --- /dev/null +++ b/src/containers/file-repository/list.tsx @@ -0,0 +1,124 @@ +import { msg, t } from '@lingui/core/macro'; +import { Td, Tr } from '@patternfly/react-table'; +import { Link } from 'react-router'; +import { + fileRepositoryCreateAction, + fileRepositoryDeleteAction, + fileRepositoryEditAction, + fileRepositorySyncAction, +} from 'src/actions'; +import { + FileRemoteAPI, + FileRepositoryAPI, + type FileRepositoryType, +} from 'src/api'; +import { + DateComponent, + ListItemActions, + ListPage, + PulpLabels, +} from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL } from 'src/utilities'; + +const listItemActions = [ + // Edit + fileRepositoryEditAction, + // Sync + fileRepositorySyncAction, + // Delete + fileRepositoryDeleteAction, +]; + +const typeaheadQuery = ({ inputText, selectedFilter, setState }) => { + if (selectedFilter !== 'remote') { + return; + } + + return FileRemoteAPI.list({ name__icontains: inputText }) + .then(({ data: { results } }) => + results.map(({ name, pulp_href }) => ({ id: pulp_href, title: name })), + ) + .then((remotes) => setState({ remotes })); +}; + +const FileRepositoryList = ListPage({ + defaultPageSize: 10, + defaultSort: '-pulp_created', + displayName: 'FileRepositoryList', + errorTitle: msg`Repositories could not be displayed.`, + filterConfig: ({ state: { remotes } }) => [ + { + id: 'name__icontains', + title: t`Repository name`, + }, + { + id: 'pulp_label_select', + title: t`Pulp Label`, + }, + { + id: 'remote', + title: t`Remote`, + inputType: 'typeahead', + options: [ + { + id: 'null', + title: t`None`, + }, + ...(remotes || []), + ], + }, + ], + headerActions: [fileRepositoryCreateAction], // Add repository + listItemActions, + noDataButton: fileRepositoryCreateAction.button, + noDataDescription: msg`Repositories will appear once created.`, + noDataTitle: msg`No repositories yet`, + query: ({ params }) => FileRepositoryAPI.list(params), + typeaheadQuery, + renderTableRow(item: FileRepositoryType, index: number, actionContext) { + const { name, pulp_created, pulp_href, pulp_labels } = item; + const id = parsePulpIDFromURL(pulp_href); + + const kebabItems = listItemActions.map((action) => + action.dropdownItem({ ...item, id }, actionContext), + ); + + return ( + + + + {name} + + + + + + + + + + + ); + }, + sortHeaders: [ + { + title: msg`Repository name`, + type: 'alpha', + id: 'name', + }, + { + title: msg`Labels`, + type: 'none', + id: 'pulp_labels', + }, + { + title: msg`Created date`, + type: 'numeric', + id: 'pulp_created', + }, + ], + title: msg`Repositories`, +}); + +export default FileRepositoryList; diff --git a/src/containers/file-repository/tab-details.tsx b/src/containers/file-repository/tab-details.tsx new file mode 100644 index 00000000..9118c1a5 --- /dev/null +++ b/src/containers/file-repository/tab-details.tsx @@ -0,0 +1,55 @@ +import { t } from '@lingui/core/macro'; +import { Link } from 'react-router'; +import { type FileRemoteType, type FileRepositoryType } from 'src/api'; +import { CopyURL, Details, PulpLabels } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { getRepoURL } from 'src/utilities'; + +interface TabProps { + item: FileRepositoryType & { + distroBasePath?: string; + remote?: FileRemoteType; + }; + 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/file-repository/tab-distributions.tsx b/src/containers/file-repository/tab-distributions.tsx new file mode 100644 index 00000000..fd71a204 --- /dev/null +++ b/src/containers/file-repository/tab-distributions.tsx @@ -0,0 +1,126 @@ +import { t } from '@lingui/core/macro'; +import { Td, Tr } from '@patternfly/react-table'; +import { FileDistributionAPI, type FileRepositoryType } from 'src/api'; +import { ClipboardCopy, DateComponent, DetailList } from 'src/components'; +import { getRepoURL } from 'src/utilities'; + +interface TabProps { + item: FileRepositoryType; + 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 FileDistributionAPI.list({ + repository: item.pulp_href, + ...newParams, + }); + }; + + const cliConfig = (base_path) => + [ + '[galaxy]', + `server_list = ${base_path}`, + '', + `[galaxy_server.${base_path}]`, + `url=${getRepoURL(base_path)}`, + 'token=', + ].join('\n'); + + 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/file-repository/tab-repository-versions.tsx b/src/containers/file-repository/tab-repository-versions.tsx new file mode 100644 index 00000000..d2a9ca08 --- /dev/null +++ b/src/containers/file-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 { FileRepositoryAPI } 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 FileRepositoryVersionType { + 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 }) => + FileRepositoryAPI.listVersions(pulpId, params); + const queryDetail = ({ number }) => + FileRepositoryAPI.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: FileRepositoryVersionType, + 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/containers/index.ts b/src/containers/index.ts index 0c3bcbdc..c75db44e 100644 --- a/src/containers/index.ts +++ b/src/containers/index.ts @@ -20,6 +20,12 @@ export { default as ExecutionEnvironmentDetailImages } from './execution-environ export { default as ExecutionEnvironmentList } from './execution-environment-list/execution-environment-list'; export { default as ExecutionEnvironmentManifest } from './execution-environment-manifest/execution-environment-manifest'; export { default as ExecutionEnvironmentRegistryList } from './execution-environment/registry-list'; +export { default as FileRemoteDetail } from './file-remote/detail'; +export { default as FileRemoteEdit } from './file-remote/edit'; +export { default as FileRemoteList } from './file-remote/list'; +export { default as FileRepositoryDetail } from './file-repository/detail'; +export { default as FileRepositoryEdit } from './file-repository/edit'; +export { default as FileRepositoryList } from './file-repository/list'; export { default as GroupDetail } from './group-management/group-detail'; export { default as GroupList } from './group-management/group-list'; export { default as LoginPage } from './login/login'; diff --git a/src/menu.tsx b/src/menu.tsx index b76c6c6e..b23b29b8 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -99,6 +99,14 @@ function standaloneMenu() { }), ], ), + menuSection('Pulp File', { condition: and(loggedIn, hasPlugin('file')) }, [ + menuItem(t`Repositories`, { + url: formatPath(Paths.file.repository.list), + }), + menuItem(t`Remotes`, { + url: formatPath(Paths.file.remote.list), + }), + ]), menuSection('Pulp RPM', { condition: and(loggedIn, hasPlugin('rpm')) }, [ menuItem(t`RPMs`, { url: formatPath(Paths.rpm.package.list), diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 2f396cc1..135a249b 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -25,6 +25,7 @@ export { parsePulpResource, translatePulpResourceToURL, } from './parse-pulp-id'; +export { pluginRepositoryBasePath } from './plugin-repository-base-path'; export { plugin_versions } from './plugin-version'; export { RepoSigningUtils } from './repo-signing'; export { repositoryBasePath, repositoryDistro } from './repository-distro'; diff --git a/src/utilities/plugin-repository-base-path.ts b/src/utilities/plugin-repository-base-path.ts new file mode 100644 index 00000000..73d2985c --- /dev/null +++ b/src/utilities/plugin-repository-base-path.ts @@ -0,0 +1,71 @@ +import { t } from '@lingui/core/macro'; +import { + AnsibleDistributionAPI, + AnsibleRepositoryAPI, + FileDistributionAPI, + FileRepositoryAPI, +} from 'src/api'; + +// returns the preferred distribution base_path given a repo name +// if there is a distribution with the same name as the repository, it will be used (as long as it's connected to the right repo too) +// if not, the oldest will be used +// reject if no distributions or repository +// optional pulp_href param skips repo lookup + +function plugin2api(plugin) { + switch (plugin) { + case 'ansible': + return { + DistributionAPI: AnsibleDistributionAPI, + RepositoryAPI: AnsibleRepositoryAPI, + }; + case 'file': + return { + DistributionAPI: FileDistributionAPI, + RepositoryAPI: FileRepositoryAPI, + }; + default: + return {}; + } +} + +export function pluginRepositoryBasePath( + plugin, + name, + pulp_href?, +): Promise { + const { RepositoryAPI, DistributionAPI } = plugin2api(plugin); + + return Promise.all([ + pulp_href + ? Promise.resolve({ name, pulp_href }) + : RepositoryAPI.list({ name, page_size: 1 }).then(firstResult), + DistributionAPI.list({ name, page_size: 1 }).then(firstResult), + ]).then(async ([repository, distribution]) => { + if (!repository) { + return Promise.reject(t`Failed to find repository ${name}`); + } + + if (distribution && distribution.repository === repository.pulp_href) { + return distribution.base_path; + } + + distribution = await DistributionAPI.list({ + repository: repository.pulp_href, + sort: 'pulp_created', + page_size: 1, + }).then(firstResult); + + if (!distribution) { + return Promise.reject( + t`Failed to find a distribution for repository ${name}`, + ); + } + + return distribution.base_path; + }); +} + +function firstResult({ data: { results } }) { + return results[0]; +}