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'); 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..0c5d6fd8 --- /dev/null +++ b/src/actions/deb-repository-delete.tsx @@ -0,0 +1,123 @@ +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, + }, + }), +}); + +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 }, +) { + 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 }) => { + 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..366d56af --- /dev/null +++ b/src/actions/deb-repository-sync.tsx @@ -0,0 +1,64 @@ +import { msg, t } from '@lingui/core/macro'; +import { DebRepositoryAPI } from 'src/api'; +import { SyncModal } from 'src/components'; +import { handleHttpError, parsePulpIDFromURL, taskAlert } from 'src/utilities'; +import { Action } from './action'; + +// pulp_deb's own API default for a sync. ansible and file offer to mirror +// 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 MIRROR_BY_DEFAULT = false; + +export const debRepositorySyncAction = Action({ + title: msg`Sync`, + modal: ({ addAlert, query, setState, state }) => + state.syncModalOpen ? ( + setState({ syncModalOpen: null })} + defaultMirror={MIRROR_BY_DEFAULT} + syncAction={(syncParams) => + syncRepository(state.syncModalOpen, { addAlert, query }, syncParams) + } + name={state.syncModalOpen.name} + /> + ) : null, + onClick: ({ name, pulp_href }, { setState }) => + setState({ + syncModalOpen: { name, pulp_href }, + }), + visible: (_item, { hasPermission }) => + hasPermission('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: MIRROR_BY_DEFAULT }, + ) + .then(({ data }) => { + addAlert(taskAlert(data.task, t`Sync started for repository "${name}".`)); + + query(); + }) + .catch( + handleHttpError( + t`Failed to sync repository "${name}"`, + () => null, + addAlert, + ), + ); +} diff --git a/src/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..4983f11d --- /dev/null +++ b/src/api/deb-remote.ts @@ -0,0 +1,71 @@ +import { PulpAPI } from './pulp'; + +export interface 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 but doesn't accept it .. and we don't edit headers + if (remote[field] === null && unmodifiedRemote[field] === null) { + delete remote[field]; + } + } + + return remote; +} + +const base = new PulpAPI(); + +export const 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..a49f5bb5 --- /dev/null +++ b/src/api/deb-repository.ts @@ -0,0 +1,42 @@ +import { PulpAPI } from './pulp'; + +export interface 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; } @@ -112,12 +109,8 @@ export const RepositoryForm = ({ const [remotesError, setRemotesError] = useState(null); const loadRemotes = (name?) => { setRemotesError(null); - (plugin === 'ansible' - ? AnsibleRemoteAPI.list({ ...(name ? { name__icontains: name } : {}) }) - : plugin === 'file' - ? FileRemoteAPI.list({ ...(name ? { name__icontains: name } : {}) }) - : Promise.reject(plugin) - ) + 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/components/sync-modal.tsx b/src/components/sync-modal.tsx index 09016cdf..bd999e47 100644 --- a/src/components/sync-modal.tsx +++ b/src/components/sync-modal.tsx @@ -5,21 +5,29 @@ import { HelpButton, Spinner } from 'src/components'; interface IProps { closeAction: () => null; + // Which way the mirror switch starts. Plugins disagree: pulp_deb's API defaults + // a sync to not mirroring, where ansible and file have always offered to. + defaultMirror?: boolean; syncAction: (syncParams) => Promise; name: string; } -export const SyncModal = ({ closeAction, syncAction, name }: IProps) => { +export const SyncModal = ({ + closeAction, + defaultMirror = true, + syncAction, + name, +}: IProps) => { const [pending, setPending] = useState(false); const [syncParams, setSyncParams] = useState({ - mirror: true, + mirror: defaultMirror, optimize: true, }); useEffect(() => { setPending(false); - setSyncParams({ mirror: true, optimize: true }); - }, [name]); + setSyncParams({ mirror: defaultMirror, optimize: true }); + }, [name, defaultMirror]); if (!name) { return null; diff --git a/src/containers/deb-remote/detail.tsx b/src/containers/deb-remote/detail.tsx new file mode 100644 index 00000000..7c0436ab --- /dev/null +++ b/src/containers/deb-remote/detail.tsx @@ -0,0 +1,44 @@ +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, page_size: 1 }).then(({ data }) => { + const remote = data?.results?.[0]; + + // There is no detail endpoint keyed by name, so a name matching nothing + // answers 200 with an empty list. Turn that into the 404 the page already + // knows how to render, instead of resolving with undefined. + if (!remote) { + return Promise.reject({ response: { status: 404 } }); + } + + return remote; + }), + renderTab: (tab, item, actionContext) => + ({ + details: , + })[tab], + tabs: (tab, name) => [ + { + active: tab === 'details', + title: t`Details`, + link: formatPath(Paths.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..80a2387b --- /dev/null +++ b/src/containers/deb-repository/detail.tsx @@ -0,0 +1,145 @@ +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 } }) => { + const crumbs = [ + { url: formatPath(Paths.deb.repository.list), name: t`Repositories` }, + { url: formatPath(Paths.deb.repository.detail, { name }), name }, + ]; + + if (tab !== 'repository-versions') { + return crumbs; + } + + // Looking at a single version keeps a link back to the list of them. + return repositoryVersion + ? [ + ...crumbs, + { + url: formatPath(Paths.deb.repository.detail, { name }, { tab }), + name: t`Versions`, + }, + { name: t`Version ${repositoryVersion}` }, + ] + : [...crumbs, { name: t`Versions` }]; + }, + 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 }) => data?.results?.[0]) + .then((repository) => { + // There is no detail endpoint keyed by name, so a name matching nothing + // answers 200 with an empty list. Turn that into the 404 the page already + // knows how to render, instead of resolving with undefined. + if (!repository) { + return Promise.reject({ response: { status: 404 } }); + } + + const err = (val) => (e) => { + console.error(e); + return val; + }; + + return Promise.all([ + // the plugin-aware variant, so the 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..5075a3ff --- /dev/null +++ b/src/containers/deb-repository/tab-distributions.tsx @@ -0,0 +1,128 @@ +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, + // 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'} + 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..6f811cd9 100644 --- a/src/utilities/plugin-repository-base-path.ts +++ b/src/utilities/plugin-repository-base-path.ts @@ -1,8 +1,13 @@ import { t } from '@lingui/core/macro'; import { AnsibleDistributionAPI, + AnsibleRemoteAPI, AnsibleRepositoryAPI, + DebDistributionAPI, + DebRemoteAPI, + DebRepositoryAPI, FileDistributionAPI, + FileRemoteAPI, FileRepositoryAPI, RPMRepositoryAPI, } from 'src/api'; @@ -18,16 +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: