mirror of
https://github.com/portainer/portainer.git
synced 2025-08-09 07:45:22 +02:00
refactor(registries): migrate list view to react [EE-4704] (#10687)
This commit is contained in:
parent
9600eb6fa1
commit
f584bf3830
61 changed files with 504 additions and 490 deletions
|
@ -1,7 +1,10 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
import { RegistryId, Registry } from '@/react/portainer/registries/types';
|
||||
import {
|
||||
RegistryId,
|
||||
Registry,
|
||||
} from '@/react/portainer/registries/types/registry';
|
||||
|
||||
import { EnvironmentId } from '../types';
|
||||
|
||||
|
|
|
@ -51,7 +51,7 @@ export function Option({
|
|||
<div className="mt-3 flex flex-col text-center">
|
||||
<h3>{title}</h3>
|
||||
<h5>{description}</h5>
|
||||
{isLimited && (
|
||||
{featureId && isLimited && (
|
||||
<BEFeatureIndicator
|
||||
showIcon={false}
|
||||
featureId={featureId}
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
export { DefaultRegistryAction } from './DefaultRegistryAction';
|
||||
export { DefaultRegistryDomain } from './DefaultRegistryDomain';
|
||||
export { DefaultRegistryName } from './DefaultRegistryName';
|
21
app/react/portainer/registries/ListView/ListView.tsx
Normal file
21
app/react/portainer/registries/ListView/ListView.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { PageHeader } from '@@/PageHeader';
|
||||
import { InformationPanel } from '@@/InformationPanel';
|
||||
|
||||
import { RegistriesDatatable } from './RegistriesDatatable';
|
||||
|
||||
export function ListView() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Registries" breadcrumbs="Registry management" reload />
|
||||
|
||||
<InformationPanel title="Information">
|
||||
<span className="small text-muted">
|
||||
View registries via an environment to manage access for user(s) and/or
|
||||
team(s)
|
||||
</span>
|
||||
</InformationPanel>
|
||||
|
||||
<RegistriesDatatable />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { AddButton as BaseAddButton } from '@@/buttons';
|
||||
|
||||
export function AddButton() {
|
||||
return (
|
||||
<BaseAddButton
|
||||
data-cy="registry-addRegistryButton"
|
||||
to="portainer.registries.new"
|
||||
>
|
||||
Add registry
|
||||
</BaseAddButton>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { DeleteButton as BaseDeleteButton } from '@@/buttons/DeleteButton';
|
||||
|
||||
import { Registry } from '../../types/registry';
|
||||
|
||||
import { useDeleteRegistriesMutation } from './useDeleteRegistriesMutation';
|
||||
|
||||
export function DeleteButton({ selectedItems }: { selectedItems: Registry[] }) {
|
||||
const mutation = useDeleteRegistriesMutation();
|
||||
|
||||
const confirmMessage = getMessage(selectedItems.length);
|
||||
|
||||
return (
|
||||
<BaseDeleteButton
|
||||
data-cy="registry-removeRegistryButton"
|
||||
disabled={selectedItems.length === 0}
|
||||
confirmMessage={confirmMessage}
|
||||
onConfirmed={handleDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
function handleDelete() {
|
||||
mutation.mutate(
|
||||
selectedItems.map((item) => item.Id),
|
||||
{
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Registries removed');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getMessage(selectedCount: number) {
|
||||
const regAttrMsg = selectedCount > 1 ? 'hese' : 'his';
|
||||
const registriesMsg = pluralize(selectedCount, 'registry', 'registries');
|
||||
return `T${regAttrMsg} ${registriesMsg} might be used by applications inside one or more environments. Removing the ${registriesMsg} could lead to a service interruption for the applications using t${regAttrMsg} ${registriesMsg}. Do you want to remove the selected ${registriesMsg}?`;
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { Radio } from 'lucide-react';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { createPersistedStore } from '@@/datatables/types';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { useRegistries } from '../../queries/useRegistries';
|
||||
|
||||
import { columns } from './columns';
|
||||
import { DeleteButton } from './DeleteButton';
|
||||
import { AddButton } from './AddButton';
|
||||
|
||||
const tableKey = 'registries';
|
||||
|
||||
const store = createPersistedStore(tableKey);
|
||||
|
||||
export function RegistriesDatatable() {
|
||||
const query = useRegistries();
|
||||
|
||||
const tableState = useTableState(store, tableKey);
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
columns={columns}
|
||||
dataset={query.data || []}
|
||||
isLoading={query.isLoading}
|
||||
settingsManager={tableState}
|
||||
title="Registries"
|
||||
titleIcon={Radio}
|
||||
renderTableActions={(selectedItems) => (
|
||||
<>
|
||||
<DeleteButton selectedItems={selectedItems} />
|
||||
|
||||
<AddButton />
|
||||
</>
|
||||
)}
|
||||
isRowSelectable={(row) => !!row.original.Id}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -38,7 +38,7 @@ export function DefaultRegistryAction() {
|
|||
Hide for all users
|
||||
</Button>
|
||||
<BEFeatureIndicator featureId={FeatureId.HIDE_DOCKER_HUB_ANONYMOUS} />
|
||||
{isLimited ? null : (
|
||||
{isLimited && (
|
||||
<Tooltip
|
||||
message="This hides the option in any registry dropdown prompts but does not prevent a user from deploying anonymously from Docker Hub directly via YAML.
|
||||
Note: Docker Hub (anonymous) will continue to show as the ONLY option if there are NO other registries available to the user."
|
|
@ -0,0 +1,71 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { Button } from '@@/buttons';
|
||||
import { BEFeatureIndicator } from '@@/BEFeatureIndicator';
|
||||
|
||||
import { DecoratedRegistry } from '../types';
|
||||
import { RegistryId, RegistryTypes } from '../../../types/registry';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
import { DefaultRegistryAction } from './DefaultRegistryAction';
|
||||
|
||||
export const actions = columnHelper.display({
|
||||
header: 'Actions',
|
||||
cell: Cell,
|
||||
});
|
||||
|
||||
const nonBrowsableTypes = [
|
||||
RegistryTypes.ANONYMOUS,
|
||||
RegistryTypes.DOCKERHUB,
|
||||
RegistryTypes.QUAY,
|
||||
];
|
||||
|
||||
function Cell({
|
||||
row: { original: item },
|
||||
}: CellContext<DecoratedRegistry, unknown>) {
|
||||
if (!item.Id) {
|
||||
return <DefaultRegistryAction />;
|
||||
}
|
||||
|
||||
return <BrowseButton registryId={item.Id} registryType={item.Type} />;
|
||||
}
|
||||
|
||||
export function BrowseButton({
|
||||
registryId,
|
||||
registryType,
|
||||
environmentId,
|
||||
}: {
|
||||
registryId: RegistryId;
|
||||
registryType: RegistryTypes;
|
||||
environmentId?: EnvironmentId;
|
||||
}) {
|
||||
const canBrowse = !nonBrowsableTypes.includes(registryType);
|
||||
|
||||
if (!canBrowse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<BEFeatureIndicator featureId={FeatureId.REGISTRY_MANAGEMENT}>
|
||||
{(isLimited) => (
|
||||
<Button
|
||||
color="link"
|
||||
as={Link}
|
||||
props={{
|
||||
to: 'portainer.registries.registry.repositories',
|
||||
params: { id: registryId, endpointId: environmentId },
|
||||
}}
|
||||
disabled={isLimited}
|
||||
icon={Search}
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
)}
|
||||
</BEFeatureIndicator>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { DecoratedRegistry } from '../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<DecoratedRegistry>();
|
|
@ -0,0 +1,5 @@
|
|||
import { actions } from './actions';
|
||||
import { name } from './name';
|
||||
import { url } from './url';
|
||||
|
||||
export const columns = [name, url, actions];
|
|
@ -0,0 +1,52 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { DecoratedRegistry } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
import { DefaultRegistryName } from './DefaultRegistryName';
|
||||
|
||||
export const name = columnHelper.accessor('Name', {
|
||||
header: 'Name',
|
||||
cell: Cell,
|
||||
});
|
||||
|
||||
function Cell({
|
||||
row: { original: item },
|
||||
}: CellContext<DecoratedRegistry, string>) {
|
||||
return <NameCell item={item} hasLink />;
|
||||
}
|
||||
|
||||
export function NameCell({
|
||||
item,
|
||||
hasLink,
|
||||
}: {
|
||||
item: DecoratedRegistry;
|
||||
hasLink?: boolean;
|
||||
}) {
|
||||
const isEdgeAdminQuery = useIsEdgeAdmin();
|
||||
|
||||
if (!item.Id) {
|
||||
return <DefaultRegistryName />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEdgeAdminQuery.isAdmin && hasLink ? (
|
||||
<Link to="portainer.registries.registry" params={{ id: item.Id }}>
|
||||
{item.Name}
|
||||
</Link>
|
||||
) : (
|
||||
item.Name
|
||||
)}
|
||||
{item.Authentication && (
|
||||
<span className="ml-2 label label-info image-tag">
|
||||
authentication-enabled
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import { DefaultRegistryDomain } from './DefaultRegistryDomain';
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const url = columnHelper.accessor('URL', {
|
||||
header: 'URL',
|
||||
cell: ({ getValue, row: { original: item } }) =>
|
||||
item.Id ? getValue() : <DefaultRegistryDomain />,
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export { RegistriesDatatable } from './RegistriesDatatable';
|
|
@ -0,0 +1,3 @@
|
|||
import { Registry } from '../../types/registry';
|
||||
|
||||
export interface DecoratedRegistry extends Registry {}
|
|
@ -0,0 +1,35 @@
|
|||
import { useMutation, useQueryClient } from 'react-query';
|
||||
|
||||
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import {
|
||||
mutationOptions,
|
||||
withError,
|
||||
withInvalidate,
|
||||
} from '@/react-tools/react-query';
|
||||
|
||||
import { buildUrl } from '../../queries/build-url';
|
||||
import { queryKeys } from '../../queries/query-keys';
|
||||
import { Registry } from '../../types/registry';
|
||||
|
||||
export function useDeleteRegistriesMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
(RegistryIds: Array<Registry['Id']>) =>
|
||||
promiseSequence(
|
||||
RegistryIds.map((RegistryId) => () => deleteRegistry(RegistryId))
|
||||
),
|
||||
mutationOptions(
|
||||
withError('Unable to delete registries'),
|
||||
withInvalidate(queryClient, [queryKeys.base()])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteRegistry(id: Registry['Id']) {
|
||||
try {
|
||||
await axios.delete(buildUrl(id));
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e, 'Unable to delete registries');
|
||||
}
|
||||
}
|
1
app/react/portainer/registries/ListView/index.ts
Normal file
1
app/react/portainer/registries/ListView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ListView } from './ListView';
|
|
@ -0,0 +1,39 @@
|
|||
import { Radio } from 'lucide-react';
|
||||
|
||||
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { url } from '@/react/portainer/registries/ListView/RegistriesDatatable/columns/url';
|
||||
import { AddButton } from '@/react/portainer/registries/ListView/RegistriesDatatable/AddButton';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { createPersistedStore } from '@@/datatables/types';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { name } from './columns/name';
|
||||
import { actions } from './columns/actions';
|
||||
|
||||
const columns = [name, url, actions];
|
||||
|
||||
const tableKey = 'registries';
|
||||
|
||||
const store = createPersistedStore(tableKey);
|
||||
|
||||
export function EnvironmentRegistriesDatatable() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const query = useEnvironmentRegistries(environmentId);
|
||||
|
||||
const tableState = useTableState(store, tableKey);
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
columns={columns}
|
||||
dataset={query.data || []}
|
||||
isLoading={query.isLoading}
|
||||
settingsManager={tableState}
|
||||
title="Registries"
|
||||
titleIcon={Radio}
|
||||
renderTableActions={() => <AddButton />}
|
||||
disableSelect
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
import { Authorized, useAuthorizations } from '@/react/hooks/useUser';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { DecoratedRegistry } from '@/react/portainer/registries/ListView/RegistriesDatatable/types';
|
||||
import { RegistryTypes } from '@/react/portainer/registries/types/registry';
|
||||
import { columnHelper } from '@/react/portainer/registries/ListView/RegistriesDatatable/columns/helper';
|
||||
import { BrowseButton } from '@/react/portainer/registries/ListView/RegistriesDatatable/columns/actions';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
export const actions = columnHelper.display({
|
||||
header: 'Actions',
|
||||
cell: Cell,
|
||||
});
|
||||
|
||||
function Cell({
|
||||
row: { original: item },
|
||||
}: CellContext<DecoratedRegistry, unknown>) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const hasUpdateAccessAuthorizations = useAuthorizations(
|
||||
['PortainerRegistryUpdateAccess'],
|
||||
environmentId,
|
||||
true
|
||||
);
|
||||
const canManageAccess =
|
||||
item.Type !== RegistryTypes.ANONYMOUS && hasUpdateAccessAuthorizations;
|
||||
|
||||
if (!item.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{canManageAccess && (
|
||||
<Authorized authorizations="PortainerRegistryUpdateAccess">
|
||||
<Button
|
||||
color="link"
|
||||
icon={Users}
|
||||
as={Link}
|
||||
props={{ to: '.access', params: { id: item.Id } }}
|
||||
>
|
||||
Manage access
|
||||
</Button>
|
||||
</Authorized>
|
||||
)}
|
||||
<BrowseButton
|
||||
registryId={item.Id}
|
||||
registryType={item.Type}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { DecoratedRegistry } from '@/react/portainer/registries/ListView/RegistriesDatatable/types';
|
||||
import { columnHelper } from '@/react/portainer/registries/ListView/RegistriesDatatable/columns/helper';
|
||||
import { NameCell } from '@/react/portainer/registries/ListView/RegistriesDatatable/columns/name';
|
||||
|
||||
export const name = columnHelper.accessor('Name', {
|
||||
header: 'Name',
|
||||
cell: Cell,
|
||||
});
|
||||
|
||||
function Cell({
|
||||
row: { original: item },
|
||||
}: CellContext<DecoratedRegistry, string>) {
|
||||
return <NameCell item={item} />;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { EnvironmentRegistriesDatatable } from './EnvironmentRegistriesDatatable';
|
|
@ -0,0 +1,17 @@
|
|||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { EnvironmentRegistriesDatatable } from './EnvironmentRegistriesDatatable';
|
||||
|
||||
export function ListView() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Environment registries"
|
||||
breadcrumbs="Registry management"
|
||||
reload
|
||||
/>
|
||||
|
||||
<EnvironmentRegistriesDatatable />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ListView } from './ListView';
|
|
@ -1,6 +1,9 @@
|
|||
import { EnvironmentId } from '../../environments/types';
|
||||
import { RegistryId } from '../types/registry';
|
||||
|
||||
export const queryKeys = {
|
||||
base: () => ['registries'] as const,
|
||||
list: (environmentId?: EnvironmentId) =>
|
||||
[...queryKeys.base(), { environmentId }] as const,
|
||||
item: (registryId: RegistryId) => [...queryKeys.base(), registryId] as const,
|
||||
};
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
export type RegistryId = number;
|
||||
export interface Registry {
|
||||
Id: RegistryId;
|
||||
Name: string;
|
||||
}
|
|
@ -5,7 +5,7 @@ import { ResourceControlResponse } from '../../access-control/types';
|
|||
import { RelativePathModel, RepoConfigResponse } from '../../gitops/types';
|
||||
import { VariableDefinition } from '../../custom-templates/components/CustomTemplatesVariablesDefinitionField';
|
||||
import { Platform } from '../types';
|
||||
import { RegistryId } from '../../registries/types';
|
||||
import { RegistryId } from '../../registries/types/registry';
|
||||
import { getDefaultRelativePathModel } from '../../gitops/RelativePathFieldset/types';
|
||||
import { isBE } from '../../feature-flags/feature-flags.service';
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue