From cc56db44c09314cd95513cdc26d85fba6a89cae8 Mon Sep 17 00:00:00 2001 From: warisshaikh1 Date: Fri, 28 Aug 2026 14:15:38 -0400 Subject: [PATCH 1/9] 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 Assisted By: Cursor (Claude Opus 5) --- 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, From 881e49e4dcfb00b6642ece8f9611fd486ed4171c Mon Sep 17 00:00:00 2001 From: waris shaikh Date: Tue, 15 Sep 2026 06:24:45 -0400 Subject: [PATCH 2/9] Page through the distributions of a deb repository being deleted The lookup asked for the first hundred and left a TODO for the rest, so deleting a repository with more distributions than that would leave the excess pointing at a repository that no longer exists. Walk the pages until as many have been collected as `count` reports, stopping early on an empty page so a disagreeing count cannot loop. Refs #277 Assisted By: Cursor (Claude Opus 5) --- src/actions/deb-repository-delete.tsx | 42 +++++++++++++++++++++------ 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/actions/deb-repository-delete.tsx b/src/actions/deb-repository-delete.tsx index d5cf5476..0c5d6fd8 100644 --- a/src/actions/deb-repository-delete.tsx +++ b/src/actions/deb-repository-delete.tsx @@ -38,25 +38,49 @@ export const debRepositoryDeleteAction = Action({ }), }); +const DISTRIBUTION_PAGE_SIZE = 100; + +// A repository can be serving more distributions than a single page holds, and +// any the lookup misses are left pointing at a repository that no longer exists. +async function listDistributions(repository) { + const distributions = []; + let page = 1; + let count = Infinity; + + while (distributions.length < count) { + const { data } = await DebDistributionAPI.list({ + repository, + page, + page_size: DISTRIBUTION_PAGE_SIZE, + }); + + // Also stops the loop should count ever disagree with what the pages return. + if (!data.results?.length) { + break; + } + + distributions.push(...data.results); + count = data.count; + page++; + } + + return distributions; +} + async function deleteRepository( { name, pulp_href, pulpId }, { addAlert, setState, listQuery }, ) { - // TODO: handle more pages - const distributionsToDelete = await DebDistributionAPI.list({ - repository: pulp_href, - page: 1, - page_size: 100, - }) - .then(({ data: { results } }) => results || []) - .catch((e) => { + const distributionsToDelete = await listDistributions(pulp_href).catch( + (e) => { handleHttpError( t`Failed to list distributions, removing only the repository.`, () => null, addAlert, )(e); return []; - }); + }, + ); const deleteRepo = DebRepositoryAPI.delete(pulpId) .then(({ data }) => { From 2f2e296795109bd04af4b0cdd4a8a58e32e99bec Mon Sep 17 00:00:00 2001 From: waris shaikh Date: Tue, 15 Sep 2026 06:24:45 -0400 Subject: [PATCH 3/9] Default a deb sync to mirror: false, as pulp_deb does Opening the sync modal offered to mirror, which deletes local content the remote no longer has. The API's own default is the non-destructive one and the modal should agree with it, so the deletion is opted into. The value was written out three times; it is now one constant, which is also where the divergence from ansible and file is explained. Refs #277 Assisted By: Cursor (Claude Opus 5) --- src/actions/deb-repository-sync.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/actions/deb-repository-sync.tsx b/src/actions/deb-repository-sync.tsx index 83dec043..49a9d80d 100644 --- a/src/actions/deb-repository-sync.tsx +++ b/src/actions/deb-repository-sync.tsx @@ -6,7 +6,13 @@ 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 +// pulp_deb's own API default for a sync. ansible and file hardcode mirror: true +// instead, and this deliberately does not follow them: mirroring deletes local +// content the remote no longer has, so it is the direction to opt into rather +// than out of. +const DEFAULT_SYNC_PARAMS = { mirror: false, optimize: true }; + +// otherwise as in ansible-repository-sync and file-repository-sync const SyncModal = ({ closeAction, syncAction, @@ -17,14 +23,11 @@ const SyncModal = ({ name: string; }) => { const [pending, setPending] = useState(false); - const [syncParams, setSyncParams] = useState({ - mirror: true, - optimize: true, - }); + const [syncParams, setSyncParams] = useState(DEFAULT_SYNC_PARAMS); useEffect(() => { setPending(false); - setSyncParams({ mirror: true, optimize: true }); + setSyncParams(DEFAULT_SYNC_PARAMS); }, [name]); if (!name) { @@ -133,7 +136,7 @@ export const debRepositorySyncAction = Action({ function syncRepository({ name, pulp_href }, { addAlert, query }, syncParams) { const pulpId = parsePulpIDFromURL(pulp_href); - return DebRepositoryAPI.sync(pulpId, syncParams || { mirror: true }) + return DebRepositoryAPI.sync(pulpId, syncParams || DEFAULT_SYNC_PARAMS) .then(({ data }) => { addAlert(taskAlert(data.task, t`Sync started for repository "${name}".`)); From 728ecedc9edea78a37860d464de3ed4bd6b6da2c Mon Sep 17 00:00:00 2001 From: waris shaikh Date: Tue, 15 Sep 2026 06:25:27 -0400 Subject: [PATCH 4/9] Turn an empty deb detail lookup into a not-found rejection Both detail pages read results[0] straight out of the response, which throws on any unexpected shape, and past page-with-tabs' catch that leaves the page loading with nothing to show. Read the first result defensively instead. The comment claimed the API returns a 404; it returns 200 and an empty list, and it is this code that synthesizes the 404. Refs #277 Assisted By: Cursor (Claude Opus 5) --- src/containers/deb-remote/detail.tsx | 20 ++++++++++++-------- src/containers/deb-repository/detail.tsx | 6 ++++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/containers/deb-remote/detail.tsx b/src/containers/deb-remote/detail.tsx index 95bac232..7c0436ab 100644 --- a/src/containers/deb-remote/detail.tsx +++ b/src/containers/deb-remote/detail.tsx @@ -16,14 +16,18 @@ const DebRemoteDetail = PageWithTabs({ 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 } }), - ), + DebRemoteAPI.list({ name, page_size: 1 }).then(({ data }) => { + const remote = data?.results?.[0]; + + // There is no detail endpoint keyed by name, so a name matching nothing + // answers 200 with an empty list. Turn that into the 404 the page already + // knows how to render, instead of resolving with undefined. + if (!remote) { + return Promise.reject({ response: { status: 404 } }); + } + + return remote; + }), renderTab: (tab, item, actionContext) => ({ details: , diff --git a/src/containers/deb-repository/detail.tsx b/src/containers/deb-repository/detail.tsx index 1b4c3f39..8ed6897b 100644 --- a/src/containers/deb-repository/detail.tsx +++ b/src/containers/deb-repository/detail.tsx @@ -63,9 +63,11 @@ const DebRepositoryDetail = PageWithTabs< listUrl: formatPath(Paths.deb.repository.list), query: ({ name }) => DebRepositoryAPI.list({ name, page_size: 1 }) - .then(({ data: { results } }) => results[0]) + .then(({ data }) => data?.results?.[0]) .then((repository) => { - // using the list api, so an empty array is really a 404 + // There is no detail endpoint keyed by name, so a name matching nothing + // answers 200 with an empty list. Turn that into the 404 the page already + // knows how to render, instead of resolving with undefined. if (!repository) { return Promise.reject({ response: { status: 404 } }); } From c4fa93c9a8b8845b4b84e45a720b478d8e173b47 Mon Sep 17 00:00:00 2001 From: waris shaikh Date: Tue, 15 Sep 2026 06:25:40 -0400 Subject: [PATCH 5/9] Collapse the deb repository breadcrumb branches Three entries tested the same tab and were filtered back out again when the answer was no. It is one decision -- which crumbs the versions tab adds -- so make it once. Refs #277 Assisted By: Cursor (Claude Opus 5) --- src/containers/deb-repository/detail.tsx | 31 ++++++++++++++---------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/containers/deb-repository/detail.tsx b/src/containers/deb-repository/detail.tsx index 8ed6897b..80a2387b 100644 --- a/src/containers/deb-repository/detail.tsx +++ b/src/containers/deb-repository/detail.tsx @@ -26,23 +26,28 @@ import { RepositoryVersionsTab } from './tab-repository-versions'; const DebRepositoryDetail = PageWithTabs< DebRepositoryType & { remote?: DebRemoteType } >({ - breadcrumbs: ({ name, tab, params: { repositoryVersion } }) => - [ + breadcrumbs: ({ name, tab, params: { repositoryVersion } }) => { + const crumbs = [ { url: formatPath(Paths.deb.repository.list), name: t`Repositories` }, { url: formatPath(Paths.deb.repository.detail, { name }), name }, - tab === 'repository-versions' && repositoryVersion - ? { + ]; + + if (tab !== 'repository-versions') { + return crumbs; + } + + // Looking at a single version keeps a link back to the list of them. + return repositoryVersion + ? [ + ...crumbs, + { url: formatPath(Paths.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), + }, + { name: t`Version ${repositoryVersion}` }, + ] + : [...crumbs, { name: t`Versions` }]; + }, displayName: 'DebRepositoryDetail', errorTitle: msg`Repository could not be displayed.`, headerActions: [ From a14567c21121bed9faadb4f344e854505b685be9 Mon Sep 17 00:00:00 2001 From: waris shaikh Date: Tue, 15 Sep 2026 06:25:40 -0400 Subject: [PATCH 6/9] Resolve the remote API through plugin2api Three chained ternaries picked the remote API by plugin, repeating the same list call in each branch. plugin2api already maps a plugin to its repository and distribution APIs, so add the remote API there and let the form ask for it: one switch to extend when a plugin is added, not a conditional to unpick. Refs #277 Assisted By: Cursor (Claude Opus 5) --- src/components/repository-form.tsx | 18 ++++-------------- src/utilities/plugin-repository-base-path.ts | 7 +++++++ 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/components/repository-form.tsx b/src/components/repository-form.tsx index 9263be25..62025ed6 100644 --- a/src/components/repository-form.tsx +++ b/src/components/repository-form.tsx @@ -8,12 +8,7 @@ import { TextInput, } from '@patternfly/react-core'; import { useEffect, useState } from 'react'; -import { - AnsibleRemoteAPI, - type AnsibleRepositoryType, - DebRemoteAPI, - FileRemoteAPI, -} from 'src/api'; +import { type AnsibleRepositoryType } from 'src/api'; import { FormFieldHelper, HelpButton, @@ -25,6 +20,7 @@ import { import { type ErrorMessagesType, errorMessage, + plugin2api, pluginRepositoryBasePath, } from 'src/utilities'; @@ -113,14 +109,8 @@ export const RepositoryForm = ({ const [remotesError, setRemotesError] = useState(null); const loadRemotes = (name?) => { setRemotesError(null); - (plugin === 'ansible' - ? AnsibleRemoteAPI.list({ ...(name ? { name__icontains: name } : {}) }) - : plugin === 'deb' - ? DebRemoteAPI.list({ ...(name ? { name__icontains: name } : {}) }) - : plugin === 'file' - ? FileRemoteAPI.list({ ...(name ? { name__icontains: name } : {}) }) - : Promise.reject(plugin) - ) + const { RemoteAPI } = plugin2api(plugin); + RemoteAPI.list(name ? { name__icontains: name } : {}) .then(({ data }) => setRemotes(data.results.map((r) => ({ ...r, id: r.pulp_href }))), ) diff --git a/src/utilities/plugin-repository-base-path.ts b/src/utilities/plugin-repository-base-path.ts index c6877f88..6f811cd9 100644 --- a/src/utilities/plugin-repository-base-path.ts +++ b/src/utilities/plugin-repository-base-path.ts @@ -1,10 +1,13 @@ import { t } from '@lingui/core/macro'; import { AnsibleDistributionAPI, + AnsibleRemoteAPI, AnsibleRepositoryAPI, DebDistributionAPI, + DebRemoteAPI, DebRepositoryAPI, FileDistributionAPI, + FileRemoteAPI, FileRepositoryAPI, RPMRepositoryAPI, } from 'src/api'; @@ -20,21 +23,25 @@ export function plugin2api(plugin) { case 'ansible': return { DistributionAPI: AnsibleDistributionAPI, + RemoteAPI: AnsibleRemoteAPI, RepositoryAPI: AnsibleRepositoryAPI, }; case 'deb': return { DistributionAPI: DebDistributionAPI, + RemoteAPI: DebRemoteAPI, RepositoryAPI: DebRepositoryAPI, }; case 'file': return { DistributionAPI: FileDistributionAPI, + RemoteAPI: FileRemoteAPI, RepositoryAPI: FileRepositoryAPI, }; case 'rpm': return { // FIXME: DistributionAPI: RPMDistributionAPI, + // FIXME: RemoteAPI: RPMRemoteAPI, RepositoryAPI: RPMRepositoryAPI, }; default: From b3a2811bede7eaa429af2986fdb292eb9bffb459 Mon Sep 17 00:00:00 2001 From: waris shaikh Date: Tue, 15 Sep 2026 06:25:40 -0400 Subject: [PATCH 7/9] Stop stubbing hasObjectPermission on deb distributions The stub answered true to every permission. Nothing in this list is gated on one, and an apt repository carries no my_permissions field for the ansible-style check to read, so copying that check would answer no to everyone instead. Leave the permission out and say why, so an action added later gets a real check rather than a stub that always agrees. Refs #277 Assisted By: Cursor (Claude Opus 5) --- src/containers/deb-repository/tab-distributions.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/containers/deb-repository/tab-distributions.tsx b/src/containers/deb-repository/tab-distributions.tsx index 5e4eb656..5075a3ff 100644 --- a/src/containers/deb-repository/tab-distributions.tsx +++ b/src/containers/deb-repository/tab-distributions.tsx @@ -79,7 +79,9 @@ export const DistributionsTab = ({ addAlert, query, hasPermission, - hasObjectPermission: (_p: string): boolean => true, + // No hasObjectPermission: an apt repository carries no my_permissions to + // check, and nothing in this list is gated on one. Whoever adds an action + // that is wants a real check here, not a stub that always agrees. }} defaultPageSize={10} defaultSort={'name'} From 15cd87f6e6dadb3b96f5c8b65693159826777795 Mon Sep 17 00:00:00 2001 From: waris shaikh Date: Tue, 15 Sep 2026 06:25:40 -0400 Subject: [PATCH 8/9] Describe the deb remote and repository as interfaces Both types hold property declarations only, with no constructor, no methods and no instantiation, which is what an interface is for. Also fixes a typo in the smartUpdate comment. Refs #277 Assisted By: Cursor (Claude Opus 5) --- src/api/deb-remote.ts | 4 ++-- src/api/deb-repository.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api/deb-remote.ts b/src/api/deb-remote.ts index 32e74716..4983f11d 100644 --- a/src/api/deb-remote.ts +++ b/src/api/deb-remote.ts @@ -1,6 +1,6 @@ import { PulpAPI } from './pulp'; -export class DebRemoteType { +export interface DebRemoteType { architectures: string; ca_cert: string; client_cert: string; @@ -46,7 +46,7 @@ function smartUpdate(remote: DebRemoteType, unmodifiedRemote: DebRemoteType) { remote[field] = null; } - // API returns headers:null bull doesn't accept it .. and we don't edit headers + // API returns headers:null but doesn't accept it .. and we don't edit headers if (remote[field] === null && unmodifiedRemote[field] === null) { delete remote[field]; } diff --git a/src/api/deb-repository.ts b/src/api/deb-repository.ts index f3d9ff2f..a49f5bb5 100644 --- a/src/api/deb-repository.ts +++ b/src/api/deb-repository.ts @@ -1,6 +1,6 @@ import { PulpAPI } from './pulp'; -export class DebRepositoryType { +export interface DebRepositoryType { autopublish?: boolean; description: string | null; latest_version_href?: string; From 9ce27a792362c418a2786c096abc35ac2cc6139d Mon Sep 17 00:00:00 2001 From: waris shaikh Date: Tue, 15 Sep 2026 06:25:40 -0400 Subject: [PATCH 9/9] Add smoke tests for the deb pages Reaches both lists and their empty states, then opens the remote form to check the APT-only fields render, since those are what the shared form gained for this plugin. Refs #277 Assisted By: Cursor (Claude Opus 5) --- cypress/e2e/smoke.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cypress/e2e/smoke.js b/cypress/e2e/smoke.js index 4a3ee5b1..04a5bcf8 100644 --- a/cypress/e2e/smoke.js +++ b/cypress/e2e/smoke.js @@ -51,6 +51,27 @@ describe('UI smoke tests', () => { // TODO }); + it('Deb repositories', () => { + cy.ui('deb/repositories'); + cy.assertTitle('Repositories'); + + cy.contains('No repositories yet'); + }); + + it('Deb remotes', () => { + cy.ui('deb/remotes'); + cy.assertTitle('Remotes'); + + cy.contains('No remotes yet'); + + // an apt remote cannot sync without being told which suites to fetch, so the + // form carries fields the other plugins have no use for + cy.contains('button', 'Add remote').click(); + cy.get('#distributions'); + cy.get('#components'); + cy.get('#architectures'); + }); + it('File repositories', () => { cy.ui('file/repositories'); cy.assertTitle('Repositories');