refactor(wizard): migrate to react [EE-2305] (#6957)

pull/6977/head
Chaim Lev-Ari 2022-05-23 17:32:51 +03:00 committed by GitHub
parent 3aacaa7caf
commit 01dc9066b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
125 changed files with 2994 additions and 1744 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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<typeof trackEvent>) {
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

View File

@ -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
);

View File

@ -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';

View File

@ -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);

View File

@ -22,5 +22,12 @@ function Example({ title }: Args) {
}
}
return <FileUploadField onChange={onChange} value={value} title={title} />;
return (
<FileUploadField
onChange={onChange}
value={value}
title={title}
inputId="file-field"
/>
);
}

View File

@ -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(
<FileUploadField title="test button" onChange={onClick} />
<FileUploadField
title="test button"
onChange={onClick}
inputId="file-field"
/>
);
const button = await findByText('test button');

View File

@ -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<HTMLInputElement>();
return (
<div className="file-upload-field">
<input
id={inputId}
ref={fileRef}
type="file"
accept={accept}

View File

@ -27,6 +27,7 @@ export function FileUploadForm({
<div className="form-group">
<div className="col-sm-12">
<FileUploadField
inputId="file-upload-field"
onChange={onChange}
value={value}
title={title}

View File

@ -1,5 +0,0 @@
.container {
display: flex;
align-items: center;
width: 100%;
}

View File

@ -10,12 +10,13 @@ import styles from './FormControl.module.css';
type Size = 'small' | 'medium' | 'large';
export interface Props {
inputId: string;
inputId?: string;
label: string | ReactNode;
size?: Size;
tooltip?: string;
children: ReactNode;
errors?: string | ReactNode;
required?: boolean;
}
export function FormControl({
@ -25,23 +26,25 @@ export function FormControl({
tooltip = '',
children,
errors,
required,
}: PropsWithChildren<Props>) {
return (
<div>
<div className={clsx('form-group', styles.container)}>
<label
htmlFor={inputId}
className={`${sizeClassLabel(size)} control-label text-left`}
>
{label}
{tooltip && <Tooltip message={tooltip} />}
</label>
<div className={clsx('form-group', styles.container)}>
<label
htmlFor={inputId}
className={clsx(sizeClassLabel(size), 'control-label', 'text-left')}
>
{label}
<div className={`${sizeClassChildren(size)}`}>{children}</div>
</div>
{required && <span className="text-danger">*</span>}
{tooltip && <Tooltip message={tooltip} />}
</label>
<div className={sizeClassChildren(size)}>{children}</div>
{errors && (
<div className="form-group col-md-12">
<div className="col-md-12">
<FormError>{errors}</FormError>
</div>
)}

View File

@ -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 <FormSection title={title}>{content}</FormSection>;
}
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<Args> = Template.bind({});
Example.args = {
title: 'title',
content: exampleContent,
};
export function FoldableSection({
title = 'title',
content = exampleContent,
}: Args) {
return (
<FormSection title={title} isFoldable>
{content}
</FormSection>
);
}

View File

@ -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<Props>) {
const [isExpanded, setIsExpanded] = useState(!isFoldable);
return (
<>
<FormSectionTitle>
{isFoldable && (
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="border-0 mx-2 bg-transparent inline-flex justify-center items-center w-2"
>
<i
className={`fa fa-caret-${isExpanded ? 'down' : 'right'}`}
aria-hidden="true"
/>
</button>
)}
{title}
</FormSectionTitle>
{isExpanded && children}
</>
);
}

View File

@ -0,0 +1 @@
export { FormSection } from './FormSection';

View File

@ -1,3 +1,4 @@
import ReactSelectCreatable, { CreatableProps } from 'react-select/creatable';
import ReactSelect, { GroupBase, Props as SelectProps } from 'react-select';
import clsx from 'clsx';
import { RefAttributes } from 'react';
@ -23,3 +24,18 @@ export function Select<
/>
);
}
export function Creatable<
Option = unknown,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({ className, ...props }: CreatableProps<Option, IsMulti, Group>) {
return (
<ReactSelectCreatable
className={clsx(styles.root, className)}
classNamePrefix="selector"
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
);
}

View File

@ -1,4 +1,3 @@
import PortainerError from '@/portainer/error';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { type EnvironmentGroupId } from '@/portainer/environment-groups/types';
import { type TagId } from '@/portainer/tags/types';
@ -7,66 +6,87 @@ import { type Environment, EnvironmentCreationTypes } from '../types';
import { arrayToJson, buildUrl, json2formData } from './utils';
export async function createLocalEndpoint(
name = 'local',
url = '',
export interface EnvironmentMetadata {
groupId?: EnvironmentGroupId;
tagIds?: TagId[];
}
interface CreateLocalDockerEnvironment {
name: string;
socketPath?: string;
publicUrl?: string;
meta?: EnvironmentMetadata;
}
export async function createLocalDockerEnvironment({
name,
socketPath = '',
publicUrl = '',
groupId: EnvironmentGroupId = 1,
tagIds: TagId[] = []
) {
let endpointUrl = url;
if (endpointUrl !== '') {
if (endpointUrl.includes('//./pipe/')) {
endpointUrl = `unix://${url}`;
} else {
// Windows named pipe
endpointUrl = `npipe://${url}`;
meta = { tagIds: [] },
}: CreateLocalDockerEnvironment) {
const url = prefixPath(socketPath);
return createEnvironment(
name,
EnvironmentCreationTypes.LocalDockerEnvironment,
{
url,
publicUrl,
meta,
}
}
);
try {
return await createEndpoint(
name,
EnvironmentCreationTypes.LocalDockerEnvironment,
{ url: endpointUrl, publicUrl, groupId, tagIds }
);
} catch (err) {
throw new PortainerError('Unable to create environment', err as Error);
function prefixPath(path: string) {
if (path === '') {
return path;
}
// Windows named pipe
if (path.startsWith('//./pipe/')) {
return `npipe://${path}`;
}
return `unix://${path}`;
}
}
export async function createLocalKubernetesEndpoint(
name = 'local',
tagIds: TagId[] = []
) {
try {
return await createEndpoint(
name,
EnvironmentCreationTypes.LocalKubernetesEnvironment,
{ tagIds, groupId: 1, tls: { skipClientVerify: true, skipVerify: true } }
);
} catch (err) {
throw new PortainerError('Unable to create environment', err as Error);
}
interface CreateLocalKubernetesEnvironment {
name: string;
meta?: EnvironmentMetadata;
}
export async function createAzureEndpoint(
name: string,
applicationId: string,
tenantId: string,
authenticationKey: string,
groupId: EnvironmentGroupId,
tagIds: TagId[]
) {
try {
await createEndpoint(name, EnvironmentCreationTypes.AzureEnvironment, {
groupId,
tagIds,
azure: { applicationId, tenantId, authenticationKey },
});
} catch (err) {
throw new PortainerError('Unable to connect to Azure', err as Error);
}
export async function createLocalKubernetesEnvironment({
name,
meta = { tagIds: [] },
}: CreateLocalKubernetesEnvironment) {
return createEnvironment(
name,
EnvironmentCreationTypes.LocalKubernetesEnvironment,
{ meta, tls: { skipClientVerify: true, skipVerify: true } }
);
}
interface AzureSettings {
applicationId: string;
tenantId: string;
authenticationKey: string;
}
interface CreateAzureEnvironment {
name: string;
azure: AzureSettings;
meta?: EnvironmentMetadata;
}
export async function createAzureEnvironment({
name,
azure,
meta = { tagIds: [] },
}: CreateAzureEnvironment) {
return createEnvironment(name, EnvironmentCreationTypes.AzureEnvironment, {
meta,
azure,
});
}
interface TLSSettings {
@ -77,47 +97,93 @@ interface TLSSettings {
keyFile?: File;
}
interface AzureSettings {
applicationId: string;
tenantId: string;
authenticationKey: string;
}
interface EndpointOptions {
export interface EnvironmentOptions {
url?: string;
publicUrl?: string;
groupId?: EnvironmentGroupId;
tagIds?: TagId[];
meta?: EnvironmentMetadata;
checkinInterval?: number;
azure?: AzureSettings;
tls?: TLSSettings;
isEdgeDevice?: boolean;
}
export async function createRemoteEndpoint(
name: string,
creationType: EnvironmentCreationTypes,
options?: EndpointOptions
) {
let endpointUrl = options?.url;
if (creationType !== EnvironmentCreationTypes.EdgeAgentEnvironment) {
endpointUrl = `tcp://${endpointUrl}`;
}
try {
return await createEndpoint(name, creationType, {
...options,
url: endpointUrl,
});
} catch (err) {
throw new PortainerError('Unable to create environment', err as Error);
}
interface CreateRemoteEnvironment {
name: string;
creationType: Exclude<
EnvironmentCreationTypes,
EnvironmentCreationTypes.EdgeAgentEnvironment
>;
url: string;
options?: Omit<EnvironmentOptions, 'url'>;
}
async function createEndpoint(
export async function createRemoteEnvironment({
creationType,
name,
url,
options = {},
}: CreateRemoteEnvironment) {
return createEnvironment(name, creationType, {
...options,
url: `tcp://${url}`,
});
}
export interface CreateAgentEnvironmentValues {
name: string;
environmentUrl: string;
meta: EnvironmentMetadata;
}
export function createAgentEnvironment({
name,
environmentUrl,
meta = { tagIds: [] },
}: CreateAgentEnvironmentValues) {
return createRemoteEnvironment({
name,
url: environmentUrl,
creationType: EnvironmentCreationTypes.AgentEnvironment,
options: {
meta,
tls: {
skipVerify: true,
skipClientVerify: true,
},
},
});
}
interface CreateEdgeAgentEnvironment {
name: string;
portainerUrl: string;
meta?: EnvironmentMetadata;
pollFrequency: number;
}
export function createEdgeAgentEnvironment({
name,
portainerUrl,
meta = { tagIds: [] },
}: CreateEdgeAgentEnvironment) {
return createEnvironment(
name,
EnvironmentCreationTypes.EdgeAgentEnvironment,
{
url: portainerUrl,
...meta,
tls: {
skipVerify: true,
skipClientVerify: true,
},
}
);
}
async function createEnvironment(
name: string,
creationType: EnvironmentCreationTypes,
options?: EndpointOptions
options?: EnvironmentOptions
) {
let payload: Record<string, unknown> = {
Name: name,
@ -125,12 +191,14 @@ async function createEndpoint(
};
if (options) {
const { groupId, tagIds = [] } = options.meta || {};
payload = {
...payload,
URL: options.url,
PublicURL: options.publicUrl,
GroupID: options.groupId,
TagIds: arrayToJson(options.tagIds),
GroupID: groupId,
TagIds: arrayToJson(tagIds),
CheckinInterval: options.checkinInterval,
IsEdgeDevice: options.isEdgeDevice,
};
@ -161,12 +229,9 @@ async function createEndpoint(
const formPayload = json2formData(payload);
try {
const { data: endpoint } = await axios.post<Environment>(
buildUrl(),
formPayload
);
const { data } = await axios.post<Environment>(buildUrl(), formPayload);
return endpoint;
return data;
} catch (e) {
throw parseAxiosError(e as Error);
}

View File

@ -25,6 +25,7 @@ export interface EnvironmentsQueryParams {
sort?: string;
order?: 'asc' | 'desc';
edgeDeviceFilter?: 'all' | 'trusted' | 'untrusted' | 'none';
name?: string;
}
export async function getEndpoints(

View File

@ -1,56 +0,0 @@
import { useQuery } from 'react-query';
import {
EnvironmentsQueryParams,
getEndpoints,
} from '@/portainer/environments/environment.service';
import { EnvironmentStatus } from '@/portainer/environments/types';
import { error as notifyError } from '@/portainer/services/notifications';
const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
interface Query extends EnvironmentsQueryParams {
page?: number;
pageLimit?: number;
}
export function useEnvironmentList(
{ page = 1, pageLimit = 100, ...query }: Query = {},
refetchOffline = false,
refreshRate = 0
) {
const { isLoading, data } = useQuery(
['environments', { page, pageLimit, ...query }],
async () => {
const start = (page - 1) * pageLimit + 1;
return getEndpoints(start, pageLimit, query);
},
{
keepPreviousData: true,
refetchInterval: (data) => {
if (refreshRate) {
return refreshRate;
}
if (!data || !refetchOffline) {
return false;
}
const hasOfflineEnvironment = data.value.some(
(env) => env.Status === EnvironmentStatus.Down
);
return hasOfflineEnvironment && ENVIRONMENTS_POLLING_INTERVAL;
},
onError(error) {
notifyError('Failed loading environments', error as Error);
},
}
);
return {
isLoading,
environments: data ? data.value : [],
totalCount: data ? data.totalCount : 0,
totalAvailable: data ? data.totalAvailable : 0,
};
}

View File

@ -0,0 +1,17 @@
import { useStatus } from '@/portainer/services/api/status.service';
import { useSettings } from '@/portainer/settings/queries';
export function useAgentDetails() {
const settingsQuery = useSettings((settings) => settings.AgentSecret);
const versionQuery = useStatus((status) => status.Version);
if (!versionQuery.isSuccess || !settingsQuery.isSuccess) {
return null;
}
const agentVersion = versionQuery.data;
const agentSecret = settingsQuery.data;
return { agentVersion, agentSecret };
}

View File

@ -0,0 +1,61 @@
import { useQueryClient, useMutation, MutationFunction } from 'react-query';
import {
createRemoteEnvironment,
createLocalDockerEnvironment,
createAzureEnvironment,
createAgentEnvironment,
createEdgeAgentEnvironment,
createLocalKubernetesEnvironment,
} from '../environment.service/create';
export function useCreateAzureEnvironmentMutation() {
return useGenericCreationMutation(createAzureEnvironment);
}
export function useCreateLocalDockerEnvironmentMutation() {
return useGenericCreationMutation(createLocalDockerEnvironment);
}
export function useCreateLocalKubernetesEnvironmentMutation() {
return useGenericCreationMutation(createLocalKubernetesEnvironment);
}
export function useCreateRemoteEnvironmentMutation(
creationType: Parameters<typeof createRemoteEnvironment>[0]['creationType']
) {
return useGenericCreationMutation(
(
params: Omit<
Parameters<typeof createRemoteEnvironment>[0],
'creationType'
>
) => createRemoteEnvironment({ creationType, ...params })
);
}
export function useCreateAgentEnvironmentMutation() {
return useGenericCreationMutation(createAgentEnvironment);
}
export function useCreateEdgeAgentEnvironmentMutation() {
return useGenericCreationMutation(createEdgeAgentEnvironment);
}
function useGenericCreationMutation<TData = unknown, TVariables = void>(
mutation: MutationFunction<TData, TVariables>
) {
const queryClient = useQueryClient();
return useMutation(mutation, {
onSuccess() {
return queryClient.invalidateQueries(['environments']);
},
meta: {
error: {
title: 'Failure',
message: 'Unable to create environment',
},
},
});
}

View File

@ -0,0 +1,70 @@
import { useQuery } from 'react-query';
import { withError } from '@/react-tools/react-query';
import { EnvironmentStatus } from '../types';
import { EnvironmentsQueryParams, getEndpoints } from '../environment.service';
export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
interface Query extends EnvironmentsQueryParams {
page?: number;
pageLimit?: number;
}
type GetEndpointsResponse = Awaited<ReturnType<typeof getEndpoints>>;
export function refetchIfAnyOffline(data?: GetEndpointsResponse) {
if (!data) {
return false;
}
const hasOfflineEnvironment = data.value.some(
(env) => env.Status === EnvironmentStatus.Down
);
if (!hasOfflineEnvironment) {
return false;
}
return ENVIRONMENTS_POLLING_INTERVAL;
}
export function useEnvironmentList(
{ page = 1, pageLimit = 100, ...query }: Query = {},
refetchInterval?:
| number
| false
| ((data?: GetEndpointsResponse) => false | number),
staleTime = 0,
enabled = true
) {
const { isLoading, data } = useQuery(
[
'environments',
{
page,
pageLimit,
...query,
},
],
async () => {
const start = (page - 1) * pageLimit + 1;
return getEndpoints(start, pageLimit, query);
},
{
staleTime,
keepPreviousData: true,
refetchInterval,
enabled,
...withError('Failure retrieving environments'),
}
);
return {
isLoading,
environments: data ? data.value : [],
totalCount: data ? data.totalCount : 0,
totalAvailable: data ? data.totalAvailable : 0,
};
}

View File

@ -32,6 +32,10 @@ export function isEdgeEnvironment(envType: EnvironmentType) {
].includes(envType);
}
export function isUnassociatedEdgeEnvironment(env: Environment) {
return isEdgeEnvironment(env.Type) && !env.EdgeID;
}
export function getRoute(environment: Environment) {
if (isEdgeEnvironment(environment.Type) && !environment.EdgeID) {
return 'portainer.endpoints.endpoint';

View File

@ -0,0 +1,43 @@
import { mixed } from 'yup';
import { MixedSchema } from 'yup/lib/mixed';
type FileSchema = MixedSchema<File | undefined>;
export function file(): FileSchema {
return mixed();
}
export function withFileSize(fileValidation: FileSchema, maxSize: number) {
return fileValidation.test(
'fileSize',
'Selected file is too big.',
validateFileSize
);
function validateFileSize(file?: File) {
if (!file) {
return true;
}
return file.size <= maxSize;
}
}
export function withFileType(
fileValidation: FileSchema,
fileTypes: File['type'][]
) {
return fileValidation.test(
'file-type',
'Selected file has unsupported format.',
validateFileType
);
function validateFileType(file?: File) {
if (!file) {
return true;
}
return fileTypes.includes(file.type);
}
}

View File

@ -29,6 +29,7 @@ function renderComponent(
lastCheckInDate={lastCheckInDate}
checkInInterval={checkInInterval}
queryDate={queryDate}
showLastCheckInDate
/>
);
}

View File

@ -7,6 +7,7 @@ interface Props {
edgeId?: string;
queryDate?: number;
lastCheckInDate?: number;
showLastCheckInDate?: boolean;
}
export function EdgeIndicator({
@ -14,6 +15,7 @@ export function EdgeIndicator({
lastCheckInDate,
checkInInterval,
queryDate,
showLastCheckInDate = false,
}: Props) {
if (!edgeId) {
return (
@ -41,7 +43,7 @@ export function EdgeIndicator({
heartbeat
</span>
{!!lastCheckInDate && (
{showLastCheckInDate && !!lastCheckInDate && (
<span
className="space-left small text-muted"
aria-label="edge-last-checkin"

View File

@ -1 +1,2 @@
export { EnvironmentItem } from './EnvironmentItem';
export { EdgeIndicator } from './EdgeIndicator';

View File

@ -27,7 +27,10 @@ import {
} from '@/portainer/components/datatables/components';
import { TableFooter } from '@/portainer/components/datatables/components/TableFooter';
import { useDebounce } from '@/portainer/hooks/useDebounce';
import { useEnvironmentList } from '@/portainer/environments/queries';
import {
refetchIfAnyOffline,
useEnvironmentList,
} from '@/portainer/environments/queries/useEnvironmentList';
import { useGroups } from '@/portainer/environment-groups/queries';
import { useTags } from '@/portainer/tags/queries';
import { Filter } from '@/portainer/home/types';
@ -135,7 +138,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
edgeDeviceFilter: 'none',
tagsPartialMatch: true,
},
true
refetchIfAnyOffline
);
useEffect(() => {

View File

@ -1,6 +1,6 @@
import angular from 'angular';
export const componentsModule = angular.module(
'portainer.docker.react.components',
'portainer.app.react.components',
[]
).name;

View File

@ -3,7 +3,7 @@ import angular from 'angular';
import { componentsModule } from './components';
import { viewsModule } from './views';
export const reactModule = angular.module('portainer.docker.react', [
export const reactModule = angular.module('portainer.app.react', [
viewsModule,
componentsModule,
]).name;

View File

@ -1,6 +1,7 @@
import angular from 'angular';
export const viewsModule = angular.module(
'portainer.docker.react.views',
[]
).name;
import { wizardModule } from './wizard';
export const viewsModule = angular.module('portainer.app.react.views', [
wizardModule,
]).name;

View File

@ -0,0 +1,57 @@
import angular from 'angular';
import { StateRegistry } from '@uirouter/angularjs';
import { r2a } from '@/react-tools/react2angular';
import {
EnvironmentCreationView,
EnvironmentTypeSelectView,
HomeView,
} from '@/react/portainer/environments/wizard';
export const wizardModule = angular
.module('portainer.app.react.views.wizard', [])
.component('wizardEnvironmentCreationView', r2a(EnvironmentCreationView, []))
.component(
'wizardEnvironmentTypeSelectView',
r2a(EnvironmentTypeSelectView, [])
)
.component('wizardMainView', r2a(HomeView, []))
.config(config).name;
function config($stateRegistryProvider: StateRegistry) {
$stateRegistryProvider.register({
name: 'portainer.wizard',
url: '/wizard',
views: {
'content@': {
component: 'wizardMainView',
},
},
});
$stateRegistryProvider.register({
name: 'portainer.wizard.endpoints.create',
url: '/create?envType',
views: {
'content@': {
component: 'wizardEnvironmentCreationView',
},
},
params: {
envType: '',
},
});
$stateRegistryProvider.register({
name: 'portainer.wizard.endpoints',
url: '/endpoints',
views: {
'content@': {
component: 'wizardEnvironmentTypeSelectView',
},
},
params: {
localEndpointId: 0,
},
});
}

View File

@ -213,6 +213,7 @@ export function SettingsOpenAMT({ settings, onSubmit }: Props) {
errors={errors.certFileContent}
>
<FileUploadField
inputId="certificate_file"
title="Upload file"
accept=".pfx"
value={certFile}

View File

@ -1,18 +1,37 @@
import { useQuery } from 'react-query';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { error as notifyError } from '@/portainer/services/notifications';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { getTags } from './tags.service';
import { Tag } from './types';
import { createTag, getTags } from './tags.service';
import { Tag, TagId } from './types';
const tagKeys = {
all: ['tags'] as const,
tag: (id: TagId) => [...tagKeys.all, id] as const,
};
export function useTags<T = Tag>(select?: (tags: Tag[]) => T[]) {
const { data, isLoading } = useQuery('tags', () => getTags(), {
const { data, isLoading } = useQuery(tagKeys.all, () => getTags(), {
staleTime: 50,
select,
onError(error) {
notifyError('Failed loading tags', error as Error);
},
...withError('Failed to retrieve tags'),
});
return { tags: data, isLoading };
}
export function useCreateTagMutation() {
const queryClient = useQueryClient();
return useMutation(
createTag,
mutationOptions(
withError('Unable to create tag'),
withInvalidate(queryClient, [tagKeys.all])
)
);
}

View File

@ -1,7 +0,0 @@
import angular from 'angular';
import controller from './wizard-view.controller.js';
angular.module('portainer.app').component('wizardView', {
templateUrl: './wizard-view.html',
controller,
});

View File

@ -1,8 +0,0 @@
import angular from 'angular';
import controller from './wizard-endpoints.controller.js';
import './wizard-endpoints.css';
angular.module('portainer.app').component('wizardEndpoints', {
templateUrl: './wizard-endpoints.html',
controller,
});

View File

@ -1,11 +0,0 @@
import angular from 'angular';
import controller from './wizard-aci.controller.js';
angular.module('portainer.app').component('wizardAci', {
templateUrl: './wizard-aci.html',
controller,
bindings: {
onUpdate: '<',
onAnalytics: '<',
},
});

View File

@ -1,65 +0,0 @@
import { buildOption } from '@/portainer/components/BoxSelector';
export default class WizardAciController {
/* @ngInject */
constructor($async, EndpointService, Notifications, NameValidator) {
this.$async = $async;
this.EndpointService = EndpointService;
this.Notifications = Notifications;
this.NameValidator = NameValidator;
this.state = {
actionInProgress: false,
endpointType: 'api',
availableOptions: [buildOption('API', 'fa fa-bolt', 'API', '', 'api')],
};
this.formValues = {
name: '',
azureApplicationId: '',
azureTenantId: '',
azureAuthenticationKey: '',
};
this.onChangeEndpointType = this.onChangeEndpointType.bind(this);
}
onChangeEndpointType(endpointType) {
this.state.endpointType = endpointType;
}
addAciEndpoint() {
return this.$async(async () => {
const { name, azureApplicationId, azureTenantId, azureAuthenticationKey } = this.formValues;
const groupId = 1;
const tagIds = [];
try {
this.state.actionInProgress = true;
// Check name is duplicated or not
let nameUsed = await this.NameValidator.validateEnvironmentName(name);
if (nameUsed) {
this.Notifications.error('Failure', null, 'This name is been used, please try another one');
return;
}
await this.EndpointService.createAzureEndpoint(name, azureApplicationId, azureTenantId, azureAuthenticationKey, groupId, tagIds);
this.Notifications.success('Environment connected', name);
this.clearForm();
this.onUpdate();
this.onAnalytics('aci-api');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to connect your environment');
} finally {
this.state.actionInProgress = false;
}
});
}
clearForm() {
this.formValues = {
name: '',
azureApplicationId: '',
azureTenantId: '',
azureAuthenticationKey: '',
};
}
}

View File

@ -1,67 +0,0 @@
<form class="form-horizontal" name="aciWizardForm">
<box-selector radio-name="'ACI'" value="$ctrl.state.endpointType" options="$ctrl.state.availableOptions" on-change="($ctrl.onChangeEndpointType)"></box-selector>
<!-- docker form section-->
<div class="form-group wizard-form">
<label for="acir_name" class="col-sm-3 col-lg-2 control-label text-left">Name<span class="wizard-form-required">*</span></label>
<div class="col-sm-9 col-lg-10" style="margin-bottom: 15px">
<input type="text" class="form-control" name="aci_name" ng-model="$ctrl.formValues.name" placeholder="e.g. docker-prod01 / kubernetes-cluster01" required auto-focus />
</div>
</div>
<div class="form-group">
<label for="azure_credential_appid" class="col-sm-3 col-lg-2 control-label text-left">Application ID:<span class="wizard-form-required">*</span></label>
<div class="col-sm-9 col-lg-10" style="margin-bottom: 15px">
<input
type="text"
class="form-control"
name="azure_credential_appid"
ng-model="$ctrl.formValues.azureApplicationId"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
required
/>
</div>
</div>
<div class="form-group">
<label for="azure_credential_tenantid" class="col-sm-3 col-lg-2 control-label text-left">Tenant ID:<span class="wizard-form-required">*</span></label>
<div class="col-sm-9 col-lg-10" style="margin-bottom: 15px">
<input
type="text"
class="form-control"
name="azure_credential_tenantid"
ng-model="$ctrl.formValues.azureTenantId"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
required
/>
</div>
</div>
<div class="form-group">
<label for="azure_credential_authkey" class="col-sm-3 col-lg-2 control-label text-left">Authentication key<span class="wizard-form-required">*</span></label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
name="azure_credential_authkey"
ng-model="$ctrl.formValues.azureAuthenticationKey"
placeholder="cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk="
required
/>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<button
type="submit"
class="btn btn-primary btn-sm wizard-connect-button"
ng-disabled="!$ctrl.formValues.name || !$ctrl.formValues.azureApplicationId || !$ctrl.formValues.azureTenantId || !$ctrl.formValues.azureAuthenticationKey || $ctrl.state.actionInProgress"
ng-click="$ctrl.addAciEndpoint()"
button-spinner="$ctrl.state.actionInProgress"
>
<span ng-hide="$ctrl.state.actionInProgress"><i class="fa fa-plug" style="margin-right: 5px"></i> Connect </span>
<span ng-show="$ctrl.state.actionInProgress">Connecting environment...</span>
</button>
</div>
</div>
</form>

View File

@ -1,11 +0,0 @@
import angular from 'angular';
import controller from './wizard-docker.controller.js';
angular.module('portainer.app').component('wizardDocker', {
templateUrl: './wizard-docker.html',
controller,
bindings: {
onUpdate: '<',
onAnalytics: '<',
},
});

View File

@ -1,237 +0,0 @@
import { PortainerEndpointCreationTypes } from 'Portainer/models/endpoint/models';
import { EndpointSecurityFormData } from 'Portainer/components/endpointSecurity/porEndpointSecurityModel';
import { getAgentShortVersion } from 'Portainer/views/endpoints/helpers';
import { buildOption } from '@/portainer/components/BoxSelector';
export default class WizardDockerController {
/* @ngInject */
constructor($async, $scope, EndpointService, StateManager, Notifications, clipboard, $filter, NameValidator) {
this.$async = $async;
this.$scope = $scope;
this.EndpointService = EndpointService;
this.StateManager = StateManager;
this.Notifications = Notifications;
this.clipboard = clipboard;
this.$filter = $filter;
this.NameValidator = NameValidator;
this.state = {
endpointType: 'agent',
ConnectSocket: false,
actionInProgress: false,
endpoints: [],
availableOptions: [
buildOption('Agent', 'fa fa-bolt', 'Agent', '', 'agent'),
buildOption('API', 'fa fa-cloud', 'API', '', 'api'),
buildOption('Socket', 'fab fa-docker', 'Socket', '', 'socket'),
],
};
this.formValues = {
name: '',
url: '',
publicURL: '',
groupId: 1,
tagIds: [],
environmentUrl: '',
dockerApiurl: '',
socketPath: '',
overrideSocket: false,
skipCertification: false,
tls: false,
securityFormData: new EndpointSecurityFormData(),
};
this.command = {};
this.onChangeEndpointType = this.onChangeEndpointType.bind(this);
this.onToggleSkipCert = this.onToggleSkipCert.bind(this);
this.onToggleTls = this.onToggleTls.bind(this);
}
onChangeEndpointType(endpointType) {
this.state.endpointType = endpointType;
}
onToggleTls(checked) {
this.$scope.$evalAsync(() => {
this.formValues.tls = checked;
});
}
onToggleSkipCert(checked) {
this.$scope.$evalAsync(() => {
this.formValues.skipCertification = checked;
});
}
copyLinuxCommand() {
this.clipboard.copyText(this.command.linuxCommand);
$('#linuxCommandNotification').show().fadeOut(2500);
}
copyWinCommand() {
this.clipboard.copyText(this.command.winCommand);
$('#winCommandNotification').show().fadeOut(2500);
}
copyLinuxSocket() {
this.clipboard.copyText(this.command.linuxSocket);
$('#linuxSocketNotification').show().fadeOut(2500);
}
copyWinSocket() {
this.clipboard.copyText(this.command.winSocket);
$('#winSocketNotification').show().fadeOut(2500);
}
onChangeFile(file) {
this.formValues.securityFormData = file;
}
// connect docker environment
connectEnvironment(type) {
return this.$async(async () => {
const name = this.formValues.name;
const url = this.$filter('stripprotocol')(this.formValues.url);
const publicUrl = url.split(':')[0];
const overrideUrl = this.formValues.socketPath;
const groupId = this.formValues.groupId;
const tagIds = this.formValues.tagIds;
const securityData = this.formValues.securityFormData;
const socketUrl = this.formValues.overrideSocket ? overrideUrl : url;
var creationType = null;
if (type === 'agent') {
creationType = PortainerEndpointCreationTypes.AgentEnvironment;
}
if (type === 'api') {
creationType = PortainerEndpointCreationTypes.LocalDockerEnvironment;
}
// Check name is duplicated or not
const nameUsed = await this.NameValidator.validateEnvironmentName(name);
if (nameUsed) {
this.Notifications.error('Failure', null, 'This name is been used, please try another one');
return;
}
switch (type) {
case 'agent':
await this.addDockerAgentEndpoint(name, creationType, url, publicUrl, groupId, tagIds);
break;
case 'api':
await this.addDockerApiEndpoint(name, creationType, url, publicUrl, groupId, tagIds, securityData);
break;
case 'socket':
await this.addDockerLocalEndpoint(name, socketUrl, publicUrl, groupId, tagIds);
break;
}
});
}
// Docker Agent Endpoint
async addDockerAgentEndpoint(name, creationType, url, publicUrl, groupId, tagIds) {
const tsl = true;
const tlsSkipVerify = true;
const tlsSkipClientVerify = true;
const tlsCaFile = null;
const tlsCertFile = null;
const tlsKeyFile = null;
await this.addRemoteEndpoint(name, creationType, url, publicUrl, groupId, tagIds, tsl, tlsSkipVerify, tlsSkipClientVerify, tlsCaFile, tlsCertFile, tlsKeyFile);
}
// Docker Api Endpoint
async addDockerApiEndpoint(name, creationType, url, publicUrl, groupId, tagIds, securityData) {
const tsl = this.formValues.tls;
const tlsSkipVerify = this.formValues.skipCertification;
const tlsSkipClientVerify = this.formValues.skipCertification;
const tlsCaFile = tlsSkipVerify ? null : securityData.TLSCACert;
const tlsCertFile = tlsSkipClientVerify ? null : securityData.TLSCert;
const tlsKeyFile = tlsSkipClientVerify ? null : securityData.TLSKey;
await this.addRemoteEndpoint(name, creationType, url, publicUrl, groupId, tagIds, tsl, tlsSkipVerify, tlsSkipClientVerify, tlsCaFile, tlsCertFile, tlsKeyFile);
}
async addDockerLocalEndpoint(name, url, publicUrl, groupId, tagIds) {
this.state.actionInProgress = true;
try {
await this.EndpointService.createLocalEndpoint(name, url, publicUrl, groupId, tagIds);
this.Notifications.success('Environment connected', name);
this.clearForm();
this.onUpdate();
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to connect your environment');
} finally {
this.state.actionInProgress = false;
}
}
async addRemoteEndpoint(name, creationType, url, publicURL, groupId, tagIds, TLS, tlsSkipVerify, tlsSkipClientVerify, tlsCaFile, tlsCertFile, tlsKeyFile) {
this.state.actionInProgress = true;
try {
await this.EndpointService.createRemoteEndpoint(
name,
creationType,
url,
publicURL,
groupId,
tagIds,
TLS,
tlsSkipVerify,
tlsSkipClientVerify,
tlsCaFile,
tlsCertFile,
tlsKeyFile
);
this.Notifications.success('Environment connected', name);
this.clearForm();
this.onUpdate();
if (creationType === PortainerEndpointCreationTypes.AgentEnvironment) {
this.onAnalytics('docker-agent');
}
if (creationType === PortainerEndpointCreationTypes.LocalDockerEnvironment) {
this.onAnalytics('docker-api');
}
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to connect your environment');
} finally {
this.state.actionInProgress = false;
}
}
clearForm() {
this.formValues = {
name: '',
url: '',
publicURL: '',
groupId: 1,
tagIds: [],
environmentUrl: '',
dockerApiurl: '',
socketPath: '',
overrodeSocket: false,
skipCertification: false,
tls: false,
securityFormData: new EndpointSecurityFormData(),
};
}
$onInit() {
return this.$async(async () => {
const agentVersion = this.StateManager.getState().application.version;
const agentShortVersion = getAgentShortVersion(agentVersion);
this.command = {
linuxCommand: `curl -L https://downloads.portainer.io/ce${agentShortVersion}/agent-stack.yml -o agent-stack.yml && docker stack deploy --compose-file=agent-stack.yml portainer-agent `,
winCommand: `curl -L https://downloads.portainer.io/ce${agentShortVersion}/agent-stack-windows.yml -o agent-stack-windows.yml && docker stack deploy --compose-file=agent-stack-windows.yml portainer-agent `,
linuxSocket: `-v "/var/run/docker.sock:/var/run/docker.sock" `,
winSocket: `-v \.\pipe\docker_engine:\.\pipe\docker_engine `,
};
});
}
}

View File

@ -1,177 +0,0 @@
<form class="form-horizontal" name="dockerWizardForm">
<!-- docker tab selection -->
<box-selector
radio-name="'Docker'"
ng-click="$ctrl.clearForm()"
value="$ctrl.state.endpointType"
options="$ctrl.state.availableOptions"
on-change="($ctrl.onChangeEndpointType)"
></box-selector>
<!-- docker tab selection -->
<div style="padding-left: 10px">
<div class="form-group">
<div ng-if="$ctrl.state.endpointType === 'agent'" class="wizard-code">
<uib-tabset>
<uib-tab index="0" heading="Linux">
<code style="display: block; white-space: pre-wrap; padding: 16px 10px"
><h6 style="color: #000">CLI script for installing agent on your Linux environment with Docker Swarm</h6>{{ $ctrl.command.linuxCommand
}}<i class="fas fa-copy wizard-copy-button" ng-click="$ctrl.copyLinuxCommand()"></i
><i id="linuxCommandNotification" class="fa fa-check green-icon" aria-hidden="true" style="margin-left: 7px; display: none"></i
></code>
</uib-tab>
<uib-tab index="1" heading="Windows">
<code style="display: block; white-space: pre-wrap; padding: 16px 10px"
><h6 style="color: #000">CLI script for installing agent on your Windows environment with Docker Swarm</h6>{{ $ctrl.command.winCommand
}}<i class="fas fa-copy wizard-copy-button" ng-click="$ctrl.copyWinCommand()"></i
><i id="winCommandNotification" class="fa fa-check green-icon" aria-hidden="true" style="margin-left: 7px; display: none"></i
></code>
</uib-tab>
</uib-tabset>
</div>
<div ng-if="$ctrl.state.endpointType === 'api' || $ctrl.state.endpointType === 'socket'" class="wizard-code">
<uib-tabset active="state.deploymentTab">
<uib-tab index="0" heading="Linux">
<code style="display: block; white-space: pre-wrap; padding: 16px 10px"
><h6 style="color: #000">When using the socket, ensure that you have started the Portainer container with the following Docker flag on Linux</h6
>{{ $ctrl.command.linuxSocket }}<i class="fas fa-copy wizard-copy-button" ng-click="$ctrl.copyLinuxSocket()"></i
><i id="linuxSocketNotification" class="fa fa-check green-icon" aria-hidden="true" style="margin-left: 7px; display: none"></i
></code>
</uib-tab>
<uib-tab index="1" heading="Windows">
<code style="display: block; white-space: pre-wrap; padding: 16px 10px"
><h6 style="color: #000">When using the socket, ensure that you have started the Portainer container with the following Docker flag on Windows</h6
>{{ $ctrl.command.winSocket }}<i class="fas fa-copy wizard-copy-button" ng-click="$ctrl.copyWinSocket()"></i
><i id="winSocketNotification" class="fa fa-check green-icon" aria-hidden="true" style="margin-left: 7px; display: none"></i
></code>
</uib-tab>
</uib-tabset>
</div>
</div>
</div>
<!-- docker form section-->
<div class="form-group wizard-form">
<label for="endpoint_name" class="col-sm-3 col-lg-2 control-label text-left">Name<span class="wizard-form-required">*</span></label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" name="endpoint_name" ng-model="$ctrl.formValues.name" placeholder="e.g. docker-prod01 / kubernetes-cluster01" auto-focus />
</div>
</div>
<div ng-if="$ctrl.state.endpointType === 'agent'" class="form-group">
<label for="endpoint_url" class="col-sm-3 col-lg-2 control-label text-left"> Environments URL<span class="wizard-form-required">*</span> </label>
<div class="col-sm-9 col-lg-10" style="margin-bottom: 15px">
<input
ng-if="$ctrl.state.endpointType === 'agent'"
type="text"
class="form-control"
name="endpoint_url"
ng-model="$ctrl.formValues.url"
placeholder="e.g. 10.0.0.10:9001 or tasks.portainer_agent:9001"
/>
</div>
</div>
<div ng-if="$ctrl.state.endpointType === 'api'">
<div class="form-group">
<label for="dockerapi_url" class="col-sm-3 col-lg-2 control-label text-left"> Docker API URL<span class="wizard-form-required">*</span> </label>
<div class="col-sm-9 col-lg-10" style="margin-bottom: 15px">
<input
ng-if="$ctrl.state.endpointType === 'api'"
type="text"
class="form-control"
name="dockerapi_url"
ng-model="$ctrl.formValues.url"
placeholder="e.g. 10.0.0.10:2375 or mydocker.mydomain.com:2375"
/>
</div>
</div>
<div class="form-group" style="padding-left: 15px; width: 15%">
<por-switch-field checked="$ctrl.formValues.tls" name="'connect_socket'" label="'TLS'" label-class="'col-sm-12 col-lg-4'" on-change="($ctrl.onToggleTls)"></por-switch-field>
</div>
<div class="form-group" style="padding-left: 15px; width: 40%">
<por-switch-field
ng-if="$ctrl.formValues.tls"
checked="$ctrl.formValues.skipCertification"
name="'skip_certification'"
label="'Skip Certification Verification'"
label-class="'col-sm-12 col-lg-4'"
on-change="($ctrl.onToggleSkipCert)"
></por-switch-field>
</div>
<div>
<wizard-tls ng-if="!$ctrl.formValues.skipCertification && $ctrl.formValues.tls" form-data="$ctrl.formValues.securityFormData" onChange="($ctrl.onChangeFile)"></wizard-tls>
</div>
</div>
<div ng-if="$ctrl.state.endpointType === 'socket'" class="form-group" style="padding-left: 15px">
<div class="form-group" style="padding-left: 15px">
<label for="override_socket" class="col-sm_12 control-label text-left"> Override default socket path </label>
<label class="switch" style="margin-left: 20px"> <input type="checkbox" ng-model="$ctrl.formValues.overrideSocket" /><i></i></label>
</div>
<div ng-if="$ctrl.formValues.overrideSocket">
<div class="form-group">
<label for="socket_path" class="col-sm-3 col-lg-2 control-label text-left">
Socket path
<portainer-tooltip position="bottom" message="Path to the Docker socket. Remember to bind-mount the socket, see the important notice above for more information.">
</portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
name="socket_path"
ng-model="$ctrl.formValues.socketPath"
placeholder="e.g. /var/run/docker.sock (on Linux) or //./pipe/docker_engine (on Windows)"
/>
</div>
</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<button
ng-if="$ctrl.state.endpointType === 'agent'"
type="submit"
class="btn btn-primary btn-sm wizard-connect-button"
ng-disabled="!$ctrl.formValues.name || !$ctrl.formValues.url || $ctrl.state.actionInProgress"
ng-click="$ctrl.connectEnvironment('agent')"
button-spinner="$ctrl.state.actionInProgress"
>
<span ng-hide="$ctrl.state.actionInProgress"><i class="fa fa-plug" style="margin-right: 5px"></i>Connect </span>
<span ng-show="$ctrl.state.actionInProgress">Connecting environment...</span>
</button>
<button
ng-if="$ctrl.state.endpointType === 'api'"
type="submit"
class="btn btn-primary btn-sm wizard-connect-button"
ng-disabled="!$ctrl.formValues.name || !$ctrl.formValues.url || $ctrl.state.actionInProgress"
ng-click="$ctrl.connectEnvironment('api')"
button-spinner="$ctrl.state.actionInProgress"
>
<span ng-hide="$ctrl.state.actionInProgress"><i class="fa fa-plug" style="margin-right: 5px"></i>Connect </span>
<span ng-show="$ctrl.state.actionInProgress">Connecting environment...</span>
</button>
<button
ng-if="$ctrl.state.endpointType === 'socket'"
type="submit"
class="btn btn-primary btn-sm wizard-connect-button"
ng-disabled="!$ctrl.formValues.name || $ctrl.state.actionInProgress"
ng-click="$ctrl.connectEnvironment('socket')"
button-spinner="$ctrl.state.actionInProgress"
>
<span ng-hide="$ctrl.state.actionInProgress"><i class="fa fa-plug" style="margin-right: 5px"></i>Connect </span>
<span ng-show="$ctrl.state.actionInProgress">Connecting environment...</span>
</button>
</div>
</div>
</form>

View File

@ -1,11 +0,0 @@
import angular from 'angular';
import controller from './wizard-kubernetes.controller.js';
angular.module('portainer.app').component('wizardKubernetes', {
templateUrl: './wizard-kubernetes.html',
controller,
bindings: {
onUpdate: '<',
onAnalytics: '<',
},
});

View File

@ -1,114 +0,0 @@
import { PortainerEndpointCreationTypes } from 'Portainer/models/endpoint/models';
import { getAgentShortVersion } from 'Portainer/views/endpoints/helpers';
import { buildOption } from '@/portainer/components/BoxSelector';
export default class WizardKubernetesController {
/* @ngInject */
constructor($async, EndpointService, StateManager, Notifications, $filter, clipboard, NameValidator) {
this.$async = $async;
this.EndpointService = EndpointService;
this.StateManager = StateManager;
this.Notifications = Notifications;
this.$filter = $filter;
this.clipboard = clipboard;
this.NameValidator = NameValidator;
this.state = {
endpointType: 'agent',
actionInProgress: false,
formValues: {
name: '',
url: '',
},
availableOptions: [buildOption('Agent', 'fa fa-bolt', 'Agent', '', 'agent')],
};
this.onChangeEndpointType = this.onChangeEndpointType.bind(this);
}
onChangeEndpointType(endpointType) {
this.state.endpointType = endpointType;
}
addKubernetesAgent() {
return this.$async(async () => {
const name = this.state.formValues.name;
const groupId = 1;
const tagIds = [];
const url = this.$filter('stripprotocol')(this.state.formValues.url);
const publicUrl = url.split(':')[0];
const creationType = PortainerEndpointCreationTypes.AgentEnvironment;
const tls = true;
const tlsSkipVerify = true;
const tlsSkipClientVerify = true;
const tlsCaFile = null;
const tlsCertFile = null;
const tlsKeyFile = null;
// Check name is duplicated or not
let nameUsed = await this.NameValidator.validateEnvironmentName(name);
if (nameUsed) {
this.Notifications.error('Failure', null, 'This name is been used, please try another one');
return;
}
await this.addRemoteEndpoint(name, creationType, url, publicUrl, groupId, tagIds, tls, tlsSkipVerify, tlsSkipClientVerify, tlsCaFile, tlsCertFile, tlsKeyFile);
});
}
async addRemoteEndpoint(name, creationType, url, publicURL, groupId, tagIds, tls, tlsSkipVerify, tlsSkipClientVerify, tlsCaFile, tlsCertFile, tlsKeyFile) {
this.state.actionInProgress = true;
try {
await this.EndpointService.createRemoteEndpoint(
name,
creationType,
url,
publicURL,
groupId,
tagIds,
tls,
tlsSkipVerify,
tlsSkipClientVerify,
tlsCaFile,
tlsCertFile,
tlsKeyFile
);
this.Notifications.success('Environment connected', name);
this.clearForm();
this.onUpdate();
this.onAnalytics('kubernetes-agent');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to conect your environment');
} finally {
this.state.actionInProgress = false;
}
}
copyLoadBalancer() {
this.clipboard.copyText(this.command.loadBalancer);
$('#loadBalancerNotification').show().fadeOut(2500);
}
copyNodePort() {
this.clipboard.copyText(this.command.nodePort);
$('#nodePortNotification').show().fadeOut(2500);
}
clearForm() {
this.state.formValues = {
name: '',
url: '',
};
}
$onInit() {
return this.$async(async () => {
const agentVersion = this.StateManager.getState().application.version;
const agentShortVersion = getAgentShortVersion(agentVersion);
this.command = {
loadBalancer: `curl -L https://downloads.portainer.io/ce${agentShortVersion}/portainer-agent-k8s-lb.yaml -o portainer-agent-k8s.yaml; kubectl apply -f portainer-agent-k8s.yaml `,
nodePort: `curl -L https://downloads.portainer.io/ce${agentShortVersion}/portainer-agent-k8s-nodeport.yaml -o portainer-agent-k8s.yaml; kubectl apply -f portainer-agent-k8s.yaml `,
};
});
}
}

View File

@ -1,65 +0,0 @@
<form class="form-horizontal" name="kubernetesWizardForm">
<box-selector radio-name="'Kubernetes'" value="$ctrl.state.endpointType" options="$ctrl.state.availableOptions" on-change="($ctrl.onChangeEndpointType)"></box-selector>
<!-- docker tab selection -->
<div style="padding-left: 10px">
<div class="form-group">
<div class="wizard-code">
<uib-tabset active="state.deploymentTab">
<uib-tab index="0" heading="Kubernetes via load balancer">
<code style="display: block; white-space: pre-wrap; padding: 16px 10px"
><h6 style="color: #000">CLI script for installing agent on your endpoint</h6>{{ $ctrl.command.loadBalancer
}}<i class="fas fa-copy wizard-copy-button" ng-click="$ctrl.copyLoadBalancer()"></i
><i id="loadBalancerNotification" class="fa fa-check green-icon" aria-hidden="true" style="margin-left: 7px; display: none"></i
></code>
</uib-tab>
<uib-tab index="1" heading="Kubernetes via node port">
<code style="display: block; white-space: pre-wrap; padding: 16px 10px"
><h6 style="color: #000">CLI script for installing agent on your endpoint</h6>{{ $ctrl.command.nodePort
}}<i class="fas fa-copy wizard-copy-button" ng-click="$ctrl.copyNodePort()"></i
><i id="nodePortNotification" class="fa fa-check green-icon" aria-hidden="true" style="margin-left: 7px; display: none"></i
></code>
</uib-tab>
</uib-tabset>
</div>
</div>
</div>
<div class="form-group wizard-form">
<label for="endpoint_name" class="col-sm-3 col-lg-2 control-label text-left">Name<span class="wizard-form-required">*</span></label>
<div class="col-sm-9 col-lg-10" style="margin-bottom: 15px">
<input
type="text"
class="form-control"
name="endpoint_name"
ng-model="$ctrl.state.formValues.name"
placeholder="e.g. docker-prod01 / kubernetes-cluster01"
required
auto-focus
/>
</div>
</div>
<div class="form-group">
<label for="endpoint_url" class="col-sm-3 col-lg-2 control-label text-left"> Environments URL<span class="wizard-form-required">*</span> </label>
<div class="col-sm-9 col-lg-10" style="margin-bottom: 15px">
<input type="text" class="form-control" name="endpoint_url" ng-model="$ctrl.state.formValues.url" placeholder="e.g. 10.0.0.10:9001 or tasks.portainer_agent:9001" required />
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<button
type="submit"
class="btn btn-primary btn-sm wizard-connect-button"
ng-disabled="!$ctrl.state.formValues.name || !$ctrl.state.formValues.url || $ctrl.state.actionInProgress"
ng-click="$ctrl.addKubernetesAgent()"
button-spinner="$ctrl.state.actionInProgress"
>
<span ng-hide="$ctrl.state.actionInProgress"><i class="fa fa-plug" style="margin-right: 5px"></i> Connect </span>
<span ng-show="$ctrl.state.actionInProgress">Connecting environment...</span>
</button>
</div>
</div>
</form>

View File

@ -1,9 +0,0 @@
import angular from 'angular';
import './wizard-endpoint-list.css';
angular.module('portainer.app').component('wizardEndpointList', {
templateUrl: './wizard-endpoint-list.html',
bindings: {
endpointList: '<',
},
});

View File

@ -1,11 +0,0 @@
<rd-widget>
<rd-widget-header icon="fa-plug" title-text="Connected Environments"> </rd-widget-header>
<rd-widget-body>
<div class="wizard-list-wrapper" ng-repeat="endpoint in $ctrl.endpointList">
<div class="wizard-list-image"><i ng-class="endpoint.Type | endpointtypeicon" aria-hidden="true" style="margin-right: 2px"></i></div>
<div class="wizard-list-title">{{ endpoint.Name }}</div>
<div class="wizard-list-subtitle">URL: {{ endpoint.URL | stripprotocol }}</div>
<div class="wizard-list-type">Type: {{ endpoint.Type | endpointtypename }}</div>
</div>
</rd-widget-body>
</rd-widget>

View File

@ -1,196 +0,0 @@
export default class WizardEndpointsController {
/* @ngInject */
constructor($async, $scope, $state, EndpointService, $analytics) {
this.$async = $async;
this.$scope = $scope;
this.$state = $state;
this.EndpointService = EndpointService;
this.$analytics = $analytics;
this.updateEndpoint = this.updateEndpoint.bind(this);
this.addAnalytics = this.addAnalytics.bind(this);
}
/**
* WIZARD ENDPOINT SECTION
*/
async updateEndpoint() {
const updateEndpoints = await this.EndpointService.endpoints();
this.endpoints = updateEndpoints.value;
}
startWizard() {
const options = this.state.options;
this.state.selections = options.filter((item) => item.selected === true);
this.state.maxStep = this.state.selections.length;
if (this.state.selections.length !== 0) {
this.state.section = this.state.selections[this.state.currentStep].endpoint;
this.state.selections[this.state.currentStep].stage = 'active';
}
if (this.state.currentStep === this.state.maxStep - 1) {
this.state.nextStep = 'Finish';
}
this.$analytics.eventTrack('endpoint-wizard-endpoint-select', {
category: 'portainer',
metadata: {
environment: this.state.analytics.docker + this.state.analytics.kubernetes + this.state.analytics.aci,
},
});
this.state.currentStep++;
}
previousStep() {
this.state.section = this.state.selections[this.state.currentStep - 2].endpoint;
this.state.selections[this.state.currentStep - 2].stage = 'active';
this.state.selections[this.state.currentStep - 1].stage = '';
this.state.nextStep = 'Next Step';
this.state.currentStep--;
}
async nextStep() {
if (this.state.currentStep >= this.state.maxStep - 1) {
this.state.nextStep = 'Finish';
}
if (this.state.currentStep === this.state.maxStep) {
// the Local Endpoint Counter from endpoints array due to including Local Endpoint been added Automatic before Wizard start
const endpointsAdded = await this.EndpointService.endpoints();
const endpointsArray = endpointsAdded.value;
const filter = endpointsArray.filter((item) => item.Type === 1 || item.Type === 5);
// NOTICE: This is the temporary fix for excluded docker api endpoint been counted as local endpoint
this.state.counter.localEndpoint = filter.length - this.state.counter.dockerApi;
this.$analytics.eventTrack('endpoint-wizard-environment-add-finish', {
category: 'portainer',
metadata: {
'docker-agent': this.state.counter.dockerAgent,
'docker-api': this.state.counter.dockerApi,
'kubernetes-agent': this.state.counter.kubernetesAgent,
'aci-api': this.state.counter.aciApi,
'local-endpoint': this.state.counter.localEndpoint,
},
});
this.$state.go('portainer.home');
} else {
this.state.section = this.state.selections[this.state.currentStep].endpoint;
this.state.selections[this.state.currentStep].stage = 'active';
this.state.selections[this.state.currentStep - 1].stage = 'completed';
this.state.currentStep++;
}
}
addAnalytics(endpoint) {
switch (endpoint) {
case 'docker-agent':
this.state.counter.dockerAgent++;
break;
case 'docker-api':
this.state.counter.dockerApi++;
break;
case 'kubernetes-agent':
this.state.counter.kubernetesAgent++;
break;
case 'aci-api':
this.state.counter.aciApi++;
break;
}
}
endpointSelect(endpoint) {
switch (endpoint) {
case 'docker':
if (this.state.options[0].selected) {
this.state.options[0].selected = false;
this.state.dockerActive = '';
this.state.analytics.docker = '';
} else {
this.state.options[0].selected = true;
this.state.dockerActive = 'wizard-active';
this.state.analytics.docker = 'Docker/';
}
break;
case 'kubernetes':
if (this.state.options[1].selected) {
this.state.options[1].selected = false;
this.state.kubernetesActive = '';
this.state.analytics.kubernetes = '';
} else {
this.state.options[1].selected = true;
this.state.kubernetesActive = 'wizard-active';
this.state.analytics.kubernetes = 'Kubernetes/';
}
break;
case 'aci':
if (this.state.options[2].selected) {
this.state.options[2].selected = false;
this.state.aciActive = '';
this.state.analytics.aci = '';
} else {
this.state.options[2].selected = true;
this.state.aciActive = 'wizard-active';
this.state.analytics.aci = 'ACI';
}
break;
}
const options = this.state.options;
this.state.selections = options.filter((item) => item.selected === true);
}
$onInit() {
return this.$async(async () => {
(this.state = {
currentStep: 0,
section: '',
dockerActive: '',
kubernetesActive: '',
aciActive: '',
maxStep: '',
previousStep: 'Previous',
nextStep: 'Next Step',
selections: [],
analytics: {
docker: '',
kubernetes: '',
aci: '',
},
counter: {
dockerAgent: 0,
dockerApi: 0,
kubernetesAgent: 0,
aciApi: 0,
localEndpoint: 0,
},
options: [
{
endpoint: 'docker',
selected: false,
stage: '',
nameClass: 'docker',
icon: 'fab fa-docker',
},
{
endpoint: 'kubernetes',
selected: false,
stage: '',
nameClass: 'kubernetes',
icon: 'fas fa-dharmachakra',
},
{
endpoint: 'aci',
selected: false,
stage: '',
nameClass: 'aci',
icon: 'fab fa-microsoft',
},
],
selectOption: '',
}),
(this.endpoints = []);
const endpoints = await this.EndpointService.endpoints();
this.endpoints = endpoints.value;
});
}
}

View File

@ -1,155 +0,0 @@
.wizard-endpoints {
display: block;
width: 200px;
height: 300px;
border: 1px solid rgb(163, 163, 163);
border-radius: 5px;
float: left;
margin-right: 15px;
padding: 25px 20px;
cursor: pointer;
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 60%);
}
.wizard-endpoints:hover {
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 80%);
border: 1px solid #3ca4ff;
color: #337ab7;
}
.wizard-active:hover {
color: #fff;
}
.wizard-active {
background: #337ab7;
color: #fff;
border: 1px solid #3ca4ff;
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 80%);
}
.wizard-form-required {
color: rgb(255, 24, 24);
padding: 0px 5px;
}
.wizard-form {
margin-top: 40px;
}
.wizard-code {
margin-right: 15px;
}
.wizard-action {
margin-top: 20px;
}
.wizard-connect-button {
margin-left: 0px !important;
margin-top: 40px;
}
.wizard-copy-button {
color: #444;
cursor: pointer;
}
.wizard-step-action {
padding-top: 40px;
padding-bottom: 40px;
text-align: right;
border-top: 1px solid #777;
}
.next-btn {
float: right;
}
.previous-btn {
float: left;
margin-left: 0px !important;
}
.wizard-wrapper {
display: grid;
grid-template-columns: 1fr 400px;
grid-template-areas:
'main sidebar'
'footer sidebar';
}
.wizard-main {
grid-column: main;
}
.wizard-aside {
grid-column: sidebar;
margin-right: 15px;
}
.wizard-footer {
grid-column: footer;
}
.wizard-endpoint-section {
padding-right: 10px;
}
.wizard-main-title {
margin-bottom: 10px;
}
.wizard-env-section {
display: block;
padding: 10px;
border: 1px solid red;
width: 80%;
margin-left: auto;
margin-right: auto;
height: 600px;
text-align: center;
}
.wizard-env-icon {
margin-left: auto;
margin-right: auto;
}
.wizard-content-wrapper {
position: relative;
left: 50%;
}
.wizard-content {
float: left;
position: relative;
left: -50%;
}
.wizard-section {
display: grid;
justify-content: left;
align-content: left;
gap: 10px;
grid-auto-flow: column;
margin-bottom: 20px;
}
.wizard-section-title {
font-size: 32px;
margin-top: 30px;
margin-bottom: 15px;
}
.wizard-setion-subtitle {
font-size: 18px;
}
.wizard-section-action {
margin-top: 50px;
margin-bottom: 20px;
}
.no-margin {
margin-left: 0px;
}

View File

@ -1,94 +0,0 @@
<rd-header>
<rd-header-title title-text="Quick Setup"></rd-header-title>
<rd-header-content>Environment Wizard</rd-header-content>
</rd-header>
<div class="wizard-wrapper" ng-if="$ctrl.state.currentStep !== 0">
<div class="wizard-main">
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-magic" title-text="Environment Wizard"> </rd-widget-header>
<rd-widget-body>
<!-- Stepper -->
<wizard-stepper endpoint-selections="$ctrl.state.selections"></wizard-stepper>
<!-- Stepper -->
<div class="col-sm-12 form-section-title wizard-main-title"> Connect to your {{ $ctrl.state.section }} environment </div>
<div ng-switch="$ctrl.state.section" class="wizard-endpoint-section">
<wizard-docker ng-switch-when="docker" on-update="($ctrl.updateEndpoint)" on-analytics="($ctrl.addAnalytics)"></wizard-docker>
<wizard-aci ng-switch-when="aci" on-update="($ctrl.updateEndpoint)" on-analytics="($ctrl.addAnalytics)"></wizard-aci>
<wizard-kubernetes ng-switch-when="kubernetes" on-update="($ctrl.updateEndpoint)" on-analytics="($ctrl.addAnalytics)"></wizard-kubernetes>
</div>
<div class="wizard-step-action">
<button
ng-click="$ctrl.previousStep()"
ng-show="$ctrl.state.currentStep !== 0"
type="submit"
class="btn btn-primary btn-sm previous-btn"
ng-disabled="$ctrl.state.currentStep === 1"
>
<i class="fas fa-arrow-left space-right"></i>{{ $ctrl.state.previousStep }}
</button>
<button ng-click="$ctrl.nextStep()" ng-show="$ctrl.state.currentStep !== 0" type="submit" class="btn btn-primary btn-sm next-btn">
{{ $ctrl.state.nextStep }} <i class="fas fa-arrow-right space-left"></i
></button>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>
<div class="wizard-aside" ng-if="$ctrl.state.currentStep !== 0">
<wizard-endpoint-list endpoint-list="$ctrl.endpoints"></wizard-endpoint-list>
</div>
</div>
<div class="row" ng-if="$ctrl.state.currentStep === 0">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-magic" title-text="Environment Wizard"> </rd-widget-header>
<rd-widget-body>
<div class="row">
<div class="col-sm-12 form-section-title"> Select your environment(s) </div>
<div>
<span class="text-muted small">You can onboard different types of environments, select all that apply.</span>
</div>
<div class="wizard-section">
<wizard-endpoint-type
endpoint-title="Docker"
description="Connect to Docker Standalone / Swarm via URL/IP, API or Socket"
icon="fab fa-docker"
active="$ctrl.state.dockerActive"
ng-click="$ctrl.endpointSelect('docker')"
></wizard-endpoint-type>
<wizard-endpoint-type
endpoint-title="Kubernetes"
description="Connect to a kubernetes environment via URL/IP"
icon="fas fa-dharmachakra"
active="$ctrl.state.kubernetesActive"
ng-click="$ctrl.endpointSelect('kubernetes')"
></wizard-endpoint-type>
<wizard-endpoint-type
endpoint-title="ACI"
description="Connect to ACI environment via API"
icon="fab fa-microsoft"
active="$ctrl.state.aciActive"
ng-click="$ctrl.endpointSelect('aci')"
></wizard-endpoint-type>
</div>
<div class="wizard-section">
<div class="wizard-section-action">
<button ng-click="$ctrl.startWizard()" ng-disabled="$ctrl.state.selections.length === 0" type="submit" class="btn btn-primary btn-sm no-margin">Start Wizard</button>
</div>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -1,9 +0,0 @@
import angular from 'angular';
import './wizard-stepper.css';
angular.module('portainer.app').component('wizardStepper', {
templateUrl: './wizard-stepper.html',
bindings: {
endpointSelections: '<',
},
});

View File

@ -1,11 +0,0 @@
import angular from 'angular';
angular.module('portainer.app').component('wizardEndpointType', {
templateUrl: './wizard-endpoint-type.html',
bindings: {
endpointTitle: '@',
description: '@',
icon: '@',
active: '<',
},
});

View File

@ -1,12 +0,0 @@
<div>
<div ng-click="$ctrl.endpointSelect('docker')">
<div class="wizard-endpoints {{ $ctrl.active }}">
<div style="text-align: center; padding: 10px"><i class="{{ $ctrl.icon }}" style="font-size: 80px"></i></div>
<div style="margin-top: 15px; text-align: center">
<h3>{{ $ctrl.endpointTitle }}</h3>
<h5>{{ $ctrl.description }}</h5>
</div>
</div>
</div>
</div>

View File

@ -1,6 +0,0 @@
<div class="stepper-wrapper">
<div ng-repeat="selection in $ctrl.endpointSelections" class="stepper-item {{ selection.stage }} ">
<div class="step-counter">{{ $index + 1 }}</div>
<div class="step-name {{ selection.nameClass }}">{{ selection.endpoint }}</div>
</div>
</div>

View File

@ -1,11 +0,0 @@
import angular from 'angular';
import './wizard-link.css';
angular.module('portainer.app').component('wizardLink', {
templateUrl: './wizard-link.html',
bindings: {
linkTitle: '@',
description: '@',
icon: '<',
},
});

View File

@ -1,7 +0,0 @@
<div class="wizard-button">
<div style="text-align: center; padding: 10px"><i class="{{ $ctrl.icon }}" style="font-size: 80px"></i></div>
<div style="margin-top: 15px; text-align: center">
<h3>{{ $ctrl.linkTitle }}</h3>
<h5>{{ $ctrl.description }}</h5>
</div>
</div>

View File

@ -1,9 +0,0 @@
import angular from 'angular';
angular.module('portainer.app').component('wizardTls', {
templateUrl: './wizard-tls.html',
bindings: {
formData: '<',
onChange: '<',
},
});

View File

@ -1,49 +0,0 @@
<div>
<div>
<!-- tls-file-ca -->
<div class="form-group">
<label class="col-sm-3 col-lg-2 control-label text-left">TLS CA certificate</label>
<div class="col-sm-9 col-lg-10">
<button type="button" class="btn btn-sm btn-primary" ngf-select="$ctrl.onChange($file)" ng-model="$ctrl.formData.TLSCACert">Select file</button>
<span class="space-left">
{{ $ctrl.formData.TLSCACert.name }}
<i class="fa fa-check green-icon" ng-if="$ctrl.formData.TLSCACert" aria-hidden="true"></i>
<i class="fa fa-times red-icon" ng-if="!$ctrl.formData.TLSCACert" aria-hidden="true"></i>
</span>
</div>
</div>
<!-- !tls-file-ca -->
<!-- tls-files-cert-key -->
<div>
<!-- tls-file-cert -->
<div class="form-group">
<label for="tls_cert" class="col-sm-3 col-lg-2 control-label text-left">TLS certificate</label>
<div class="col-sm-9 col-lg-10">
<button type="button" class="btn btn-sm btn-primary" ngf-select="$ctrl.onChange($file)" ng-model="$ctrl.formData.TLSCert">Select file</button>
<span class="space-left">
{{ $ctrl.formData.TLSCert.name }}
<i class="fa fa-check green-icon" ng-if="$ctrl.formData.TLSCert" aria-hidden="true"></i>
<i class="fa fa-times red-icon" ng-if="!$ctrl.formData.TLSCert" aria-hidden="true"></i>
</span>
</div>
</div>
<!-- !tls-file-cert -->
<!-- tls-file-key -->
<div class="form-group">
<label class="col-sm-3 col-lg-2 control-label text-left">TLS key</label>
<div class="col-sm-9 col-lg-10">
<button type="button" class="btn btn-sm btn-primary" ngf-select="$ctrl.onChange($file)" ng-model="$ctrl.formData.TLSKey">Select file</button>
<span class="space-left">
{{ $ctrl.formData.TLSKey.name }}
<i class="fa fa-check green-icon" ng-if="$ctrl.formData.TLSKey" aria-hidden="true"></i>
<i class="fa fa-times red-icon" ng-if="!$ctrl.formData.TLSKey" aria-hidden="true"></i>
</span>
</div>
</div>
<!-- !tls-file-key -->
</div>
<!-- tls-files-cert-key -->
</div>
<!-- !tls-file-upload -->
</div>

View File

@ -1,87 +0,0 @@
import { PortainerEndpointCreationTypes } from 'Portainer/models/endpoint/models';
export default class WizardViewController {
/* @ngInject */
constructor($async, $state, EndpointService, $analytics) {
this.$async = $async;
this.$state = $state;
this.EndpointService = EndpointService;
this.$analytics = $analytics;
}
/**
* WIZARD APPLICATION
*/
manageLocalEndpoint() {
this.$state.go('portainer.home');
}
addRemoteEndpoint() {
this.$state.go('portainer.wizard.endpoints');
}
async createLocalKubernetesEndpoint() {
this.state.endpoint.loading = true;
try {
await this.EndpointService.createLocalKubernetesEndpoint();
this.state.endpoint.loading = false;
this.state.endpoint.added = true;
this.state.endpoint.connected = 'kubernetes';
this.state.local.icon = 'fas fa-dharmachakra';
} catch (err) {
this.state.endpoint.kubernetesError = true;
}
}
async createLocalDockerEndpoint() {
try {
await this.EndpointService.createLocalEndpoint();
this.state.endpoint.loading = false;
this.state.endpoint.added = true;
this.state.endpoint.connected = 'docker';
this.state.local.icon = 'fab fa-docker';
} finally {
this.state.endpoint.loading = false;
}
}
$onInit() {
return this.$async(async () => {
this.state = {
local: {
icon: '',
},
remote: {
icon: 'fa fa-plug',
},
endpoint: {
kubernetesError: false,
connected: '',
loading: false,
added: false,
},
};
const endpoints = await this.EndpointService.endpoints();
if (endpoints.totalCount === '0') {
await this.createLocalKubernetesEndpoint();
if (this.state.endpoint.kubernetesError) {
await this.createLocalDockerEndpoint();
}
} else {
const addedLocalEndpoint = endpoints.value[0];
if (addedLocalEndpoint.Type === PortainerEndpointCreationTypes.LocalDockerEnvironment) {
this.state.endpoint.added = true;
this.state.endpoint.connected = 'docker';
this.state.local.icon = 'fab fa-docker';
}
if (addedLocalEndpoint.Type === PortainerEndpointCreationTypes.LocalKubernetesEnvironment) {
this.state.endpoint.added = true;
this.state.endpoint.connected = 'kubernetes';
this.state.local.icon = 'fas fa-dharmachakra';
}
}
});
}
}

View File

@ -1,48 +0,0 @@
<rd-header>
<rd-header-title title-text="Quick Setup"></rd-header-title>
<rd-header-content>Environment Wizard</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-magic" title-text="Environment Wizard"> </rd-widget-header>
<rd-widget-body>
<div class="row">
<div class="col-sm-12 form-section-title"> Welcome to Portainer </div>
<div>
<span class="text-muted small" ng-show="$ctrl.state.endpoint.added">
We have connected your local environment of {{ $ctrl.state.endpoint.connected }} to Portainer. <br
/></span>
<span class="text-muted small" ng-show="!$ctrl.state.endpoint.loading && !$ctrl.state.endpoint.added">
We could not connect your local environment to Portainer. <br />
Please ensure your environment is correctly exposed. For help with installation vist
<a href="https://documentation.portainer.io/quickstart/">https://documentation.portainer.io/quickstart</a><br />
</span>
<span class="text-muted small"> Get started below with your local portainer or connect more container environments. </span>
</div>
<wizard-link
ng-show="$ctrl.state.endpoint.added"
icon="$ctrl.state.local.icon"
title="Get Started"
link-title="Get Started"
description="Proceed using the local environment which Portainer is running in"
ui-sref="portainer.home"
analytics-on
analytics-category="portainer"
analytics-event="endpoint-wizard-endpoint-select"
analytics-properties="{ metadata: { environment: 'Get-started-local-environment' }}"
></wizard-link>
<wizard-link
title="Add Environments"
link-title="Add Environments"
icon="$ctrl.state.remote.icon"
description="Connect to other environments"
ui-sref="portainer.wizard.endpoints"
></wizard-link>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -33,8 +33,8 @@ export function withInvalidate(
) {
return {
onSuccess() {
return queryKeysToInvalidate.map((keys) =>
queryClient.invalidateQueries(keys)
return Promise.all(
queryKeysToInvalidate.map((keys) => queryClient.invalidateQueries(keys))
);
},
};
@ -58,7 +58,7 @@ export function queryOptions<
return mergeOptions(options);
}
function mergeOptions<T>(...options: T[]) {
function mergeOptions<T>(options: T[]) {
return options.reduce(
(acc, option) => ({
...acc,

View File

@ -6,7 +6,7 @@
margin-bottom: 20px;
margin-left: 10px;
}
.stepper-item {
.step-wrapper {
position: relative;
display: flex;
flex-direction: column;
@ -14,20 +14,7 @@
flex: 1;
}
.docker {
margin-left: -5px;
text-transform: capitalize;
}
.kubernetes {
margin-left: -20px;
text-transform: capitalize;
}
.aci {
margin-left: 5px;
text-transform: uppercase;
}
.stepper-item::before {
.step-wrapper::before {
position: absolute;
content: '';
width: 100%;
@ -37,7 +24,7 @@
border-bottom: 5px solid var(--bg-stepper-item-counter);
}
.stepper-item::after {
.step-wrapper::after {
position: absolute;
content: '';
border-bottom: 5px solid var(--bg-stepper-item-counter);
@ -47,7 +34,13 @@
z-index: 2;
}
.stepper-item .step-counter {
.step .step-name {
position: absolute;
bottom: -25px;
min-width: max-content;
}
.step-wrapper .step {
position: relative;
z-index: 5;
display: flex;
@ -60,23 +53,28 @@
margin-bottom: 6px;
}
.stepper-item.active {
.step-wrapper.active {
font-weight: bold;
background: var(--bg-stepper-item-active);
content: none;
}
.stepper-item.active .step-counter {
.step-wrapper.active .step {
background: #337ab7;
}
.step-wrapper.active .step-counter {
color: #fff;
}
.stepper-item.completed .step-counter {
.step-wrapper.completed .step {
background-color: #48b400;
}
.step-wrapper.completed .step-counter {
color: #fff;
}
.stepper-item.completed::after {
.step-wrapper.completed::after {
position: absolute;
content: '';
border-bottom: 5px solid #48b400;
@ -86,10 +84,10 @@
z-index: 3;
}
.stepper-item:first-child::before {
.step-wrapper:first-child::before {
content: none;
}
.stepper-item:last-child::after {
.step-wrapper:last-child::after {
content: none;
}

View File

@ -0,0 +1,43 @@
import { Meta } from '@storybook/react';
import { useState } from 'react';
import { Button } from '@/portainer/components/Button';
import { Step, Stepper } from './Stepper';
export default {
component: Stepper,
title: 'Components/Stepper',
} as Meta;
interface Args {
totalSteps: number;
}
function Template({ totalSteps = 5 }: Args) {
const steps: Step[] = Array.from({ length: totalSteps }).map((_, index) => ({
title: `step ${index + 1}`,
}));
const [currentStep, setCurrentStep] = useState(1);
return (
<>
<Stepper currentStep={currentStep} steps={steps} />
<Button
onClick={() => setCurrentStep(currentStep - 1)}
disabled={currentStep <= 1}
>
Previous
</Button>
<Button
onClick={() => setCurrentStep(currentStep + 1)}
disabled={currentStep >= steps.length}
>
Next
</Button>
</>
);
}
export { Template };

View File

@ -0,0 +1,33 @@
import clsx from 'clsx';
import styles from './Stepper.module.css';
export interface Step {
title: string;
}
interface Props {
currentStep: number;
steps: Step[];
}
export function Stepper({ currentStep, steps }: Props) {
return (
<div className={styles.stepperWrapper}>
{steps.map((step, index) => (
<div
key={step.title}
className={clsx(styles.stepWrapper, {
[styles.active]: index + 1 === currentStep,
[styles.completed]: index + 1 < currentStep,
})}
>
<div className={styles.step}>
<div className={styles.stepCounter}>{index + 1}</div>
<div className={styles.stepName}>{step.title}</div>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1 @@
export { Stepper } from './Stepper';

View File

@ -0,0 +1,4 @@
.remove-tag-btn {
border: 0;
background: transparent;
}

View File

@ -0,0 +1,25 @@
import { Meta } from '@storybook/react';
import { useState } from 'react';
import { TagId } from '@/portainer/tags/types';
import { TagSelector } from './TagSelector';
export default {
component: TagSelector,
title: 'Components/TagSelector',
} as Meta;
function Example() {
const [value, setValue] = useState<TagId[]>([]);
return <TagSelector value={value} onChange={setValue} />;
}
function ExampleWithCreate() {
const [value, setValue] = useState<TagId[]>([]);
return <TagSelector value={value} onChange={setValue} allowCreate />;
}
export { Example, ExampleWithCreate };

View File

@ -0,0 +1,61 @@
import { Tag, TagId } from '@/portainer/tags/types';
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { server, rest } from '@/setup-tests/server';
import { TagSelector } from './TagSelector';
test('should show a message when no tags and allowCreate is false', async () => {
const { getByText } = await renderComponent({ allowCreate: false }, []);
expect(
getByText('No tags available. Head over to the', {
exact: false,
})
).toBeInTheDocument();
});
test('should show the selected tags', async () => {
const tags: Tag[] = [
{
ID: 1,
Name: 'tag1',
},
{
ID: 2,
Name: 'tag2',
},
];
const selectedTags = [tags[1]];
const { getByText } = await renderComponent(
{ value: selectedTags.map((t) => t.ID) },
tags
);
expect(getByText(selectedTags[0].Name)).toBeInTheDocument();
});
async function renderComponent(
{
value = [],
allowCreate = false,
onChange = jest.fn(),
}: {
value?: TagId[];
allowCreate?: boolean;
onChange?: jest.Mock;
} = {},
tags: Tag[] = []
) {
server.use(rest.get('/api/tags', (_req, res, ctx) => res(ctx.json(tags))));
const queries = renderWithQueryClient(
<TagSelector value={value} allowCreate={allowCreate} onChange={onChange} />
);
const tagElement = await queries.findAllByText('tags', { exact: false });
expect(tagElement.length).toBeGreaterThanOrEqual(1);
return queries;
}

View File

@ -0,0 +1,118 @@
import clsx from 'clsx';
import _ from 'lodash';
import { TagId } from '@/portainer/tags/types';
import {
Creatable,
Select,
} from '@/portainer/components/form-components/ReactSelect';
import { useCreateTagMutation, useTags } from '@/portainer/tags/queries';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import { Link } from '@/portainer/components/Link';
import styles from './TagSelector.module.css';
interface Props {
value: TagId[];
allowCreate?: boolean;
onChange(value: TagId[]): void;
}
interface Option {
value: TagId;
label: string;
}
export function TagSelector({ value, allowCreate = false, onChange }: Props) {
// change the struct because react-select has a bug with Creatable (https://github.com/JedWatson/react-select/issues/3417#issuecomment-461868989)
const tagsQuery = useTags((tags) =>
tags.map((opt) => ({ label: opt.Name, value: opt.ID }))
);
const createTagMutation = useCreateTagMutation();
if (!tagsQuery.tags) {
return null;
}
const { tags } = tagsQuery;
const selectedTags = _.compact(
value.map((id) => tags.find((tag) => tag.value === id))
);
const SelectComponent = allowCreate ? Creatable : Select;
if (!tags.length && !allowCreate) {
return (
<div className="form-group">
<div className="col-sm-12 small text-muted">
No tags available. Head over to the
<Link to="portainer.tags" className="space-right space-left">
Tags view
</Link>
to add tags
</div>
</div>
);
}
return (
<>
{value.length > 0 && (
<FormControl label="Selected tags">
{selectedTags.map((tag) => (
<span className="tag space-right interactive" key={tag.value}>
{tag.label}
<button
type="button"
title="Remove tag"
className={clsx(styles.removeTagBtn, 'space-left')}
onClick={() => handleRemove(tag.value)}
>
<i className="fa fa-trash-alt white-icon" aria-hidden="true" />
</button>
</span>
))}
</FormControl>
)}
<FormControl label="Tags" inputId="tags-selector">
<SelectComponent
inputId="tags-selector"
value={[] as { label: string; value: number }[]}
hideSelectedOptions
options={tags.filter((tag) => !value.includes(tag.value))}
closeMenuOnSelect={false}
onChange={handleAdd}
noOptionsMessage={() => 'No tags available'}
formatCreateLabel={(inputValue) => `Create "${inputValue}"`}
onCreateOption={handleCreateOption}
/>
</FormControl>
</>
);
function handleAdd(tag?: Option | null) {
if (!tag) {
return;
}
onChange([...value, tag.value]);
}
function handleRemove(tagId: TagId) {
onChange(value.filter((id) => id !== tagId));
}
function handleCreateOption(inputValue: string) {
if (!allowCreate) {
return;
}
createTagMutation.mutate(inputValue, {
onSuccess(tag) {
handleAdd({ label: tag.Name, value: tag.ID });
},
});
}
}

View File

@ -0,0 +1 @@
export { TagSelector } from './TagSelector';

View File

@ -0,0 +1,67 @@
import { useState } from 'react';
import { useRouter } from '@uirouter/react';
import _ from 'lodash';
import { Button } from '@/portainer/components/Button';
import { PageHeader } from '@/portainer/components/PageHeader';
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
import {
EnvironmentSelector,
EnvironmentSelectorValue,
} from './EnvironmentSelector';
import { environmentTypes } from './environment-types';
export function EnvironmentTypeSelectView() {
const [types, setTypes] = useState<EnvironmentSelectorValue[]>([]);
const { trackEvent } = useAnalytics();
const router = useRouter();
return (
<>
<PageHeader
title="Quick Setup"
breadcrumbs={[{ label: 'Environment Wizard' }]}
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<WidgetTitle icon="fa-magic" title="Environment Wizard" />
<WidgetBody>
<EnvironmentSelector value={types} onChange={setTypes} />
<Button
disabled={types.length === 0}
onClick={() => startWizard()}
>
Start Wizard
</Button>
</WidgetBody>
</Widget>
</div>
</div>
</>
);
function startWizard() {
if (types.length === 0) {
return;
}
const steps = _.compact(
types.map((id) => environmentTypes.find((eType) => eType.id === id))
);
trackEvent('endpoint-wizard-endpoint-select', {
category: 'portainer',
metadata: {
environment: steps.map((step) => step.title).join('/'),
},
});
router.stateService.go('portainer.wizard.endpoints.create', {
envType: types,
});
}
}

View File

@ -0,0 +1,46 @@
import { FormSection } from '@/portainer/components/form-components/FormSection';
import { Option } from '../components/Option';
import { environmentTypes } from './environment-types';
export type EnvironmentSelectorValue = typeof environmentTypes[number]['id'];
interface Props {
value: EnvironmentSelectorValue[];
onChange(value: EnvironmentSelectorValue[]): void;
}
export function EnvironmentSelector({ value, onChange }: Props) {
return (
<div className="row">
<FormSection title="Select your environment(s)">
<p className="text-muted small">
You can onboard different types of environments, select all that
apply.
</p>
<div className="flex gap-4 flex-wrap">
{environmentTypes.map((eType) => (
<Option
key={eType.id}
title={eType.title}
description={eType.description}
icon={eType.icon}
active={value.includes(eType.id)}
onClick={() => handleClick(eType.id)}
/>
))}
</div>
</FormSection>
</div>
);
function handleClick(eType: EnvironmentSelectorValue) {
if (value.includes(eType)) {
onChange(value.filter((v) => v !== eType));
return;
}
onChange([...value, eType]);
}
}

View File

@ -0,0 +1,21 @@
export const environmentTypes = [
{
id: 'docker',
title: 'Docker',
icon: 'fab fa-docker',
description:
'Connect to Docker Standalone / Swarm via URL/IP, API or Socket',
},
{
id: 'kubernetes',
title: 'Kubernetes',
icon: 'fas fa-dharmachakra',
description: 'Connect to a kubernetes environment via URL/IP',
},
{
id: 'aci',
title: 'ACI',
description: 'Connect to ACI environment via API',
icon: 'fab fa-microsoft',
},
] as const;

View File

@ -0,0 +1 @@
export { EnvironmentTypeSelectView } from './EndpointTypeView';

View File

@ -0,0 +1,16 @@
.wizard-step-action {
padding-top: 40px;
padding-bottom: 40px;
text-align: right;
border-top: 1px solid #777;
}
.wizard-wrapper {
display: grid;
grid-template-columns: 1fr 400px;
grid-template-areas:
'main sidebar'
'footer sidebar';
gap: 10px;
margin: 0 15px;
}

View File

@ -0,0 +1,206 @@
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { useState } from 'react';
import _ from 'lodash';
import clsx from 'clsx';
import { Stepper } from '@/react/components/Stepper';
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
import { notifyError } from '@/portainer/services/notifications';
import { PageHeader } from '@/portainer/components/PageHeader';
import { Button } from '@/portainer/components/Button';
import { Environment, EnvironmentId } from '@/portainer/environments/types';
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
import { FormSection } from '@/portainer/components/form-components/FormSection';
import { environmentTypes } from '../EnvironmentTypeSelectView/environment-types';
import { EnvironmentSelectorValue } from '../EnvironmentTypeSelectView/EnvironmentSelector';
import { WizardDocker } from './WizardDocker';
import { WizardAzure } from './WizardAzure';
import { WizardKubernetes } from './WizardKubernetes';
import { AnalyticsState, AnalyticsStateKey } from './types';
import styles from './EnvironmentsCreationView.module.css';
import { WizardEndpointsList } from './WizardEndpointsList';
export function EnvironmentCreationView() {
const {
params: { localEndpointId: localEndpointIdParam },
} = useCurrentStateAndParams();
const [environmentIds, setEnvironmentIds] = useState<EnvironmentId[]>(() => {
const localEndpointId = parseInt(localEndpointIdParam, 10);
if (!localEndpointId || Number.isNaN(localEndpointId)) {
return [];
}
return [localEndpointId];
});
const envTypes = useParamEnvironmentTypes();
const { trackEvent } = useAnalytics();
const router = useRouter();
const steps = _.compact(
envTypes.map((id) => environmentTypes.find((eType) => eType.id === id))
);
const { analytics, setAnalytics } = useAnalyticsState();
const {
currentStep,
onNextClick,
onPreviousClick,
currentStepIndex,
Component,
isFirstStep,
isLastStep,
} = useStepper(steps, handleFinish);
return (
<>
<PageHeader
title="Quick Setup"
breadcrumbs={[{ label: 'Environment Wizard' }]}
/>
<div className={styles.wizardWrapper}>
<Widget>
<WidgetTitle icon="fa-magic" title="Environment Wizard" />
<WidgetBody>
<Stepper steps={steps} currentStep={currentStepIndex + 1} />
<div className="mt-12">
<FormSection
title={`Connect to your ${currentStep.title}
environment`}
>
<Component onCreate={handleCreateEnvironment} />
<div
className={clsx(
styles.wizardStepAction,
'flex justify-between'
)}
>
<Button disabled={isFirstStep} onClick={onPreviousClick}>
<i className="fas fa-arrow-left space-right" /> Previous
</Button>
<Button onClick={onNextClick}>
{isLastStep ? 'Finish' : 'Next'}
<i className="fas fa-arrow-right space-left" />
</Button>
</div>
</FormSection>
</div>
</WidgetBody>
</Widget>
<div>
<WizardEndpointsList environmentIds={environmentIds} />
</div>
</div>
</>
);
function handleCreateEnvironment(
environment: Environment,
analytics: AnalyticsStateKey
) {
setEnvironmentIds((prev) => [...prev, environment.Id]);
setAnalytics(analytics);
}
function handleFinish() {
trackEvent('endpoint-wizard-environment-add-finish', {
category: 'portainer',
metadata: Object.fromEntries(
Object.entries(analytics).map(([key, value]) => [
_.kebabCase(key),
value,
])
),
});
router.stateService.go('portainer.home');
}
}
function useParamEnvironmentTypes(): EnvironmentSelectorValue[] {
const {
params: { envType },
} = useCurrentStateAndParams();
const router = useRouter();
if (!envType) {
notifyError('No environment type provided');
router.stateService.go('portainer.wizard.endpoints');
return [];
}
return Array.isArray(envType) ? envType : [envType];
}
function useStepper(
steps: typeof environmentTypes[number][],
onFinish: () => void
) {
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const isFirstStep = currentStepIndex === 0;
const isLastStep = currentStepIndex === steps.length - 1;
const currentStep = steps[currentStepIndex];
return {
currentStep,
onNextClick,
onPreviousClick,
isFirstStep,
isLastStep,
currentStepIndex,
Component: getComponent(currentStep.id),
};
function onNextClick() {
if (!isLastStep) {
setCurrentStepIndex(currentStepIndex + 1);
return;
}
onFinish();
}
function onPreviousClick() {
setCurrentStepIndex(currentStepIndex - 1);
}
function getComponent(id: EnvironmentSelectorValue) {
switch (id) {
case 'docker':
return WizardDocker;
case 'aci':
return WizardAzure;
case 'kubernetes':
return WizardKubernetes;
default:
throw new Error(`Unknown environment type ${id}`);
}
}
}
function useAnalyticsState() {
const [analytics, setAnalyticsState] = useState<AnalyticsState>({
dockerAgent: 0,
dockerApi: 0,
kubernetesAgent: 0,
kubernetesEdgeAgent: 0,
kaasAgent: 0,
aciApi: 0,
localEndpoint: 0,
nomadEdgeAgent: 0,
});
return { analytics, setAnalytics };
function setAnalytics(key: AnalyticsStateKey) {
setAnalyticsState((prevState) => ({
...prevState,
[key]: prevState[key] + 1,
}));
}
}

View File

@ -0,0 +1,167 @@
import { Field, Form, Formik } from 'formik';
import { useReducer, useState } from 'react';
import { object, SchemaOf, string } from 'yup';
import { BoxSelector, buildOption } from '@/portainer/components/BoxSelector';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import { Input } from '@/portainer/components/form-components/Input';
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
import { useCreateAzureEnvironmentMutation } from '@/portainer/environments/queries/useCreateEnvironmentMutation';
import { notifySuccess } from '@/portainer/services/notifications';
import { Environment } from '@/portainer/environments/types';
import { EnvironmentMetadata } from '@/portainer/environments/environment.service/create';
import { NameField, nameValidation } from '../shared/NameField';
import { AnalyticsStateKey } from '../types';
import { MetadataFieldset } from '../shared/MetadataFieldset';
import { metadataValidation } from '../shared/MetadataFieldset/validation';
interface FormValues {
name: string;
applicationId: string;
tenantId: string;
authenticationKey: string;
meta: EnvironmentMetadata;
}
const initialValues: FormValues = {
name: '',
applicationId: '',
tenantId: '',
authenticationKey: '',
meta: {
groupId: 1,
tagIds: [],
},
};
const options = [buildOption('api', 'fa fa-bolt', 'API', '', 'api')];
interface Props {
onCreate(environment: Environment, analytics: AnalyticsStateKey): void;
}
export function WizardAzure({ onCreate }: Props) {
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
const [creationType, setCreationType] = useState(options[0].id);
const mutation = useCreateAzureEnvironmentMutation();
return (
<div className="form-horizontal">
<BoxSelector
options={options}
radioName="creation-type"
onChange={(value) => setCreationType(value)}
value={creationType}
/>
<Formik<FormValues>
initialValues={initialValues}
onSubmit={handleSubmit}
key={formKey}
validateOnMount
validationSchema={validationSchema}
>
{({ errors, dirty, isValid }) => (
<Form>
<NameField />
<FormControl
label="Application ID"
errors={errors.applicationId}
inputId="applicationId-input"
required
>
<Field
name="applicationId"
id="applicationId-input"
as={Input}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
/>
</FormControl>
<FormControl
label="Tenant ID"
errors={errors.tenantId}
inputId="tenantId-input"
required
>
<Field
name="tenantId"
id="tenantId-input"
as={Input}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
/>
</FormControl>
<FormControl
label="Authentication Key"
errors={errors.authenticationKey}
inputId="authenticationKey-input"
required
>
<Field
name="authenticationKey"
id="authenticationKey-input"
as={Input}
placeholder="cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk="
/>
</FormControl>
<MetadataFieldset />
<div className="row">
<div className="col-sm-12">
<LoadingButton
loadingText="Connecting environment..."
isLoading={mutation.isLoading}
disabled={!dirty || !isValid}
>
<i className="fa fa-plug" aria-hidden="true" /> Connect
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
</div>
);
function handleSubmit({
applicationId,
authenticationKey,
meta,
name,
tenantId,
}: typeof initialValues) {
mutation.mutate(
{
name,
azure: {
applicationId,
authenticationKey,
tenantId,
},
meta,
},
{
onSuccess(environment) {
notifySuccess('Environment created', environment.Name);
clearForm();
onCreate(environment, 'aciApi');
},
}
);
}
}
function validationSchema(): SchemaOf<FormValues> {
return object({
name: nameValidation(),
applicationId: string().required('Application ID is required'),
tenantId: string().required('Tenant ID is required'),
authenticationKey: string().required('Authentication Key is required'),
meta: metadataValidation(),
});
}

View File

@ -0,0 +1 @@
export { WizardAzure } from './WizardAzure';

View File

@ -0,0 +1,131 @@
import { Field, Form, Formik } from 'formik';
import { useReducer } from 'react';
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
import { useCreateRemoteEnvironmentMutation } from '@/portainer/environments/queries/useCreateEnvironmentMutation';
import { notifySuccess } from '@/portainer/services/notifications';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import { Input } from '@/portainer/components/form-components/Input';
import {
Environment,
EnvironmentCreationTypes,
} from '@/portainer/environments/types';
import { NameField } from '../../shared/NameField';
import { MetadataFieldset } from '../../shared/MetadataFieldset';
import { validation } from './APIForm.validation';
import { FormValues } from './types';
import { TLSFieldset } from './TLSFieldset';
interface Props {
onCreate(environment: Environment): void;
}
export function APIForm({ onCreate }: Props) {
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
const initialValues: FormValues = {
url: '',
name: '',
tls: false,
meta: {
groupId: 1,
tagIds: [],
},
};
const mutation = useCreateRemoteEnvironmentMutation(
EnvironmentCreationTypes.LocalDockerEnvironment
);
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validation}
validateOnMount
key={formKey}
>
{({ isValid, dirty }) => (
<Form>
<NameField />
<FormControl
inputId="url-field"
label="Docker API URL"
required
tooltip="URL or IP address of a Docker host. The Docker API must be exposed over a TCP port. Please refer to the Docker documentation to configure it."
>
<Field
as={Input}
id="url-field"
name="url"
placeholder="e.g. 10.0.0.10:2375 or mydocker.mydomain.com:2375"
/>
</FormControl>
<TLSFieldset />
<MetadataFieldset />
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
className="wizard-connect-button"
loadingText="Connecting environment..."
isLoading={mutation.isLoading}
disabled={!dirty || !isValid}
>
<i className="fa fa-plug" aria-hidden="true" /> Connect
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
);
function handleSubmit(values: FormValues) {
const tls = getTlsValues();
mutation.mutate(
{
name: values.name,
url: values.url,
options: {
tls,
meta: values.meta,
},
},
{
onSuccess(environment) {
notifySuccess('Environment created', environment.Name);
clearForm();
onCreate(environment);
},
}
);
function getTlsValues() {
if (!values.tls) {
return undefined;
}
return {
skipVerify: values.skipVerify,
...getCertFiles(),
};
function getCertFiles() {
if (values.skipVerify) {
return {};
}
return {
caCertFile: values.caCertFile,
certFile: values.certFile,
keyFile: values.keyFile,
};
}
}
}
}

View File

@ -0,0 +1,18 @@
import { boolean, object, SchemaOf, string } from 'yup';
import { metadataValidation } from '../../shared/MetadataFieldset/validation';
import { nameValidation } from '../../shared/NameField';
import { validation as certsValidation } from './TLSFieldset';
import { FormValues } from './types';
export function validation(): SchemaOf<FormValues> {
return object({
name: nameValidation(),
url: string().required('This field is required.'),
tls: boolean().default(false),
skipVerify: boolean(),
meta: metadataValidation(),
...certsValidation(),
});
}

View File

@ -0,0 +1,20 @@
import { Environment } from '@/portainer/environments/types';
import { APIForm } from './APIForm';
import { DeploymentScripts } from './DeploymentScripts';
interface Props {
onCreate(environment: Environment): void;
}
export function APITab({ onCreate }: Props) {
return (
<>
<DeploymentScripts />
<div className="wizard-form">
<APIForm onCreate={onCreate} />
</div>
</>
);
}

View File

@ -0,0 +1,63 @@
import { useState } from 'react';
import { CopyButton } from '@/portainer/components/Button/CopyButton';
import { Code } from '@/portainer/components/Code';
import { NavTabs } from '@/portainer/components/NavTabs/NavTabs';
import { useAgentDetails } from '@/portainer/environments/queries/useAgentDetails';
const deployments = [
{
id: 'linux',
label: 'Linux',
command: `-v "/var/run/docker.sock:/var/run/docker.sock"`,
},
{
id: 'win',
label: 'Windows',
command: '-v \\.\\pipe\\docker_engine:\\.\\pipe\\docker_engine',
},
];
export function DeploymentScripts() {
const [deployType, setDeployType] = useState(deployments[0].id);
const agentDetailsQuery = useAgentDetails();
if (!agentDetailsQuery) {
return null;
}
const options = deployments.map((c) => ({
id: c.id,
label: c.label,
children: <DeployCode code={c.command} />,
}));
return (
<NavTabs
options={options}
onSelect={(id: string) => setDeployType(id)}
selectedId={deployType}
/>
);
}
interface DeployCodeProps {
code: string;
}
function DeployCode({ code }: DeployCodeProps) {
return (
<>
<span className="text-muted small">
When using the socket, ensure that you have started the Portainer
container with the following Docker flag:
</span>
<Code>{code}</Code>
<CopyButton copyText={code} className="my-6">
Copy command
</CopyButton>
</>
);
}

View File

@ -0,0 +1,106 @@
import { useFormikContext } from 'formik';
import { FileUploadField } from '@/portainer/components/form-components/FileUpload';
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import {
file,
withFileSize,
withFileType,
} from '@/portainer/helpers/yup-file-validation';
import { FormValues } from './types';
export function TLSFieldset() {
const { values, setFieldValue, errors } = useFormikContext<FormValues>();
return (
<>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="TLS"
checked={values.tls}
onChange={(checked) => setFieldValue('tls', checked)}
/>
</div>
</div>
{values.tls && (
<>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Skip Certification Verification"
checked={!!values.skipVerify}
onChange={(checked) => setFieldValue('skipVerify', checked)}
/>
</div>
</div>
{!values.skipVerify && (
<>
<FormControl
label="TLS CA certificate"
inputId="ca-cert-field"
errors={errors.caCertFile}
>
<FileUploadField
inputId="ca-cert-field"
onChange={(file) => setFieldValue('caCertFile', file)}
value={values.caCertFile}
/>
</FormControl>
<FormControl
label="TLS certificate"
inputId="cert-field"
errors={errors.certFile}
>
<FileUploadField
inputId="cert-field"
onChange={(file) => setFieldValue('certFile', file)}
value={values.certFile}
/>
</FormControl>
<FormControl
label="TLS key"
inputId="tls-key-field"
errors={errors.keyFile}
>
<FileUploadField
inputId="tls-key-field"
onChange={(file) => setFieldValue('keyFile', file)}
value={values.keyFile}
/>
</FormControl>
</>
)}
</>
)}
</>
);
}
const MAX_FILE_SIZE = 5_242_880; // 5MB
const ALLOWED_FILE_TYPES = [
'application/x-x509-ca-cert',
'application/x-pem-file',
];
function certValidation() {
return withFileType(
withFileSize(file(), MAX_FILE_SIZE),
ALLOWED_FILE_TYPES
).when(['tls', 'skipVerify'], {
is: (tls: boolean, skipVerify: boolean) => tls && !skipVerify,
then: (schema) => schema.required('File is required'),
});
}
export function validation() {
return {
caCertFile: certValidation(),
certFile: certValidation(),
keyFile: certValidation(),
};
}

View File

@ -0,0 +1 @@
export { APITab } from './APITab';

View File

@ -0,0 +1,12 @@
import { EnvironmentMetadata } from '@/portainer/environments/environment.service/create';
export interface FormValues {
name: string;
url: string;
tls: boolean;
skipVerify?: boolean;
caCertFile?: File;
certFile?: File;
keyFile?: File;
meta: EnvironmentMetadata;
}

View File

@ -0,0 +1,21 @@
import { Environment } from '@/portainer/environments/types';
import { AgentForm } from '../../shared/AgentForm/AgentForm';
import { DeploymentScripts } from './DeploymentScripts';
interface Props {
onCreate(environment: Environment): void;
}
export function AgentTab({ onCreate }: Props) {
return (
<>
<DeploymentScripts />
<div className="wizard-form">
<AgentForm onCreate={onCreate} />
</div>
</>
);
}

View File

@ -0,0 +1,78 @@
import { useState } from 'react';
import { CopyButton } from '@/portainer/components/Button/CopyButton';
import { Code } from '@/portainer/components/Code';
import { NavTabs } from '@/portainer/components/NavTabs/NavTabs';
import { getAgentShortVersion } from '@/portainer/views/endpoints/helpers';
import { useAgentDetails } from '@/portainer/environments/queries/useAgentDetails';
const deployments = [
{
id: 'linux',
label: 'Linux',
command: linuxCommand,
},
{
id: 'win',
label: 'Windows',
command: winCommand,
},
];
export function DeploymentScripts() {
const [deployType, setDeployType] = useState(deployments[0].id);
const agentDetailsQuery = useAgentDetails();
if (!agentDetailsQuery) {
return null;
}
const { agentVersion } = agentDetailsQuery;
const options = deployments.map((c) => {
const code = c.command(agentVersion);
return {
id: c.id,
label: c.label,
children: <DeployCode code={code} />,
};
});
return (
<NavTabs
options={options}
onSelect={(id: string) => setDeployType(id)}
selectedId={deployType}
/>
);
}
interface DeployCodeProps {
code: string;
}
function DeployCode({ code }: DeployCodeProps) {
return (
<>
<span className="text-muted small">
CLI script for installing agent on your environment with Docker Swarm:
</span>
<Code>{code}</Code>
<CopyButton copyText={code}>Copy command</CopyButton>
</>
);
}
function linuxCommand(agentVersion: string) {
const agentShortVersion = getAgentShortVersion(agentVersion);
return `curl -L https://downloads.portainer.io/ee${agentShortVersion}/agent-stack.yml -o agent-stack.yml && docker stack deploy --compose-file=agent-stack.yml portainer-agent`;
}
function winCommand(agentVersion: string) {
const agentShortVersion = getAgentShortVersion(agentVersion);
return `curl -L https://downloads.portainer.io/ee${agentShortVersion}/agent-stack-windows.yml -o agent-stack-windows.yml && docker stack deploy --compose-file=agent-stack-windows.yml portainer-agent `;
}

View File

@ -0,0 +1 @@
export { AgentTab } from './AgentTab';

View File

@ -0,0 +1,112 @@
import { Field, Form, Formik, useFormikContext } from 'formik';
import { useReducer } from 'react';
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
import { useCreateLocalDockerEnvironmentMutation } from '@/portainer/environments/queries/useCreateEnvironmentMutation';
import { notifySuccess } from '@/portainer/services/notifications';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import { Input } from '@/portainer/components/form-components/Input';
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
import { Environment } from '@/portainer/environments/types';
import { NameField } from '../../shared/NameField';
import { MetadataFieldset } from '../../shared/MetadataFieldset';
import { validation } from './SocketForm.validation';
import { FormValues } from './types';
interface Props {
onCreate(environment: Environment): void;
}
export function SocketForm({ onCreate }: Props) {
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
const initialValues: FormValues = {
name: '',
socketPath: '',
overridePath: false,
meta: { groupId: 1, tagIds: [] },
};
const mutation = useCreateLocalDockerEnvironmentMutation();
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validation}
validateOnMount
key={formKey}
>
{({ isValid, dirty }) => (
<Form>
<NameField />
<OverrideSocketFieldset />
<MetadataFieldset />
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
className="wizard-connect-button"
loadingText="Connecting environment..."
isLoading={mutation.isLoading}
disabled={!dirty || !isValid}
>
<i className="fa fa-plug" aria-hidden="true" /> Connect
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
);
function handleSubmit(values: FormValues) {
mutation.mutate(
{
name: values.name,
socketPath: values.overridePath ? values.socketPath : '',
},
{
onSuccess(environment) {
notifySuccess('Environment created', environment.Name);
clearForm();
onCreate(environment);
},
}
);
}
}
function OverrideSocketFieldset() {
const { values, setFieldValue, errors } = useFormikContext<FormValues>();
return (
<>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
checked={values.overridePath}
onChange={(checked) => setFieldValue('overridePath', checked)}
label="Override default socket path"
/>
</div>
</div>
{values.overridePath && (
<FormControl
label="Socket Path"
tooltip="Path to the Docker socket. Remember to bind-mount the socket, see the important notice above for more information."
errors={errors.socketPath}
>
<Field
name="socketPath"
as={Input}
placeholder="e.g. /var/run/docker.sock (on Linux) or //./pipe/docker_engine (on Windows)"
/>
</FormControl>
)}
</>
);
}

View File

@ -0,0 +1,23 @@
import { boolean, object, SchemaOf, string } from 'yup';
import { metadataValidation } from '../../shared/MetadataFieldset/validation';
import { nameValidation } from '../../shared/NameField';
import { FormValues } from './types';
export function validation(): SchemaOf<FormValues> {
return object({
name: nameValidation(),
meta: metadataValidation(),
overridePath: boolean().default(false),
socketPath: string()
.default('')
.when('overridePath', (overridePath, schema) =>
overridePath
? schema.required(
'Socket Path is required when override path is enabled'
)
: schema
),
});
}

View File

@ -0,0 +1,21 @@
import { Environment } from '@/portainer/environments/types';
import { DeploymentScripts } from '../APITab/DeploymentScripts';
import { SocketForm } from './SocketForm';
interface Props {
onCreate(environment: Environment): void;
}
export function SocketTab({ onCreate }: Props) {
return (
<>
<DeploymentScripts />
<div className="wizard-form">
<SocketForm onCreate={onCreate} />
</div>
</>
);
}

View File

@ -0,0 +1 @@
export { SocketTab } from './SocketTab';

View File

@ -0,0 +1,8 @@
import { EnvironmentMetadata } from '@/portainer/environments/environment.service/create';
export interface FormValues {
name: string;
socketPath: string;
overridePath: boolean;
meta: EnvironmentMetadata;
}

View File

@ -0,0 +1,68 @@
import { useState } from 'react';
import { BoxSelector, buildOption } from '@/portainer/components/BoxSelector';
import { Environment } from '@/portainer/environments/types';
import { AnalyticsStateKey } from '../types';
import { AgentTab } from './AgentTab';
import { APITab } from './APITab';
import { SocketTab } from './SocketTab';
interface Props {
onCreate(environment: Environment, analytics: AnalyticsStateKey): void;
}
const options = [
buildOption('Agent', 'fa fa-bolt', 'Agent', '', 'agent'),
buildOption('API', 'fa fa-cloud', 'API', '', 'api'),
buildOption('Socket', 'fab fa-docker', 'Socket', '', 'socket'),
];
export function WizardDocker({ onCreate }: Props) {
const [creationType, setCreationType] = useState(options[0].value);
const form = getForm(creationType);
return (
<div className="form-horizontal">
<div className="form-group">
<div className="col-sm-12">
<BoxSelector
onChange={(v) => setCreationType(v)}
options={options}
value={creationType}
radioName="creation-type"
/>
</div>
</div>
{form}
</div>
);
function getForm(creationType: 'agent' | 'api' | 'socket') {
switch (creationType) {
case 'agent':
return (
<AgentTab
onCreate={(environment) => onCreate(environment, 'dockerAgent')}
/>
);
case 'api':
return (
<APITab
onCreate={(environment) => onCreate(environment, 'dockerApi')}
/>
);
case 'socket':
return (
<SocketTab
onCreate={(environment) => onCreate(environment, 'localEndpoint')}
/>
);
default:
return null;
}
}
}

View File

@ -0,0 +1 @@
export { WizardDocker } from './WizardDocker';

View File

@ -0,0 +1,81 @@
import clsx from 'clsx';
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
import {
environmentTypeIcon,
endpointTypeName,
stripProtocol,
} from '@/portainer/filters/filters';
import { EnvironmentId } from '@/portainer/environments/types';
import { EdgeIndicator } from '@/portainer/home/EnvironmentList/EnvironmentItem';
import {
isEdgeEnvironment,
isUnassociatedEdgeEnvironment,
} from '@/portainer/environments/utils';
import {
ENVIRONMENTS_POLLING_INTERVAL,
useEnvironmentList,
} from '@/portainer/environments/queries/useEnvironmentList';
import styles from './WizardEndpointsList.module.css';
interface Props {
environmentIds: EnvironmentId[];
}
export function WizardEndpointsList({ environmentIds }: Props) {
const { environments } = useEnvironmentList(
{ endpointIds: environmentIds },
(environments) => {
if (!environments) {
return false;
}
if (!environments.value.some(isUnassociatedEdgeEnvironment)) {
return false;
}
return ENVIRONMENTS_POLLING_INTERVAL;
},
0,
environmentIds.length > 0
);
return (
<Widget>
<WidgetTitle icon="fa-plug" title="Connected Environments" />
<WidgetBody>
{environments.map((environment) => (
<div className={styles.wizardListWrapper} key={environment.Id}>
<div className={styles.wizardListImage}>
<i
aria-hidden="true"
className={clsx(
'space-right',
environmentTypeIcon(environment.Type)
)}
/>
</div>
<div className={styles.wizardListTitle}>{environment.Name}</div>
<div className={styles.wizardListSubtitle}>
URL: {stripProtocol(environment.URL)}
</div>
<div className={styles.wizardListType}>
Type: {endpointTypeName(environment.Type)}
</div>
{isEdgeEnvironment(environment.Type) && (
<div className={styles.wizardListEdgeStatus}>
<EdgeIndicator
edgeId={environment.EdgeID}
checkInInterval={environment.EdgeCheckinInterval}
queryDate={environment.QueryDate}
lastCheckInDate={environment.LastCheckInDate}
/>
</div>
)}
</div>
))}
</WidgetBody>
</Widget>
);
}

Some files were not shown because too many files have changed in this diff Show More