diff --git a/src/api/container-remote-native.ts b/src/api/container-remote-native.ts new file mode 100644 index 00000000..2b5ff4c2 --- /dev/null +++ b/src/api/container-remote-native.ts @@ -0,0 +1,23 @@ +import { PulpAPI } from './pulp'; + +const base = new PulpAPI(); + +export interface ContainerRemoteNativeType { + pulp_href: string; + name: string; + url: string; + pulp_created: string; + pulp_last_updated: string; + policy: string; + tls_validation: boolean; + proxy_url: string | null; + ca_cert: string | null; + client_cert: string | null; + hidden_fields: { name: string; is_set: boolean }[]; +} + +export const ContainerRemoteNativeAPI = { + get: (id: string) => base.http.get(`remotes/container/container/${id}/`), + + list: (params?) => base.list('remotes/container/container/', params), +}; diff --git a/src/api/container-repository-native.ts b/src/api/container-repository-native.ts new file mode 100644 index 00000000..87ddb6ea --- /dev/null +++ b/src/api/container-repository-native.ts @@ -0,0 +1,46 @@ +import { PulpAPI } from './pulp'; +import { config } from 'src/ui-config'; + +const base = new PulpAPI(); + +const toRelativeHref = (href: string) => + href?.replace(config.API_BASE_PATH, '') || href; + +export interface ContainerRepositoryNativeType { + pulp_href: string; + name: string; + description: string | null; + remote: string | null; + pulp_created: string; + pulp_last_updated: string; + latest_version_href?: string; + retain_repo_versions?: number; + pulp_labels?: Record; +} + +export interface ContainerRepositoryVersionType { + pulp_href: string; + pulp_created: string; + number: number; + repository: string; + base_version: string | null; + content_summary: { + added: Record; + removed: Record; + present: Record; + }; +} + +export const ContainerRepositoryNativeAPI = { + get: (id: string) => base.http.get(`repositories/container/container/${id}/`), + + getByHref: (href: string) => base.http.get(toRelativeHref(href)), + + list: (params?) => base.list('repositories/container/container/', params), + + listVersions: (id: string, params?) => + base.list(`repositories/container/container/${id}/versions/`, params), + + listVersionsByHref: (href: string, params?) => + base.list(`${toRelativeHref(href)}versions/`, params), +}; diff --git a/src/api/index.ts b/src/api/index.ts index d1499327..64fcb425 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -12,6 +12,15 @@ export { ContainerDistributionAPI, ContainerPullThroughDistributionAPI, } from './container-distribution'; +export { + ContainerRepositoryNativeAPI, + type ContainerRepositoryNativeType, + type ContainerRepositoryVersionType, +} from './container-repository-native'; +export { + ContainerRemoteNativeAPI, + type ContainerRemoteNativeType, +} from './container-remote-native'; export { ContainerTagAPI } from './container-tag'; export { ExecutionEnvironmentAPI } from './execution-environment'; export { ExecutionEnvironmentNamespaceAPI } from './execution-environment-namespace'; diff --git a/src/components/container-repository-sidebar.tsx b/src/components/container-repository-sidebar.tsx new file mode 100644 index 00000000..cc93766c --- /dev/null +++ b/src/components/container-repository-sidebar.tsx @@ -0,0 +1,168 @@ +import { t } from '@lingui/core/macro'; +import { + Nav, + NavExpandable, + NavItem, + Title, +} from '@patternfly/react-core'; +import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router'; +import { + ContainerDistributionAPI, +} from 'src/api'; +import { EmptyStateNoData } from 'src/components'; +import { Paths, formatEEPath } from 'src/paths'; +import { NavList, SearchInput, Spinner } from './patternfly-wrappers/l10n'; + +interface NativeContainerDistributionType { + name: string; + base_path: string; + remote: string | null; + description: string | null; + pulp_created: string; +} + +type RepositoryTypeGroup = 'local' | 'remote'; + +interface IProps { + selectedRepository?: string; +} + +const PAGE_SIZE = 200; + +const repositoryType = ( + repository: NativeContainerDistributionType, +): RepositoryTypeGroup => + repository.remote ? 'remote' : 'local'; + +async function loadAllRepositories() { + const result = await ContainerDistributionAPI.list({ + page_size: PAGE_SIZE, + sort: 'name', + }); + return result.data.results as NativeContainerDistributionType[]; +} + +export const ContainerRepositorySidebar = ({ selectedRepository }: IProps) => { + const [filter, setFilter] = useState(''); + const [loading, setLoading] = useState(true); + const [repositories, setRepositories] = useState([]); + const [error, setError] = useState(null); + const typeLabels: Record = { + local: t`Local`, + remote: t`Remote`, + }; + + useEffect(() => { + let cancelled = false; + + loadAllRepositories() + .then((items) => { + if (!cancelled) { + setRepositories(items); + setLoading(false); + } + }) + .catch((e) => { + if (!cancelled) { + setError(e?.message || t`Failed to load repositories.`); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + const groupedRepositories = useMemo(() => { + const normalizedFilter = filter.toLowerCase(); + const filtered = repositories.filter(({ name, base_path }) => { + const haystack = [name, base_path] + .filter(Boolean) + .join('/') + .toLowerCase(); + + return haystack.includes(normalizedFilter); + }); + + return { + local: filtered.filter((repository) => repositoryType(repository) === 'local'), + remote: filtered.filter((repository) => repositoryType(repository) === 'remote'), + }; + }, [filter, repositories]); + + const hasResults = Object.values(groupedRepositories).some( + ({ length }) => length > 0, + ); + + return ( + + ); +}; diff --git a/src/components/index.ts b/src/components/index.ts index d9626b0c..19568768 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -19,6 +19,7 @@ export { CollectionUsedbyDependenciesList } from './collection-usedby-dependenci export { CompoundFilter, type FilterOption } from './compound-filter'; export { ConfirmModal } from './confirm-modal'; export { ContainerRepositoryForm } from './container-repository-form'; +export { ContainerRepositorySidebar } from './container-repository-sidebar'; export { CopyCollectionToRepositoryModal } from './copy-collection-to-repository-modal'; export { CopyURL } from './copy-url'; export { DarkmodeSwitcher } from './darkmode-switcher'; diff --git a/src/containers/execution-environment-detail/execution-environment-detail-native.tsx b/src/containers/execution-environment-detail/execution-environment-detail-native.tsx new file mode 100644 index 00000000..16830316 --- /dev/null +++ b/src/containers/execution-environment-detail/execution-environment-detail-native.tsx @@ -0,0 +1,335 @@ +import { t } from '@lingui/core/macro'; +import { Component } from 'react'; +import { + ContainerDistributionAPI, + ContainerRemoteNativeAPI, + ContainerRepositoryNativeAPI, + type ContainerRepositoryNativeType, + type ContainerRemoteNativeType, +} from 'src/api'; +import { AppContext, type IAppContextType } from 'src/app-context'; +import { + AlertList, + type AlertType, + BaseHeader, + Breadcrumbs, + LinkTabs, + LoadingSpinner, + Main, + NotFound, + closeAlert, +} from 'src/components'; +import { Paths, formatEEPath, formatPath } from 'src/paths'; +import { + ParamHelper, + parsePulpIDFromURL, + type RouteProps, + withRouter, +} from 'src/utilities'; +import { DetailsTab } from './tab-details'; +import { DistributionsTab } from './tab-distributions'; +import { RepositoryVersionsTab } from './tab-repository-versions'; + +interface ContainerDistributionType { + pulp_href: string; + pulp_created: string; + pulp_last_updated: string; + name: string; + description: string | null; + base_path: string; + registry_path: string; + repository: string | null; + remote: string | null; +} + +export interface ContainerDetailItem + extends Omit { + repositoryHref?: string | null; + remoteHref?: string | null; + repository?: ContainerRepositoryNativeType | null; + remote?: ContainerRemoteNativeType | null; +} + +export interface ContainerDetailTabProps { + item: ContainerDetailItem; + actionContext: { + addAlert: (alert: AlertType) => void; + state: { params: Record }; + hasPermission: (permission: string) => boolean; + hasObjectPermission: (permission: string) => boolean; + }; +} + +interface IState { + alerts: AlertType[]; + item: ContainerDetailItem | null; + loading: boolean; + notFound: boolean; + params: Record; +} + +const containerName = ({ + namespace, + container, +}: Record): string => + [namespace, container].filter(Boolean).join('/'); + +class ExecutionEnvironmentDetail extends Component { + static contextType = AppContext; + + constructor(props) { + super(props); + + const params = ParamHelper.parseParamString(props.location.search) as Record< + string, + string + >; + + if (!params.tab) { + params.tab = 'details'; + } + + this.state = { + alerts: [], + item: null, + loading: true, + notFound: false, + params, + }; + } + + componentDidMount() { + this.setState({ alerts: (this.context as IAppContextType).alerts || [] }); + (this.context as IAppContextType).setAlerts([]); + + this.load(); + } + + componentDidUpdate(prevProps) { + const oldContainer = containerName(prevProps.routeParams); + const newContainer = containerName(this.props.routeParams); + + if (oldContainer !== newContainer) { + this.load(); + return; + } + + if (prevProps.location.search !== this.props.location.search) { + const params = ParamHelper.parseParamString( + this.props.location.search, + ) as Record; + this.setState({ params: { tab: 'details', ...params } }); + } + } + + render() { + const { alerts, item, loading, notFound, params } = this.state; + + if (notFound) { + return ( + <> + + closeAlert(index, { + alerts, + setAlerts: (next) => this.setState({ alerts: next }), + }) + } + /> + + + ); + } + + const tab = params.tab || 'details'; + const title = item?.name || containerName(this.props.routeParams); + + return ( + <> + + closeAlert(index, { + alerts, + setAlerts: (next) => this.setState({ alerts: next }), + }) + } + /> + + } + > + {item && ( +
+
{this.renderTabs(tab, item)}
+
+ )} +
+
+ {loading ? ( + + ) : ( +
+ {item && this.renderTab(tab, item)} +
+ )} +
+ + ); + } + + private breadcrumbs(item: ContainerDetailItem | null, tab: string, params) { + const basePath = item?.base_path || containerName(this.props.routeParams); + + return [ + { url: formatPath(Paths.container.repository.list), name: t`Containers` }, + { + url: formatEEPath(Paths.container.repository.detail, { + container: basePath, + }), + name: item?.name || basePath, + }, + tab === 'repository-versions' && params.repositoryVersion + ? { + url: formatEEPath( + Paths.container.repository.detail, + { container: basePath }, + { tab: 'repository-versions' }, + ), + name: t`Versions`, + } + : null, + tab === 'repository-versions' && params.repositoryVersion + ? { name: t`Version ${params.repositoryVersion}` } + : null, + tab === 'repository-versions' && !params.repositoryVersion + ? { name: t`Versions` } + : null, + tab === 'distributions' ? { name: t`Distributions` } : null, + tab === 'details' ? { name: t`Details` } : null, + ].filter(Boolean); + } + + private renderTabs(tab: string, item: ContainerDetailItem) { + return ( + + ); + } + + private renderTab(tab: string, item: ContainerDetailItem) { + const actionContext = { + addAlert: (alert: AlertType) => this.addAlert(alert), + state: { params: this.state.params }, + hasPermission: (this.context as IAppContextType).hasPermission, + hasObjectPermission: (_permission: string) => true, + }; + + return ( + { + details: , + 'repository-versions': ( + + ), + distributions: ( + + ), + }[tab] || + ); + } + + private load() { + const basePath = containerName(this.props.routeParams); + + this.setState({ loading: true, notFound: false }, () => { + ContainerDistributionAPI.list({ base_path: basePath, page_size: 1 }) + .then(({ data }) => { + const distribution = data?.results?.[0] as ContainerDistributionType; + + if (!distribution) { + throw new Error('not-found'); + } + + const repositoryPromise = distribution.repository + ? ContainerRepositoryNativeAPI.getByHref(distribution.repository) + .then((result) => result.data as ContainerRepositoryNativeType) + .catch(() => null) + : Promise.resolve(null); + + return Promise.all([Promise.resolve(distribution), repositoryPromise]); + }) + .then(([distribution, repository]) => { + const remoteHref = repository?.remote || distribution.remote; + const remotePromise = remoteHref + ? ContainerRemoteNativeAPI.get(parsePulpIDFromURL(remoteHref)) + .then((result) => result.data as ContainerRemoteNativeType) + .catch(() => null) + : Promise.resolve(null); + + return Promise.all([ + Promise.resolve(distribution), + Promise.resolve(repository), + remotePromise, + ]); + }) + .then(([distribution, repository, remote]) => { + this.setState({ + item: { + ...distribution, + repositoryHref: distribution.repository, + remoteHref: distribution.remote, + repository, + remote, + name: repository?.name || distribution.name || distribution.base_path, + description: repository?.description || distribution.description, + }, + loading: false, + notFound: false, + }); + }) + .catch(() => { + this.setState({ loading: false, notFound: true, item: null }); + }); + }); + } + + private addAlert(alert: AlertType) { + this.setState({ alerts: [...this.state.alerts, alert] }); + } +} + +export default withRouter(ExecutionEnvironmentDetail); diff --git a/src/containers/execution-environment-detail/tab-details.tsx b/src/containers/execution-environment-detail/tab-details.tsx new file mode 100644 index 00000000..e8aa6e01 --- /dev/null +++ b/src/containers/execution-environment-detail/tab-details.tsx @@ -0,0 +1,56 @@ +import { t } from '@lingui/core/macro'; +import { Link } from 'react-router'; +import { DateComponent, Details, PulpLabels } from 'src/components'; +import { Paths, formatPath } from 'src/paths'; +import { type ContainerDetailTabProps } from './execution-environment-detail-native'; + +export const DetailsTab = ({ item }: ContainerDetailTabProps) => { + const remote = item.remote; + + return ( +
, + }, + { + label: t`Last modified`, + value: ( + + ), + }, + { + label: t`Labels`, + value: , + }, + { + label: t`Remotes`, + value: remote ? ( + + {remote.name} + + ) : ( + t`None` + ), + }, + { + label: t`Remote URL`, + value: remote?.url || t`None`, + }, + ]} + /> + ); +}; diff --git a/src/containers/execution-environment-detail/tab-distributions.tsx b/src/containers/execution-environment-detail/tab-distributions.tsx new file mode 100644 index 00000000..f25c60ff --- /dev/null +++ b/src/containers/execution-environment-detail/tab-distributions.tsx @@ -0,0 +1,109 @@ +import { t } from '@lingui/core/macro'; +import { Td, Tr } from '@patternfly/react-table'; +import { ContainerDistributionAPI } from 'src/api'; +import { ClipboardCopy, DateComponent, DetailList } from 'src/components'; +import { getContainersURL } from 'src/utilities'; +import { type ContainerDetailTabProps } from './execution-environment-detail-native'; + +interface DistributionType { + pulp_href: string; + pulp_created: string; + name: string; + base_path: string; + registry_path: string; + repository: string | null; + remote: string | null; +} + +export const DistributionsTab = ({ + item, + actionContext: { addAlert, hasPermission }, +}: ContainerDetailTabProps) => { + const query = ({ params } = { params: null }) => { + const newParams = { ...params }; + newParams.ordering = newParams.sort; + delete newParams.sort; + + const relationFilter = item.repository?.pulp_href + ? { repository: item.repository.pulp_href } + : { base_path: item.base_path }; + + return ContainerDistributionAPI.list({ + ...relationFilter, + ...newParams, + }); + }; + + const cliConfig = (basePath: string) => + `podman pull ${getContainersURL({ name: basePath, tag: 'latest' })}`; + + const renderTableRow = (distribution: DistributionType, index: number) => { + const { name, base_path, pulp_created } = distribution; + + return ( + + {name} + {base_path} + + + + + + {cliConfig(base_path)} + + + + ); + }; + + return ( + + actionContext={{ + addAlert, + query, + hasPermission, + hasObjectPermission: (_permission: 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`No distributions found for this container repository.`} + noDataTitle={t`No distributions yet`} + 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/execution-environment-detail/tab-repository-versions.tsx b/src/containers/execution-environment-detail/tab-repository-versions.tsx new file mode 100644 index 00000000..31661559 --- /dev/null +++ b/src/containers/execution-environment-detail/tab-repository-versions.tsx @@ -0,0 +1,331 @@ +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 { + GenericPulpAPI, + ContainerRepositoryNativeAPI, + type ContainerRepositoryVersionType, +} from 'src/api'; +import { + ClipboardCopy, + DateComponent, + DetailList, + Details, + EmptyStateNoData, + ListItemActions, + Spinner, +} from 'src/components'; +import { Paths, formatEEPath } from 'src/paths'; +import { getContainersURL } from 'src/utilities'; +import { type ContainerDetailTabProps } from './execution-environment-detail-native'; + +const ContentSummary = ({ data }: { data: object }) => { + if (!Object.keys(data).length) { + return <>{t`None`}; + } + + return ( + + + + + + {Object.entries(data).map(([key, value]) => ( + + + + + ))} +
{t`Count`}{t`Pulp type`}
{value['count']}{key}
+ ); +}; + +const BaseVersion = ({ + basePath, + data, +}: { + basePath: string; + data?: string; +}) => { + if (!data) { + return <>{t`None`}; + } + + const number = data.split('/').at(-2); + return ( + + {number} + + ); +}; + +interface ContainerTagType { + name: string; + tagged_manifest: string; +} + +interface ContainerManifestType { + pulp_href: string; + digest: string; +} + +interface PullReference { + tag: string; + digest?: string; +} + +const PullReferences = ({ + refs, + basePath, + loading, +}: { + refs: PullReference[]; + basePath: string; + loading: boolean; +}) => { + if (loading) { + return ; + } + + if (!refs.length) { + return <>{t`None`}; + } + + return ( +
+ {refs.map((ref) => ( +
+
+ {ref.tag} +
+ + {getContainersURL({ name: basePath, tag: ref.tag })} + + {ref.digest ? ( +
+ + {getContainersURL({ name: basePath, digest: ref.digest })} + +
+ ) : null} +
+ ))} +
+ ); +}; + +export const RepositoryVersionsTab = ({ + item, + actionContext: { addAlert, state, hasPermission, hasObjectPermission }, +}: ContainerDetailTabProps) => { + const repository = item.repository; + + if (!repository) { + return ( + + ); + } + + const latestHref = repository.latest_version_href; + const basePath = item.base_path; + const queryList = ({ params }) => + ContainerRepositoryNativeAPI.listVersionsByHref(repository.pulp_href, params); + const queryDetail = ({ number }) => + ContainerRepositoryNativeAPI.listVersionsByHref(repository.pulp_href, { + number, + }); + const [modalState, setModalState] = useState({}); + const [version, setVersion] = useState( + null, + ); + const [pullReferences, setPullReferences] = useState([]); + const [pullReferencesLoading, setPullReferencesLoading] = useState(false); + + 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]); + + useEffect(() => { + if (!version?.pulp_href) { + setPullReferences([]); + return; + } + + setPullReferencesLoading(true); + + Promise.all([ + GenericPulpAPI.list('content/container/tags/', { + repository_version: version.pulp_href, + limit: 200, + offset: 0, + }), + GenericPulpAPI.list('content/container/manifests/', { + repository_version: version.pulp_href, + limit: 200, + offset: 0, + }), + ]) + .then(([tagResult, manifestResult]) => { + const tags = (tagResult?.data?.results || []) as ContainerTagType[]; + const manifests = + (manifestResult?.data?.results || []) as ContainerManifestType[]; + + const digestByManifestHref = new Map( + manifests.map((manifest) => [manifest.pulp_href, manifest.digest]), + ); + + setPullReferences( + tags.map((tag) => ({ + tag: tag.name, + digest: digestByManifestHref.get(tag.tagged_manifest), + })), + ); + }) + .catch(() => setPullReferences([])) + .finally(() => setPullReferencesLoading(false)); + }, [version?.pulp_href]); + + const renderTableRow = ( + versionItem: ContainerRepositoryVersionType, + index: number, + actionCtx, + listItemActions, + ) => { + const { number, pulp_created, pulp_href } = versionItem; + + const isLatest = latestHref === pulp_href; + + const kebabItems = listItemActions.map((action) => + action.dropdownItem({ ...versionItem, isLatest }, actionCtx), + ); + + 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: , + }, + { + label: t`Pull references`, + value: ( + + ), + }, + ]} + /> + ) : ( + + ) + ) : ( + + actionContext={{ + addAlert, + state: modalState, + setState: setModalState, + query: queryList, + hasPermission, + hasObjectPermission, + }} + 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/execution-environment-list/execution-environment-list-native.tsx b/src/containers/execution-environment-list/execution-environment-list-native.tsx new file mode 100644 index 00000000..75227c2a --- /dev/null +++ b/src/containers/execution-environment-list/execution-environment-list-native.tsx @@ -0,0 +1,399 @@ +import { t } from '@lingui/core/macro'; +import { + Label, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, +} from '@patternfly/react-core'; +import { Table, Tbody, Td, Tr } from '@patternfly/react-table'; +import { Component } from 'react'; +import { Link } from 'react-router'; +import { ContainerDistributionAPI } from 'src/api'; +import { AppContext, type IAppContextType } from 'src/app-context'; +import { + AlertList, + type AlertType, + AppliedFilters, + BaseHeader, + ClipboardCopy, + CompoundFilter, + ContainerRepositorySidebar, + DateComponent, + DeleteExecutionEnvironmentModal, + EmptyStateFilter, + EmptyStateNoData, + ExternalLink, + HelpButton, + ListItemActions, + LoadingSpinner, + Main, + PulpPagination, + SortTable, + Tooltip, + closeAlert, +} from 'src/components'; +import { Paths, formatEEPath } from 'src/paths'; +import { + ParamHelper, + type RouteProps, + filterIsSet, + getContainersURL, + withRouter, +} from 'src/utilities'; +import './execution-environment.scss'; + +interface ExecutionEnvironmentType { + pulp_created: string; + name: string; + description: string; + pulp_last_updated: string; + base_path: string; + remote: string | null; + registry_path: string; +} + +interface IState { + alerts: AlertType[]; + itemCount: number; + items: ExecutionEnvironmentType[]; + loading: boolean; + params: { + page?: number; + page_size?: number; + }; + showDeleteModal: boolean; + selectedItem: ExecutionEnvironmentType; + inputText: string; +} + +class ExecutionEnvironmentList extends Component { + static contextType = AppContext; + + constructor(props) { + super(props); + + const params = ParamHelper.parseParamString(props.location.search, [ + 'page', + 'page_size', + ]); + + if (!params['page_size']) { + params['page_size'] = 10; + } + + if (!params['sort']) { + params['sort'] = 'name'; + } + + this.state = { + alerts: [], + itemCount: 0, + items: [], + loading: true, + params, + showDeleteModal: false, + selectedItem: null, + inputText: '', + }; + } + + componentDidMount() { + this.setState({ alerts: (this.context as IAppContextType).alerts || [] }); + (this.context as IAppContextType).setAlerts([]); + + this.queryEnvironments(); + } + + render() { + const { + alerts, + itemCount, + items, + loading, + params, + showDeleteModal, + selectedItem, + } = this.state; + + const noData = + items.length === 0 && !filterIsSet(params, ['name__icontains']); + + const tlsVerify = window.location.protocol == 'https:'; + const serverURL = getContainersURL({ name: '' }).replace(/\/$/, ''); + const containerURL = getContainersURL({ name: 'example', tag: 'latest' }); + const instructions = ( + + podman login --tls-verify={tlsVerify.toString()} {serverURL} + {'\n'} + podman image tag example {containerURL} + {'\n'} + podman push --tls-verify={tlsVerify.toString()} {containerURL} + {'\n'} + + ); + + const pushImagesButton = ( + + {instructions} + {t`Documentation`} + + } + hasAutoWidth + header={t`Push container images`} + prefix={ + {t`Push container images`} + } + /> + ); + + return ( + <> + + closeAlert(i, { + alerts, + setAlerts: (alerts) => this.setState({ alerts }), + }) + } + /> + + + {showDeleteModal && ( + + this.setState({ showDeleteModal: false, selectedItem: null }) + } + afterDelete={() => this.queryEnvironments()} + addAlert={(text, variant, description = undefined) => + this.setState({ + alerts: alerts.concat([ + { title: text, variant: variant, description: description }, + ]), + }) + } + /> + )} + {noData && !loading ? ( + + ) : ( +
+ {loading ? ( + + ) : ( +
+ +
+
+ + + + + + this.setState({ inputText: text }) + } + updateParams={(p) => + this.updateParams(p, () => + this.queryEnvironments(), + ) + } + params={params} + filterConfig={[ + { + id: 'name__icontains', + title: t`Container repository name`, + }, + ]} + /> + + +
+ {pushImagesButton} +
+
+
+
+
+ + + this.updateParams(p, () => this.queryEnvironments()) + } + count={itemCount} + isTop + /> +
+
+ { + this.updateParams(p, () => this.queryEnvironments()); + this.setState({ inputText: '' }); + }} + params={params} + ignoredParams={['page_size', 'page', 'sort']} + niceNames={{ + name__icontains: t`Name`, + }} + /> +
+ {this.renderTable(params)} + + + this.updateParams(p, () => this.queryEnvironments()) + } + count={itemCount} + /> +
+
+ )} +
+ )} + + ); + } + + private renderTable(params) { + const { items } = this.state; + if (items.length === 0) { + return ; + } + + const sortTableOptions = { + headers: [ + { + title: t`Container repository name`, + type: 'alpha', + id: 'name', + }, + { + title: t`Description`, + type: 'alpha', + id: 'description', + }, + { + title: t`Created`, + type: 'numeric', + id: 'created_at', + }, + { + title: t`Last modified`, + type: 'alpha', + id: 'updated_at', + }, + { + title: t`Container registry type`, + type: 'none', + id: 'type', + }, + { + title: '', + type: 'none', + id: 'controls', + }, + ], + }; + + return ( + + + this.updateParams(p, () => this.queryEnvironments()) + } + /> + {items.map((user, i) => this.renderTableRow(user, i))} +
+ ); + } + + private renderTableRow(item, index: number) { + const description = item.description; + + return ( + + + + {item.name} + + + {description ? ( + + {description} + + ) : ( + + )} + + + + + + + + + + + + ); + } + + private queryEnvironments() { + this.setState({ loading: true }, () => + ContainerDistributionAPI.list(this.state.params) + .then((result) => { + this.setState({ + items: result.data.results, + itemCount: result.data.count, + loading: false, + }); + }) + .catch((e) => + this.addAlert(t`Error loading environments.`, 'danger', e?.message), + ), + ); + } + + private updateParams(params, callback = null) { + ParamHelper.updateParams({ + params, + navigate: (to) => this.props.navigate(to), + setState: (state) => this.setState(state, callback), + }); + } + + private addAlert(title, variant, description?) { + this.addAlertObj({ + description, + title, + variant, + }); + } + + private addAlertObj(alert) { + this.setState({ + alerts: [...this.state.alerts, alert], + }); + } + + +} + +export default withRouter(ExecutionEnvironmentList); diff --git a/src/containers/execution-environment-list/execution-environment.scss b/src/containers/execution-environment-list/execution-environment.scss index 5a6cbf59..9f09bf7e 100644 --- a/src/containers/execution-environment-list/execution-environment.scss +++ b/src/containers/execution-environment-list/execution-environment.scss @@ -1,3 +1,50 @@ .delete-container-modal-message { padding-bottom: var(--pf-v5-global--spacer--md); } + +.execution-environment-layout { + display: grid; + gap: var(--pf-v5-global--spacer--lg); + grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr); +} + +.execution-environment-layout__content { + min-width: 0; +} + +.container-repository-sidebar { + border: 1px solid var(--pf-v5-global--BorderColor--100); + border-radius: var(--pf-v5-global--BorderRadius--sm); + padding: var(--pf-v5-global--spacer--md); + position: sticky; + top: var(--pf-v5-global--spacer--lg); +} + +.container-repository-sidebar__header { + display: grid; + gap: var(--pf-v5-global--spacer--sm); + margin-bottom: var(--pf-v5-global--spacer--md); +} + +.container-repository-sidebar__status { + display: flex; + justify-content: center; + padding: var(--pf-v5-global--spacer--xl) 0; +} + +.container-repository-sidebar__link { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 992px) { + .execution-environment-layout { + grid-template-columns: minmax(0, 1fr); + } + + .container-repository-sidebar { + position: static; + } +} diff --git a/src/containers/execution-environment/registry-list-native.tsx b/src/containers/execution-environment/registry-list-native.tsx new file mode 100644 index 00000000..6ab9cd6b --- /dev/null +++ b/src/containers/execution-environment/registry-list-native.tsx @@ -0,0 +1,482 @@ +import { t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { + Button, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, +} from '@patternfly/react-core'; +import { Table, Tbody, Td, Tr } from '@patternfly/react-table'; +import { Component } from 'react'; +import { + ContainerRemoteNativeAPI, + ExecutionEnvironmentRegistryAPI, + type ContainerRemoteNativeType, + type RemoteType, +} from 'src/api'; +import { AppContext, type IAppContextType } from 'src/app-context'; +import { + AlertList, + type AlertType, + AppliedFilters, + BaseHeader, + CompoundFilter, + CopyURL, + DateComponent, + DeleteModal, + EmptyStateFilter, + EmptyStateNoData, + ListItemActions, + LoadingSpinner, + Main, + PulpPagination, + RemoteForm, + SortTable, + closeAlert, +} from 'src/components'; +import { + type ErrorMessagesType, + ParamHelper, + type RouteProps, + filterIsSet, + jsxErrorMessage, + mapErrorMessages, + taskAlert, + withRouter, +} from 'src/utilities'; + +interface IState { + alerts: AlertType[]; + itemCount: number; + items: ContainerRemoteNativeType[]; + loading: boolean; + params: { + page?: number; + page_size?: number; + }; + remoteFormErrors: ErrorMessagesType; + remoteFormNew: boolean; + remoteToEdit?: RemoteType; + remoteUnmodified?: RemoteType; + showDeleteModal: boolean; + showRemoteFormModal: boolean; + inputText: string; +} + +class ExecutionEnvironmentRegistryList extends Component { + static contextType = AppContext; + + constructor(props) { + super(props); + + const params = ParamHelper.parseParamString(props.location.search, [ + 'page', + 'page_size', + ]); + + if (!params['page_size']) { + params['page_size'] = 10; + } + + if (!params['sort']) { + params['sort'] = 'name'; + } + + this.state = { + alerts: [], + itemCount: 0, + items: [], + loading: true, + params, + remoteFormErrors: {}, + remoteFormNew: false, + remoteToEdit: null, + remoteUnmodified: null, + showDeleteModal: false, + showRemoteFormModal: false, + inputText: '', + }; + } + + componentDidMount() { + this.queryRegistries(); + } + + render() { + const { + alerts, + itemCount, + items, + loading, + params, + remoteFormErrors, + remoteFormNew, + remoteToEdit, + remoteUnmodified, + showDeleteModal, + showRemoteFormModal, + } = this.state; + const noData = + items.length === 0 && !filterIsSet(params, ['name__icontains']); + + const { hasPermission } = this.context as IAppContextType; + const addButton = hasPermission('galaxy.add_containerregistryremote') ? ( + + ) : null; + + return ( + <> + + closeAlert(i, { + alerts, + setAlerts: (alerts) => this.setState({ alerts }), + }) + } + /> + {showRemoteFormModal && ( + + this.setState({ + remoteToEdit: null, + remoteUnmodified: null, + showRemoteFormModal: false, + }) + } + errorMessages={remoteFormErrors} + plugin='container' + remote={remoteToEdit} + saveRemote={() => { + const { remoteFormNew, remoteToEdit } = this.state; + const newRemote = { ...remoteToEdit }; + + if (remoteFormNew) { + // prevent "This field may not be blank." when writing in and then deleting username/password/etc + // only when creating, edit diffs with remoteUnmodified + Object.keys(newRemote).forEach((k) => { + if (newRemote[k] === '' || newRemote[k] == null) { + delete newRemote[k]; + } + }); + } + + const promise = remoteFormNew + ? ExecutionEnvironmentRegistryAPI.create(newRemote) + : ExecutionEnvironmentRegistryAPI.smartUpdate( + remoteToEdit.id, + remoteToEdit, + remoteUnmodified, + ); + + promise + .then(() => { + this.setState( + { + remoteToEdit: null, + remoteUnmodified: null, + showRemoteFormModal: false, + }, + () => this.queryRegistries(), + ); + }) + .catch((err) => + this.setState({ remoteFormErrors: mapErrorMessages(err) }), + ); + }} + showModal={showRemoteFormModal} + title={ + remoteFormNew ? t`Add remote registry` : t`Edit remote registry` + } + updateRemote={(r: RemoteType) => this.setState({ remoteToEdit: r })} + /> + )} + {showDeleteModal && remoteToEdit && ( + + this.setState({ showDeleteModal: false, remoteToEdit: null }) + } + deleteAction={() => this.deleteRegistry(remoteToEdit)} + title={t`Delete remote registry?`} + > + + {remoteToEdit.name} will be deleted. + + + )} + + {noData && !loading ? ( + + ) : ( +
+ {loading ? ( + + ) : ( +
+
+ + + + + + this.setState({ inputText: text }) + } + updateParams={(p) => + this.updateParams(p, () => this.queryRegistries()) + } + params={params} + filterConfig={[ + { + id: 'name__icontains', + title: t`Name`, + }, + ]} + /> + + {addButton} + + + + + + this.updateParams(p, () => this.queryRegistries()) + } + count={itemCount} + isTop + /> +
+
+ { + this.updateParams(p, () => this.queryRegistries()); + this.setState({ inputText: '' }); + }} + params={params} + ignoredParams={['page_size', 'page', 'sort']} + niceNames={{ + name__icontains: t`Name`, + }} + /> +
+ {this.renderTable(params)} + + this.updateParams(p, () => this.queryRegistries()) + } + count={itemCount} + /> +
+ )} +
+ )} + + ); + } + + private renderTable(params) { + const { items } = this.state; + if (items.length === 0) { + return ; + } + + const sortTableOptions = { + headers: [ + { + title: t`Name`, + type: 'alpha', + id: 'name', + }, + { + title: t`Created`, + type: 'alpha', + id: 'created_at', + }, + { + title: t`Last updated`, + type: 'alpha', + id: 'updated_at', + }, + { + title: t`Registry URL`, + type: 'alpha', + id: 'url', + }, + { + title: '', + type: 'none', + id: 'controls', + }, + ], + }; + + return ( + + + this.updateParams(p, () => this.queryRegistries()) + } + /> + {items.map((user, i) => this.renderTableRow(user, i))} +
+ ); + } + + private renderTableRow(item, index: number) { + return ( + + {item.name} + + + + + + + + + + + + ); + } + + private queryRegistries(noLoading = false) { + this.setState(noLoading ? null : { loading: true }, () => + ContainerRemoteNativeAPI.list(this.state.params) + .then((result) => { + this.setState({ + items: result.data.results, + itemCount: result.data.count, + loading: false, + }); + }) + .catch(() => { + this.setState({ loading: false }); + this.addAlert(t`Remotes could not be loaded.`, 'danger'); + }), + ); + } + + private deleteRegistry({ id, name }) { + ExecutionEnvironmentRegistryAPI.delete(id) + .then(() => + this.addAlert( + t`Remote registry "${name}" has been successfully deleted.`, + 'success', + ), + ) + .catch((err) => { + const { status, statusText } = err.response; + this.addAlert( + t`Remote registry "${name}" could not be deleted.`, + 'danger', + jsxErrorMessage(status, statusText), + ); + }) + .then(() => { + this.queryRegistries(); + this.setState({ showDeleteModal: false, remoteToEdit: null }); + }); + } + + private syncRegistry({ id, name }) { + ExecutionEnvironmentRegistryAPI.sync(id) + .then(({ data }) => { + this.addAlertObj( + taskAlert(data.task, t`Sync started for remote registry "${name}".`), + ); + this.queryRegistries(true); + }) + .catch((err) => { + const { status, statusText } = err.response; + this.addAlert( + t`Remote registry "${name}" could not be synced.`, + 'danger', + jsxErrorMessage(status, statusText), + ); + }); + } + + private indexRegistry({ id, name }) { + ExecutionEnvironmentRegistryAPI.index(id) + .then(({ data }) => { + this.addAlertObj( + taskAlert( + data.task, + t`Indexing started for container "${name}".`, + 'success', + ), + ); + }) + .catch((err) => { + const { status, statusText } = err.response; + this.addAlert( + t`Container "${name}" could not be indexed.`, + 'danger', + jsxErrorMessage(status, statusText), + ); + }); + } + + private addAlertObj(alert: AlertType) { + this.setState({ + alerts: [...this.state.alerts, alert], + }); + } + + private addAlert(title, variant, description?) { + this.addAlertObj({ + description, + title, + variant, + }); + } + + private updateParams(params, callback = null) { + ParamHelper.updateParams({ + params, + navigate: (to) => this.props.navigate(to), + setState: (state) => this.setState(state, callback), + }); + } +} + +export default withRouter(ExecutionEnvironmentRegistryList); diff --git a/src/containers/index.ts b/src/containers/index.ts index c75db44e..10e11eb3 100644 --- a/src/containers/index.ts +++ b/src/containers/index.ts @@ -13,13 +13,13 @@ export { default as CollectionDistributions } from './collection-detail/collecti export { default as CollectionDocs } from './collection-detail/collection-docs'; export { default as CollectionImportLog } from './collection-detail/collection-import-log'; export { default as EditNamespace } from './edit-namespace/edit-namespace'; -export { default as ExecutionEnvironmentDetail } from './execution-environment-detail/execution-environment-detail'; +export { default as ExecutionEnvironmentDetail } from './execution-environment-detail/execution-environment-detail-native'; export { default as ExecutionEnvironmentDetailAccess } from './execution-environment-detail/execution-environment-detail-access'; export { default as ExecutionEnvironmentDetailActivities } from './execution-environment-detail/execution-environment-detail-activities'; export { default as ExecutionEnvironmentDetailImages } from './execution-environment-detail/execution-environment-detail-images'; -export { default as ExecutionEnvironmentList } from './execution-environment-list/execution-environment-list'; +export { default as ExecutionEnvironmentList } from './execution-environment-list/execution-environment-list-native'; export { default as ExecutionEnvironmentManifest } from './execution-environment-manifest/execution-environment-manifest'; -export { default as ExecutionEnvironmentRegistryList } from './execution-environment/registry-list'; +export { default as ExecutionEnvironmentRegistryList } from './execution-environment/registry-list-native'; export { default as FileRemoteDetail } from './file-remote/detail'; export { default as FileRemoteEdit } from './file-remote/edit'; export { default as FileRemoteList } from './file-remote/list'; diff --git a/src/menu.tsx b/src/menu.tsx index 614e615b..c519d366 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -91,11 +91,9 @@ function standaloneMenu() { [ menuItem(t`Containers`, { url: formatPath(Paths.container.repository.list), - condition: BROKEN, }), menuItem(t`Remote registries`, { url: formatPath(Paths.container.remote.list), - condition: BROKEN, }), ], ),