mirror of https://github.com/portainer/portainer
refactor(wizard): migrate to react [EE-2305] (#6957)
parent
3aacaa7caf
commit
01dc9066b7
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { FormSection } from './FormSection';
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ export interface EnvironmentsQueryParams {
|
|||
sort?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
edgeDeviceFilter?: 'all' | 'trusted' | 'untrusted' | 'none';
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export async function getEndpoints(
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -29,6 +29,7 @@ function renderComponent(
|
|||
lastCheckInDate={lastCheckInDate}
|
||||
checkInInterval={checkInInterval}
|
||||
queryDate={queryDate}
|
||||
showLastCheckInDate
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export { EnvironmentItem } from './EnvironmentItem';
|
||||
export { EdgeIndicator } from './EdgeIndicator';
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import angular from 'angular';
|
||||
|
||||
export const componentsModule = angular.module(
|
||||
'portainer.docker.react.components',
|
||||
'portainer.app.react.components',
|
||||
[]
|
||||
).name;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
|
@ -213,6 +213,7 @@ export function SettingsOpenAMT({ settings, onSubmit }: Props) {
|
|||
errors={errors.certFileContent}
|
||||
>
|
||||
<FileUploadField
|
||||
inputId="certificate_file"
|
||||
title="Upload file"
|
||||
accept=".pfx"
|
||||
value={certFile}
|
||||
|
|
|
@ -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])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
|
@ -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,
|
||||
});
|
|
@ -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: '<',
|
||||
},
|
||||
});
|
|
@ -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: '',
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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: '<',
|
||||
},
|
||||
});
|
|
@ -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 `,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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: '<',
|
||||
},
|
||||
});
|
|
@ -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 `,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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: '<',
|
||||
},
|
||||
});
|
|
@ -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>
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
|
@ -1,9 +0,0 @@
|
|||
import angular from 'angular';
|
||||
import './wizard-stepper.css';
|
||||
|
||||
angular.module('portainer.app').component('wizardStepper', {
|
||||
templateUrl: './wizard-stepper.html',
|
||||
bindings: {
|
||||
endpointSelections: '<',
|
||||
},
|
||||
});
|
|
@ -1,11 +0,0 @@
|
|||
import angular from 'angular';
|
||||
|
||||
angular.module('portainer.app').component('wizardEndpointType', {
|
||||
templateUrl: './wizard-endpoint-type.html',
|
||||
bindings: {
|
||||
endpointTitle: '@',
|
||||
description: '@',
|
||||
icon: '@',
|
||||
active: '<',
|
||||
},
|
||||
});
|
|
@ -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>
|
|
@ -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>
|
|
@ -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: '<',
|
||||
},
|
||||
});
|
|
@ -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>
|
|
@ -1,9 +0,0 @@
|
|||
import angular from 'angular';
|
||||
|
||||
angular.module('portainer.app').component('wizardTls', {
|
||||
templateUrl: './wizard-tls.html',
|
||||
bindings: {
|
||||
formData: '<',
|
||||
onChange: '<',
|
||||
},
|
||||
});
|
|
@ -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>
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 };
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { Stepper } from './Stepper';
|
|
@ -0,0 +1,4 @@
|
|||
.remove-tag-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
|
@ -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 };
|
|
@ -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;
|
||||
}
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { TagSelector } from './TagSelector';
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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]);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1 @@
|
|||
export { EnvironmentTypeSelectView } from './EndpointTypeView';
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
});
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { WizardAzure } from './WizardAzure';
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
});
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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(),
|
||||
};
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { APITab } from './APITab';
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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 `;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { AgentTab } from './AgentTab';
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
),
|
||||
});
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { SocketTab } from './SocketTab';
|
|
@ -0,0 +1,8 @@
|
|||
import { EnvironmentMetadata } from '@/portainer/environments/environment.service/create';
|
||||
|
||||
export interface FormValues {
|
||||
name: string;
|
||||
socketPath: string;
|
||||
overridePath: boolean;
|
||||
meta: EnvironmentMetadata;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { WizardDocker } from './WizardDocker';
|
|
@ -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
Loading…
Reference in New Issue