refactor(gitops): migrate git form to react [EE-4849] (#8268)

pull/8462/head
Chaim Lev-Ari 2 years ago committed by GitHub
parent afe6cd6df0
commit 273a3f9a10
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,66 +0,0 @@
import { BROWSER_OS_PLATFORM } from './react/constants';
export const API_ENDPOINT_AUTH = 'api/auth';
export const API_ENDPOINT_BACKUP = 'api/backup';
export const API_ENDPOINT_CUSTOM_TEMPLATES = 'api/custom_templates';
export const API_ENDPOINT_EDGE_GROUPS = 'api/edge_groups';
export const API_ENDPOINT_EDGE_JOBS = 'api/edge_jobs';
export const API_ENDPOINT_EDGE_STACKS = 'api/edge_stacks';
export const API_ENDPOINT_EDGE_TEMPLATES = 'api/edge_templates';
export const API_ENDPOINT_ENDPOINTS = 'api/endpoints';
export const API_ENDPOINT_ENDPOINT_GROUPS = 'api/endpoint_groups';
export const API_ENDPOINT_KUBERNETES = 'api/kubernetes';
export const API_ENDPOINT_MOTD = 'api/motd';
export const API_ENDPOINT_REGISTRIES = 'api/registries';
export const API_ENDPOINT_RESOURCE_CONTROLS = 'api/resource_controls';
export const API_ENDPOINT_SETTINGS = 'api/settings';
export const API_ENDPOINT_STACKS = 'api/stacks';
export const API_ENDPOINT_SUPPORT = 'api/support';
export const API_ENDPOINT_USERS = 'api/users';
export const API_ENDPOINT_TAGS = 'api/tags';
export const API_ENDPOINT_TEAMS = 'api/teams';
export const API_ENDPOINT_TEAM_MEMBERSHIPS = 'api/team_memberships';
export const API_ENDPOINT_TEMPLATES = 'api/templates';
export const API_ENDPOINT_WEBHOOKS = 'api/webhooks';
export const PAGINATION_MAX_ITEMS = 10;
export const APPLICATION_CACHE_VALIDITY = 3600;
export const CONSOLE_COMMANDS_LABEL_PREFIX = 'io.portainer.commands.';
export const PREDEFINED_NETWORKS = ['host', 'bridge', 'ingress', 'nat', 'none'];
export const KUBERNETES_DEFAULT_NAMESPACE = 'default';
export const KUBERNETES_SYSTEM_NAMESPACES = ['kube-system', 'kube-public', 'kube-node-lease', 'portainer'];
export const PORTAINER_FADEOUT = 1500;
export const STACK_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$';
export const TEMPLATE_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$';
// don't declare new constants, either:
// - if only used in one file or module, declare in that file or module (as a regular js constant)
// - if needed across modules, declare like the above constant and use es6 import for that
angular
.module('portainer')
.constant('API_ENDPOINT_AUTH', API_ENDPOINT_AUTH)
.constant('API_ENDPOINT_BACKUP', API_ENDPOINT_BACKUP)
.constant('API_ENDPOINT_CUSTOM_TEMPLATES', API_ENDPOINT_CUSTOM_TEMPLATES)
.constant('API_ENDPOINT_EDGE_GROUPS', API_ENDPOINT_EDGE_GROUPS)
.constant('API_ENDPOINT_EDGE_JOBS', API_ENDPOINT_EDGE_JOBS)
.constant('API_ENDPOINT_EDGE_STACKS', API_ENDPOINT_EDGE_STACKS)
.constant('API_ENDPOINT_EDGE_TEMPLATES', API_ENDPOINT_EDGE_TEMPLATES)
.constant('API_ENDPOINT_ENDPOINTS', API_ENDPOINT_ENDPOINTS)
.constant('API_ENDPOINT_ENDPOINT_GROUPS', API_ENDPOINT_ENDPOINT_GROUPS)
.constant('API_ENDPOINT_KUBERNETES', API_ENDPOINT_KUBERNETES)
.constant('API_ENDPOINT_MOTD', API_ENDPOINT_MOTD)
.constant('API_ENDPOINT_REGISTRIES', API_ENDPOINT_REGISTRIES)
.constant('API_ENDPOINT_RESOURCE_CONTROLS', API_ENDPOINT_RESOURCE_CONTROLS)
.constant('API_ENDPOINT_SETTINGS', API_ENDPOINT_SETTINGS)
.constant('API_ENDPOINT_STACKS', API_ENDPOINT_STACKS)
.constant('API_ENDPOINT_SUPPORT', API_ENDPOINT_SUPPORT)
.constant('API_ENDPOINT_USERS', API_ENDPOINT_USERS)
.constant('API_ENDPOINT_TAGS', API_ENDPOINT_TAGS)
.constant('API_ENDPOINT_TEAMS', API_ENDPOINT_TEAMS)
.constant('API_ENDPOINT_TEAM_MEMBERSHIPS', API_ENDPOINT_TEAM_MEMBERSHIPS)
.constant('API_ENDPOINT_TEMPLATES', API_ENDPOINT_TEMPLATES)
.constant('API_ENDPOINT_WEBHOOKS', API_ENDPOINT_WEBHOOKS)
.constant('PAGINATION_MAX_ITEMS', PAGINATION_MAX_ITEMS)
.constant('APPLICATION_CACHE_VALIDITY', APPLICATION_CACHE_VALIDITY)
.constant('CONSOLE_COMMANDS_LABEL_PREFIX', CONSOLE_COMMANDS_LABEL_PREFIX)
.constant('PREDEFINED_NETWORKS', PREDEFINED_NETWORKS)
.constant('BROWSER_OS_PLATFORM', BROWSER_OS_PLATFORM);

@ -0,0 +1,30 @@
// try to declare constant where it's needed, not in a global file
export const API_ENDPOINT_AUTH = 'api/auth';
export const API_ENDPOINT_BACKUP = 'api/backup';
export const API_ENDPOINT_CUSTOM_TEMPLATES = 'api/custom_templates';
export const API_ENDPOINT_EDGE_GROUPS = 'api/edge_groups';
export const API_ENDPOINT_EDGE_JOBS = 'api/edge_jobs';
export const API_ENDPOINT_EDGE_STACKS = 'api/edge_stacks';
export const API_ENDPOINT_EDGE_TEMPLATES = 'api/edge_templates';
export const API_ENDPOINT_ENDPOINTS = 'api/endpoints';
export const API_ENDPOINT_ENDPOINT_GROUPS = 'api/endpoint_groups';
export const API_ENDPOINT_KUBERNETES = 'api/kubernetes';
export const API_ENDPOINT_MOTD = 'api/motd';
export const API_ENDPOINT_REGISTRIES = 'api/registries';
export const API_ENDPOINT_RESOURCE_CONTROLS = 'api/resource_controls';
export const API_ENDPOINT_SETTINGS = 'api/settings';
export const API_ENDPOINT_STACKS = 'api/stacks';
export const API_ENDPOINT_SUPPORT = 'api/support';
export const API_ENDPOINT_USERS = 'api/users';
export const API_ENDPOINT_TAGS = 'api/tags';
export const API_ENDPOINT_TEAMS = 'api/teams';
export const API_ENDPOINT_TEAM_MEMBERSHIPS = 'api/team_memberships';
export const API_ENDPOINT_TEMPLATES = 'api/templates';
export const API_ENDPOINT_WEBHOOKS = 'api/webhooks';
export const PAGINATION_MAX_ITEMS = 10;
export const APPLICATION_CACHE_VALIDITY = 3600;
export const CONSOLE_COMMANDS_LABEL_PREFIX = 'io.portainer.commands.';
export const PREDEFINED_NETWORKS = ['host', 'bridge', 'ingress', 'nat', 'none'];
export const PORTAINER_FADEOUT = 1500;
export const STACK_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$';
export const TEMPLATE_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$';

@ -16,8 +16,13 @@ class DockerComposeFormController {
this.onChangeFormValues = this.onChangeFormValues.bind(this);
}
onChangeFormValues(values) {
this.formValues = values;
onChangeFormValues(newValues) {
return this.$async(async () => {
this.formValues = {
...this.formValues,
...newValues,
};
});
}
onChangeMethod(method) {

@ -21,7 +21,7 @@
<file-upload-description> You can upload a Compose file from your computer. </file-upload-description>
</file-upload-form>
<git-form ng-if="$ctrl.state.Method === 'repository'" model="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)" hide-rebuild-info="true"></git-form>
<git-form ng-if="$ctrl.state.Method === 'repository'" value="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)"></git-form>
<!-- template -->
<div ng-if="$ctrl.state.Method === 'template'">

@ -39,7 +39,9 @@ class KubeManifestFormController {
}
onChangeMethod(method) {
this.state.Method = method;
return this.$async(async () => {
this.state.Method = method;
});
}
}

@ -32,10 +32,4 @@
</file-upload-description>
</file-upload-form>
<git-form
ng-if="$ctrl.state.Method === 'repository'"
deploy-method="kubernetes"
model="$ctrl.formValues"
on-change="($ctrl.onChangeFormValues)"
hide-rebuild-info="true"
></git-form>
<git-form ng-if="$ctrl.state.Method === 'repository'" deploy-method="kubernetes" value="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)"></git-form>

@ -19,6 +19,7 @@ import './portainer/__module';
import { onStartupAngular } from './app';
import { configApp } from './config';
import { constantsModule } from './ng-constants';
import { nomadModule } from './nomad';
@ -52,6 +53,7 @@ angular
'moment-picker',
'angulartics',
analyticsModule,
constantsModule,
])
.run(onStartupAngular)
.config(configApp);

@ -63,11 +63,11 @@
<div ng-if="!ctrl.isExternalApplication()">
<git-form-info-panel
ng-if="ctrl.state.appType == ctrl.KubernetesDeploymentTypes.GIT"
class-name="text-muted"
class-name="'text-muted'"
url="ctrl.stack.GitConfig.URL"
config-file-path="ctrl.stack.GitConfig.ConfigFilePath"
additional-files="ctrl.stack.AdditionalFiles"
type="application"
type="'application'"
></git-form-info-panel>
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"> Namespace </div>
<!-- #region NAMESPACE -->

@ -13,7 +13,7 @@
<uib-tab index="0">
<uib-tab-heading> <pr-icon icon="'code'"></pr-icon> Deploy </uib-tab-heading>
<div class="col-sm-12 form-section-title"> Namespace </div>
<form class="form-horizontal mt-3" name="deploymentForm">
<form class="form-horizontal mt-3" name="ctrl.deploymentForm">
<div class="form-group" ng-if="ctrl.formValues.Namespace">
<label for="target_node" class="col-lg-2 col-sm-3 control-label text-left">Namespace</label>
<div class="col-sm-8">
@ -78,14 +78,12 @@
<!-- repository -->
<git-form
ng-if="ctrl.state.BuildMethod === ctrl.BuildMethods.GIT"
model="ctrl.formValues"
value="ctrl.formValues"
on-change="(ctrl.onChangeFormValues)"
additional-file="true"
auto-update="true"
show-auth-explanation="true"
path-text-title="Manifest path"
path-placeholder="deployment.yml"
deploy-method="{{ ctrl.DeployMethod }}"
is-additional-files-field-visible="true"
is-auth-explanation-visible="true"
deploy-method="{{ ctrl.state.DeployType === ctrl.ManifestDeployTypes.COMPOSE ? 'compose' : 'manifest' }}"
base-webhook-url="{{ ctrl.state.baseWebhookUrl }}"
></git-form>
<!-- !repository -->
@ -161,7 +159,7 @@
<button
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="!deploymentForm.$valid ||ctrl.disableDeploy()"
ng-disabled="!ctrl.deploymentForm.$valid || ctrl.disableDeploy()"
ng-click="ctrl.deploy()"
button-spinner="ctrl.state.actionInProgress"
data-cy="k8sAppDeploy-deployButton"

@ -1,7 +1,6 @@
import angular from 'angular';
import _ from 'lodash-es';
import stripAnsi from 'strip-ansi';
import uuidv4 from 'uuid/v4';
import PortainerError from '@/portainer/error';
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
@ -9,11 +8,13 @@ import { renderTemplate } from '@/react/portainer/custom-templates/components/ut
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
import { editor, git, customTemplate, url } from '@@/BoxSelector/common-options/build-methods';
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { baseStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
import { confirmWebEditorDiscard } from '@@/modals/confirm';
class KubernetesDeployController {
/* @ngInject */
constructor($async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, WebhookHelper, CustomTemplateService) {
constructor($async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, CustomTemplateService) {
this.$async = $async;
this.$state = $state;
this.$window = $window;
@ -21,9 +22,7 @@ class KubernetesDeployController {
this.Notifications = Notifications;
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
this.StackService = StackService;
this.WebhookHelper = WebhookHelper;
this.CustomTemplateService = CustomTemplateService;
this.DeployMethod = 'manifest';
this.isTemplateVariablesEnabled = isBE;
@ -45,6 +44,7 @@ class KubernetesDeployController {
isEditorDirty: false,
templateId: null,
template: null,
baseWebhookUrl: baseStackWebhookUrl(),
};
this.formValues = {
@ -56,11 +56,8 @@ class KubernetesDeployController {
RepositoryPassword: '',
AdditionalFiles: [],
ComposeFilePathInRepository: '',
RepositoryAutomaticUpdates: false,
RepositoryMechanism: RepositoryMechanismTypes.INTERVAL,
RepositoryFetchInterval: '5m',
RepositoryWebhookURL: WebhookHelper.returnStackWebhookUrl(uuidv4()),
Variables: {},
AutoUpdate: parseAutoUpdateResponse(),
};
this.ManifestDeployTypes = KubernetesDeployManifestTypes;
@ -145,36 +142,33 @@ class KubernetesDeployController {
}
onChangeMethod(method) {
this.state.BuildMethod = method;
return this.$async(async () => {
this.state.BuildMethod = method;
});
}
onChangeDeployType(type) {
this.state.DeployType = type;
if (type == this.ManifestDeployTypes.COMPOSE) {
this.DeployMethod = 'compose';
} else {
this.DeployMethod = 'manifest';
}
return this.$async(async () => {
this.state.DeployType = type;
});
}
disableDeploy() {
const isGitFormInvalid =
this.state.BuildMethod === KubernetesDeployBuildMethods.GIT &&
(!this.formValues.RepositoryURL || !this.formValues.FilePathInRepository || (this.formValues.RepositoryAuthentication && !this.formValues.RepositoryPassword)) &&
_.isEmpty(this.formValues.Namespace);
const isWebEditorInvalid =
this.state.BuildMethod === KubernetesDeployBuildMethods.WEB_EDITOR && _.isEmpty(this.formValues.EditorContent) && _.isEmpty(this.formValues.Namespace);
const isURLFormInvalid = this.state.BuildMethod == KubernetesDeployBuildMethods.WEB_EDITOR.URL && _.isEmpty(this.formValues.ManifestURL);
const isNamespaceInvalid = _.isEmpty(this.formValues.Namespace);
return !this.formValues.StackName || isGitFormInvalid || isWebEditorInvalid || isURLFormInvalid || this.state.actionInProgress || isNamespaceInvalid;
return !this.formValues.StackName || isWebEditorInvalid || isURLFormInvalid || this.state.actionInProgress || isNamespaceInvalid;
}
onChangeFormValues(values) {
this.formValues = {
...this.formValues,
...values,
};
onChangeFormValues(newValues) {
return this.$async(async () => {
this.formValues = {
...this.formValues,
...newValues,
};
});
}
onChangeTemplateId(templateId, template) {
@ -262,14 +256,7 @@ class KubernetesDeployController {
}
payload.ManifestFile = this.formValues.ComposeFilePathInRepository;
payload.AdditionalFiles = this.formValues.AdditionalFiles;
if (this.formValues.RepositoryAutomaticUpdates) {
payload.AutoUpdate = {};
if (this.formValues.RepositoryMechanism === RepositoryMechanismTypes.INTERVAL) {
payload.AutoUpdate.Interval = this.formValues.RepositoryFetchInterval;
} else if (this.formValues.RepositoryMechanism === RepositoryMechanismTypes.WEBHOOK) {
payload.AutoUpdate.Webhook = this.formValues.RepositoryWebhookURL.split('/').reverse()[0];
}
}
payload.AutoUpdate = transformAutoUpdateViewModel(this.formValues.AutoUpdate);
} else if (method === KubernetesDeployRequestMethods.STRING) {
payload.StackFileContent = this.formValues.EditorContent;
} else {

@ -0,0 +1,65 @@
import angular from 'angular';
import {
API_ENDPOINT_AUTH,
API_ENDPOINT_BACKUP,
API_ENDPOINT_CUSTOM_TEMPLATES,
API_ENDPOINT_EDGE_GROUPS,
API_ENDPOINT_EDGE_JOBS,
API_ENDPOINT_EDGE_STACKS,
API_ENDPOINT_EDGE_TEMPLATES,
API_ENDPOINT_ENDPOINTS,
API_ENDPOINT_ENDPOINT_GROUPS,
API_ENDPOINT_KUBERNETES,
API_ENDPOINT_MOTD,
API_ENDPOINT_REGISTRIES,
API_ENDPOINT_RESOURCE_CONTROLS,
API_ENDPOINT_SETTINGS,
API_ENDPOINT_STACKS,
API_ENDPOINT_SUPPORT,
API_ENDPOINT_USERS,
API_ENDPOINT_TAGS,
API_ENDPOINT_TEAMS,
API_ENDPOINT_TEAM_MEMBERSHIPS,
API_ENDPOINT_TEMPLATES,
API_ENDPOINT_WEBHOOKS,
PAGINATION_MAX_ITEMS,
APPLICATION_CACHE_VALIDITY,
CONSOLE_COMMANDS_LABEL_PREFIX,
PREDEFINED_NETWORKS,
} from './constants';
import { BROWSER_OS_PLATFORM } from './react/constants';
// don't declare new constants, either:
// - if only used in one file or module, declare in that file or module (as a regular js constant)
// - if needed across modules, declare like in `./constants` and use es6 import for that
export const constantsModule = angular
.module('portainer.app.constants', [])
.constant('API_ENDPOINT_AUTH', API_ENDPOINT_AUTH)
.constant('API_ENDPOINT_BACKUP', API_ENDPOINT_BACKUP)
.constant('API_ENDPOINT_CUSTOM_TEMPLATES', API_ENDPOINT_CUSTOM_TEMPLATES)
.constant('API_ENDPOINT_EDGE_GROUPS', API_ENDPOINT_EDGE_GROUPS)
.constant('API_ENDPOINT_EDGE_JOBS', API_ENDPOINT_EDGE_JOBS)
.constant('API_ENDPOINT_EDGE_STACKS', API_ENDPOINT_EDGE_STACKS)
.constant('API_ENDPOINT_EDGE_TEMPLATES', API_ENDPOINT_EDGE_TEMPLATES)
.constant('API_ENDPOINT_ENDPOINTS', API_ENDPOINT_ENDPOINTS)
.constant('API_ENDPOINT_ENDPOINT_GROUPS', API_ENDPOINT_ENDPOINT_GROUPS)
.constant('API_ENDPOINT_KUBERNETES', API_ENDPOINT_KUBERNETES)
.constant('API_ENDPOINT_MOTD', API_ENDPOINT_MOTD)
.constant('API_ENDPOINT_REGISTRIES', API_ENDPOINT_REGISTRIES)
.constant('API_ENDPOINT_RESOURCE_CONTROLS', API_ENDPOINT_RESOURCE_CONTROLS)
.constant('API_ENDPOINT_SETTINGS', API_ENDPOINT_SETTINGS)
.constant('API_ENDPOINT_STACKS', API_ENDPOINT_STACKS)
.constant('API_ENDPOINT_SUPPORT', API_ENDPOINT_SUPPORT)
.constant('API_ENDPOINT_USERS', API_ENDPOINT_USERS)
.constant('API_ENDPOINT_TAGS', API_ENDPOINT_TAGS)
.constant('API_ENDPOINT_TEAMS', API_ENDPOINT_TEAMS)
.constant('API_ENDPOINT_TEAM_MEMBERSHIPS', API_ENDPOINT_TEAM_MEMBERSHIPS)
.constant('API_ENDPOINT_TEMPLATES', API_ENDPOINT_TEMPLATES)
.constant('API_ENDPOINT_WEBHOOKS', API_ENDPOINT_WEBHOOKS)
.constant('PAGINATION_MAX_ITEMS', PAGINATION_MAX_ITEMS)
.constant('APPLICATION_CACHE_VALIDITY', APPLICATION_CACHE_VALIDITY)
.constant('CONSOLE_COMMANDS_LABEL_PREFIX', CONSOLE_COMMANDS_LABEL_PREFIX)
.constant('PREDEFINED_NETWORKS', PREDEFINED_NETWORKS)
.constant('BROWSER_OS_PLATFORM', BROWSER_OS_PLATFORM).name;

@ -11,6 +11,7 @@ import servicesModule from './services';
import { reactModule } from './react';
import { sidebarModule } from './react/views/sidebar';
import environmentsModule from './environments';
import { helpersModule } from './helpers';
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
authManager.checkAuthOnRefresh();
@ -41,6 +42,7 @@ angular
reactModule,
sidebarModule,
environmentsModule,
helpersModule,
])
.config([
'$stateRegistryProvider',

@ -1,19 +0,0 @@
class GitFormAdditionalFileItemController {
onChangePath(value) {
const fieldIsInvalid = typeof value === 'undefined';
if (fieldIsInvalid) {
return;
}
this.onChange(this.index, { value });
}
removeValue() {
this.onChange(this.index);
}
$onInit() {
this.formName = `variableForm${this.index}`;
}
}
export default GitFormAdditionalFileItemController;

@ -1,20 +0,0 @@
<ng-form class="env-item form-horizontal" name="$ctrl.{{ $ctrl.formName }}">
<div class="form-group col-sm-12">
<div class="form-inline mt-3">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon required">path</span>
<input type="text" name="name" class="form-control" ng-model="$ctrl.variable" ng-change="$ctrl.onChangePath($ctrl.variable)" required />
</div>
<button class="btn btn-dangerlight" type="button" ng-click="$ctrl.removeValue()" title="Remove">
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
</button>
</div>
<div ng-show="$ctrl[$ctrl.formName].name.$invalid">
<div class="small text-warning">
<div ng-messages="$ctrl[$ctrl.formName].name.$error" class="mt-1">
<p class="vertical-center" ng-message="required"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Path is required. </p>
</div>
</div>
</div>
</div>
</ng-form>

@ -1,14 +0,0 @@
import controller from './git-form-additional-file-item.controller.js';
export const gitFormAdditionalFileItem = {
templateUrl: './git-form-additional-file-item.html',
controller,
bindings: {
variable: '<',
index: '<',
onChange: '<',
onRemove: '<',
},
};

@ -1,26 +0,0 @@
class GitFormAutoUpdateFieldsetController {
/* @ngInject */
constructor() {
this.add = this.add.bind(this);
this.onChangeVariable = this.onChangeVariable.bind(this);
}
add() {
this.model.AdditionalFiles.push('');
}
onChangeVariable(index, variable) {
if (!variable) {
this.model.AdditionalFiles.splice(index, 1);
} else {
this.model.AdditionalFiles[index] = variable.value;
}
this.onChange({
...this.model,
AdditionalFiles: this.model.AdditionalFiles,
});
}
}
export default GitFormAutoUpdateFieldsetController;

@ -1,18 +0,0 @@
<div class="form-group">
<div class="col-sm-12 p-0">
<div class="col-sm-3 col-lg-2">
<label class="control-label text-left">Additional paths</label>
</div>
<div class="col-sm-9 pt-1">
<span class="label label-default interactive vertical-center" ng-click="$ctrl.add()"> <pr-icon icon="'plus'" size="'sm'" mode="'alt'"></pr-icon> <span>add file</span> </span>
</div>
</div>
<div class="col-sm-12 form-inline">
<git-form-additional-file-item
ng-repeat="variable in $ctrl.model.AdditionalFiles track by $index"
variable="variable"
index="$index"
on-change="($ctrl.onChangeVariable)"
></git-form-additional-file-item>
</div>
</div>

@ -1,10 +0,0 @@
import controller from './git-form-additional-files-panel.controller.js';
export const gitFormAdditionalFilesPanel = {
templateUrl: './git-form-additional-files-panel.html',
controller,
bindings: {
model: '<',
onChange: '<',
},
};

@ -0,0 +1,97 @@
import { IFormController } from 'angular';
import { FormikErrors } from 'formik';
import { notifyError } from '@/portainer/services/notifications';
import { IAuthenticationService } from '@/portainer/services/types';
import { GitAuthModel } from '@/react/portainer/gitops/types';
import { gitAuthValidation } from '@/react/portainer/gitops/AuthFieldset';
import { getGitCredentials } from '@/portainer/views/account/git-credential/gitCredential.service';
import { GitCredential } from '@/portainer/views/account/git-credential/types';
import { validateForm } from '@@/form-components/validate-form';
export default class GitFormAuthFieldsetController {
errors?: FormikErrors<GitAuthModel> = {};
$async: <T>(fn: () => Promise<T>) => Promise<T>;
gitFormAuthFieldset?: IFormController;
gitCredentials: Array<GitCredential> = [];
Authentication: IAuthenticationService;
value?: GitAuthModel;
onChange?: (value: GitAuthModel) => void;
/* @ngInject */
constructor(
$async: <T>(fn: () => Promise<T>) => Promise<T>,
Authentication: IAuthenticationService
) {
this.$async = $async;
this.Authentication = Authentication;
this.handleChange = this.handleChange.bind(this);
this.runGitValidation = this.runGitValidation.bind(this);
}
async handleChange(newValues: Partial<GitAuthModel>) {
// this should never happen, but just in case
if (!this.value) {
throw new Error('GitFormController: value is required');
}
const value = {
...this.value,
...newValues,
};
this.onChange?.(value);
await this.runGitValidation(value);
}
async runGitValidation(value: GitAuthModel) {
return this.$async(async () => {
this.errors = {};
this.gitFormAuthFieldset?.$setValidity(
'gitFormAuth',
true,
this.gitFormAuthFieldset
);
this.errors = await validateForm<GitAuthModel>(
() => gitAuthValidation(this.gitCredentials),
value
);
if (this.errors && Object.keys(this.errors).length > 0) {
this.gitFormAuthFieldset?.$setValidity(
'gitFormAuth',
false,
this.gitFormAuthFieldset
);
}
});
}
async $onInit() {
try {
this.gitCredentials = await getGitCredentials(
this.Authentication.getUserDetails().ID
);
} catch (err) {
notifyError(
'Failure',
err as Error,
'Unable to retrieve user saved git credentials'
);
}
// this should never happen, but just in case
if (!this.value) {
throw new Error('GitFormController: value is required');
}
await this.runGitValidation(this.value);
}
}

@ -0,0 +1,21 @@
import { IComponentOptions } from 'angular';
import controller from './git-form-auth-fieldset.controller';
export const gitFormAuthFieldset: IComponentOptions = {
controller,
template: `
<ng-form name="$ctrl.gitFormAuthFieldset">
<react-git-form-auth-fieldset
value="$ctrl.value"
on-change="$ctrl.handleChange"
is-explanation-visible="$ctrl.isExplanationVisible"
errors="$ctrl.errors">
</react-git-form-auth-fieldset>
</ng-form>`,
bindings: {
value: '<',
onChange: '<',
isExplanationVisible: '<',
},
};

@ -1,55 +0,0 @@
class GitFormComposeAuthFieldsetController {
/* @ngInject */
constructor($scope) {
Object.assign(this, { $scope });
this.authValues = {
username: '',
password: '',
};
this.handleChange = this.handleChange.bind(this);
this.onChangeField = this.onChangeField.bind(this);
this.onChangeAuth = this.onChangeAuth.bind(this);
this.onChangeUsername = this.onChangeField('RepositoryUsername');
this.onChangePassword = this.onChangeField('RepositoryPassword');
}
handleChange(...args) {
this.$scope.$evalAsync(() => {
this.onChange(...args);
});
}
onChangeField(field) {
return (value) => {
this.handleChange({
...this.model,
[field]: value,
});
};
}
onChangeAuth(auth) {
if (!auth) {
this.authValues.username = this.model.RepositoryUsername;
this.authValues.password = this.model.RepositoryPassword;
}
this.handleChange({
...this.model,
RepositoryAuthentication: auth,
RepositoryUsername: auth ? this.authValues.username : '',
RepositoryPassword: auth ? this.authValues.password : '',
});
}
$onInit() {
if (this.model.RepositoryAuthentication) {
this.authValues.username = this.model.RepositoryUsername;
this.authValues.password = this.model.RepositoryPassword;
}
}
}
export default GitFormComposeAuthFieldsetController;

@ -1,11 +0,0 @@
.inline-label {
display: inline-block;
padding: 0 15px;
min-width: 200px;
}
.inline-input {
display: inline-block;
margin-left: 15px;
width: calc(100% - 235px);
}

@ -1,51 +0,0 @@
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="$ctrl.model.RepositoryAuthentication"
label="'Authentication'"
label-class="'col-sm-3 col-lg-2'"
name="'authSwitch'"
on-change="($ctrl.onChangeAuth)"
data-cy="'component-gitAuthToggle'"
switch-values="{on:'Yes',off:'No'}"
></por-switch-field>
</div>
</div>
<div class="small mt-1 mb-3" ng-if="$ctrl.model.RepositoryAuthentication && $ctrl.showAuthExplanation">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
<span class="text-muted">Enabling authentication will store the credentials and it is advisable to use a git service account</span>
</div>
<div ng-if="$ctrl.model.RepositoryAuthentication" class="row">
<div class="form-group">
<label for="repository_username" class="col-lg-2 col-sm-3 control-label required text-left"> Username </label>
<div class="col-sm-8">
<input
type="text"
class="form-control"
ng-model="$ctrl.model.RepositoryUsername"
name="repository_username"
placeholder="git username"
ng-change="$ctrl.onChangeUsername($ctrl.model.RepositoryUsername)"
data-cy="component-gitUsernameInput"
/>
</div>
</div>
<div class="form-group flex">
<label for="repository_password" class="col-lg-2 col-sm-3 control-label !pt-0 text-left">
<div class="required"> Personal Access Token </div>
<portainer-tooltip message="'Provide a personal access token or password'"></portainer-tooltip>
</label>
<div class="col-sm-8">
<input
type="password"
class="form-control"
ng-model="$ctrl.model.RepositoryPassword"
name="repository_password"
placeholder="*******"
ng-change="$ctrl.onChangePassword($ctrl.model.RepositoryPassword)"
ng-required="!$ctrl.isEdit"
data-cy="component-gitPasswordInput"
/>
</div>
</div>
</div>

@ -1,13 +0,0 @@
import controller from './git-form-auth-fieldset.controller.js';
import './git-form-auth-fieldset.css';
export const gitFormAuthFieldset = {
templateUrl: './git-form-auth-fieldset.html',
controller,
bindings: {
model: '<',
onChange: '<',
showAuthExplanation: '<',
isEdit: '<',
},
};

@ -0,0 +1,80 @@
import { IFormController } from 'angular';
import { FormikErrors } from 'formik';
import { IAuthenticationService } from '@/portainer/services/types';
import { AutoUpdateModel } from '@/react/portainer/gitops/types';
import { autoUpdateValidation } from '@/react/portainer/gitops/AutoUpdateFieldset/validation';
import { validateForm } from '@@/form-components/validate-form';
export default class GitFormAutoUpdateFieldsetController {
errors?: FormikErrors<AutoUpdateModel> = {};
$async: <T>(fn: () => Promise<T>) => Promise<T>;
gitFormAutoUpdate?: IFormController;
Authentication: IAuthenticationService;
value?: AutoUpdateModel;
onChange?: (value: AutoUpdateModel) => void;
/* @ngInject */
constructor(
$async: <T>(fn: () => Promise<T>) => Promise<T>,
Authentication: IAuthenticationService
) {
this.$async = $async;
this.Authentication = Authentication;
this.handleChange = this.handleChange.bind(this);
this.runGitValidation = this.runGitValidation.bind(this);
}
async handleChange(newValues: Partial<AutoUpdateModel>) {
// this should never happen, but just in case
if (!this.value) {
throw new Error('GitFormController: value is required');
}
const value = {
...this.value,
...newValues,
};
this.onChange?.(value);
await this.runGitValidation(value);
}
async runGitValidation(value: AutoUpdateModel) {
return this.$async(async () => {
this.errors = {};
this.gitFormAutoUpdate?.$setValidity(
'gitFormAuth',
true,
this.gitFormAutoUpdate
);
this.errors = await validateForm<AutoUpdateModel>(
() => autoUpdateValidation(),
value
);
if (this.errors && Object.keys(this.errors).length > 0) {
this.gitFormAutoUpdate?.$setValidity(
'gitFormAuth',
false,
this.gitFormAutoUpdate
);
}
});
}
async $onInit() {
// this should never happen, but just in case
if (!this.value) {
throw new Error('GitFormController: value is required');
}
await this.runGitValidation(this.value);
}
}

@ -0,0 +1,24 @@
import { IComponentOptions } from 'angular';
import controller from './git-form-auto-update-fieldset.controller';
export const gitFormAutoUpdate: IComponentOptions = {
template: `<ng-form name="$ctrl.gitFormAutoUpdate">
<react-git-form-auto-update-fieldset
value="$ctrl.value"
on-change="$ctrl.handleChange"
environment-type="$ctrl.environmentType"
is-force-pull-visible="$ctrl.isForcePullVisible"
base-webhook-url="$ctrl.baseWebhookUrl"
errors="$ctrl.errors">
</react-git-form-auto-update-fieldset>
</ng-form>`,
bindings: {
value: '<',
onChange: '<',
environmentType: '@',
isForcePullVisible: '<',
baseWebhookUrl: '@',
},
controller,
};

@ -1,38 +0,0 @@
import { FeatureId } from '@/react/portainer/feature-flags/enums';
class GitFormAutoUpdateFieldsetController {
/* @ngInject */
constructor($scope, clipboard, StateManager) {
Object.assign(this, { $scope, clipboard, StateManager });
this.onChangeAutoUpdate = this.onChangeField('RepositoryAutomaticUpdates');
this.onChangeMechanism = this.onChangeField('RepositoryMechanism');
this.onChangeInterval = this.onChangeField('RepositoryFetchInterval');
this.limitedFeature = FeatureId.FORCE_REDEPLOYMENT;
this.stackPullImageFeature = FeatureId.STACK_PULL_IMAGE;
}
copyWebhook() {
this.clipboard.copyText(this.model.RepositoryWebhookURL);
$('#copyNotification').show();
$('#copyNotification').fadeOut(2000);
}
onChangeField(field) {
return (value) => {
this.$scope.$evalAsync(() => {
this.onChange({
...this.model,
[field]: value,
});
});
};
}
$onInit() {
this.environmentType = this.StateManager.getState().endpoint.mode.provider;
}
}
export default GitFormAutoUpdateFieldsetController;

@ -1,122 +0,0 @@
<ng-form name="autoUpdateForm" class="form-group">
<div class="small vertical-center mb-2">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
<span class="text-muted">
When enabled, at each polling interval or webhook invocation, if the git repo differs from what was stored locally on the last git pull, the changes are deployed.</span
>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
name="'autoUpdate'"
checked="$ctrl.model.RepositoryAutomaticUpdates"
label="'Automatic updates'"
label-class="'col-sm-3 col-lg-2'"
on-change="($ctrl.onChangeAutoUpdate)"
switch-values="{on:'Yes',off:'No'}"
></por-switch-field>
</div>
</div>
<div class="small vertical-center" ng-if="$ctrl.model.RepositoryAutomaticUpdates">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
<span class="text-muted"
>Any changes to this stack or application that have been made locally via Portainer or directly in the cluster will be overwritten by the git repository content, which may
cause service interruption.</span
>
</div>
<div class="form-group mt-2" ng-if="$ctrl.model.RepositoryAutomaticUpdates">
<label for="repository_mechanism" class="col-lg-2 col-sm-3 control-label text-left"> Mechanism </label>
<div class="col-sm-8">
<div class="input-group col-sm-10 input-group-sm">
<div class="btn-group btn-group-sm">
<label class="btn btn-light" ng-click="$ctrl.onChangeMechanism($ctrl.model.RepositoryMechanism)" ng-model="$ctrl.model.RepositoryMechanism" uib-btn-radio="'Interval'"
>Polling</label
>
<label class="btn btn-light" ng-click="$ctrl.onChangeMechanism($ctrl.model.RepositoryMechanism)" ng-model="$ctrl.model.RepositoryMechanism" uib-btn-radio="'Webhook'"
>Webhook</label
>
</div>
</div>
</div>
</div>
<div class="form-group" ng-if="$ctrl.model.RepositoryAutomaticUpdates && $ctrl.model.RepositoryMechanism === 'Webhook'">
<label for="repository_mechanism" class="col-sm-3 col-lg-2 control-label text-left">
Webhook
<portainer-tooltip
message="$ctrl.environmentType === 'KUBERNETES' ?
'See&nbsp;<a href=\'https://docs.portainer.io/user/kubernetes/applications/webhooks\' target=\'_blank\' rel=\'noreferrer\'>Portainer documentation on webhook usage</a>.' :
'See&nbsp;<a href=\'https://docs.portainer.io/user/docker/stacks/webhooks\' target=\'_blank\' rel=\'noreferrer\'>Portainer documentation on webhook usage</a>.'"
set-html-message="true"
></portainer-tooltip>
</label>
<div class="col-sm-8">
<span class="text-muted"> {{ $ctrl.model.RepositoryWebhookURL | truncatelr }} </span>
<button type="button" class="btn btn-sm btn-light btn-sm space-left vertical-center" ng-if="$ctrl.model.RepositoryWebhookURL" ng-click="$ctrl.copyWebhook()">
<pr-icon icon="'copy'" size="'sm'"></pr-icon> Copy link
</button>
<span>
<pr-icon icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span>
</div>
</div>
<div class="form-group" ng-if="$ctrl.model.RepositoryAutomaticUpdates && $ctrl.model.RepositoryMechanism === 'Interval'">
<label for="repository_fetch_interval" class="col-sm-3 col-lg-2 control-label required text-left">
Fetch interval
<portainer-tooltip
message="'Specify how frequently polling occurs using syntax such as, 5m = 5 minutes, 24h = 24 hours, 6h40m = 6 hours and 40 minutes.'"
></portainer-tooltip>
</label>
<div class="col-sm-8">
<input
type="text"
class="form-control"
ng-change="$ctrl.onChangeInterval($ctrl.model.RepositoryFetchInterval)"
ng-model="$ctrl.model.RepositoryFetchInterval"
name="repository_fetch_interval"
placeholder="5m"
required
interval-format
/>
<div class="help-group">
<div class="form-group col-md-12 pt-1" ng-show="autoUpdateForm.repository_fetch_interval.$touched && autoUpdateForm.repository_fetch_interval.$invalid">
<div class="small text-warning">
<div ng-messages="autoUpdateForm.repository_fetch_interval.$error">
<p ng-message="required"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This field is required.</p>
<p ng-message="invalidIntervalFormat"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Please enter a valid time interval.</p>
<p ng-message="minimumInterval"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Minimum interval is 1m</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="form-group" ng-if="$ctrl.showForcePullImage && $ctrl.model.RepositoryAutomaticUpdates">
<div class="col-sm-12">
<por-switch-field
name="'forcePullImage'"
feature-id="$ctrl.stackPullImageFeature"
checked="$ctrl.model.ForcePullImage"
label="'Re-pull image'"
label-class="'col-sm-3 col-lg-2'"
tooltip="'If enabled, then when redeploy is triggered via the webhook or polling, if there\'s a newer image with the tag that you\'ve specified (e.g. changeable development builds), it\'s pulled and redeployed. If you haven\'t specified a tag, or have specified \'latest\' as the tag, then the image with the tag \'latest\' is pulled and redeployed.'"
></por-switch-field>
</div>
</div>
<div class="form-group" ng-if="$ctrl.model.RepositoryAutomaticUpdates">
<div class="col-sm-12">
<por-switch-field
name="'forceUpdate'"
feature-id="$ctrl.limitedFeature"
checked="$ctrl.model.RepositoryAutomaticUpdatesForce"
label="$ctrl.environmentType === 'KUBERNETES' ? 'Always apply manifest' : 'Force redeployment'"
tooltip="$ctrl.environmentType === 'KUBERNETES' ? '<p>If enabled, then when redeploy is triggered via the webhook or polling, kubectl apply is always performed, even if Portainer detects no difference between the git repo and what was stored locally on last git pull.</ p><p>This is useful if you want your git repo to be the source of truth and are fine with changes made directly to resources in the cluster being overwritten.</ p>' : ''"
set-tooltip-html-message="true"
label-class="'col-sm-3 col-lg-2'"
on-change="($ctrl.onChangeAutoUpdateForce)"
></por-switch-field>
</div>
</div>
</ng-form>

@ -1,11 +0,0 @@
import controller from './git-form-auto-update-fieldset.controller.js';
export const gitFormAutoUpdateFieldset = {
templateUrl: './git-form-auto-update-fieldset.html',
controller,
bindings: {
model: '<',
onChange: '<',
showForcePullImage: '<',
},
};

@ -1,29 +0,0 @@
<ng-form name="pathForm">
<div class="form-group">
<span class="col-sm-12 text-muted small vertical-center">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
<span
>Indicate the path to the {{ $ctrl.deployMethod == 'compose' ? 'Compose' : 'Manifest' }} file from the root of your repository.
<span ng-if="$ctrl.isDockerStandalone">
To enable rebuilding of an image if already present on Docker standalone environments, include<code>pull_policy: build</code>in your compose file as per
<a href="https://docs.docker.com/compose/compose-file/#pull_policy">Docker documentation</a>.</span
></span
>
</span>
</div>
<div class="form-group">
<label for="stack_repository_path" class="col-lg-2 col-sm-3 control-label required text-left">{{ $ctrl.deployMethod == 'compose' ? 'Compose' : 'Manifest' }} path</label>
<div class="col-sm-8">
<input
type="text"
class="form-control"
name="repoPathField"
ng-model="$ctrl.value"
ng-change="$ctrl.onChange($ctrl.value)"
id="stack_repository_path"
placeholder="{{ $ctrl.deployMethod == 'compose' ? 'docker-compose.yml' : 'manifest.yml' }}"
required
/>
</div>
</div>
</ng-form>

@ -1,9 +0,0 @@
export const gitFormComposePathField = {
templateUrl: './git-form-compose-path-field.html',
bindings: {
deployMethod: '@',
value: '<',
onChange: '<',
isDockerStandalone: '<',
},
};

@ -1,15 +0,0 @@
<div class="form-group" ng-class="$ctrl.className">
<div class="col-sm-12">
<p>
This {{ $ctrl.type }} was deployed from the git repository <code>{{ $ctrl.url }}</code>
.
</p>
<p>
Update
<code
>{{ $ctrl.configFilePath }}<span ng-if="$ctrl.additionalFiles.length > 0">, {{ $ctrl.additionalFiles.join(',') }}</span></code
>
in git and pull from here to update the {{ $ctrl.type }}.
</p>
</div>
</div>

@ -1,10 +0,0 @@
export const gitFormInfoPanel = {
templateUrl: './git-form-info-panel.html',
bindings: {
url: '<',
configFilePath: '<',
additionalFiles: '<',
className: '@',
type: '@',
},
};

@ -0,0 +1,83 @@
import { IComponentOptions, IFormController } from 'angular';
import { GitFormModel } from '@/react/portainer/gitops/types';
import { AsyncService } from '@/portainer/services/types';
import { refFieldValidation } from '@/react/portainer/gitops/RefField/RefField';
import { validateForm } from '@@/form-components/validate-form';
class GitFormRefFieldController {
$async: AsyncService;
value?: string;
onChange?: (value: string) => void;
gitFormRefField?: IFormController;
error?: string = '';
model?: GitFormModel;
stackId?: number = 0;
/* @ngInject */
constructor($async: AsyncService) {
this.$async = $async;
this.handleChange = this.handleChange.bind(this);
this.runValidation = this.runValidation.bind(this);
}
async handleChange(value: string) {
return this.$async(async () => {
this.onChange?.(value);
await this.runValidation(value);
});
}
async runValidation(value: string) {
return this.$async(async () => {
this.error = '';
this.gitFormRefField?.$setValidity(
'gitFormRefField',
true,
this.gitFormRefField
);
this.error = await validateForm<string>(
() => refFieldValidation(),
value
);
if (this.error) {
this.gitFormRefField?.$setValidity(
'gitFormRefField',
false,
this.gitFormRefField
);
}
});
}
}
export const gitFormRefField: IComponentOptions = {
controller: GitFormRefFieldController,
template: `
<ng-form name="$ctrl.gitFormRefField">
<react-git-form-ref-field
is-url-valid="$ctrl.isUrlValid"
model="$ctrl.model"
value="$ctrl.value"
on-change="$ctrl.handleChange"
stack-id="$ctrl.stackId"
error="$ctrl.error">
</react-git-form-ref-field>
</ng-form>`,
bindings: {
isUrlValid: '<',
value: '<',
onChange: '<',
model: '<',
stackId: '<',
},
};

@ -1,23 +0,0 @@
<div class="form-group">
<span class="col-sm-12 text-muted small vertical-center">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
<span>
Specify a reference of the repository using the following syntax: branches with <code>refs/heads/branch_name</code> or tags with <code>refs/tags/tag_name</code>. If not
specified, will use the default <code>HEAD</code> reference normally the <code>master</code> branch.
</span>
</span>
</div>
<div class="form-group">
<label for="stack_repository_reference_name" class="col-lg-2 col-sm-3 control-label text-left">Repository reference</label>
<div class="col-sm-8">
<input
type="text"
class="form-control"
ng-model="$ctrl.value"
id="stack_repository_reference_name"
placeholder="refs/heads/master"
ng-change="$ctrl.onChange($ctrl.value)"
data-cy="component-gitRefInput"
/>
</div>
</div>

@ -1,7 +0,0 @@
export const gitFormRefField = {
templateUrl: './git-form-ref-field.html',
bindings: {
value: '<',
onChange: '<',
},
};

@ -1,19 +0,0 @@
<div class="form-group">
<span class="col-sm-12 text-muted small"> You can use the URL of a git repository. </span>
</div>
<div class="form-group">
<label for="stack_repository_url" class="col-lg-2 col-sm-3 control-label required text-left">Repository URL</label>
<div class="col-sm-8">
<input
type="text"
name="repoUrlField"
class="form-control"
ng-model="$ctrl.value"
ng-change="$ctrl.onChange($ctrl.value)"
id="stack_repository_url"
placeholder="https://github.com/portainer/portainer-compose"
data-cy="component-gitUrlInput"
required
/>
</div>
</div>

@ -1,7 +0,0 @@
export const gitFormUrlField = {
templateUrl: './git-form-url-field.html',
bindings: {
value: '<',
onChange: '<',
},
};

@ -1,25 +0,0 @@
export default class GitFormController {
/* @ngInject */
constructor(StateManager) {
this.StateManager = StateManager;
this.onChangeField = this.onChangeField.bind(this);
this.onChangeURL = this.onChangeField('RepositoryURL');
this.onChangeRefName = this.onChangeField('RepositoryReferenceName');
this.onChangeComposePath = this.onChangeField('ComposeFilePathInRepository');
}
onChangeField(field) {
return (value) => {
this.onChange({
...this.model,
[field]: value,
});
};
}
$onInit() {
this.deployMethod = this.deployMethod || 'compose';
this.isDockerStandalone = !this.hideRebuildInfo && this.StateManager.getState().endpoint.mode.provider === 'DOCKER_STANDALONE';
}
}

@ -0,0 +1,87 @@
import { IFormController } from 'angular';
import { FormikErrors } from 'formik';
import { GitFormModel } from '@/react/portainer/gitops/types';
import { validateGitForm } from '@/react/portainer/gitops/GitForm';
import { notifyError } from '@/portainer/services/notifications';
import { IAuthenticationService } from '@/portainer/services/types';
import { getGitCredentials } from '@/portainer/views/account/git-credential/gitCredential.service';
import { GitCredential } from '@/portainer/views/account/git-credential/types';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
export default class GitFormController {
errors?: FormikErrors<GitFormModel>;
$async: <T>(fn: () => Promise<T>) => Promise<T>;
gitForm?: IFormController;
gitCredentials: Array<GitCredential> = [];
Authentication: IAuthenticationService;
value?: GitFormModel;
onChange?: (value: GitFormModel) => void;
/* @ngInject */
constructor(
$async: <T>(fn: () => Promise<T>) => Promise<T>,
Authentication: IAuthenticationService
) {
this.$async = $async;
this.Authentication = Authentication;
this.handleChange = this.handleChange.bind(this);
this.runGitFormValidation = this.runGitFormValidation.bind(this);
}
async handleChange(newValues: Partial<GitFormModel>) {
// this should never happen, but just in case
if (!this.value) {
throw new Error('GitFormController: value is required');
}
const value = {
...this.value,
...newValues,
};
this.onChange?.(value);
await this.runGitFormValidation(value);
}
async runGitFormValidation(value: GitFormModel) {
return this.$async(async () => {
this.errors = {};
this.gitForm?.$setValidity('gitForm', true, this.gitForm);
this.errors = await validateGitForm(this.gitCredentials, value);
if (this.errors && Object.keys(this.errors).length > 0) {
this.gitForm?.$setValidity('gitForm', false, this.gitForm);
}
});
}
async $onInit() {
if (isBE) {
try {
this.gitCredentials = await getGitCredentials(
this.Authentication.getUserDetails().ID
);
} catch (err) {
notifyError(
'Failure',
err as Error,
'Unable to retrieve user saved git credentials'
);
}
}
// this should never happen, but just in case
if (!this.value) {
throw new Error('GitFormController: value is required');
}
await this.runGitFormValidation(this.value);
}
}

@ -1,24 +0,0 @@
<ng-form name="$ctrl.gitForm">
<div class="col-sm-12 form-section-title"> Git repository </div>
<git-form-auth-fieldset model="$ctrl.model" on-change="($ctrl.onChange)"></git-form-auth-fieldset>
<git-form-url-field value="$ctrl.model.RepositoryURL" on-change="($ctrl.onChangeURL)"></git-form-url-field>
<git-form-ref-field value="$ctrl.model.RepositoryReferenceName" on-change="($ctrl.onChangeRefName)"></git-form-ref-field>
<git-form-compose-path-field
value="$ctrl.model.ComposeFilePathInRepository"
on-change="($ctrl.onChangeComposePath)"
deploy-method="{{ $ctrl.deployMethod }}"
is-docker-standalone="$ctrl.isDockerStandalone"
></git-form-compose-path-field>
<git-form-additional-files-panel ng-if="$ctrl.additionalFile" model="$ctrl.model" on-change="($ctrl.onChange)"></git-form-additional-files-panel>
<git-form-auto-update-fieldset
ng-if="$ctrl.autoUpdate"
model="$ctrl.model"
on-change="($ctrl.onChange)"
show-force-pull-image="$ctrl.showForcePullImage"
></git-form-auto-update-fieldset>
</ng-form>

@ -1,16 +0,0 @@
import controller from './git-form.controller.js';
export const gitForm = {
templateUrl: './git-form.html',
controller,
bindings: {
deployMethod: '@',
model: '<',
onChange: '<',
additionalFile: '<',
autoUpdate: '<',
showAuthExplanation: '<',
showForcePullImage: '<',
hideRebuildInfo: '@',
},
};

@ -0,0 +1,33 @@
import { IComponentOptions } from 'angular';
import controller from './git-form.controller';
export const gitForm: IComponentOptions = {
template: `
<ng-form name="$ctrl.gitForm">
<react-git-form
value="$ctrl.value"
on-change="$ctrl.handleChange"
is-docker-standalone="$ctrl.isDockerStandalone"
deploy-method="$ctrl.deployMethod"
is-additional-files-field-visible="$ctrl.isAdditionalFilesFieldVisible"
is-auto-update-visible="$ctrl.isAutoUpdateVisible"
is-force-pull-visible="$ctrl.isForcePullVisible"
is-auth-explanation-visible="$ctrl.isAuthExplanationVisible"
base-webhook-url="$ctrl.baseWebhookUrl"
errors="$ctrl.errors">
</react-git-form>
</ng-form>`,
bindings: {
value: '<',
onChange: '<',
isDockerStandalone: '<',
deployMethod: '@',
baseWebhookUrl: '@',
isAdditionalFilesFieldVisible: '<',
isAutoUpdateVisible: '<',
isForcePullVisible: '<',
isAuthExplanationVisible: '<',
},
controller,
};

@ -1,23 +0,0 @@
import angular from 'angular';
import { gitForm } from './git-form';
import { gitFormAuthFieldset } from './git-form-auth-fieldset';
import { gitFormAdditionalFilesPanel } from './git-form-additional-files-panel';
import { gitFormAdditionalFileItem } from './/git-form-additional-files-panel/git-form-additional-file-item';
import { gitFormAutoUpdateFieldset } from './git-form-auto-update-fieldset';
import { gitFormComposePathField } from './git-form-compose-path-field';
import { gitFormRefField } from './git-form-ref-field';
import { gitFormUrlField } from './git-form-url-field';
import { gitFormInfoPanel } from './git-form-info-panel';
export default angular
.module('portainer.app.components.forms.git', [])
.component('gitFormComposePathField', gitFormComposePathField)
.component('gitFormRefField', gitFormRefField)
.component('gitForm', gitForm)
.component('gitFormUrlField', gitFormUrlField)
.component('gitFormInfoPanel', gitFormInfoPanel)
.component('gitFormAdditionalFilesPanel', gitFormAdditionalFilesPanel)
.component('gitFormAdditionalFileItem', gitFormAdditionalFileItem)
.component('gitFormAutoUpdateFieldset', gitFormAutoUpdateFieldset)
.component('gitFormAuthFieldset', gitFormAuthFieldset).name;

@ -0,0 +1,13 @@
import angular from 'angular';
import { gitForm } from './git-form';
import { gitFormAuthFieldset } from './git-form-auth-fieldset';
import { gitFormAutoUpdate } from './git-form-auto-update-fieldset';
import { gitFormRefField } from './git-form-ref-field';
export const gitFormModule = angular
.module('portainer.app.components.git-form', [])
.component('gitForm', gitForm)
.component('gitFormAuthFieldset', gitFormAuthFieldset)
.component('gitFormAutoUpdateFieldset', gitFormAutoUpdate)
.component('gitFormRefField', gitFormRefField).name;

@ -1,95 +0,0 @@
import { ModalType } from '@@/modals';
import { confirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
class KubernetesAppGitFormController {
/* @ngInject */
constructor($async, $state, StackService, Notifications) {
this.$async = $async;
this.$state = $state;
this.StackService = StackService;
this.Notifications = Notifications;
this.state = {
saveGitSettingsInProgress: false,
redeployInProgress: false,
showConfig: true,
isEdit: false,
};
this.formValues = {
RefName: '',
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
};
this.onChange = this.onChange.bind(this);
this.onChangeRef = this.onChangeRef.bind(this);
}
onChangeRef(value) {
this.onChange({ RefName: value });
}
onChange(values) {
this.formValues = {
...this.formValues,
...values,
};
}
async pullAndRedeployApplication() {
return this.$async(async () => {
try {
const confirmed = await confirm({
title: 'Are you sure?',
message: 'Any changes to this application will be overridden by the definition in git and may cause a service interruption. Do you wish to continue?',
confirmButton: buildConfirmButton('Update', 'warning'),
modalType: ModalType.Warn,
});
if (!confirmed) {
return;
}
this.state.redeployInProgress = true;
await this.StackService.updateKubeGit(this.stack.Id, this.stack.EndpointId, this.namespace, this.formValues);
this.Notifications.success('Success', 'Pulled and redeployed stack successfully');
await this.$state.reload(this.$state.current);
} catch (err) {
this.Notifications.error('Failure', err, 'Failed redeploying application');
} finally {
this.state.redeployInProgress = false;
}
});
}
async saveGitSettings() {
return this.$async(async () => {
try {
this.state.saveGitSettingsInProgress = true;
await this.StackService.updateKubeStack({ EndpointId: this.stack.EndpointId, Id: this.stack.Id }, null, this.formValues);
this.Notifications.success('Success', 'Save stack settings successfully');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to save application settings');
} finally {
this.state.saveGitSettingsInProgress = false;
}
});
}
isSubmitButtonDisabled() {
return this.state.saveGitSettingsInProgress || this.state.redeployInProgress;
}
$onInit() {
this.formValues.RefName = this.stack.GitConfig.ReferenceName;
if (this.stack.GitConfig && this.stack.GitConfig.Authentication) {
this.formValues.RepositoryUsername = this.stack.GitConfig.Authentication.Username;
this.formValues.RepositoryAuthentication = true;
this.state.isEdit = true;
}
}
}
export default KubernetesAppGitFormController;

@ -1,57 +0,0 @@
<form name="$ctrl.redeployGitForm">
<div class="col-sm-12 form-section-title"> Redeploy from git repository </div>
<div class="form-group text-muted">
<div class="col-sm-12">
<p> Pull the latest manifest from git and redeploy the application. </p>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<p>
<a class="small interactive" ng-click="$ctrl.state.showConfig = !$ctrl.state.showConfig">
<pr-icon ng-if="$ctrl.state.showConfig" icon="'minus'" class-name="'mr-1'"></pr-icon>
<pr-icon ng-if="!$ctrl.state.showConfig" icon="'plus'" class-name="'mr-1'"></pr-icon>
{{ $ctrl.state.showConfig ? 'Hide' : 'Advanced' }} configuration
</a>
</p>
</div>
</div>
<git-form-ref-field ng-if="$ctrl.state.showConfig" value="$ctrl.formValues.RefName" on-change="($ctrl.onChangeRef)"></git-form-ref-field>
<git-form-auth-fieldset
ng-if="$ctrl.state.showConfig"
model="$ctrl.formValues"
is-edit="$ctrl.state.isEdit"
on-change="($ctrl.onChange)"
show-auth-explanation="true"
></git-form-auth-fieldset>
<div class="col-sm-12 form-section-title"> Actions </div>
<!-- #Git buttons -->
<button
class="btn btn-sm btn-primary"
ng-click="$ctrl.pullAndRedeployApplication()"
ng-disabled="$ctrl.isSubmitButtonDisabled() || !$ctrl.redeployGitForm.$valid"
style="margin-top: 7px; margin-left: 0"
button-spinner="ctrl.state.redeployInProgress"
analytics-on
analytics-category="kubernetes"
analytics-event="kubernetes-application-edit-git-pull"
>
<span ng-show="!$ctrl.state.redeployInProgress">
<pr-icon icon="'refresh-cw'" class="!mr-1"></pr-icon>
Pull and update application
</span>
<span ng-show="$ctrl.state.redeployInProgress">In progress...</span>
</button>
<button
class="btn btn-sm btn-primary"
ng-click="$ctrl.saveGitSettings()"
ng-disabled="$ctrl.isSubmitButtonDisabled() || !$ctrl.redeployGitForm.$valid"
style="margin-top: 7px; margin-left: 0"
button-spinner="$ctrl.state.saveGitSettingsInProgress"
>
<span ng-show="!$ctrl.state.saveGitSettingsInProgress"> Save settings </span>
<span ng-show="$ctrl.state.saveGitSettingsInProgress">In progress...</span>
</button>
</form>

@ -1,13 +0,0 @@
import angular from 'angular';
import controller from './kubernetes-app-git-form.controller';
const kubernetesAppGitForm = {
templateUrl: './kubernetes-app-git-form.html',
controller,
bindings: {
namespace: '<',
stack: '<',
},
};
angular.module('portainer.app').component('kubernetesAppGitForm', kubernetesAppGitForm);

@ -1,16 +1,17 @@
import uuidv4 from 'uuid/v4';
import { RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { confirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { ModalType } from '@@/modals';
import { parseAutoUpdateResponse } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { baseStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
class KubernetesRedeployAppGitFormController {
/* @ngInject */
constructor($async, $state, StackService, Notifications, WebhookHelper) {
constructor($async, $state, StackService, Notifications) {
this.$async = $async;
this.$state = $state;
this.StackService = StackService;
this.Notifications = Notifications;
this.WebhookHelper = WebhookHelper;
this.state = {
saveGitSettingsInProgress: false,
@ -18,6 +19,7 @@ class KubernetesRedeployAppGitFormController {
showConfig: false,
isEdit: false,
hasUnsavedChanges: false,
baseWebhookUrl: baseStackWebhookUrl(),
};
this.formValues = {
@ -26,37 +28,44 @@ class KubernetesRedeployAppGitFormController {
RepositoryUsername: '',
RepositoryPassword: '',
// auto update
AutoUpdate: {
RepositoryAutomaticUpdates: false,
RepositoryMechanism: RepositoryMechanismTypes.INTERVAL,
RepositoryFetchInterval: '5m',
RepositoryWebhookURL: '',
},
AutoUpdate: parseAutoUpdateResponse(),
};
this.onChange = this.onChange.bind(this);
this.onChangeRef = this.onChangeRef.bind(this);
this.onChangeAutoUpdate = this.onChangeAutoUpdate.bind(this);
this.onChangeGitAuth = this.onChangeGitAuth.bind(this);
}
onChangeRef(value) {
this.onChange({ RefName: value });
}
onChange(values) {
this.formValues = {
...this.formValues,
...values,
};
this.state.hasUnsavedChanges = angular.toJson(this.savedFormValues) !== angular.toJson(this.formValues);
async onChange(values) {
return this.$async(async () => {
this.formValues = {
...this.formValues,
...values,
};
this.state.hasUnsavedChanges = angular.toJson(this.savedFormValues) !== angular.toJson(this.formValues);
});
}
onChangeAutoUpdate(values) {
this.onChange({
AutoUpdate: {
...this.formValues.AutoUpdate,
...values,
},
onChangeGitAuth(values) {
return this.$async(async () => {
this.onChange(values);
});
}
async onChangeAutoUpdate(values) {
return this.$async(async () => {
await this.onChange({
AutoUpdate: {
...this.formValues.AutoUpdate,
...values,
},
});
});
}
@ -126,22 +135,8 @@ class KubernetesRedeployAppGitFormController {
$onInit() {
this.formValues.RefName = this.stack.GitConfig.ReferenceName;
// Init auto update
if (this.stack.AutoUpdate && (this.stack.AutoUpdate.Interval || this.stack.AutoUpdate.Webhook)) {
this.formValues.AutoUpdate.RepositoryAutomaticUpdates = true;
if (this.stack.AutoUpdate.Interval) {
this.formValues.AutoUpdate.RepositoryMechanism = RepositoryMechanismTypes.INTERVAL;
this.formValues.AutoUpdate.RepositoryFetchInterval = this.stack.AutoUpdate.Interval;
} else if (this.stack.AutoUpdate.Webhook) {
this.formValues.AutoUpdate.RepositoryMechanism = RepositoryMechanismTypes.WEBHOOK;
this.formValues.AutoUpdate.RepositoryWebhookURL = this.WebhookHelper.returnStackWebhookUrl(this.stack.AutoUpdate.Webhook);
}
}
if (!this.formValues.AutoUpdate.RepositoryWebhookURL) {
this.formValues.AutoUpdate.RepositoryWebhookURL = this.WebhookHelper.returnStackWebhookUrl(uuidv4());
}
this.formValues.AutoUpdate = parseAutoUpdateResponse(this.stack.AutoUpdate);
if (this.stack.GitConfig && this.stack.GitConfig.Authentication) {
this.formValues.RepositoryUsername = this.stack.GitConfig.Authentication.Username;

@ -5,7 +5,14 @@
<p> Pull the latest manifest from git and redeploy the application. </p>
</div>
</div>
<git-form-auto-update-fieldset model="$ctrl.formValues.AutoUpdate" on-change="($ctrl.onChangeAutoUpdate)"></git-form-auto-update-fieldset>
<git-form-auto-update-fieldset
value="$ctrl.formValues.AutoUpdate"
on-change="($ctrl.onChangeAutoUpdate)"
environment-type="KUBERNETES"
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
></git-form-auto-update-fieldset>
<time-window-display></time-window-display>
<div class="form-group">
<div class="col-sm-12">
<p>
@ -17,14 +24,14 @@
</p>
</div>
</div>
<git-form-ref-field ng-if="$ctrl.state.showConfig" value="$ctrl.formValues.RefName" on-change="($ctrl.onChangeRef)"></git-form-ref-field>
<git-form-auth-fieldset
<git-form-ref-field
ng-if="$ctrl.state.showConfig"
value="$ctrl.formValues.RefName"
on-change="($ctrl.onChangeRef)"
model="$ctrl.formValues"
is-edit="$ctrl.state.isEdit"
on-change="($ctrl.onChange)"
show-auth-explanation="true"
></git-form-auth-fieldset>
is-url-valid="true"
></git-form-ref-field>
<div class="col-sm-12 form-section-title"> Actions </div>
<!-- #Git buttons -->

@ -1,18 +1,19 @@
import uuidv4 from 'uuid/v4';
import { RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { confirmStackUpdate } from '@/react/docker/stacks/common/confirm-stack-update';
import { parseAutoUpdateResponse } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { baseStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
class StackRedeployGitFormController {
/* @ngInject */
constructor($async, $state, $compile, $scope, StackService, Notifications, WebhookHelper, FormHelper) {
constructor($async, $state, $compile, $scope, StackService, ModalService, Notifications, FormHelper) {
this.$async = $async;
this.$state = $state;
this.$compile = $compile;
this.$scope = $scope;
this.StackService = StackService;
this.Notifications = Notifications;
this.WebhookHelper = WebhookHelper;
this.FormHelper = FormHelper;
$scope.stackPullImageFeature = FeatureId.STACK_PULL_IMAGE;
this.state = {
@ -21,6 +22,7 @@ class StackRedeployGitFormController {
showConfig: false,
isEdit: false,
hasUnsavedChanges: false,
baseWebhookUrl: baseStackWebhookUrl(),
};
this.formValues = {
@ -34,12 +36,7 @@ class StackRedeployGitFormController {
Prune: false,
},
// auto update
AutoUpdate: {
RepositoryAutomaticUpdates: false,
RepositoryMechanism: RepositoryMechanismTypes.INTERVAL,
RepositoryFetchInterval: '5m',
RepositoryWebhookURL: '',
},
AutoUpdate: parseAutoUpdateResponse(),
};
this.onChange = this.onChange.bind(this);
@ -47,6 +44,7 @@ class StackRedeployGitFormController {
this.onChangeAutoUpdate = this.onChangeAutoUpdate.bind(this);
this.onChangeEnvVar = this.onChangeEnvVar.bind(this);
this.onChangeOption = this.onChangeOption.bind(this);
this.onChangeGitAuth = this.onChangeGitAuth.bind(this);
}
buildAnalyticsProperties() {
@ -69,27 +67,19 @@ class StackRedeployGitFormController {
}
onChange(values) {
this.formValues = {
...this.formValues,
...values,
};
this.state.hasUnsavedChanges = angular.toJson(this.savedFormValues) !== angular.toJson(this.formValues);
return this.$async(async () => {
this.formValues = {
...this.formValues,
...values,
};
this.state.hasUnsavedChanges = angular.toJson(this.savedFormValues) !== angular.toJson(this.formValues);
});
}
onChangeRef(value) {
this.onChange({ RefName: value });
}
onChangeAutoUpdate(values) {
this.onChange({
AutoUpdate: {
...this.formValues.AutoUpdate,
...values,
},
});
}
onChangeEnvVar(value) {
this.onChange({ Env: value });
}
@ -167,29 +157,28 @@ class StackRedeployGitFormController {
return isEnabled !== wasEnabled;
}
$onInit() {
onChangeGitAuth(values) {
this.onChange(values);
}
onChangeAutoUpdate(values) {
this.onChange({
AutoUpdate: {
...this.formValues.AutoUpdate,
...values,
},
});
}
async $onInit() {
this.formValues.RefName = this.model.ReferenceName;
this.formValues.Env = this.stack.Env;
if (this.stack.Option) {
this.formValues.Option = this.stack.Option;
}
// Init auto update
if (this.stack.AutoUpdate && (this.stack.AutoUpdate.Interval || this.stack.AutoUpdate.Webhook)) {
this.formValues.AutoUpdate.RepositoryAutomaticUpdates = true;
if (this.stack.AutoUpdate.Interval) {
this.formValues.AutoUpdate.RepositoryMechanism = RepositoryMechanismTypes.INTERVAL;
this.formValues.AutoUpdate.RepositoryFetchInterval = this.stack.AutoUpdate.Interval;
} else if (this.stack.AutoUpdate.Webhook) {
this.formValues.AutoUpdate.RepositoryMechanism = RepositoryMechanismTypes.WEBHOOK;
this.formValues.AutoUpdate.RepositoryWebhookURL = this.WebhookHelper.returnStackWebhookUrl(this.stack.AutoUpdate.Webhook);
}
}
if (!this.formValues.AutoUpdate.RepositoryWebhookURL) {
this.formValues.AutoUpdate.RepositoryWebhookURL = this.WebhookHelper.returnStackWebhookUrl(uuidv4());
}
this.formValues.AutoUpdate = parseAutoUpdateResponse(this.stack.AutoUpdate);
if (this.stack.GitConfig && this.stack.GitConfig.Authentication) {
this.formValues.RepositoryUsername = this.stack.GitConfig.Authentication.Username;

@ -1,17 +1,19 @@
<form name="$ctrl.redeployGitForm" class="form-horizontal my-8">
<div class="col-sm-12 form-section-title"> Redeploy from git repository </div>
<git-form-info-panel
class-name="text-muted small"
class-name="'text-muted small'"
url="$ctrl.model.URL"
type="stack"
type="'stack'"
config-file-path="$ctrl.model.ConfigFilePath"
additional-files="$ctrl.stack.AdditionalFiles"
></git-form-info-panel>
<git-form-auto-update-fieldset
model="$ctrl.formValues.AutoUpdate"
value="$ctrl.formValues.AutoUpdate"
on-change="($ctrl.onChangeAutoUpdate)"
show-force-pull-image="$ctrl.stack.Type !== 3"
environment-type="DOCKER"
is-force-pull-visible="$ctrl.stack.Type !== 3"
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
></git-form-auto-update-fieldset>
<div class="form-group">
<div class="col-sm-12">
@ -24,14 +26,17 @@
</p>
</div>
</div>
<git-form-ref-field ng-if="$ctrl.state.showConfig" value="$ctrl.formValues.RefName" on-change="($ctrl.onChangeRef)"></git-form-ref-field>
<git-form-auth-fieldset
<git-form-ref-field
ng-if="$ctrl.state.showConfig"
is-edit="$ctrl.state.isEdit"
value="$ctrl.formValues.RefName"
on-change="($ctrl.onChangeRef)"
model="$ctrl.formValues"
on-change="($ctrl.onChange)"
show-auth-explanation="true"
></git-form-auth-fieldset>
is-url-valid="true"
stack-id="$ctrl.gitStackId"
></git-form-ref-field>
<git-form-auth-fieldset ng-if="$ctrl.state.showConfig" value="$ctrl.formValues" on-change="($ctrl.onChangeGitAuth)" is-auth-explanation-visible="true"></git-form-auth-fieldset>
<environment-variables-panel
ng-model="$ctrl.formValues.Env"
explanation="These values will be used as substitutions in the stack file"
@ -39,6 +44,9 @@
show-help-message="true"
></environment-variables-panel>
<option-panel ng-if="$ctrl.stack.Type === 1 && $ctrl.endpoint.apiVersion >= 1.27" ng-model="$ctrl.formValues.Option" on-change="($ctrl.onChangeOption)"></option-panel>
<div class="col-sm-12 form-section-title"> Actions </div>
<button
class="btn btn-sm btn-primary"
ng-click="$ctrl.submit()"

@ -1,13 +1,13 @@
import angular from 'angular';
import formComponentsModule from './form-components';
import gitFormModule from './forms/git-form';
import porAccessManagementModule from './accessManagement';
import widgetModule from './widget';
import { boxSelectorModule } from './BoxSelector';
import { beFeatureIndicator } from './BEFeatureIndicator';
import { InformationPanelAngular } from './InformationPanel';
import { gitFormModule } from './forms/git-form';
export default angular
.module('portainer.app.components', [boxSelectorModule, widgetModule, gitFormModule, porAccessManagementModule, formComponentsModule])

@ -1,8 +1,19 @@
export default class PortainerError extends Error {
err?: Error;
isPortainerError = true;
constructor(msg: string, err?: Error) {
super(msg);
this.err = err;
}
}
export function isPortainerError(error: unknown): error is PortainerError {
return (
!!error &&
typeof error === 'object' &&
'isPortainerError' in error &&
(error as PortainerError).isPortainerError
);
}

@ -0,0 +1,7 @@
import angular from 'angular';
import { WebhookHelperFactory } from './webhookHelper';
export const helpersModule = angular
.module('portainer.app.helpers', [])
.factory('WebhookHelper', WebhookHelperFactory).name;

@ -6,5 +6,5 @@
*/
export function baseHref() {
const base = document.getElementById('base');
return base ? base.getAttribute('href') : '/';
return base ? base.getAttribute('href') || '/' : '/';
}

@ -1,33 +0,0 @@
import { baseHref } from '@/portainer/helpers/pathHelper';
angular.module('portainer.app').factory('WebhookHelper', [
'$location',
'API_ENDPOINT_WEBHOOKS',
'API_ENDPOINT_STACKS',
function WebhookHelperFactory($location, API_ENDPOINT_WEBHOOKS, API_ENDPOINT_STACKS) {
'use strict';
var helper = {};
let base;
const protocol = $location.protocol().toLowerCase();
if (protocol !== 'file') {
const host = $location.host();
const port = $location.port();
const displayPort = (protocol === 'http' && port === 80) || (protocol === 'https' && port === 443) ? '' : ':' + port;
base = `${protocol}://${host}${displayPort}${baseHref()}`;
} else {
base = baseHref();
}
helper.returnWebhookUrl = function (token) {
return `${base}${API_ENDPOINT_WEBHOOKS}/${token}`;
};
helper.returnStackWebhookUrl = function (token) {
return `${base}${API_ENDPOINT_STACKS}/webhooks/${token}`;
};
return helper;
},
]);

@ -0,0 +1,43 @@
import { API_ENDPOINT_STACKS, API_ENDPOINT_WEBHOOKS } from '@/constants';
import { baseHref } from './pathHelper';
const baseUrl = getBaseUrl();
export function dockerWebhookUrl(token: string) {
return `${baseUrl}${API_ENDPOINT_WEBHOOKS}/${token}`;
}
export function baseStackWebhookUrl() {
return `${baseUrl}${API_ENDPOINT_STACKS}/webhooks`;
}
export function stackWebhookUrl(token: string) {
return `${baseStackWebhookUrl()}/${token}`;
}
/* @ngInject */
export function WebhookHelperFactory() {
return {
returnWebhookUrl: dockerWebhookUrl,
getBaseStackWebhookUrl: baseStackWebhookUrl,
returnStackWebhookUrl: stackWebhookUrl,
};
}
function getBaseUrl() {
const protocol = window.location.protocol.toLowerCase().replace(':', '');
if (protocol === 'file') {
return baseHref();
}
const { hostname } = window.location;
const port = parseInt(window.location.port, 10);
const displayPort =
(protocol === 'http' && port === 80) ||
(protocol === 'https' && port === 443)
? ''
: `:${port}`;
return `${protocol}://${hostname}${displayPort}${baseHref()}`;
}

@ -3,6 +3,8 @@ export function UserViewModel(data) {
this.Username = data.Username;
this.Role = data.Role;
this.ThemeSettings = data.ThemeSettings;
this.EndpointAuthorizations = data.EndpointAuthorizations;
this.PortainerAuthorizations = data.PortainerAuthorizations;
if (data.Role === 1) {
this.RoleName = 'administrator';
} else {
@ -10,8 +12,6 @@ export function UserViewModel(data) {
}
this.AuthenticationMethod = data.AuthenticationMethod;
this.Checked = false;
this.EndpointAuthorizations = null;
this.PortainerAuthorizations = null;
}
export function UserTokenModel(data) {

@ -0,0 +1,69 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { AutoUpdateFieldset } from '@/react/portainer/gitops/AutoUpdateFieldset';
import { GitForm } from '@/react/portainer/gitops/GitForm';
import { AuthFieldset } from '@/react/portainer/gitops/AuthFieldset';
import { InfoPanel } from '@/react/portainer/gitops/InfoPanel';
import { RefField } from '@/react/portainer/gitops/RefField';
export const gitFormModule = angular
.module('portainer.app.components.forms.git', [])
.component(
'reactGitForm',
r2a(withUIRouter(withReactQuery(withCurrentUser(GitForm))), [
'value',
'onChange',
'isDockerStandalone',
'deployMethod',
'isAdditionalFilesFieldVisible',
'isForcePullVisible',
'isAuthExplanationVisible',
'errors',
'baseWebhookUrl',
])
)
.component(
'gitFormInfoPanel',
r2a(InfoPanel, [
'additionalFiles',
'className',
'configFilePath',
'type',
'url',
])
)
.component(
'reactGitFormAutoUpdateFieldset',
r2a(AutoUpdateFieldset, [
'value',
'onChange',
'environmentType',
'isForcePullVisible',
'errors',
'baseWebhookUrl',
])
)
.component(
'reactGitFormAuthFieldset',
r2a(withUIRouter(withReactQuery(withCurrentUser(AuthFieldset))), [
'value',
'isExplanationVisible',
'onChange',
'errors',
])
)
.component(
'reactGitFormRefField',
r2a(withUIRouter(withReactQuery(withCurrentUser(RefField))), [
'error',
'model',
'onChange',
'stackId',
'value',
'isUrlValid',
])
).name;

@ -38,13 +38,18 @@ import { PortainerSelect } from '@@/form-components/PortainerSelect';
import { Slider } from '@@/form-components/Slider';
import { TagButton } from '@@/TagButton';
import { BETeaserButton } from '@@/BETeaserButton';
import { TimeWindowDisplay } from '@@/TimeWindowDisplay';
import { fileUploadField } from './file-upload-field';
import { switchField } from './switch-field';
import { customTemplatesModule } from './custom-templates';
import { gitFormModule } from './git-form';
export const componentsModule = angular
.module('portainer.app.react.components', [customTemplatesModule])
.module('portainer.app.react.components', [
customTemplatesModule,
gitFormModule,
])
.component(
'tagSelector',
r2a(withUIRouter(withReactQuery(TagSelector)), [
@ -232,4 +237,8 @@ export const componentsModule = angular
'porAccessManagementUsersSelector',
r2a(PorAccessManagementUsersSelector, ['onChange', 'options', 'value'])
)
.component('edgeKeyDisplay', r2a(EdgeKeyDisplay, ['edgeKey'])).name;
.component('edgeKeyDisplay', r2a(EdgeKeyDisplay, ['edgeKey']))
.component(
'timeWindowDisplay',
r2a(withReactQuery(withUIRouter(TimeWindowDisplay)), [])
).name;

@ -1,5 +1,5 @@
import _ from 'lodash-es';
import { RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { StackViewModel, OrphanedStackViewModel } from '../../models/stack';
angular.module('portainer.app').factory('StackService', [
@ -278,17 +278,8 @@ angular.module('portainer.app').factory('StackService', [
StackFileContent: stackFile,
};
} else {
const autoUpdate = {};
if (gitConfig.AutoUpdate && gitConfig.AutoUpdate.RepositoryAutomaticUpdates) {
if (gitConfig.AutoUpdate.RepositoryMechanism === RepositoryMechanismTypes.INTERVAL) {
autoUpdate.Interval = gitConfig.AutoUpdate.RepositoryFetchInterval;
} else if (gitConfig.AutoUpdate.RepositoryMechanism === RepositoryMechanismTypes.WEBHOOK) {
autoUpdate.Webhook = gitConfig.AutoUpdate.RepositoryWebhookURL.split('/').reverse()[0];
}
}
payload = {
AutoUpdate: autoUpdate,
AutoUpdate: transformAutoUpdateViewModel(gitConfig.AutoUpdate),
RepositoryReferenceName: gitConfig.RefName,
RepositoryAuthentication: gitConfig.RepositoryAuthentication,
RepositoryUsername: gitConfig.RepositoryUsername,
@ -465,21 +456,10 @@ angular.module('portainer.app').factory('StackService', [
}
service.updateGitStackSettings = function (id, endpointId, env, gitConfig) {
// prepare auto update
const autoUpdate = {};
if (gitConfig.AutoUpdate.RepositoryAutomaticUpdates) {
if (gitConfig.AutoUpdate.RepositoryMechanism === RepositoryMechanismTypes.INTERVAL) {
autoUpdate.Interval = gitConfig.AutoUpdate.RepositoryFetchInterval;
} else if (gitConfig.AutoUpdate.RepositoryMechanism === RepositoryMechanismTypes.WEBHOOK) {
autoUpdate.Webhook = gitConfig.AutoUpdate.RepositoryWebhookURL.split('/').reverse()[0];
}
}
return Stack.updateGitStackSettings(
{ endpointId, id },
{
AutoUpdate: autoUpdate,
AutoUpdate: transformAutoUpdateViewModel(gitConfig.AutoUpdate),
Env: env,
RepositoryReferenceName: gitConfig.RefName,
RepositoryAuthentication: gitConfig.RepositoryAuthentication,

@ -3,3 +3,9 @@ import { Environment } from '@/react/portainer/environments/types';
export interface StateManager {
updateEndpointState(endpoint: Environment): Promise<void>;
}
export interface IAuthenticationService {
getUserDetails(): { ID: number };
}
export type AsyncService = <T>(fn: () => Promise<T>) => Promise<T>;

@ -0,0 +1,155 @@
import { useMutation, useQuery, useQueryClient } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { success as notifySuccess } from '@/portainer/services/notifications';
import {
CreateGitCredentialPayload,
GitCredential,
UpdateGitCredentialPayload,
} from './types';
export async function createGitCredential(
gitCredential: CreateGitCredentialPayload
) {
try {
await axios.post(buildGitUrl(gitCredential.userId), gitCredential);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to create git credential');
}
}
export async function getGitCredentials(userId: number) {
try {
const { data } = await axios.get<GitCredential[]>(buildGitUrl(userId));
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to get git credentials');
}
}
export async function getGitCredential(userId: number, id: number) {
try {
const { data } = await axios.get<GitCredential>(buildGitUrl(userId, id));
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to get git credential');
}
}
export async function deleteGitCredential(credential: GitCredential) {
try {
await axios.delete<GitCredential[]>(
buildGitUrl(credential.userId, credential.id)
);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to delete git credential');
}
}
export async function updateGitCredential(
credential: Partial<UpdateGitCredentialPayload>,
userId: number,
id: number
) {
try {
const { data } = await axios.put(buildGitUrl(userId, id), credential);
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update credential');
}
}
export function useUpdateGitCredentialMutation() {
const queryClient = useQueryClient();
return useMutation(
({
credential,
userId,
id,
}: {
credential: UpdateGitCredentialPayload;
userId: number;
id: number;
}) => updateGitCredential(credential, userId, id),
{
onSuccess: (_, data) => {
notifySuccess(
'Git credential updated successfully',
data.credential.name
);
return queryClient.invalidateQueries(['gitcredentials']);
},
meta: {
error: {
title: 'Failure',
message: 'Unable to update credential',
},
},
}
);
}
export function useDeleteGitCredentialMutation() {
const queryClient = useQueryClient();
return useMutation(deleteGitCredential, {
onSuccess: (_, credential) => {
notifySuccess('Git Credential deleted successfully', credential.name);
return queryClient.invalidateQueries(['gitcredentials']);
},
meta: {
error: {
title: 'Failure',
message: 'Unable to delete git credential',
},
},
});
}
export function useGitCredentials(userId: number) {
return useQuery('gitcredentials', () => getGitCredentials(userId), {
staleTime: 20,
meta: {
error: {
title: 'Failure',
message: 'Unable to retrieve git credentials',
},
},
});
}
export function useGitCredential(userId: number, id: number) {
return useQuery(['gitcredentials', id], () => getGitCredential(userId, id), {
meta: {
error: {
title: 'Failure',
message: 'Unable to retrieve git credential',
},
},
});
}
export function useCreateGitCredentialMutation() {
const queryClient = useQueryClient();
return useMutation(createGitCredential, {
onSuccess: (_, payload) => {
notifySuccess('Credentials created successfully', payload.name);
return queryClient.invalidateQueries(['gitcredentials']);
},
meta: {
error: {
title: 'Failure',
message: 'Unable to create credential',
},
},
});
}
function buildGitUrl(userId: number, credentialId?: number) {
return credentialId
? `/users/${userId}/gitcredentials/${credentialId}`
: `/users/${userId}/gitcredentials`;
}

@ -0,0 +1,35 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@@/datatables/types';
export interface GitCredentialTableSettings
extends SortableTableSettings,
PaginationTableSettings {}
export interface GitCredentialFormValues {
name: string;
username?: string;
password: string;
}
export interface CreateGitCredentialPayload {
userId: number;
name: string;
username?: string;
password: string;
}
export interface UpdateGitCredentialPayload {
name: string;
username?: string;
password: string;
}
export type GitCredential = {
id: number;
userId: number;
name: string;
username: string;
creationDate: number;
};

@ -62,7 +62,12 @@
</div>
<!-- !upload -->
<!-- repository -->
<git-form ng-if="$ctrl.state.Method === 'repository'" model="$ctrl.formValues" on-change="($ctrl.handleChange)"></git-form>
<git-form
ng-if="$ctrl.state.Method === 'repository'"
value="$ctrl.formValues"
on-change="($ctrl.handleChange)"
is-docker-standalone="$ctrl.isDockerStandalone"
></git-form>
<div class="form-group" ng-if="!$ctrl.state.isTemplateValid">
<div class="col-sm-12">

@ -206,6 +206,7 @@ class CreateCustomTemplateViewController {
this.state.endpointMode = applicationState.endpoint.mode;
let stackType = 0;
if (this.state.endpointMode.provider === 'DOCKER_STANDALONE') {
this.isDockerStandalone = true;
stackType = 2;
} else if (this.state.endpointMode.provider === 'DOCKER_SWARM_MODE') {
stackType = 1;

@ -1,5 +1,4 @@
import angular from 'angular';
import uuidv4 from 'uuid/v4';
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
import { STACK_NAME_VALIDATION_REGEX } from '@/constants';
@ -9,6 +8,8 @@ import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { editor, upload, git, customTemplate } from '@@/BoxSelector/common-options/build-methods';
import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { baseStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
angular
.module('portainer.app')
@ -29,8 +30,6 @@ angular
ContainerHelper,
CustomTemplateService,
ContainerService,
WebhookHelper,
clipboard,
endpoint
) {
$scope.onChangeTemplateId = onChangeTemplateId;
@ -55,11 +54,9 @@ angular
AdditionalFiles: [],
ComposeFilePathInRepository: 'docker-compose.yml',
AccessControlData: new AccessControlFormData(),
RepositoryAutomaticUpdates: false,
RepositoryMechanism: RepositoryMechanismTypes.INTERVAL,
RepositoryFetchInterval: '5m',
RepositoryWebhookURL: WebhookHelper.returnStackWebhookUrl(uuidv4()),
EnableWebhook: false,
Variables: {},
AutoUpdate: parseAutoUpdateResponse(),
};
$scope.state = {
@ -72,6 +69,7 @@ angular
isEditorDirty: false,
selectedTemplate: null,
selectedTemplateId: null,
baseWebhookUrl: baseStackWebhookUrl(),
};
$window.onbeforeunload = () => {
@ -99,14 +97,6 @@ angular
});
};
$scope.addAdditionalFiles = function () {
$scope.formValues.AdditionalFiles.push('');
};
$scope.removeAdditionalFiles = function (index) {
$scope.formValues.AdditionalFiles.splice(index, 1);
};
function buildAnalyticsProperties() {
const metadata = { type: methodLabel($scope.state.Method) };
@ -163,7 +153,6 @@ angular
function createSwarmStack(name, method) {
var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env);
const endpointId = +$state.params.endpointId;
if (method === 'template' || method === 'editor') {
var stackFileContent = $scope.formValues.StackFileContent;
return StackService.createSwarmStackFromFileContent(name, stackFileContent, env, endpointId);
@ -183,25 +172,13 @@ angular
RepositoryAuthentication: $scope.formValues.RepositoryAuthentication,
RepositoryUsername: $scope.formValues.RepositoryUsername,
RepositoryPassword: $scope.formValues.RepositoryPassword,
AutoUpdate: transformAutoUpdateViewModel($scope.formValues.AutoUpdate),
};
getAutoUpdatesProperty(repositoryOptions);
return StackService.createSwarmStackFromGitRepository(name, repositoryOptions, env, endpointId);
}
}
function getAutoUpdatesProperty(repositoryOptions) {
if ($scope.formValues.RepositoryAutomaticUpdates) {
repositoryOptions.AutoUpdate = {};
if ($scope.formValues.RepositoryMechanism === RepositoryMechanismTypes.INTERVAL) {
repositoryOptions.AutoUpdate.Interval = $scope.formValues.RepositoryFetchInterval;
} else if ($scope.formValues.RepositoryMechanism === RepositoryMechanismTypes.WEBHOOK) {
repositoryOptions.AutoUpdate.Webhook = $scope.formValues.RepositoryWebhookURL.split('/').reverse()[0];
}
}
}
function createComposeStack(name, method) {
var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env);
const endpointId = +$state.params.endpointId;
@ -221,20 +198,13 @@ angular
RepositoryAuthentication: $scope.formValues.RepositoryAuthentication,
RepositoryUsername: $scope.formValues.RepositoryUsername,
RepositoryPassword: $scope.formValues.RepositoryPassword,
AutoUpdate: transformAutoUpdateViewModel($scope.formValues.AutoUpdate),
};
getAutoUpdatesProperty(repositoryOptions);
return StackService.createComposeStackFromGitRepository(name, repositoryOptions, env, endpointId);
}
}
$scope.copyWebhook = function () {
clipboard.copyText($scope.formValues.RepositoryWebhookURL);
$('#copyNotification').show();
$('#copyNotification').fadeOut(2000);
};
$scope.handleEnvVarChange = handleEnvVarChange;
function handleEnvVarChange(value) {
$scope.formValues.Env = value;
@ -348,6 +318,7 @@ angular
async function initView() {
var endpointMode = $scope.applicationState.endpoint.mode;
$scope.state.StackType = 2;
$scope.isDockerStandalone = endpointMode.provider === 'DOCKER_STANDALONE';
if (endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER') {
$scope.state.StackType = 1;
}
@ -369,11 +340,13 @@ angular
initView();
function onChangeFormValues(values) {
$scope.formValues = {
...$scope.formValues,
...values,
};
function onChangeFormValues(newValues) {
return $async(async () => {
$scope.formValues = {
...$scope.formValues,
...newValues,
};
});
}
}
);

@ -79,14 +79,14 @@
<!-- !upload -->
<git-form
ng-if="state.Method === 'repository'"
model="formValues"
value="formValues"
on-change="(onChangeFormValues)"
additional-file="true"
auto-update="true"
show-force-pull-image="true"
show-auth-explanation="true"
path-text-title="Compose path"
path-placeholder="docker-compose.yml"
is-docker-standalone="isDockerStandalone"
is-additional-files-field-visible="true"
is-auto-update-visible="true"
is-auth-explanation-visible="true"
is-force-pull-visible="true"
base-webhook-url="{{ state.baseWebhookUrl }}"
></git-form>
<div ng-show="state.Method === 'template'">

@ -102,7 +102,19 @@ export function createMockEnvironment(): Environment {
allowVolumeBrowserForRegularUsers: false,
enableHostManagementFeatures: false,
},
DeploymentOptions: {
overrideGlobalOptions: false,
hideAddWithForm: true,
hideWebEditor: false,
hideFileUpload: false,
},
Gpus: [],
Agent: { Version: '1.0.0' },
EnableImageNotification: false,
ChangeWindow: {
Enabled: false,
EndTime: '',
StartTime: '',
},
};
}

@ -0,0 +1,87 @@
import moment from 'moment';
import 'moment-timezone';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { TextTip } from '@@/Tip/TextTip';
import { withEdition } from '../portainer/feature-flags/withEdition';
const TimeWindowDisplayWrapper = withEdition(TimeWindowDisplay, 'BE');
export { TimeWindowDisplayWrapper as TimeWindowDisplay };
function TimeWindowDisplay() {
const currentEnvQuery = useCurrentEnvironment(false);
if (!currentEnvQuery.data) {
return null;
}
const { ChangeWindow } = currentEnvQuery.data;
if (!ChangeWindow.Enabled) {
return null;
}
const timezone = moment.tz.guess();
const isDST = moment().isDST();
const { startTime: startTimeLocal, endTime: endTimeLocal } = utcToTime(
{ startTime: ChangeWindow.StartTime, endTime: ChangeWindow.EndTime },
timezone
);
const { startTime: startTimeUtc, endTime: endTimeUtc } = parseInterval(
ChangeWindow.StartTime,
ChangeWindow.EndTime
);
return (
<TextTip color="orange">
A change window is enabled, automatic updates will not occur outside of{' '}
<span className="font-bold">
{shortTime(startTimeUtc)} - {shortTime(endTimeUtc)} UTC (
{shortTime(startTimeLocal)} -{shortTime(endTimeLocal)}{' '}
{isDST ? 'DST' : ''} {timezone})
</span>
.
</TextTip>
);
}
function utcToTime(
utcTime: { startTime: string; endTime: string },
timezone: string
) {
const startTime = moment
.tz(utcTime.startTime, 'HH:mm', 'GMT')
.tz(timezone)
.format('HH:mm');
const endTime = moment
.tz(utcTime.endTime, 'HH:mm', 'GMT')
.tz(timezone)
.format('HH:mm');
return parseInterval(startTime, endTime);
}
function parseTime(originalTime: string) {
const [startHour, startMin] = originalTime.split(':');
const time = new Date();
time.setHours(parseInt(startHour, 10));
time.setMinutes(parseInt(startMin, 10));
return time;
}
function parseInterval(startTime: string, endTime: string) {
return {
startTime: parseTime(startTime),
endTime: parseTime(endTime),
};
}
function shortTime(time: Date) {
return moment(time).format('h:mm a');
}

@ -1,5 +1,6 @@
import { PropsWithChildren } from 'react';
import { AlertCircle } from 'lucide-react';
import clsx from 'clsx';
import { Icon, IconMode } from '@@/Icon';
@ -8,17 +9,18 @@ type Color = 'orange' | 'blue';
export interface Props {
icon?: React.ReactNode;
color?: Color;
className?: string;
}
export function TextTip({
color = 'orange',
icon = AlertCircle,
className,
children,
}: PropsWithChildren<Props>) {
return (
<p className="small inline-flex items-center gap-1">
<Icon icon={icon} mode={getMode(color)} className="shrink-0" />
<p className={clsx('small inline-flex items-center gap-1', className)}>
<Icon icon={icon} mode={getMode(color)} />
<span className="text-muted">{children}</span>
</p>
);

@ -1,12 +1,12 @@
import { HelpCircle } from 'lucide-react';
import { useMemo } from 'react';
import { ReactNode, useMemo } from 'react';
import sanitize from 'sanitize-html';
import { TooltipWithChildren, Position } from '../TooltipWithChildren';
export interface Props {
position?: Position;
message: string;
message: ReactNode;
className?: string;
setHtmlMessage?: boolean;
}
@ -19,7 +19,7 @@ export function Tooltip({
}: Props) {
// allow angular views to set html messages for the tooltip
const htmlMessage = useMemo(() => {
if (setHtmlMessage) {
if (setHtmlMessage && typeof message === 'string') {
// eslint-disable-next-line react/no-danger
return <div dangerouslySetInnerHTML={{ __html: sanitize(message) }} />;
}

@ -21,6 +21,8 @@ export function ButtonGroup({
function sizeClass(size: Size | undefined) {
switch (size) {
case 'small':
return 'btn-group-sm';
case 'xsmall':
return 'btn-group-xs';
case 'large':

@ -6,12 +6,10 @@
.container {
display: flex;
align-items: baseline;
margin-top: 10px;
}
.display-text {
.copy-button {
opacity: 0;
margin-left: 7px;
color: #23ae89;
}

@ -1,4 +1,4 @@
import { PropsWithChildren } from 'react';
import { ComponentProps, PropsWithChildren } from 'react';
import clsx from 'clsx';
import { Check, Copy } from 'lucide-react';
@ -14,6 +14,7 @@ export interface Props {
fadeDelay?: number;
displayText?: string;
className?: string;
color?: ComponentProps<typeof Button>['color'];
}
export function CopyButton({
@ -21,6 +22,7 @@ export function CopyButton({
fadeDelay = 1000,
displayText = 'copied',
className,
color,
children,
}: PropsWithChildren<Props>) {
const { handleCopy, copiedSuccessfully } = useCopy(copyText, fadeDelay);
@ -29,19 +31,20 @@ export function CopyButton({
<div className={styles.container}>
<Button
className={className}
color={color}
size="small"
onClick={handleCopy}
title="Copy Value"
type="button"
icon={Copy}
>
<Icon icon={Copy} />
{children}
</Button>
<span
className={clsx(
copiedSuccessfully && styles.fadeout,
styles.displayText,
styles.copyButton,
'space-left',
'vertical-center'
)}

@ -2,6 +2,7 @@ import clsx from 'clsx';
import { PropsWithChildren, ReactNode } from 'react';
import { ButtonGroup, Size } from '@@/buttons/ButtonGroup';
import { Button } from '@@/buttons';
import styles from './ButtonSelector.module.css';
@ -59,10 +60,12 @@ function OptionItem({
readOnly,
}: PropsWithChildren<OptionItemProps>) {
return (
<label
className={clsx('btn btn-primary', {
<Button
color="light"
as="label"
disabled={disabled || readOnly}
className={clsx({
active: selected,
disabled: readOnly || disabled,
})}
>
{children}
@ -73,6 +76,6 @@ function OptionItem({
disabled={disabled}
readOnly={readOnly}
/>
</label>
</Button>
);
}

@ -1,4 +1,4 @@
import { PropsWithChildren, ReactNode } from 'react';
import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
import clsx from 'clsx';
import { Tooltip } from '@@/Tip/Tooltip';
@ -11,11 +11,12 @@ export interface Props {
inputId?: string;
label: ReactNode;
size?: Size;
tooltip?: string;
tooltip?: ComponentProps<typeof Tooltip>['message'];
setTooltipHtmlMessage?: ComponentProps<typeof Tooltip>['setHtmlMessage'];
children: ReactNode;
errors?: ReactNode;
required?: boolean;
setTooltipHtmlMessage?: boolean;
className?: string;
}
export function FormControl({
@ -25,12 +26,14 @@ export function FormControl({
tooltip = '',
children,
errors,
required,
className,
required = false,
setTooltipHtmlMessage,
}: PropsWithChildren<Props>) {
return (
<div
className={clsx(
className,
'form-group',
'after:clear-both after:table after:content-[""]' // to fix issues with float
)}
@ -50,12 +53,7 @@ export function FormControl({
<div className={sizeClassChildren(size)}>
{children}
{errors && (
<span className="help-block">
<FormError>{errors}</FormError>
</span>
)}
{errors && <FormError>{errors}</FormError>}
</div>
</div>
);

@ -10,7 +10,9 @@ interface Props {
export function FormError({ children, className }: PropsWithChildren<Props>) {
return (
<p className={clsx(`text-muted small vertical-center`, className)}>
<p
className={clsx(`text-muted small vertical-center help-block`, className)}
>
<Icon icon={AlertTriangle} className="icon-warning" />
<span className="text-warning">{children}</span>
</p>

@ -1,14 +1,24 @@
import clsx from 'clsx';
import { InputHTMLAttributes } from 'react';
import { forwardRef, InputHTMLAttributes, Ref } from 'react';
export const InputWithRef = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>(
// eslint-disable-next-line react/jsx-props-no-spreading
(props, ref) => <Input {...props} mRef={ref} />
);
export function Input({
className,
mRef: ref,
...props
}: InputHTMLAttributes<HTMLInputElement>) {
}: InputHTMLAttributes<HTMLInputElement> & { mRef?: Ref<HTMLInputElement> }) {
return (
<input
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
ref={ref}
className={clsx('form-control', className)}
/>
);

@ -1 +1 @@
export { InputList } from './InputList';
export { InputList, type ItemProps } from './InputList';

@ -1,4 +1,5 @@
import clsx from 'clsx';
import { ComponentProps } from 'react';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
@ -13,14 +14,14 @@ export interface Props {
onChange(value: boolean): void;
name?: string;
tooltip?: string;
tooltip?: ComponentProps<typeof Tooltip>['message'];
setTooltipHtmlMessage?: ComponentProps<typeof Tooltip>['setHtmlMessage'];
labelClass?: string;
switchClass?: string;
fieldClass?: string;
dataCy?: string;
disabled?: boolean;
featureId?: FeatureId;
setTooltipHtmlMessage?: boolean;
}
export function SwitchField({

@ -0,0 +1,27 @@
import { useRef } from 'react';
import { TestContext, TestFunction } from 'yup';
function cacheTest<T, TContext>(
asyncValidate: TestFunction<T, TContext>
): TestFunction<T, TContext> {
let valid = true;
let value: T | undefined;
return async (newValue: T, context: TestContext<TContext>) => {
if (newValue !== value) {
value = newValue;
const response = await asyncValidate.call(context, newValue, context);
valid = !!response;
}
return valid;
};
}
export function useCachedValidation<T, TContext>(
test: TestFunction<T, TContext>
) {
const ref = useRef(cacheTest(test));
return ref.current;
}

@ -0,0 +1,26 @@
import { useRef, useState, useCallback, useEffect } from 'react';
export function useCaretPosition<
T extends HTMLInputElement | HTMLTextAreaElement = HTMLInputElement
>() {
const node = useRef<T>(null);
const [start, setStart] = useState(0);
const [end, setEnd] = useState(0);
const updateCaret = useCallback(() => {
if (node.current) {
const { selectionStart, selectionEnd } = node.current;
setStart(selectionStart || 0);
setEnd(selectionEnd || 0);
}
}, []);
useEffect(() => {
if (node.current) {
node.current.setSelectionRange(start, end);
}
});
return { start, end, ref: node, updateCaret };
}

@ -0,0 +1,19 @@
import { yupToFormErrors } from 'formik';
import { SchemaOf } from 'yup';
export async function validateForm<T>(
schemaBuilder: () => SchemaOf<T>,
formValues: T
) {
const validationSchema = schemaBuilder();
try {
await validationSchema.validate(formValues, {
strict: true,
abortEarly: false,
});
return undefined;
} catch (error) {
return yupToFormErrors<T>(error);
}
}

@ -1,3 +1,5 @@
export type StackId = number;
export enum StackType {
/**
* Represents a stack managed via docker stack

@ -58,7 +58,9 @@ export function ScriptTabs({
children: (
<>
<Code>{cmd}</Code>
<CopyButton copyText={cmd}>Copy</CopyButton>
<div className="mt-2">
<CopyButton copyText={cmd}>Copy</CopyButton>
</div>
</>
),
};

@ -1,8 +1,8 @@
import { useEnvironment } from '@/react/portainer/environments/queries/useEnvironment';
import { useEnvironment } from '@/react/portainer/environments/queries';
import { useEnvironmentId } from './useEnvironmentId';
export function useCurrentEnvironment() {
const id = useEnvironmentId();
export function useCurrentEnvironment(force = true) {
const id = useEnvironmentId(force);
return useEnvironment(id);
}

@ -1,21 +1,22 @@
import _ from 'lodash';
import { useState, useRef, useCallback } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react';
export function useDebounce(
defaultValue: string,
onChange: (value: string) => void
) {
const [searchValue, setSearchValue] = useState(defaultValue);
export function useDebounce(value: string, onChange: (value: string) => void) {
const [debouncedValue, setDebouncedValue] = useState(value);
const onChangeDebounces = useRef(_.debounce(onChange, 300));
const handleChange = useCallback(
(value: string) => {
setSearchValue(value);
setDebouncedValue(value);
onChangeDebounces.current(value);
},
[onChangeDebounces, setSearchValue]
[onChangeDebounces, setDebouncedValue]
);
return [searchValue, handleChange] as const;
useEffect(() => {
setDebouncedValue(value);
}, [value]);
return [debouncedValue, handleChange] as const;
}

@ -1,13 +1,19 @@
import { useCurrentStateAndParams } from '@uirouter/react';
export function useEnvironmentId() {
import { EnvironmentId } from '@/react/portainer/environments/types';
export function useEnvironmentId(force = true): EnvironmentId {
const {
params: { endpointId: environmentId },
} = useCurrentStateAndParams();
if (!environmentId) {
if (!force) {
return 0;
}
throw new Error('endpointId url param is required');
}
return environmentId;
return parseInt(environmentId, 10);
}

@ -94,9 +94,11 @@ export function CreateAccessToken({
</Trans>
</TextTip>
<Code>{accessToken}</Code>
<CopyButton copyText={accessToken}>
<Trans ns={translationNS}>Copy access token</Trans>
</CopyButton>
<div className="mt-2">
<CopyButton copyText={accessToken}>
<Trans ns={translationNS}>Copy access token</Trans>
</CopyButton>
</div>
<hr />
<Button
type="button"

@ -108,12 +108,29 @@ export interface EnvironmentSecuritySettings {
enableHostManagementFeatures: boolean;
}
export type DeploymentOptions = {
overrideGlobalOptions: boolean;
hideAddWithForm: boolean;
hideWebEditor: boolean;
hideFileUpload: boolean;
};
/**
* EndpointChangeWindow determine when automatic stack/app updates may occur
*/
interface EndpointChangeWindow {
Enabled: boolean;
StartTime: string;
EndTime: string;
}
export type Environment = {
Agent: { Version: string };
Id: EnvironmentId;
Type: EnvironmentType;
TagIds: TagId[];
GroupId: EnvironmentGroupId;
DeploymentOptions: DeploymentOptions | null;
EdgeID?: string;
EdgeKey: string;
EdgeCheckinInterval?: number;
@ -132,7 +149,11 @@ export type Environment = {
Edge: EnvironmentEdge;
SecuritySettings: EnvironmentSecuritySettings;
Gpus: { name: string; value: string }[];
EnableImageNotification: boolean;
LocalTimeZone?: string;
/** Automatic update change window restriction for stacks and apps */
ChangeWindow: EndpointChangeWindow;
};
/**

@ -56,7 +56,9 @@ function DeployCode({ code }: DeployCodeProps) {
</span>
<Code>{code}</Code>
<CopyButton copyText={code}>Copy command</CopyButton>
<div className="mt-2">
<CopyButton copyText={code}>Copy command</CopyButton>
</div>
</>
);
}

@ -79,7 +79,9 @@ function DeployCode({ code }: DeployCodeProps) {
<div className="code-script">
<Code>{code}</Code>
</div>
<CopyButton copyText={code}>Copy command</CopyButton>
<div className="mt-2">
<CopyButton copyText={code}>Copy command</CopyButton>
</div>
</>
);
}

@ -106,7 +106,9 @@ function DeployCode({
</p>
)}
<Code>{code}</Code>
<CopyButton copyText={code}>Copy command</CopyButton>
<div className="mt-2">
<CopyButton copyText={code}>Copy command</CopyButton>
</div>
</>
);
}

@ -1,12 +1,12 @@
import { useField } from 'formik';
import { string } from 'yup';
import { useRef, useEffect } from 'react';
import { getEnvironments } from '@/react/portainer/environments/environment.service';
import { useDebounce } from '@/react/hooks/useDebounce';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { useCachedValidation } from '@@/form-components/useCachedTest';
interface Props {
readonly?: boolean;
@ -25,10 +25,6 @@ export function NameField({
const [debouncedValue, setDebouncedValue] = useDebounce(value, setValue);
useEffect(() => {
setDebouncedValue(value);
}, [setDebouncedValue, value]);
return (
<FormControl
label="Name"
@ -69,27 +65,10 @@ export async function isNameUnique(name = '') {
}
}
function cacheTest(
asyncValidate: (val?: string) => Promise<boolean> | undefined
) {
let valid = true;
let value = '';
return async (newValue = '') => {
if (newValue !== value) {
value = newValue;
const response = await asyncValidate(newValue);
valid = !!response;
}
return valid;
};
}
export function useNameValidation() {
const uniquenessTest = useRef(cacheTest(isNameUnique));
const uniquenessTest = useCachedValidation(isNameUnique);
return string()
.required('Name is required')
.test('unique-name', 'Name should be unique', uniquenessTest.current);
.test('unique-name', 'Name should be unique', uniquenessTest);
}

@ -0,0 +1,56 @@
import { FormikErrors } from 'formik';
import { FormError } from '@@/form-components/FormError';
import { InputGroup } from '@@/form-components/InputGroup';
import { InputList, ItemProps } from '@@/form-components/InputList';
import { useCaretPosition } from '@@/form-components/useCaretPosition';
interface Props {
value: Array<string>;
onChange: (value: Array<string>) => void;
errors?: FormikErrors<string>[] | string | string[];
}
export function AdditionalFileField({ onChange, value, errors }: Props) {
return (
<InputList
errors={errors}
label="Additional paths"
onChange={onChange}
value={value}
addLabel="Add file"
item={Item}
itemBuilder={() => ''}
/>
);
}
function Item({
item,
onChange,
disabled,
error,
readOnly,
}: ItemProps<string>) {
const { ref, updateCaret } = useCaretPosition();
return (
<>
<InputGroup size="small" className="col-sm-5">
<InputGroup.Addon>path</InputGroup.Addon>
<InputGroup.Input
mRef={ref}
required
disabled={disabled}
readOnly={readOnly}
value={item}
onChange={(e) => {
onChange(e.target.value);
updateCaret();
}}
/>
</InputGroup>
{error && <FormError>{error}</FormError>}
</>
);
}

@ -0,0 +1,181 @@
import { FormikErrors } from 'formik';
import { boolean, number, object, SchemaOf, string } from 'yup';
import { GitAuthModel } from '@/react/portainer/gitops/types';
import { useDebounce } from '@/react/hooks/useDebounce';
import { GitCredential } from '@/portainer/views/account/git-credential/types';
import { SwitchField } from '@@/form-components/SwitchField';
import { Input } from '@@/form-components/Input';
import { FormControl } from '@@/form-components/FormControl';
import { TextTip } from '@@/Tip/TextTip';
import { isBE } from '../../feature-flags/feature-flags.service';
import { CredentialSelector } from './CredentialSelector';
import { NewCredentialForm } from './NewCredentialForm';
interface Props {
value: GitAuthModel;
onChange: (value: Partial<GitAuthModel>) => void;
isExplanationVisible?: boolean;
errors?: FormikErrors<GitAuthModel>;
}
export function AuthFieldset({
value,
onChange,
isExplanationVisible,
errors,
}: Props) {
const [username, setUsername] = useDebounce(
value.RepositoryUsername || '',
(username) => handleChange({ RepositoryUsername: username })
);
const [password, setPassword] = useDebounce(
value.RepositoryPassword || '',
(password) => handleChange({ RepositoryPassword: password })
);
return (
<>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Authentication"
labelClass="col-sm-3 col-lg-2"
name="authentication"
checked={value.RepositoryAuthentication}
onChange={(value) =>
handleChange({ RepositoryAuthentication: value })
}
data-cy="component-gitAuthToggle"
/>
</div>
</div>
{value.RepositoryAuthentication && (
<>
{isExplanationVisible && (
<TextTip color="orange">
Enabling authentication will store the credentials and it is
advisable to use a git service account
</TextTip>
)}
{isBE && (
<CredentialSelector
onChange={handleChangeGitCredential}
value={value.RepositoryGitCredentialID}
/>
)}
<div className="form-group">
<div className="col-sm-12">
<FormControl label="Username" errors={errors?.RepositoryUsername}>
<Input
value={username}
name="repository_username"
placeholder={
value.RepositoryGitCredentialID ? '' : 'git username'
}
onChange={(e) => setUsername(e.target.value)}
data-cy="component-gitUsernameInput"
readOnly={!!value.RepositoryGitCredentialID}
/>
</FormControl>
</div>
</div>
<div className="form-group !mb-0">
<div className="col-sm-12">
<FormControl
label="Personal Access Token"
tooltip="Provide a personal access token or password"
errors={errors?.RepositoryPassword}
>
<Input
type="password"
value={password}
name="repository_password"
placeholder="*******"
onChange={(e) => setPassword(e.target.value)}
data-cy="component-gitPasswordInput"
readOnly={!!value.RepositoryGitCredentialID}
/>
</FormControl>
</div>
</div>
{!value.RepositoryGitCredentialID &&
value.RepositoryPassword &&
isBE && (
<NewCredentialForm
value={value}
onChange={handleChange}
errors={errors}
/>
)}
</>
)}
</>
);
function handleChangeGitCredential(gitCredential?: GitCredential | null) {
handleChange(
gitCredential
? {
RepositoryGitCredentialID: gitCredential.id,
RepositoryUsername: gitCredential?.username,
RepositoryPassword: '',
SaveCredential: false,
NewCredentialName: '',
}
: {
RepositoryGitCredentialID: 0,
RepositoryUsername: '',
RepositoryPassword: '',
}
);
}
function handleChange(partialValue: Partial<GitAuthModel>) {
onChange(partialValue);
}
}
export function gitAuthValidation(
gitCredentials: Array<GitCredential>
): SchemaOf<GitAuthModel> {
return object({
RepositoryAuthentication: boolean().default(false),
RepositoryGitCredentialID: number().default(0),
RepositoryUsername: string()
.when(['RepositoryAuthentication', 'RepositoryGitCredentialID'], {
is: (auth: boolean, id: number) => auth && !id,
then: string().required('Username is required'),
})
.default(''),
RepositoryPassword: string()
.when(['RepositoryAuthentication', 'RepositoryGitCredentialID'], {
is: (auth: boolean, id: number) => auth && !id,
then: string().required('Password is required'),
})
.default(''),
SaveCredential: boolean().default(false),
NewCredentialName: string()
.default('')
.when(['RepositoryAuthentication', 'SaveCredential'], {
is: true,
then: string()
.required('Name is required')
.test(
'is-unique',
'This name is already been used, please try another one',
(name) => !!name && !gitCredentials.find((x) => x.name === name)
)
.matches(
/^[-_a-z0-9]+$/,
"This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123')."
),
}),
});
}

@ -0,0 +1,48 @@
import { useGitCredentials } from '@/portainer/views/account/git-credential/gitCredential.service';
import { GitCredential } from '@/portainer/views/account/git-credential/types';
import { useUser } from '@/react/hooks/useUser';
import { FormControl } from '@@/form-components/FormControl';
import { Select } from '@@/form-components/ReactSelect';
export function CredentialSelector({
value,
onChange,
error,
}: {
value?: number;
onChange(gitCredential?: GitCredential | null): void;
error?: string;
}) {
const { user } = useUser();
const gitCredentialsQuery = useGitCredentials(user.Id);
const gitCredentials = gitCredentialsQuery.data ?? [];
return (
<div className="form-group">
<div className="col-sm-12">
<FormControl
label="Git Credentials"
inputId="git-creds-selector"
errors={error}
>
<Select
placeholder="select git credential or fill in below"
value={gitCredentials.find(
(gitCredential) => gitCredential.id === value
)}
options={gitCredentials}
getOptionLabel={(gitCredential) => gitCredential.name}
getOptionValue={(gitCredential) => gitCredential.id.toString()}
onChange={onChange}
isClearable
noOptionsMessage={() => 'no saved credentials'}
inputId="git-creds-selector"
/>
</FormControl>
</div>
</div>
);
}

@ -0,0 +1,55 @@
import { FormikErrors } from 'formik';
import { Checkbox } from '@@/form-components/Checkbox';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { TextTip } from '@@/Tip/TextTip';
import { GitAuthModel } from '../types';
export function NewCredentialForm({
value,
onChange,
errors,
}: {
value: GitAuthModel;
onChange: (value: Partial<GitAuthModel>) => void;
errors?: FormikErrors<GitAuthModel>;
}) {
return (
<div className="form-group">
<div className="col-sm-12">
<FormControl label="">
<div className="flex items-center gap-2">
<Checkbox
id="repository-save-credential"
label="save credential"
checked={value.SaveCredential}
className="[&+label]:mb-0"
onChange={(e) => onChange({ SaveCredential: e.target.checked })}
/>
<Input
value={value.NewCredentialName}
name="new_credential_name"
placeholder="credential name"
className="ml-4 w-48"
onChange={(e) => onChange({ NewCredentialName: e.target.value })}
disabled={!value.SaveCredential}
/>
{errors?.NewCredentialName && (
<div className="small text-danger">
{errors.NewCredentialName}
</div>
)}
{value.SaveCredential && (
<TextTip color="blue" className="!mb-0">
This git credential can be managed through your account page
</TextTip>
)}
</div>
</FormControl>
</div>
</div>
);
}

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

Loading…
Cancel
Save