From 019b5a27cfad681760d1ec68ad84611283d3d11d Mon Sep 17 00:00:00 2001 From: warisshaikh1 Date: Fri, 28 Aug 2026 14:15:38 -0400 Subject: [PATCH] Add deb repository and remote pages Adds a "Pulp deb" menu section with Repositories and Remotes, mirroring the structure of Pulp file: list, detail and edit for each, plus the versions and distributions tabs on a repository. Deliberately scoped to repositories and remotes. Publications are left out because deb has two publication endpoints rather than a field -- publications/deb/apt generates fresh metadata and needs a signing service, publications/deb/verbatim republishes upstream's Release byte for byte -- so a publications tab needs a design decision rather than just wiring. Content browsing is left out for the same reason: deb has a dozen content endpoints where rpm has one. RemoteForm gains the APT fields, which have no equivalent in the other plugins: distributions (suites), components, architectures, gpgkey, and the sync_sources/sync_udebs/sync_installer switches. `distributions` is added to requiredFields for deb only -- pulp_deb answers a remote without it with "This field is required.", unlike every other plugin where url alone is enough. gpgkey reuses the FileUpload treatment the certificate fields already use, since it is an armoured key file. The remaining changes are registry entries: plugin2api, the plugin unions on LazyRepositories/LazyDistributions/RepositoryForm, and the deb-only fields on the shared RemoteType. All are additive; ansible, container and file behaviour is unchanged. Refs #277 --- src/actions/deb-remote-create.tsx | 9 + src/actions/deb-remote-delete.tsx | 44 ++++ src/actions/deb-remote-edit.tsx | 9 + src/actions/deb-repository-create.tsx | 9 + src/actions/deb-repository-delete.tsx | 99 +++++++ src/actions/deb-repository-edit.tsx | 9 + src/actions/deb-repository-sync.tsx | 149 +++++++++++ src/actions/index.ts | 7 + src/api/deb-distribution.ts | 11 + src/api/deb-remote.ts | 71 ++++++ src/api/deb-repository.ts | 42 +++ src/api/index.ts | 3 + src/api/response-types/remote.ts | 9 + src/app-routes.tsx | 30 +++ src/components/lazy-distributions.tsx | 2 +- src/components/lazy-repositories.tsx | 2 +- src/components/remote-form.tsx | 173 ++++++++++++- src/components/repository-form.tsx | 11 +- src/containers/deb-remote/detail.tsx | 40 +++ src/containers/deb-remote/edit.tsx | 155 +++++++++++ src/containers/deb-remote/list.tsx | 79 ++++++ src/containers/deb-remote/tab-details.tsx | 83 ++++++ src/containers/deb-repository/detail.tsx | 138 ++++++++++ src/containers/deb-repository/edit.tsx | 179 +++++++++++++ src/containers/deb-repository/list.tsx | 124 +++++++++ src/containers/deb-repository/tab-details.tsx | 65 +++++ .../deb-repository/tab-distributions.tsx | 126 +++++++++ .../tab-repository-versions.tsx | 241 ++++++++++++++++++ src/containers/index.ts | 6 + src/menu.tsx | 8 + src/paths.ts | 12 + src/utilities/plugin-repository-base-path.ts | 7 + 32 files changed, 1942 insertions(+), 10 deletions(-) create mode 100644 src/actions/deb-remote-create.tsx create mode 100644 src/actions/deb-remote-delete.tsx create mode 100644 src/actions/deb-remote-edit.tsx create mode 100644 src/actions/deb-repository-create.tsx create mode 100644 src/actions/deb-repository-delete.tsx create mode 100644 src/actions/deb-repository-edit.tsx create mode 100644 src/actions/deb-repository-sync.tsx create mode 100644 src/api/deb-distribution.ts create mode 100644 src/api/deb-remote.ts create mode 100644 src/api/deb-repository.ts create mode 100644 src/containers/deb-remote/detail.tsx create mode 100644 src/containers/deb-remote/edit.tsx create mode 100644 src/containers/deb-remote/list.tsx create mode 100644 src/containers/deb-remote/tab-details.tsx create mode 100644 src/containers/deb-repository/detail.tsx create mode 100644 src/containers/deb-repository/edit.tsx create mode 100644 src/containers/deb-repository/list.tsx create mode 100644 src/containers/deb-repository/tab-details.tsx create mode 100644 src/containers/deb-repository/tab-distributions.tsx create mode 100644 src/containers/deb-repository/tab-repository-versions.tsx diff --git a/src/actions/deb-remote-create.tsx b/src/actions/deb-remote-create.tsx new file mode 100644 index 00000000..34059aaf --- /dev/null +++ b/src/actions/deb-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 debRemoteCreateAction = Action({ + title: msg`Add remote`, + onClick: (item, { navigate }) => + navigate(formatPath(Paths.deb.remote.edit, { name: '_' })), +}); diff --git a/src/actions/deb-remote-delete.tsx b/src/actions/deb-remote-delete.tsx new file mode 100644 index 00000000..1efb8016 --- /dev/null +++ b/src/actions/deb-remote-delete.tsx @@ -0,0 +1,44 @@ +import { msg, t } from '@lingui/core/macro'; +import { DebRemoteAPI } from 'src/api'; +import { DeleteRemoteModal } from 'src/components'; +import { + handleHttpError, + parsePulpIDFromURL, + taskAlert, + waitForTaskUrl, +} from 'src/utilities'; +import { Action } from './action'; + +export const debRemoteDeleteAction = 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 DebRemoteAPI.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/deb-remote-edit.tsx b/src/actions/deb-remote-edit.tsx new file mode 100644 index 00000000..49a5356e --- /dev/null +++ b/src/actions/deb-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 debRemoteEditAction = Action({ + title: msg`Edit`, + onClick: ({ name }, { navigate }) => + navigate(formatPath(Paths.deb.remote.edit, { name })), +}); diff --git a/src/actions/deb-repository-create.tsx b/src/actions/deb-repository-create.tsx new file mode 100644 index 00000000..77d52bb3 --- /dev/null +++ b/src/actions/deb-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 debRepositoryCreateAction = Action({ + title: msg`Add repository`, + onClick: (item, { navigate }) => + navigate(formatPath(Paths.deb.repository.edit, { name: '_' })), +}); diff --git a/src/actions/deb-repository-delete.tsx b/src/actions/deb-repository-delete.tsx new file mode 100644 index 00000000..d5cf5476 --- /dev/null +++ b/src/actions/deb-repository-delete.tsx @@ -0,0 +1,99 @@ +import { msg, t } from '@lingui/core/macro'; +import { DebDistributionAPI, DebRepositoryAPI } from 'src/api'; +import { DeleteRepositoryModal } from 'src/components'; +import { + handleHttpError, + parsePulpIDFromURL, + taskAlert, + waitForTaskUrl, +} from 'src/utilities'; +import { Action } from './action'; + +export const debRepositoryDeleteAction = 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 DebDistributionAPI.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 = DebRepositoryAPI.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 DebDistributionAPI.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/deb-repository-edit.tsx b/src/actions/deb-repository-edit.tsx new file mode 100644 index 00000000..1ffa21a7 --- /dev/null +++ b/src/actions/deb-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 debRepositoryEditAction = Action({ + title: msg`Edit`, + onClick: ({ name }, { navigate }) => + navigate(formatPath(Paths.deb.repository.edit, { name })), +}); diff --git a/src/actions/deb-repository-sync.tsx b/src/actions/deb-repository-sync.tsx new file mode 100644 index 00000000..83dec043 --- /dev/null +++ b/src/actions/deb-repository-sync.tsx @@ -0,0 +1,149 @@ +import { msg, t } from '@lingui/core/macro'; +import { Button, FormGroup, Modal, Switch } from '@patternfly/react-core'; +import { useEffect, useState } from 'react'; +import { DebRepositoryAPI } from 'src/api'; +import { HelpButton, Spinner } from 'src/components'; +import { handleHttpError, parsePulpIDFromURL, taskAlert } from 'src/utilities'; +import { Action } from './action'; + +// as in ansible-repository-sync and file-repository-sync +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 debRepositorySyncAction = 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('deb.change_aptrepository'), + 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 DebRepositoryAPI.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 a493b36a..8fac49b5 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -13,6 +13,13 @@ 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 { debRemoteCreateAction } from './deb-remote-create'; +export { debRemoteDeleteAction } from './deb-remote-delete'; +export { debRemoteEditAction } from './deb-remote-edit'; +export { debRepositoryCreateAction } from './deb-repository-create'; +export { debRepositoryDeleteAction } from './deb-repository-delete'; +export { debRepositoryEditAction } from './deb-repository-edit'; +export { debRepositorySyncAction } from './deb-repository-sync'; export { fileRemoteCreateAction } from './file-remote-create'; export { fileRemoteDeleteAction } from './file-remote-delete'; export { fileRemoteEditAction } from './file-remote-edit'; diff --git a/src/api/deb-distribution.ts b/src/api/deb-distribution.ts new file mode 100644 index 00000000..cfcb5a23 --- /dev/null +++ b/src/api/deb-distribution.ts @@ -0,0 +1,11 @@ +import { PulpAPI } from './pulp'; + +const base = new PulpAPI(); + +export const DebDistributionAPI = { + create: (data) => base.http.post(`distributions/deb/apt/`, data), + + delete: (id) => base.http.delete(`distributions/deb/apt/${id}/`), + + list: (params?) => base.list(`distributions/deb/apt/`, params), +}; diff --git a/src/api/deb-remote.ts b/src/api/deb-remote.ts new file mode 100644 index 00000000..32e74716 --- /dev/null +++ b/src/api/deb-remote.ts @@ -0,0 +1,71 @@ +import { PulpAPI } from './pulp'; + +export class DebRemoteType { + architectures: string; + ca_cert: string; + client_cert: string; + components: string; + distributions: string; + download_concurrency: number; + gpgkey: string; + ignore_missing_package_indices?: boolean; + name: string; + proxy_url: string; + pulp_href?: string; + rate_limit: number; + sync_installer?: boolean; + sync_sources?: boolean; + sync_udebs?: boolean; + tls_validation: boolean; + url: string; + + // connect_timeout + // headers + // max_retries + // policy + // prn + // 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[]; +} + +// as in file-remote +function smartUpdate(remote: DebRemoteType, unmodifiedRemote: DebRemoteType) { + 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 DebRemoteAPI = { + create: (data) => base.http.post(`remotes/deb/apt/`, data), + + delete: (id) => base.http.delete(`remotes/deb/apt/${id}/`), + + get: (id) => base.http.get(`remotes/deb/apt/${id}/`), + + list: (params?) => base.list(`remotes/deb/apt/`, params), + + smartUpdate: (id, newValue: DebRemoteType, oldValue: DebRemoteType) => + base.http.put(`remotes/deb/apt/${id}/`, smartUpdate(newValue, oldValue)), +}; diff --git a/src/api/deb-repository.ts b/src/api/deb-repository.ts new file mode 100644 index 00000000..f3d9ff2f --- /dev/null +++ b/src/api/deb-repository.ts @@ -0,0 +1,42 @@ +import { PulpAPI } from './pulp'; + +export class DebRepositoryType { + autopublish?: boolean; + description: string | null; + latest_version_href?: string; + name: string; + prn?: string; + publish_upstream_release_fields?: boolean; + pulp_created?: string; + pulp_href?: string; + pulp_labels: Record; + pulp_last_updated?: string; + remote: string | null; + retain_repo_versions: number; + signing_service?: string | null; + versions_href?: string; +} + +const base = new PulpAPI(); + +export const DebRepositoryAPI = { + create: (data) => base.http.post(`repositories/deb/apt/`, data), + + delete: (id) => base.http.delete(`repositories/deb/apt/${id}/`), + + list: (params?) => base.list(`repositories/deb/apt/`, params), + + listVersions: (id: string, params?) => + base.list(`repositories/deb/apt/${id}/versions/`, params), + + revert: (id: string, version_href) => + base.http.post(`repositories/deb/apt/${id}/modify/`, { + base_version: version_href, + }), + + sync: (id: string, body = {}) => + base.http.post(`repositories/deb/apt/${id}/sync/`, body), + + update: (id: string, data) => + base.http.put(`repositories/deb/apt/${id}/`, data), +}; diff --git a/src/api/index.ts b/src/api/index.ts index d1499327..e39f6c98 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -13,6 +13,9 @@ export { ContainerPullThroughDistributionAPI, } from './container-distribution'; export { ContainerTagAPI } from './container-tag'; +export { DebDistributionAPI } from './deb-distribution'; +export { DebRemoteAPI, type DebRemoteType } from './deb-remote'; +export { DebRepositoryAPI, type DebRepositoryType } from './deb-repository'; export { ExecutionEnvironmentAPI } from './execution-environment'; export { ExecutionEnvironmentNamespaceAPI } from './execution-environment-namespace'; export { ExecutionEnvironmentRegistryAPI } from './execution-environment-registry'; diff --git a/src/api/response-types/remote.ts b/src/api/response-types/remote.ts index e88b7a22..abac5057 100644 --- a/src/api/response-types/remote.ts +++ b/src/api/response-types/remote.ts @@ -34,6 +34,15 @@ export class RemoteType { ca_cert?: string; sync_dependencies?: boolean; + // deb (remotes/deb/apt) only; `distributions` is required there + architectures?: string; + components?: string; + distributions?: string; + gpgkey?: string; + sync_installer?: boolean; + sync_sources?: boolean; + sync_udebs?: boolean; + hidden_fields: { name: string; is_set: boolean }[]; repositories: { diff --git a/src/app-routes.tsx b/src/app-routes.tsx index dd21576b..552578fe 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -19,6 +19,12 @@ import { CollectionDistributions, CollectionDocs, CollectionImportLog, + DebRemoteDetail, + DebRemoteEdit, + DebRemoteList, + DebRepositoryDetail, + DebRepositoryEdit, + DebRepositoryList, EditNamespace, EditRole, EditUser, @@ -161,6 +167,30 @@ const routes: IRouteConfig[] = [ component: AnsibleRepositoryList, path: Paths.ansible.repository.list, }, + { + component: DebRemoteDetail, + path: Paths.deb.remote.detail, + }, + { + component: DebRemoteEdit, + path: Paths.deb.remote.edit, + }, + { + component: DebRemoteList, + path: Paths.deb.remote.list, + }, + { + component: DebRepositoryDetail, + path: Paths.deb.repository.detail, + }, + { + component: DebRepositoryEdit, + path: Paths.deb.repository.edit, + }, + { + component: DebRepositoryList, + path: Paths.deb.repository.list, + }, { component: FileRemoteDetail, path: Paths.file.remote.detail, diff --git a/src/components/lazy-distributions.tsx b/src/components/lazy-distributions.tsx index 320b5199..68eefd9d 100644 --- a/src/components/lazy-distributions.tsx +++ b/src/components/lazy-distributions.tsx @@ -11,7 +11,7 @@ export const LazyDistributions = ({ repositoryHref, }: { emptyText?: string; - plugin: 'ansible' | 'file' | 'rpm'; + plugin: 'ansible' | 'deb' | 'file' | 'rpm'; repositoryHref: string; }) => { const [distributions, setDistributions] = useState([]); diff --git a/src/components/lazy-repositories.tsx b/src/components/lazy-repositories.tsx index 972ca616..b3aaf150 100644 --- a/src/components/lazy-repositories.tsx +++ b/src/components/lazy-repositories.tsx @@ -14,7 +14,7 @@ export const LazyRepositories = ({ }: { content_href?: string; emptyText?: string; - plugin: 'ansible' | 'file' | 'rpm'; + plugin: 'ansible' | 'deb' | 'file' | 'rpm'; remote_href?: string; }) => { const [repositories, setRepositories] = useState([]); diff --git a/src/components/remote-form.tsx b/src/components/remote-form.tsx index b74df696..45a37736 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' | 'deb' | 'file'; remote: RemoteType; saveRemote: () => void; showMain?: boolean; @@ -62,6 +62,7 @@ interface IState { client_key: FormFilename; client_cert: FormFilename; ca_cert: FormFilename; + gpgkey: FormFilename; }; } @@ -120,7 +121,7 @@ export class RemoteForm extends Component { constructor(props) { super(props); - const { requirements_file, client_key, client_cert, ca_cert } = + const { requirements_file, client_key, client_cert, ca_cert, gpgkey } = props.remote || {}; this.state = { @@ -141,6 +142,10 @@ export class RemoteForm extends Component { name: ca_cert ? 'ca_cert' : '', original: !!ca_cert, }, + gpgkey: { + name: gpgkey ? 'gpgkey' : '', + original: !!gpgkey, + }, }, }; @@ -169,7 +174,10 @@ export class RemoteForm extends Component { return null; } - const requiredFields = ['name', 'url']; + // pulp_deb rejects a remote with no suites to sync, so unlike every other + // plugin `distributions` is required rather than merely available. + const requiredFields = + plugin === 'deb' ? ['name', 'url', 'distributions'] : ['name', 'url']; let disabledFields = allowEditName ? [] : ['name']; const isCommunityRemote = @@ -181,6 +189,7 @@ export class RemoteForm extends Component { break; case 'container': + case 'deb': case 'file': disabledFields = disabledFields.concat([ 'auth_url', @@ -245,7 +254,7 @@ export class RemoteForm extends Component { isCommunityRemote, }: { extra?: ReactNode; isCommunityRemote: boolean }, ) { - const { errorMessages, remote } = this.props; + const { errorMessages, plugin, remote } = this.props; const { filenames } = this.state; const { collection_signing } = (this.context as IAppContextType) .featureFlags; @@ -329,6 +338,162 @@ export class RemoteForm extends Component { /> + {plugin === 'deb' ? ( + <> + + } + isRequired={requiredFields.includes('distributions')} + > + + this.updateRemote(value, 'distributions') + } + /> + + {errorMessages['distributions']} + + + + + } + > + + this.updateRemote(value, 'components') + } + /> + + {errorMessages['components']} + + + + + } + > + + this.updateRemote(value, 'architectures') + } + /> + + {errorMessages['architectures']} + + + + + } + > + { + this.setState({ + filenames: { + ...filenames, + gpgkey: { name: '', original: false }, + }, + }); + this.updateRemote(null, 'gpgkey'); + }} + /> + + {errorMessages['gpgkey']} + + + + + + this.updateRemote(value, 'sync_sources') + } + label={t`Source packages will be synchronized`} + labelOff={t`Source packages will be skipped`} + /> + + + + + this.updateRemote(value, 'sync_udebs') + } + label={t`Installer packages will be synchronized`} + labelOff={t`Installer packages will be skipped`} + /> + + + + + this.updateRemote(value, 'sync_installer') + } + label={t`Installer files will be synchronized`} + labelOff={t`Installer files will be skipped`} + /> + + + ) : null} + {!disabledFields.includes('signed_only') && collection_signing ? ( void; onSave: ({ createDistribution }) => void; - plugin: 'ansible' | 'file' | 'rpm'; + plugin: 'ansible' | 'deb' | 'file' | 'rpm'; repository: AnsibleRepositoryType; updateRepository: (r) => void; } @@ -114,9 +115,11 @@ export const RepositoryForm = ({ setRemotesError(null); (plugin === 'ansible' ? AnsibleRemoteAPI.list({ ...(name ? { name__icontains: name } : {}) }) - : plugin === 'file' - ? FileRemoteAPI.list({ ...(name ? { name__icontains: name } : {}) }) - : Promise.reject(plugin) + : plugin === 'deb' + ? DebRemoteAPI.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 }))), diff --git a/src/containers/deb-remote/detail.tsx b/src/containers/deb-remote/detail.tsx new file mode 100644 index 00000000..95bac232 --- /dev/null +++ b/src/containers/deb-remote/detail.tsx @@ -0,0 +1,40 @@ +import { msg, t } from '@lingui/core/macro'; +import { debRemoteDeleteAction, debRemoteEditAction } from 'src/actions'; +import { DebRemoteAPI, type DebRemoteType } from 'src/api'; +import { PageWithTabs } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { DetailsTab } from './tab-details'; + +const DebRemoteDetail = PageWithTabs({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.deb.remote.list), name: t`Remotes` }, + { url: formatPath(Paths.deb.remote.detail, { name }), name }, + ].filter(Boolean), + displayName: 'DebRemoteDetail', + errorTitle: msg`Remote could not be displayed.`, + headerActions: [debRemoteEditAction, debRemoteDeleteAction], + listUrl: formatPath(Paths.deb.remote.list), + query: ({ name }) => + DebRemoteAPI.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.deb.remote.detail, { name }, { tab: 'details' }), + }, + ], +}); + +export default DebRemoteDetail; diff --git a/src/containers/deb-remote/edit.tsx b/src/containers/deb-remote/edit.tsx new file mode 100644 index 00000000..31078241 --- /dev/null +++ b/src/containers/deb-remote/edit.tsx @@ -0,0 +1,155 @@ +import { msg, t } from '@lingui/core/macro'; +import { DebRemoteAPI, type DebRemoteType } from 'src/api'; +import { Page, RemoteForm } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL, taskAlert } from 'src/utilities'; + +const initialRemote: DebRemoteType = { + name: '', + url: '', + // Required by the API, unlike every other plugin's remote: a deb remote with + // no suites to sync is rejected rather than syncing everything. + distributions: '', + components: null, + architectures: null, + gpgkey: null, + ca_cert: null, + client_cert: null, + tls_validation: true, + proxy_url: null, + download_concurrency: null, + rate_limit: null, + + hidden_fields: [ + 'client_key', + 'proxy_username', + 'proxy_password', + 'username', + 'password', + ].map((name) => ({ name, is_set: false })), +}; + +const DebRemoteEdit = Page({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.deb.remote.list), name: t`Remotes` }, + name && { url: formatPath(Paths.deb.remote.detail, { name }), name }, + name ? { name: t`Edit` } : { name: t`Add` }, + ].filter(Boolean), + + displayName: 'DebRemoteEdit', + errorTitle: msg`Remote could not be displayed.`, + listUrl: formatPath(Paths.deb.remote.list), + query: ({ name }) => + DebRemoteAPI.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 + ? DebRemoteAPI.create(data) + : DebRemoteAPI.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.deb.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.deb.remote.detail, { + name: item.name, + }) + : formatPath(Paths.deb.remote.list), + ); + }; + + return ( + setState({ remoteToEdit: r })} + /> + ); + }, +}); + +export default DebRemoteEdit; diff --git a/src/containers/deb-remote/list.tsx b/src/containers/deb-remote/list.tsx new file mode 100644 index 00000000..56bbb27f --- /dev/null +++ b/src/containers/deb-remote/list.tsx @@ -0,0 +1,79 @@ +import { msg, t } from '@lingui/core/macro'; +import { Td, Tr } from '@patternfly/react-table'; +import { Link } from 'react-router'; +import { + debRemoteCreateAction, + debRemoteDeleteAction, + debRemoteEditAction, +} from 'src/actions'; +import { DebRemoteAPI, type DebRemoteType } from 'src/api'; +import { CopyURL, ListItemActions, ListPage } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL } from 'src/utilities'; + +const listItemActions = [ + // Edit + debRemoteEditAction, + // Delete + debRemoteDeleteAction, +]; + +const DebRemoteList = ListPage({ + defaultPageSize: 10, + defaultSort: '-pulp_created', + displayName: 'DebRemoteList', + errorTitle: msg`Remotes could not be displayed.`, + filterConfig: () => [ + { + id: 'name__icontains', + title: t`Remote name`, + }, + ], + headerActions: [debRemoteCreateAction], // Add remote + listItemActions, + noDataButton: debRemoteCreateAction.button, + noDataDescription: msg`Remotes will appear once created.`, + noDataTitle: msg`No remotes yet`, + query: ({ params }) => DebRemoteAPI.list(params), + renderTableRow(item: DebRemoteType, index: number, actionContext) { + const { distributions, name, pulp_href, url } = item; + const id = parsePulpIDFromURL(pulp_href); + + const kebabItems = listItemActions.map((action) => + action.dropdownItem({ ...item, id }, actionContext), + ); + + return ( + + + {name} + + + + + {distributions || '---'} + + + ); + }, + sortHeaders: [ + { + title: msg`Remote name`, + type: 'alpha', + id: 'name', + }, + { + title: msg`URL`, + type: 'alpha', + id: 'url', + }, + { + title: msg`Distributions`, + type: 'none', + id: 'distributions', + }, + ], + title: msg`Remotes`, +}); + +export default DebRemoteList; diff --git a/src/containers/deb-remote/tab-details.tsx b/src/containers/deb-remote/tab-details.tsx new file mode 100644 index 00000000..36e099fd --- /dev/null +++ b/src/containers/deb-remote/tab-details.tsx @@ -0,0 +1,83 @@ +import { t } from '@lingui/core/macro'; +import { type DebRemoteType } from 'src/api'; +import { + CopyURL, + Details, + LazyRepositories, + PulpCodeBlock, +} from 'src/components'; + +interface TabProps { + item: DebRemoteType; + actionContext: object; +} + +const MaybeCode = ({ code, filename }: { code: string; filename: string }) => + code ? : <>{t`None`}; + +export const DetailsTab = ({ item }: TabProps) => ( +
, + }, + // The APT-specific fields. `distributions` is required by the API, the + // other two default to every component / architecture the release offers. + { label: t`Distributions`, value: item?.distributions || t`None` }, + { label: t`Components`, value: item?.components || t`All` }, + { label: t`Architectures`, value: item?.architectures || t`All` }, + { + label: t`Sync sources`, + value: item?.sync_sources ? t`Enabled` : t`Disabled`, + }, + { + label: t`Sync installer packages`, + value: item?.sync_udebs ? t`Enabled` : t`Disabled`, + }, + { + label: t`Sync installer files`, + value: item?.sync_installer ? t`Enabled` : t`Disabled`, + }, + { + label: t`GPG key`, + value: ( + + ), + }, + { + label: t`Proxy URL`, + value: , + }, + { + label: t`TLS validation`, + value: item?.tls_validation ? t`Enabled` : t`Disabled`, + }, + { + label: t`Client certificate`, + value: ( + + ), + }, + { + label: t`CA certificate`, + value: ( + + ), + }, + { + label: t`Download concurrency`, + value: item?.download_concurrency ?? t`None`, + }, + { label: t`Rate limit`, value: item?.rate_limit ?? t`None` }, + { + label: t`Repositories`, + value: , + }, + ]} + /> +); diff --git a/src/containers/deb-repository/detail.tsx b/src/containers/deb-repository/detail.tsx new file mode 100644 index 00000000..1b4c3f39 --- /dev/null +++ b/src/containers/deb-repository/detail.tsx @@ -0,0 +1,138 @@ +import { msg, t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { + debRepositoryDeleteAction, + debRepositoryEditAction, + debRepositorySyncAction, +} from 'src/actions'; +import { + DebRemoteAPI, + type DebRemoteType, + DebRepositoryAPI, + type DebRepositoryType, +} from 'src/api'; +import { PageWithTabs } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { + lastSyncStatus, + lastSynced, + parsePulpIDFromURL, + pluginRepositoryBasePath, +} from 'src/utilities'; +import { DetailsTab } from './tab-details'; +import { DistributionsTab } from './tab-distributions'; +import { RepositoryVersionsTab } from './tab-repository-versions'; + +const DebRepositoryDetail = PageWithTabs< + DebRepositoryType & { remote?: DebRemoteType } +>({ + breadcrumbs: ({ name, tab, params: { repositoryVersion } }) => + [ + { url: formatPath(Paths.deb.repository.list), name: t`Repositories` }, + { url: formatPath(Paths.deb.repository.detail, { name }), name }, + tab === 'repository-versions' && repositoryVersion + ? { + url: formatPath(Paths.deb.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: 'DebRepositoryDetail', + errorTitle: msg`Repository could not be displayed.`, + headerActions: [ + debRepositoryEditAction, + debRepositorySyncAction, + debRepositoryDeleteAction, + ], + headerDetails: (item) => ( + <> + {item?.last_sync_task && ( +

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

+ )} + + ), + listUrl: formatPath(Paths.deb.repository.list), + query: ({ name }) => + DebRepositoryAPI.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([ + // the plugin-aware variant, so the deb distribution endpoint is the + // one consulted + pluginRepositoryBasePath( + 'deb', + repository.name, + repository.pulp_href, + ).catch(err(null)), + repository.remote + ? DebRemoteAPI.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.deb.repository.detail, + { name }, + { tab: 'details' }, + ), + }, + { + active: tab === 'repository-versions', + title: t`Versions`, + link: formatPath( + Paths.deb.repository.detail, + { name }, + { tab: 'repository-versions' }, + ), + }, + { + active: tab === 'distributions', + title: t`Distributions`, + link: formatPath( + Paths.deb.repository.detail, + { name }, + { tab: 'distributions' }, + ), + }, + ], +}); + +export default DebRepositoryDetail; diff --git a/src/containers/deb-repository/edit.tsx b/src/containers/deb-repository/edit.tsx new file mode 100644 index 00000000..9752aa6b --- /dev/null +++ b/src/containers/deb-repository/edit.tsx @@ -0,0 +1,179 @@ +import { msg, t } from '@lingui/core/macro'; +import { + DebDistributionAPI, + DebRepositoryAPI, + type DebRepositoryType, +} from 'src/api'; +import { Page, RepositoryForm } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { parsePulpIDFromURL, taskAlert } from 'src/utilities'; + +const initialRepository: DebRepositoryType = { + name: '', + description: '', + retain_repo_versions: 1, + pulp_labels: {}, + remote: null, +}; + +const DebRepositoryEdit = Page({ + breadcrumbs: ({ name }) => + [ + { url: formatPath(Paths.deb.repository.list), name: t`Repositories` }, + name && { + url: formatPath(Paths.deb.repository.detail, { name }), + name, + }, + name ? { name: t`Edit` } : { name: t`Add` }, + ].filter(Boolean), + + displayName: 'DebRepositoryEdit', + errorTitle: msg`Repository could not be displayed.`, + listUrl: formatPath(Paths.deb.repository.list), + query: ({ name }) => + DebRepositoryAPI.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 + ? DebRepositoryAPI.create(data).then(({ data: newData }) => { + queueAlert({ + variant: 'success', + title: t`Successfully created repository ${data.name}`, + }); + + return newData.pulp_href; + }) + : DebRepositoryAPI.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) => + DebDistributionAPI.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 DebDistributionAPI.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.deb.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.deb.repository.detail, { + name: item.name, + }) + : formatPath(Paths.deb.repository.list), + ); + }; + + return ( + setState({ repositoryToEdit: r })} + /> + ); + }, +}); + +export default DebRepositoryEdit; diff --git a/src/containers/deb-repository/list.tsx b/src/containers/deb-repository/list.tsx new file mode 100644 index 00000000..c27b5ab0 --- /dev/null +++ b/src/containers/deb-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 { + debRepositoryCreateAction, + debRepositoryDeleteAction, + debRepositoryEditAction, + debRepositorySyncAction, +} from 'src/actions'; +import { + DebRemoteAPI, + DebRepositoryAPI, + type DebRepositoryType, +} 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 + debRepositoryEditAction, + // Sync + debRepositorySyncAction, + // Delete + debRepositoryDeleteAction, +]; + +const typeaheadQuery = ({ inputText, selectedFilter, setState }) => { + if (selectedFilter !== 'remote') { + return; + } + + return DebRemoteAPI.list({ name__icontains: inputText }) + .then(({ data: { results } }) => + results.map(({ name, pulp_href }) => ({ id: pulp_href, title: name })), + ) + .then((remotes) => setState({ remotes })); +}; + +const DebRepositoryList = ListPage({ + defaultPageSize: 10, + defaultSort: '-pulp_created', + displayName: 'DebRepositoryList', + 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: [debRepositoryCreateAction], // Add repository + listItemActions, + noDataButton: debRepositoryCreateAction.button, + noDataDescription: msg`Repositories will appear once created.`, + noDataTitle: msg`No repositories yet`, + query: ({ params }) => DebRepositoryAPI.list(params), + typeaheadQuery, + renderTableRow(item: DebRepositoryType, 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 DebRepositoryList; diff --git a/src/containers/deb-repository/tab-details.tsx b/src/containers/deb-repository/tab-details.tsx new file mode 100644 index 00000000..06a07aff --- /dev/null +++ b/src/containers/deb-repository/tab-details.tsx @@ -0,0 +1,65 @@ +import { t } from '@lingui/core/macro'; +import { Link } from 'react-router'; +import { type DebRemoteType, type DebRepositoryType } from 'src/api'; +import { CopyURL, Details, PulpLabels } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { getRepoURL } from 'src/utilities'; + +interface TabProps { + item: DebRepositoryType & { + distroBasePath?: string; + remote?: DebRemoteType; + }; + actionContext: { addAlert: (alert) => void; state: { params } }; +} + +export const DetailsTab = ({ item }: TabProps) => { + return ( +
+ ) : ( + '---' + ), + }, + { + label: t`Labels`, + value: , + }, + { + label: t`Remote`, + value: item?.remote ? ( + + {item?.remote.name} + + ) : ( + t`None` + ), + }, + { + label: t`Autopublish`, + value: item?.autopublish ? t`Enabled` : t`Disabled`, + }, + { + label: t`Publish upstream release fields`, + value: item?.publish_upstream_release_fields + ? t`Enabled` + : t`Disabled`, + }, + ]} + /> + ); +}; diff --git a/src/containers/deb-repository/tab-distributions.tsx b/src/containers/deb-repository/tab-distributions.tsx new file mode 100644 index 00000000..5e4eb656 --- /dev/null +++ b/src/containers/deb-repository/tab-distributions.tsx @@ -0,0 +1,126 @@ +import { t } from '@lingui/core/macro'; +import { Td, Tr } from '@patternfly/react-table'; +import { DebDistributionAPI, type DebRepositoryType } from 'src/api'; +import { ClipboardCopy, DateComponent, DetailList } from 'src/components'; +import { getRepoURL } from 'src/utilities'; + +interface TabProps { + item: DebRepositoryType; + 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 DebDistributionAPI.list({ + repository: item.pulp_href, + ...newParams, + }); + }; + + // A deb remote requires `distributions`, so unlike the file equivalent this + // cannot be a complete command without knowing which suites to sync. + const cliConfig = (base_path) => + `pulp deb remote create --name "${item.name}" --url "${getRepoURL(base_path)}" --distributions ""`; + + 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/deb-repository/tab-repository-versions.tsx b/src/containers/deb-repository/tab-repository-versions.tsx new file mode 100644 index 00000000..42cbb1b2 --- /dev/null +++ b/src/containers/deb-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 { DebRepositoryAPI } 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 DebRepositoryVersionType { + 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 }) => + DebRepositoryAPI.listVersions(pulpId, params); + const queryDetail = ({ number }) => + DebRepositoryAPI.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: DebRepositoryVersionType, + 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 c75db44e..cc1dd5e5 100644 --- a/src/containers/index.ts +++ b/src/containers/index.ts @@ -12,6 +12,12 @@ export { default as CollectionDetail } from './collection-detail/collection-deta export { default as CollectionDistributions } from './collection-detail/collection-distributions'; export { default as CollectionDocs } from './collection-detail/collection-docs'; export { default as CollectionImportLog } from './collection-detail/collection-import-log'; +export { default as DebRemoteDetail } from './deb-remote/detail'; +export { default as DebRemoteEdit } from './deb-remote/edit'; +export { default as DebRemoteList } from './deb-remote/list'; +export { default as DebRepositoryDetail } from './deb-repository/detail'; +export { default as DebRepositoryEdit } from './deb-repository/edit'; +export { default as DebRepositoryList } from './deb-repository/list'; export { default as EditNamespace } from './edit-namespace/edit-namespace'; export { default as ExecutionEnvironmentDetail } from './execution-environment-detail/execution-environment-detail'; export { default as ExecutionEnvironmentDetailAccess } from './execution-environment-detail/execution-environment-detail-access'; diff --git a/src/menu.tsx b/src/menu.tsx index 614e615b..5727d5d1 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -99,6 +99,14 @@ function standaloneMenu() { }), ], ), + menuSection('Pulp deb', { condition: and(loggedIn, hasPlugin('deb')) }, [ + menuItem(t`Repositories`, { + url: formatPath(Paths.deb.repository.list), + }), + menuItem(t`Remotes`, { + url: formatPath(Paths.deb.remote.list), + }), + ]), menuSection('Pulp file', { condition: and(loggedIn, hasPlugin('file')) }, [ menuItem(t`Repositories`, { url: formatPath(Paths.file.repository.list), diff --git a/src/paths.ts b/src/paths.ts index 02a6aff1..9e28bb7a 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -122,6 +122,18 @@ export const Paths = { profile: '/users/profile', }, }, + deb: { + remote: { + detail: '/deb/remotes/detail/:name', + edit: '/deb/remotes/edit/:name', + list: '/deb/remotes', + }, + repository: { + detail: '/deb/repositories/detail/:name', + edit: '/deb/repositories/edit/:name', + list: '/deb/repositories', + }, + }, file: { remote: { detail: '/file/remotes/detail/:name', diff --git a/src/utilities/plugin-repository-base-path.ts b/src/utilities/plugin-repository-base-path.ts index b11237d4..c6877f88 100644 --- a/src/utilities/plugin-repository-base-path.ts +++ b/src/utilities/plugin-repository-base-path.ts @@ -2,6 +2,8 @@ import { t } from '@lingui/core/macro'; import { AnsibleDistributionAPI, AnsibleRepositoryAPI, + DebDistributionAPI, + DebRepositoryAPI, FileDistributionAPI, FileRepositoryAPI, RPMRepositoryAPI, @@ -20,6 +22,11 @@ export function plugin2api(plugin) { DistributionAPI: AnsibleDistributionAPI, RepositoryAPI: AnsibleRepositoryAPI, }; + case 'deb': + return { + DistributionAPI: DebDistributionAPI, + RepositoryAPI: DebRepositoryAPI, + }; case 'file': return { DistributionAPI: FileDistributionAPI,