diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 90bcf3cc6..40367820b 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -187,6 +187,15 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } + isUnique, err := handler.isNameUnique(payload.Name, 0) + if err != nil { + return httperror.InternalServerError("Unable to check if name is unique", err) + } + + if !isUnique { + return httperror.NewError(http.StatusConflict, "Name is not unique", nil) + } + endpoint, endpointCreationError := handler.createEndpoint(payload) if endpointCreationError != nil { return endpointCreationError diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index 1ce86a63d..60ff222a0 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -50,6 +50,7 @@ var endpointGroupNames map[portainer.EndpointGroupID]string // @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags" // @param endpointIds query []int false "will return only these environments(endpoints)" // @param edgeDeviceFilter query string false "will return only these edge environments, none will return only regular edge environments" Enum("all", "trusted", "untrusted", "none") +// @param name query string false "will return only environments(endpoints) with this name" // @success 200 {array} portainer.Endpoint "Endpoints" // @failure 500 "Server error" // @router /endpoints [get] @@ -127,6 +128,11 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, groupIDs) } + name, _ := request.RetrieveQueryParameter(r, "name", true) + if name != "" { + filteredEndpoints = filterEndpointsByName(filteredEndpoints, name) + } + edgeDeviceFilter, _ := request.RetrieveQueryParameter(r, "edgeDeviceFilter", false) if edgeDeviceFilter != "" { filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, edgeDeviceFilter) @@ -465,3 +471,18 @@ func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids []portainer.Endp return filteredEndpoints } + +func filterEndpointsByName(endpoints []portainer.Endpoint, name string) []portainer.Endpoint { + if name == "" { + return endpoints + } + + filteredEndpoints := make([]portainer.Endpoint, 0) + + for _, endpoint := range endpoints { + if endpoint.Name == name { + filteredEndpoints = append(filteredEndpoints, endpoint) + } + } + return filteredEndpoints +} diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index f11746fb6..8196d7ef3 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -88,7 +88,18 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * } if payload.Name != nil { - endpoint.Name = *payload.Name + name := *payload.Name + isUnique, err := handler.isNameUnique(name, endpoint.ID) + if err != nil { + return httperror.InternalServerError("Unable to check if name is unique", err) + } + + if !isUnique { + return httperror.NewError(http.StatusConflict, "Name is not unique", nil) + } + + endpoint.Name = name + } if payload.URL != nil { diff --git a/api/http/handler/endpoints/unique_name.go b/api/http/handler/endpoints/unique_name.go new file mode 100644 index 000000000..bad167d1b --- /dev/null +++ b/api/http/handler/endpoints/unique_name.go @@ -0,0 +1,18 @@ +package endpoints + +import portainer "github.com/portainer/portainer/api" + +func (handler *Handler) isNameUnique(name string, endpointID portainer.EndpointID) (bool, error) { + endpoints, err := handler.DataStore.Endpoint().Endpoints() + if err != nil { + return false, err + } + + for _, endpoint := range endpoints { + if endpoint.Name == name && (endpointID == 0 || endpoint.ID != endpointID) { + return false, nil + } + } + + return true, nil +} diff --git a/app/angulartics.matomo/analytics-services.ts b/app/angulartics.matomo/analytics-services.ts index 6141a4da6..dcc729630 100644 --- a/app/angulartics.matomo/analytics-services.ts +++ b/app/angulartics.matomo/analytics-services.ts @@ -1,5 +1,7 @@ import _ from 'lodash'; +import { useSettings } from '@/portainer/settings/queries'; + const categories = [ 'docker', 'kubernetes', @@ -61,6 +63,18 @@ export function push( } } +export function useAnalytics() { + const telemetryQuery = useSettings((settings) => settings.EnableTelemetry); + + return { trackEvent: handleTrackEvent }; + + function handleTrackEvent(...args: Parameters) { + if (telemetryQuery.data) { + trackEvent(...args); + } + } +} + export function trackEvent(action: string, properties: TrackEventProps) { /** * @description Logs an event with an event category (Videos, Music, Games...), an event diff --git a/app/edge/EdgeDevices/EdgeDevicesView/EdgeDevicesDatatable/EdgeDevicesDatatableContainer.tsx b/app/edge/EdgeDevices/EdgeDevicesView/EdgeDevicesDatatable/EdgeDevicesDatatableContainer.tsx index 275b76ce2..16f1a2181 100644 --- a/app/edge/EdgeDevices/EdgeDevicesView/EdgeDevicesDatatable/EdgeDevicesDatatableContainer.tsx +++ b/app/edge/EdgeDevices/EdgeDevicesView/EdgeDevicesDatatable/EdgeDevicesDatatableContainer.tsx @@ -4,7 +4,7 @@ import { TableSettingsProvider, useTableSettings, } from '@/portainer/components/datatables/components/useTableSettings'; -import { useEnvironmentList } from '@/portainer/environments/queries'; +import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList'; import { Environment } from '@/portainer/environments/types'; import { useSearchBarState } from '@/portainer/components/datatables/components/SearchBar'; import { useDebounce } from '@/portainer/hooks/useDebounce'; @@ -92,7 +92,6 @@ function Loader({ children, storageKey }: LoaderProps) { search: debouncedSearchValue, ...pagination, }, - false, settings.autoRefreshRate * 1000 ); diff --git a/app/edge/EdgeDevices/WaitingRoomView/WaitingRoomView.tsx b/app/edge/EdgeDevices/WaitingRoomView/WaitingRoomView.tsx index 9e5d44ce1..02d0f8c6d 100644 --- a/app/edge/EdgeDevices/WaitingRoomView/WaitingRoomView.tsx +++ b/app/edge/EdgeDevices/WaitingRoomView/WaitingRoomView.tsx @@ -2,7 +2,7 @@ import { useRouter } from '@uirouter/react'; import { TableSettingsProvider } from '@/portainer/components/datatables/components/useTableSettings'; import { PageHeader } from '@/portainer/components/PageHeader'; -import { useEnvironmentList } from '@/portainer/environments/queries'; +import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList'; import { r2a } from '@/react-tools/react2angular'; import { DataTable } from './Datatable/Datatable'; diff --git a/app/portainer/__module.js b/app/portainer/__module.js index f3d3465fd..0e81f2394 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -344,26 +344,6 @@ angular }, }; - const wizard = { - name: 'portainer.wizard', - url: '/wizard', - views: { - 'content@': { - component: 'wizardView', - }, - }, - }; - - const wizardEndpoints = { - name: 'portainer.wizard.endpoints', - url: '/endpoints', - views: { - 'content@': { - component: 'wizardEndpoints', - }, - }, - }; - var initEndpoint = { name: 'portainer.init.endpoint', url: '/endpoint', @@ -529,8 +509,6 @@ angular $stateRegistryProvider.register(groupCreation); $stateRegistryProvider.register(home); $stateRegistryProvider.register(init); - $stateRegistryProvider.register(wizard); - $stateRegistryProvider.register(wizardEndpoints); $stateRegistryProvider.register(initEndpoint); $stateRegistryProvider.register(initAdmin); $stateRegistryProvider.register(registries); diff --git a/app/portainer/components/form-components/FileUpload/FileUploadField.stories.tsx b/app/portainer/components/form-components/FileUpload/FileUploadField.stories.tsx index 535097a67..66e990fda 100644 --- a/app/portainer/components/form-components/FileUpload/FileUploadField.stories.tsx +++ b/app/portainer/components/form-components/FileUpload/FileUploadField.stories.tsx @@ -22,5 +22,12 @@ function Example({ title }: Args) { } } - return ; + return ( + + ); } diff --git a/app/portainer/components/form-components/FileUpload/FileUploadField.test.tsx b/app/portainer/components/form-components/FileUpload/FileUploadField.test.tsx index dca222b94..fe287a991 100644 --- a/app/portainer/components/form-components/FileUpload/FileUploadField.test.tsx +++ b/app/portainer/components/form-components/FileUpload/FileUploadField.test.tsx @@ -5,7 +5,11 @@ import { FileUploadField } from './FileUploadField'; test('render should make the file button clickable and fire onChange event after click', async () => { const onClick = jest.fn(); const { findByText, findByLabelText } = render( - + ); const button = await findByText('test button'); diff --git a/app/portainer/components/form-components/FileUpload/FileUploadField.tsx b/app/portainer/components/form-components/FileUpload/FileUploadField.tsx index c742feabe..7d005eeeb 100644 --- a/app/portainer/components/form-components/FileUpload/FileUploadField.tsx +++ b/app/portainer/components/form-components/FileUpload/FileUploadField.tsx @@ -11,6 +11,7 @@ export interface Props { accept?: string; title?: string; required?: boolean; + inputId: string; } export function FileUploadField({ @@ -19,12 +20,14 @@ export function FileUploadField({ accept, title = 'Select a file', required = false, + inputId, }: Props) { const fileRef = createRef(); return (
) { return ( -
-
- +
+
+ {required && *} + + {tooltip && } + + +
{children}
{errors && ( -
+
{errors}
)} diff --git a/app/portainer/components/form-components/FormSection/FormSection.stories.tsx b/app/portainer/components/form-components/FormSection/FormSection.stories.tsx new file mode 100644 index 000000000..fcd78b593 --- /dev/null +++ b/app/portainer/components/form-components/FormSection/FormSection.stories.tsx @@ -0,0 +1,43 @@ +import { Meta, Story } from '@storybook/react'; + +import { FormSection } from './FormSection'; + +export default { + component: FormSection, + title: 'Components/Form/FormSection', +} as Meta; + +interface Args { + title: string; + content: string; +} + +function Template({ title, content }: Args) { + return {content}; +} + +const exampleContent = `Content + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam egestas turpis magna, + vel pretium dui rhoncus nec. Maecenas felis purus, consectetur non porta sit amet, + auctor sed sapien. Aliquam eu nunc felis. Pellentesque pulvinar velit id quam pellentesque, + nec imperdiet dui finibus. In blandit augue nibh, nec tincidunt nisi porttitor quis. + Nullam nec nibh maximus, consequat quam sed, dapibus purus. Donec facilisis commodo mi, in commodo augue molestie sed. + `; + +export const Example: Story = Template.bind({}); +Example.args = { + title: 'title', + content: exampleContent, +}; + +export function FoldableSection({ + title = 'title', + content = exampleContent, +}: Args) { + return ( + + {content} + + ); +} diff --git a/app/portainer/components/form-components/FormSection/FormSection.tsx b/app/portainer/components/form-components/FormSection/FormSection.tsx new file mode 100644 index 000000000..c833bcf39 --- /dev/null +++ b/app/portainer/components/form-components/FormSection/FormSection.tsx @@ -0,0 +1,39 @@ +import { PropsWithChildren, useState } from 'react'; + +import { FormSectionTitle } from '../FormSectionTitle'; + +interface Props { + title: string; + isFoldable?: boolean; +} + +export function FormSection({ + title, + children, + isFoldable = false, +}: PropsWithChildren) { + const [isExpanded, setIsExpanded] = useState(!isFoldable); + + return ( + <> + + {isFoldable && ( +