mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 20:35:25 +02:00
feat(kube): introduce custom templates [EE-1125] (#5434)
* feat(kube): introduce custom templates refactor(customtemplates): use build option chore(deps): upgrade yaml parser feat(customtemplates): add and edit RC to kube templates fix(kube): show docker icon fix(custom-templates): save rc * fix(kube/templates): route to correct routes
This commit is contained in:
parent
a176ec5ace
commit
e4fe4f9a43
49 changed files with 1562 additions and 107 deletions
|
@ -1,6 +1,7 @@
|
|||
import registriesModule from './registries';
|
||||
import customTemplateModule from './custom-templates';
|
||||
|
||||
angular.module('portainer.kubernetes', ['portainer.app', registriesModule]).config([
|
||||
angular.module('portainer.kubernetes', ['portainer.app', registriesModule, customTemplateModule]).config([
|
||||
'$stateRegistryProvider',
|
||||
function ($stateRegistryProvider) {
|
||||
'use strict';
|
||||
|
@ -208,12 +209,15 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule]).conf
|
|||
|
||||
const deploy = {
|
||||
name: 'kubernetes.deploy',
|
||||
url: '/deploy',
|
||||
url: '/deploy?templateId',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubernetesDeployView',
|
||||
},
|
||||
},
|
||||
params: {
|
||||
templateId: '',
|
||||
},
|
||||
};
|
||||
|
||||
const resourcePools = {
|
||||
|
|
|
@ -162,7 +162,7 @@
|
|||
<span class="label label-info image-tag label-margins" ng-if="$ctrl.isSystemNamespace(item)">system</span>
|
||||
<span class="label label-primary image-tag label-margins" ng-if="!$ctrl.isSystemNamespace(item) && $ctrl.isExternalApplication(item)">external</span>
|
||||
</td>
|
||||
<td>{{ item.StackName }}</td>
|
||||
<td>{{ item.StackName || '-' }}</td>
|
||||
<td>
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: item.ResourcePool })">{{ item.ResourcePool }}</a>
|
||||
</td>
|
||||
|
|
|
@ -91,7 +91,7 @@
|
|||
<td
|
||||
><a ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool })">{{ item.Name }}</a></td
|
||||
>
|
||||
<td>{{ item.StackName }}</td>
|
||||
<td>{{ item.StackName || '-' }}</td>
|
||||
<td title="{{ item.Image }}">{{ item.Image | truncate: 64 }}</td>
|
||||
</tr>
|
||||
<tr ng-if="!$ctrl.dataset">
|
||||
|
|
|
@ -114,7 +114,7 @@
|
|||
<span style="margin-left: 5px;" class="label label-info image-tag" ng-if="$ctrl.isSystemNamespace(item)">system</span>
|
||||
<span style="margin-left: 5px;" class="label label-primary image-tag" ng-if="!$ctrl.isSystemNamespace(item) && $ctrl.isExternalApplication(item)">external</span>
|
||||
</td>
|
||||
<td>{{ item.StackName }}</td>
|
||||
<td>{{ item.StackName || '-' }}</td>
|
||||
<td>
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: item.ResourcePool })">{{ item.ResourcePool }}</a>
|
||||
</td>
|
||||
|
|
|
@ -106,7 +106,7 @@
|
|||
<a ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool })">{{ item.Name }}</a>
|
||||
<span style="margin-left: 5px;" class="label label-primary image-tag" ng-if="$ctrl.isExternalApplication(item)">external</span>
|
||||
</td>
|
||||
<td>{{ item.StackName }}</td>
|
||||
<td>{{ item.StackName || '-' }}</td>
|
||||
<td title="{{ item.Image }}"
|
||||
>{{ item.Image | truncate: 64 }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
|
||||
>
|
||||
|
|
|
@ -8,6 +8,16 @@
|
|||
Dashboard
|
||||
</sidebar-menu-item>
|
||||
|
||||
<sidebar-menu-item
|
||||
path="kubernetes.templates.custom"
|
||||
path-params="{ endpointId: $ctrl.endpointId }"
|
||||
icon-class="fa-rocket fa-fw"
|
||||
class-name="sidebar-list"
|
||||
data-cy="k8sSidebar-customTemplates"
|
||||
>
|
||||
Custom Templates
|
||||
</sidebar-menu-item>
|
||||
|
||||
<sidebar-menu-item
|
||||
path="kubernetes.resourcePools"
|
||||
path-params="{ endpointId: $ctrl.endpointId }"
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
KubernetesPortainerApplicationNote,
|
||||
KubernetesPortainerApplicationOwnerLabel,
|
||||
KubernetesPortainerApplicationStackNameLabel,
|
||||
KubernetesPortainerApplicationStackIdLabel,
|
||||
} from 'Kubernetes/models/application/models';
|
||||
import { KubernetesServiceTypes } from 'Kubernetes/models/service/models';
|
||||
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
|
||||
|
@ -54,10 +55,16 @@ class KubernetesApplicationConverter {
|
|||
const containers = data.spec.template ? _.without(_.concat(data.spec.template.spec.containers, data.spec.template.spec.initContainers), undefined) : data.spec.containers;
|
||||
res.Id = data.metadata.uid;
|
||||
res.Name = data.metadata.name;
|
||||
res.StackName = data.metadata.labels ? data.metadata.labels[KubernetesPortainerApplicationStackNameLabel] || '-' : '-';
|
||||
res.ApplicationOwner = data.metadata.labels ? data.metadata.labels[KubernetesPortainerApplicationOwnerLabel] || '' : '';
|
||||
|
||||
if (data.metadata.labels) {
|
||||
const { labels } = data.metadata;
|
||||
res.StackId = labels[KubernetesPortainerApplicationStackIdLabel] ? parseInt(labels[KubernetesPortainerApplicationStackIdLabel], 10) : null;
|
||||
res.StackName = labels[KubernetesPortainerApplicationStackNameLabel] || '';
|
||||
res.ApplicationOwner = labels[KubernetesPortainerApplicationOwnerLabel] || '';
|
||||
res.ApplicationName = labels[KubernetesPortainerApplicationNameLabel] || res.Name;
|
||||
}
|
||||
|
||||
res.Note = data.metadata.annotations ? data.metadata.annotations[KubernetesPortainerApplicationNote] || '' : '';
|
||||
res.ApplicationName = data.metadata.labels ? data.metadata.labels[KubernetesPortainerApplicationNameLabel] || res.Name : res.Name;
|
||||
res.ResourcePool = data.metadata.namespace;
|
||||
if (containers.length) {
|
||||
res.Image = containers[0].image;
|
||||
|
|
61
app/kubernetes/custom-templates/index.js
Normal file
61
app/kubernetes/custom-templates/index.js
Normal file
|
@ -0,0 +1,61 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { kubeCustomTemplatesView } from './kube-custom-templates-view';
|
||||
import { kubeEditCustomTemplateView } from './kube-edit-custom-template-view';
|
||||
import { kubeCreateCustomTemplateView } from './kube-create-custom-template-view';
|
||||
|
||||
export default angular
|
||||
.module('portainer.kubernetes.custom-templates', [])
|
||||
.config(config)
|
||||
.component('kubeCustomTemplatesView', kubeCustomTemplatesView)
|
||||
.component('kubeEditCustomTemplateView', kubeEditCustomTemplateView)
|
||||
.component('kubeCreateCustomTemplateView', kubeCreateCustomTemplateView).name;
|
||||
|
||||
function config($stateRegistryProvider) {
|
||||
const templates = {
|
||||
name: 'kubernetes.templates',
|
||||
url: '/templates',
|
||||
abstract: true,
|
||||
};
|
||||
|
||||
const customTemplates = {
|
||||
name: 'kubernetes.templates.custom',
|
||||
url: '/custom',
|
||||
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubeCustomTemplatesView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const customTemplatesNew = {
|
||||
name: 'kubernetes.templates.custom.new',
|
||||
url: '/new?fileContent',
|
||||
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubeCreateCustomTemplateView',
|
||||
},
|
||||
},
|
||||
params: {
|
||||
fileContent: '',
|
||||
},
|
||||
};
|
||||
|
||||
const customTemplatesEdit = {
|
||||
name: 'kubernetes.templates.custom.edit',
|
||||
url: '/:id',
|
||||
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubeEditCustomTemplateView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
$stateRegistryProvider.register(templates);
|
||||
$stateRegistryProvider.register(customTemplates);
|
||||
$stateRegistryProvider.register(customTemplatesNew);
|
||||
$stateRegistryProvider.register(customTemplatesEdit);
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import controller from './kube-create-custom-template-view.controller.js';
|
||||
|
||||
export const kubeCreateCustomTemplateView = {
|
||||
templateUrl: './kube-create-custom-template-view.html',
|
||||
controller,
|
||||
};
|
|
@ -0,0 +1,169 @@
|
|||
import { buildOption } from '@/portainer/components/box-selector';
|
||||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
|
||||
class KubeCreateCustomTemplateViewController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, Authentication, CustomTemplateService, FormValidator, ModalService, Notifications, ResourceControlService) {
|
||||
Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, ModalService, Notifications, ResourceControlService });
|
||||
|
||||
this.methodOptions = [
|
||||
buildOption('method_editor', 'fa fa-edit', 'Web editor', 'Use our Web editor', 'editor'),
|
||||
buildOption('method_upload', 'fa fa-upload', 'Upload', 'Upload from your computer', 'upload'),
|
||||
];
|
||||
|
||||
this.templates = null;
|
||||
|
||||
this.state = {
|
||||
method: 'editor',
|
||||
actionInProgress: false,
|
||||
formValidationError: '',
|
||||
isEditorDirty: false,
|
||||
};
|
||||
|
||||
this.formValues = {
|
||||
FileContent: '',
|
||||
File: null,
|
||||
Title: '',
|
||||
Description: '',
|
||||
Note: '',
|
||||
Logo: '',
|
||||
AccessControlData: new AccessControlFormData(),
|
||||
};
|
||||
|
||||
this.onChangeFile = this.onChangeFile.bind(this);
|
||||
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
||||
this.onChangeMethod = this.onChangeMethod.bind(this);
|
||||
this.onBeforeOnload = this.onBeforeOnload.bind(this);
|
||||
}
|
||||
|
||||
onChangeMethod(method) {
|
||||
this.state.method = method;
|
||||
}
|
||||
|
||||
onChangeFileContent(content) {
|
||||
this.formValues.FileContent = content;
|
||||
this.state.isEditorDirty = true;
|
||||
}
|
||||
|
||||
onChangeFile(file) {
|
||||
this.formValues.File = file;
|
||||
}
|
||||
|
||||
async createCustomTemplate() {
|
||||
return this.$async(async () => {
|
||||
const { method } = this.state;
|
||||
|
||||
if (!this.validateForm(method)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.actionInProgress = true;
|
||||
try {
|
||||
const customTemplate = await this.createCustomTemplateByMethod(method, this.formValues);
|
||||
|
||||
const accessControlData = this.formValues.AccessControlData;
|
||||
const userDetails = this.Authentication.getUserDetails();
|
||||
const userId = userDetails.ID;
|
||||
await this.ResourceControlService.applyResourceControl(userId, accessControlData, customTemplate.ResourceControl);
|
||||
|
||||
this.Notifications.success('Custom template successfully created');
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('kubernetes.templates.custom');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Failed creating custom template');
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createCustomTemplateByMethod(method, template) {
|
||||
template.Type = 3;
|
||||
|
||||
switch (method) {
|
||||
case 'editor':
|
||||
return this.createCustomTemplateFromFileContent(template);
|
||||
case 'upload':
|
||||
return this.createCustomTemplateFromFileUpload(template);
|
||||
}
|
||||
}
|
||||
|
||||
createCustomTemplateFromFileContent(template) {
|
||||
return this.CustomTemplateService.createCustomTemplateFromFileContent(template);
|
||||
}
|
||||
|
||||
createCustomTemplateFromFileUpload(template) {
|
||||
return this.CustomTemplateService.createCustomTemplateFromFileUpload(template);
|
||||
}
|
||||
|
||||
validateForm(method) {
|
||||
this.state.formValidationError = '';
|
||||
|
||||
if (method === 'editor' && this.formValues.FileContent === '') {
|
||||
this.state.formValidationError = 'Template file content must not be empty';
|
||||
return false;
|
||||
}
|
||||
|
||||
const title = this.formValues.Title;
|
||||
const isNotUnique = this.templates.some((template) => template.Title === title);
|
||||
if (isNotUnique) {
|
||||
this.state.formValidationError = 'A template with the same name already exists';
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAdmin = this.Authentication.isAdmin();
|
||||
const accessControlData = this.formValues.AccessControlData;
|
||||
const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
|
||||
|
||||
if (error) {
|
||||
this.state.formValidationError = error;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async $onInit() {
|
||||
return this.$async(async () => {
|
||||
const { fileContent, type } = this.$state.params;
|
||||
|
||||
this.formValues.FileContent = fileContent;
|
||||
if (type) {
|
||||
this.formValues.Type = +type;
|
||||
}
|
||||
|
||||
try {
|
||||
this.templates = await this.CustomTemplateService.customTemplates(3);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure loading', err, 'Failed loading custom templates');
|
||||
}
|
||||
|
||||
this.state.loading = false;
|
||||
|
||||
window.addEventListener('beforeunload', this.onBeforeOnload);
|
||||
});
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
window.removeEventListener('beforeunload', this.onBeforeOnload);
|
||||
}
|
||||
|
||||
isEditorDirty() {
|
||||
return this.state.method === 'editor' && this.formValues.FileContent && this.state.isEditorDirty;
|
||||
}
|
||||
|
||||
onBeforeOnload(event) {
|
||||
if (this.isEditorDirty()) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
}
|
||||
|
||||
uiCanExit() {
|
||||
if (this.isEditorDirty()) {
|
||||
return this.ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default KubeCreateCustomTemplateViewController;
|
|
@ -0,0 +1,71 @@
|
|||
<rd-header>
|
||||
<rd-header-title title-text="Create Custom template"></rd-header-title>
|
||||
<rd-header-content> <a ui-sref="kubernetes.templates.custom">Custom Templates</a> > Create Custom template </rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" name="$ctrl.form">
|
||||
<custom-template-common-fields form-values="$ctrl.formValues"></custom-template-common-fields>
|
||||
|
||||
<!-- build-method -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Build method
|
||||
</div>
|
||||
<box-selector radio-name="method" ng-model="$ctrl.state.method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
|
||||
|
||||
<web-editor-form
|
||||
ng-if="$ctrl.state.method === 'editor'"
|
||||
identifier="template-creation-editor"
|
||||
value="$ctrl.formValues.FileContent"
|
||||
on-change="($ctrl.onChangeFileContent)"
|
||||
ng-required="true"
|
||||
yml="true"
|
||||
placeholder="# Define or paste the content of your manifest file here"
|
||||
>
|
||||
<editor-description>
|
||||
<p>Templates allow deploying any kind of Kubernetes resource (Deployment, Secret, ConfigMap...)</p>
|
||||
<p>
|
||||
You can get more information about Kubernetes file format in the
|
||||
<a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/" target="_blank">official documentation</a>.
|
||||
</p>
|
||||
</editor-description>
|
||||
</web-editor-form>
|
||||
|
||||
<file-upload-form ng-if="$ctrl.state.method === 'upload'" file="$ctrl.formValues.File" on-change="($ctrl.onChangeFile)" ng-required="true">
|
||||
<file-upload-description>
|
||||
You can upload a Manifest file from your computer.
|
||||
</file-upload-description>
|
||||
</file-upload-form>
|
||||
|
||||
<por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-disabled="$ctrl.state.actionInProgress || $ctrl.form.$invalid || ($ctrl.state.method === 'editor' && !$ctrl.formValues.FileContent)"
|
||||
ng-click="$ctrl.createCustomTemplate()"
|
||||
button-spinner="$ctrl.state.actionInProgress"
|
||||
>
|
||||
<span ng-hide="$ctrl.state.actionInProgress">Create custom template</span>
|
||||
<span ng-show="$ctrl.state.actionInProgress">Creation in progress...</span>
|
||||
</button>
|
||||
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">
|
||||
{{ $ctrl.state.formValidationError }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,6 @@
|
|||
import controller from './kube-custom-templates-view.controller.js';
|
||||
|
||||
export const kubeCustomTemplatesView = {
|
||||
templateUrl: './kube-custom-templates-view.html',
|
||||
controller,
|
||||
};
|
|
@ -0,0 +1,79 @@
|
|||
import _ from 'lodash-es';
|
||||
|
||||
export default class KubeCustomTemplatesViewController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, Authentication, CustomTemplateService, FormValidator, ModalService, Notifications) {
|
||||
Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, ModalService, Notifications });
|
||||
|
||||
this.state = {
|
||||
selectedTemplate: null,
|
||||
formValidationError: '',
|
||||
actionInProgress: false,
|
||||
};
|
||||
|
||||
this.currentUser = {
|
||||
isAdmin: false,
|
||||
id: null,
|
||||
};
|
||||
|
||||
this.isEditAllowed = this.isEditAllowed.bind(this);
|
||||
this.getTemplates = this.getTemplates.bind(this);
|
||||
this.validateForm = this.validateForm.bind(this);
|
||||
this.confirmDelete = this.confirmDelete.bind(this);
|
||||
this.selectTemplate = this.selectTemplate.bind(this);
|
||||
}
|
||||
|
||||
selectTemplate(template) {
|
||||
this.$state.go('kubernetes.deploy', { templateId: template.Id });
|
||||
}
|
||||
|
||||
isEditAllowed(template) {
|
||||
// todo - check if current user is admin/endpointadmin/owner
|
||||
return this.currentUser.isAdmin || this.currentUser.id === template.CreatedByUserId;
|
||||
}
|
||||
|
||||
getTemplates() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.templates = await this.CustomTemplateService.customTemplates(3);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failed loading templates', err, 'Unable to load custom templates');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
validateForm(accessControlData, isAdmin) {
|
||||
this.state.formValidationError = '';
|
||||
const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
|
||||
|
||||
if (error) {
|
||||
this.state.formValidationError = error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
confirmDelete(templateId) {
|
||||
return this.$async(async () => {
|
||||
const confirmed = await this.ModalService.confirmDeletionAsync('Are you sure that you want to delete this template?');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.CustomTemplateService.remove(templateId);
|
||||
_.remove(this.templates, { Id: templateId });
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Failed to delete template');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.getTemplates();
|
||||
|
||||
this.currentUser.isAdmin = this.Authentication.isAdmin();
|
||||
const user = this.Authentication.getUserDetails();
|
||||
this.currentUser.id = user.ID;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
<rd-header id="view-top">
|
||||
<rd-header-title title-text="Custom Templates">
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="kubernetes.templates.custom" ui-sref-opts="{reload: true}">
|
||||
<i class="fa fa-sync" aria-hidden="true"></i>
|
||||
</a>
|
||||
</rd-header-title>
|
||||
<rd-header-content>Custom Templates</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<custom-templates-list
|
||||
ng-if="$ctrl.templates"
|
||||
title-text="Templates"
|
||||
title-icon="fa-rocket"
|
||||
templates="$ctrl.templates"
|
||||
table-key="customTemplates"
|
||||
is-edit-allowed="$ctrl.isEditAllowed"
|
||||
on-select-click="($ctrl.selectTemplate)"
|
||||
on-delete-click="($ctrl.confirmDelete)"
|
||||
create-path="kubernetes.templates.custom.new"
|
||||
edit-path="kubernetes.templates.custom.edit"
|
||||
></custom-templates-list>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,6 @@
|
|||
import controller from './kube-edit-custom-template-view.controller.js';
|
||||
|
||||
export const kubeEditCustomTemplateView = {
|
||||
templateUrl: './kube-edit-custom-template-view.html',
|
||||
controller,
|
||||
};
|
|
@ -0,0 +1,143 @@
|
|||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { ResourceControlViewModel } from '@/portainer/models/resourceControl/resourceControl';
|
||||
|
||||
class KubeEditCustomTemplateViewController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) {
|
||||
Object.assign(this, { $async, $state, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService });
|
||||
|
||||
this.formValues = null;
|
||||
this.state = {
|
||||
formValidationError: '',
|
||||
isEditorDirty: false,
|
||||
};
|
||||
this.templates = [];
|
||||
|
||||
this.getTemplate = this.getTemplate.bind(this);
|
||||
this.submitAction = this.submitAction.bind(this);
|
||||
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
||||
this.onBeforeUnload = this.onBeforeUnload.bind(this);
|
||||
}
|
||||
|
||||
getTemplate() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
const { id } = this.$state.params;
|
||||
|
||||
const [template, file] = await Promise.all([this.CustomTemplateService.customTemplate(id), this.CustomTemplateService.customTemplateFile(id)]);
|
||||
template.FileContent = file;
|
||||
this.formValues = template;
|
||||
this.oldFileContent = this.formValues.FileContent;
|
||||
|
||||
this.formValues.ResourceControl = new ResourceControlViewModel(template.ResourceControl);
|
||||
this.formValues.AccessControlData = new AccessControlFormData();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve custom template data');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
validateForm() {
|
||||
this.state.formValidationError = '';
|
||||
|
||||
if (!this.formValues.FileContent) {
|
||||
this.state.formValidationError = 'Template file content must not be empty';
|
||||
return false;
|
||||
}
|
||||
|
||||
const title = this.formValues.Title;
|
||||
const id = this.$state.params.id;
|
||||
|
||||
const isNotUnique = this.templates.some((template) => template.Title === title && template.Id != id);
|
||||
if (isNotUnique) {
|
||||
this.state.formValidationError = `A template with the name ${title} already exists`;
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAdmin = this.Authentication.isAdmin();
|
||||
const accessControlData = this.formValues.AccessControlData;
|
||||
const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
|
||||
|
||||
if (error) {
|
||||
this.state.formValidationError = error;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
submitAction() {
|
||||
return this.$async(async () => {
|
||||
if (!this.validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.actionInProgress = true;
|
||||
try {
|
||||
await this.CustomTemplateService.updateCustomTemplate(this.formValues.Id, this.formValues);
|
||||
|
||||
const userDetails = this.Authentication.getUserDetails();
|
||||
const userId = userDetails.ID;
|
||||
await this.ResourceControlService.applyResourceControl(userId, this.formValues.AccessControlData, this.formValues.ResourceControl);
|
||||
|
||||
this.Notifications.success('Custom template successfully updated');
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('kubernetes.templates.custom');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to update custom template');
|
||||
} finally {
|
||||
this.actionInProgress = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onChangeFileContent(value) {
|
||||
if (stripSpaces(this.formValues.FileContent) !== stripSpaces(value)) {
|
||||
this.formValues.FileContent = value;
|
||||
this.state.isEditorDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
async $onInit() {
|
||||
this.$async(async () => {
|
||||
this.getTemplate();
|
||||
|
||||
try {
|
||||
this.templates = await this.CustomTemplateService.customTemplates();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure loading', err, 'Failed loading custom templates');
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', this.onBeforeUnload);
|
||||
});
|
||||
}
|
||||
|
||||
isEditorDirty() {
|
||||
return this.formValues.FileContent !== this.oldFileContent && this.state.isEditorDirty;
|
||||
}
|
||||
|
||||
uiCanExit() {
|
||||
if (this.isEditorDirty()) {
|
||||
return this.ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnload(event) {
|
||||
if (this.formValues.FileContent !== this.oldFileContent && this.state.isEditorDirty) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
window.removeEventListener('beforeunload', this.onBeforeUnload);
|
||||
}
|
||||
}
|
||||
|
||||
export default KubeEditCustomTemplateViewController;
|
||||
|
||||
function stripSpaces(str = '') {
|
||||
return str.replace(/(\r\n|\n|\r)/gm, '');
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
<rd-header>
|
||||
<rd-header-title title-text="Edit Custom Template">
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="kubernetes.templates.custom.edit({id:$ctrl.formValues.Id})" ui-sref-opts="{reload: true}">
|
||||
<i class="fa fa-sync" aria-hidden="true"></i>
|
||||
</a>
|
||||
</rd-header-title>
|
||||
<rd-header-content> <a ui-sref="kubernetes.templates.custom">Custom templates</a> > {{ $ctrl.formValues.Title }} </rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row" ng-if="$ctrl.formValues">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" name="$ctrl.form">
|
||||
<custom-template-common-fields form-values="$ctrl.formValues"></custom-template-common-fields>
|
||||
|
||||
<web-editor-form
|
||||
identifier="template-editor"
|
||||
value="$ctrl.formValues.FileContent"
|
||||
on-change="($ctrl.onChangeFileContent)"
|
||||
ng-required="true"
|
||||
yml="true"
|
||||
placeholder="# Define or paste the content of your manifest file here"
|
||||
>
|
||||
<editor-description>
|
||||
<p>Templates allow deploying any kind of Kubernetes resource (Deployment, Secret, ConfigMap...)</p>
|
||||
<p>
|
||||
You can get more information about Kubernetes file format in the
|
||||
<a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/" target="_blank">official documentation</a>.
|
||||
</p>
|
||||
</editor-description>
|
||||
</web-editor-form>
|
||||
|
||||
<por-access-control-form form-data="$ctrl.formValues.AccessControlData" resource-control="$ctrl.formValues.ResourceControl"></por-access-control-form>
|
||||
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-disabled="$ctrl.actionInProgress || $ctrl.form.$invalid || !$ctrl.formValues.Title || !$ctrl.formValues.FileContent"
|
||||
ng-click="$ctrl.submitAction()"
|
||||
button-spinner="$ctrl.actionInProgress"
|
||||
>
|
||||
<span ng-hide="$ctrl.actionInProgress">Update the template</span>
|
||||
<span ng-show="$ctrl.actionInProgress">Update in progress...</span>
|
||||
</button>
|
||||
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">
|
||||
{{ $ctrl.state.formValidationError }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
|
@ -6,7 +6,7 @@ class KubernetesStackHelper {
|
|||
const res = _.reduce(
|
||||
applications,
|
||||
(acc, app) => {
|
||||
if (app.StackName !== '-') {
|
||||
if (app.StackName) {
|
||||
let stack = _.find(acc, { Name: app.StackName, ResourcePool: app.ResourcePool });
|
||||
if (!stack) {
|
||||
stack = new KubernetesStack();
|
||||
|
|
|
@ -40,6 +40,7 @@ export const KubernetesApplicationQuotaDefaults = {
|
|||
};
|
||||
|
||||
export const KubernetesPortainerApplicationStackNameLabel = 'io.portainer.kubernetes.application.stack';
|
||||
export const KubernetesPortainerApplicationStackIdLabel = 'io.portainer.kubernetes.application.stackid';
|
||||
|
||||
export const KubernetesPortainerApplicationNameLabel = 'io.portainer.kubernetes.application.name';
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ export const KubernetesDeployManifestTypes = Object.freeze({
|
|||
export const KubernetesDeployBuildMethods = Object.freeze({
|
||||
GIT: 1,
|
||||
WEB_EDITOR: 2,
|
||||
CUSTOM_TEMPLATE: 3,
|
||||
});
|
||||
|
||||
export const KubernetesDeployRequestMethods = Object.freeze({
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
</tr>
|
||||
<tr>
|
||||
<td>Stack</td>
|
||||
<td>{{ ctrl.application.StackName }}</td>
|
||||
<td>{{ ctrl.application.StackName || '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Namespace</td>
|
||||
|
@ -191,21 +191,15 @@
|
|||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div ng-if="!ctrl.isSystemNamespace()">
|
||||
<button
|
||||
ng-if="!ctrl.isExternalApplication()"
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
ui-sref="kubernetes.applications.application.edit"
|
||||
style="margin-left: 0; margin-bottom: 15px;"
|
||||
>
|
||||
<div ng-if="!ctrl.isSystemNamespace()" style="margin-bottom: 15px;">
|
||||
<button ng-if="!ctrl.isExternalApplication()" type="button" class="btn btn-sm btn-primary" ui-sref="kubernetes.applications.application.edit" style="margin-left: 0;">
|
||||
<i class="fa fa-file-code space-right" aria-hidden="true"></i>Edit this application
|
||||
</button>
|
||||
<button
|
||||
ng-if="ctrl.application.ApplicationType !== ctrl.KubernetesApplicationTypes.POD"
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
style="margin-left: 0; margin-bottom: 15px;"
|
||||
style="margin-left: 0;"
|
||||
ng-click="ctrl.redeployApplication()"
|
||||
>
|
||||
<i class="fa fa-redo space-right" aria-hidden="true"></i>Redeploy
|
||||
|
@ -214,12 +208,19 @@
|
|||
ng-if="!ctrl.isExternalApplication()"
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
style="margin-left: 0; margin-bottom: 15px;"
|
||||
style="margin-left: 0;"
|
||||
ng-click="ctrl.rollbackApplication()"
|
||||
ng-disabled="ctrl.application.Revisions.length < 2"
|
||||
>
|
||||
<i class="fas fa-history space-right" aria-hidden="true"></i>Rollback to previous configuration
|
||||
</button>
|
||||
<a
|
||||
ng-if="ctrl.isStack() && ctrl.stackFileContent"
|
||||
class="btn btn-sm btn-primary space-left"
|
||||
ui-sref="kubernetes.templates.custom.new({fileContent: ctrl.stackFileContent})"
|
||||
>
|
||||
<i class="fas fa-plus space-right" aria-hidden="true"></i>Create template from application
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ACCESSING APPLICATION -->
|
||||
|
|
|
@ -107,7 +107,8 @@ class KubernetesApplicationController {
|
|||
KubernetesStackService,
|
||||
KubernetesPodService,
|
||||
KubernetesNodeService,
|
||||
EndpointProvider
|
||||
EndpointProvider,
|
||||
StackService
|
||||
) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
|
@ -115,6 +116,7 @@ class KubernetesApplicationController {
|
|||
this.Notifications = Notifications;
|
||||
this.LocalStorage = LocalStorage;
|
||||
this.ModalService = ModalService;
|
||||
this.StackService = StackService;
|
||||
|
||||
this.KubernetesApplicationService = KubernetesApplicationService;
|
||||
this.KubernetesEventService = KubernetesEventService;
|
||||
|
@ -193,6 +195,10 @@ class KubernetesApplicationController {
|
|||
return !rule.Host && !rule.IP ? false : true;
|
||||
}
|
||||
|
||||
isStack() {
|
||||
return this.application.StackId;
|
||||
}
|
||||
|
||||
/**
|
||||
* ROLLBACK
|
||||
*/
|
||||
|
@ -308,6 +314,11 @@ class KubernetesApplicationController {
|
|||
|
||||
this.placements = computePlacements(nodes, this.application);
|
||||
this.state.placementWarning = _.find(this.placements, { AcceptsApplication: true }) ? false : true;
|
||||
|
||||
if (application.StackId) {
|
||||
const file = await this.StackService.getStackFile(application.StackId);
|
||||
this.stackFileContent = file;
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve application details');
|
||||
} finally {
|
||||
|
|
|
@ -23,16 +23,18 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Deployment type
|
||||
</div>
|
||||
<box-selector radio-name="deploy" ng-model="ctrl.state.DeployType" options="ctrl.deployOptions" data-cy="k8sAppDeploy-deploymentSelector"></box-selector>
|
||||
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Build method
|
||||
</div>
|
||||
<box-selector radio-name="method" ng-model="ctrl.state.BuildMethod" options="ctrl.methodOptions" data-cy="k8sAppDeploy-buildSelector"></box-selector>
|
||||
|
||||
<div ng-if="ctrl.state.BuildMethod !== ctrl.BuildMethods.CUSTOM_TEMPLATE">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Deployment type
|
||||
</div>
|
||||
<box-selector radio-name="deploy" ng-model="ctrl.state.DeployType" options="ctrl.deployOptions" data-cy="k8sAppDeploy-deploymentSelector"></box-selector>
|
||||
</div>
|
||||
|
||||
<!-- repository -->
|
||||
<div ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.GIT">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
|
@ -62,9 +64,17 @@
|
|||
</div>
|
||||
<!-- !repository -->
|
||||
|
||||
<custom-template-selector
|
||||
ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.CUSTOM_TEMPLATE"
|
||||
new-template-path="kubernetes.templates.custom.new"
|
||||
stack-type="3"
|
||||
on-change="(ctrl.onChangeTemplateId)"
|
||||
value="ctrl.state.templateId"
|
||||
></custom-template-selector>
|
||||
|
||||
<!-- editor -->
|
||||
<web-editor-form
|
||||
ng-if="ctrl.state.BuildMethod === ctrl.BuildMethods.WEB_EDITOR"
|
||||
ng-if="ctrl.state.BuildMethod === ctrl.BuildMethods.WEB_EDITOR || (ctrl.state.BuildMethod === ctrl.BuildMethods.CUSTOM_TEMPLATE && ctrl.state.templateId)"
|
||||
identifier="kubernetes-deploy-editor"
|
||||
value="ctrl.formValues.EditorContent"
|
||||
on-change="(ctrl.onChangeFileContent)"
|
||||
|
|
|
@ -6,10 +6,11 @@ import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, Kubernetes
|
|||
import { buildOption } from '@/portainer/components/box-selector';
|
||||
class KubernetesDeployController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $window, ModalService, Notifications, EndpointProvider, KubernetesResourcePoolService, StackService) {
|
||||
constructor($async, $state, $window, CustomTemplateService, ModalService, Notifications, EndpointProvider, KubernetesResourcePoolService, StackService) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$window = $window;
|
||||
this.CustomTemplateService = CustomTemplateService;
|
||||
this.ModalService = ModalService;
|
||||
this.Notifications = Notifications;
|
||||
this.EndpointProvider = EndpointProvider;
|
||||
|
@ -18,12 +19,13 @@ class KubernetesDeployController {
|
|||
|
||||
this.deployOptions = [
|
||||
buildOption('method_kubernetes', 'fa fa-cubes', 'Kubernetes', 'Kubernetes manifest format', KubernetesDeployManifestTypes.KUBERNETES),
|
||||
buildOption('method_compose', 'fa fa-docker', 'Compose', 'docker-compose format', KubernetesDeployManifestTypes.COMPOSE),
|
||||
buildOption('method_compose', 'fab fa-docker', 'Compose', 'docker-compose format', KubernetesDeployManifestTypes.COMPOSE),
|
||||
];
|
||||
|
||||
this.methodOptions = [
|
||||
buildOption('method_repo', 'fab fa-github', 'Git Repository', 'Use a git repository', KubernetesDeployBuildMethods.GIT),
|
||||
buildOption('method_editor', 'fa fa-edit', 'Web editor', 'Use our Web editor', KubernetesDeployBuildMethods.WEB_EDITOR),
|
||||
buildOption('method_template', 'fa fa-rocket', 'Custom Template', 'Use a custom template', KubernetesDeployBuildMethods.CUSTOM_TEMPLATE),
|
||||
];
|
||||
|
||||
this.state = {
|
||||
|
@ -33,6 +35,7 @@ class KubernetesDeployController {
|
|||
activeTab: 0,
|
||||
viewReady: false,
|
||||
isEditorDirty: false,
|
||||
templateId: null,
|
||||
};
|
||||
|
||||
this.formValues = {};
|
||||
|
@ -40,7 +43,7 @@ class KubernetesDeployController {
|
|||
this.BuildMethods = KubernetesDeployBuildMethods;
|
||||
this.endpointId = this.EndpointProvider.endpointID();
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.onChangeTemplateId = this.onChangeTemplateId.bind(this);
|
||||
this.deployAsync = this.deployAsync.bind(this);
|
||||
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
||||
this.getNamespacesAsync = this.getNamespacesAsync.bind(this);
|
||||
|
@ -75,6 +78,23 @@ class KubernetesDeployController {
|
|||
this.onChangeFormValues({ RepositoryReferenceName: value });
|
||||
}
|
||||
|
||||
onChangeTemplateId(templateId) {
|
||||
return this.$async(async () => {
|
||||
if (this.state.templateId === templateId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.templateId = templateId;
|
||||
|
||||
try {
|
||||
const fileContent = await this.CustomTemplateService.customTemplateFile(templateId);
|
||||
this.onChangeFileContent(fileContent);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to load template file');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onChangeFileContent(value) {
|
||||
this.formValues.EditorContent = value;
|
||||
this.state.isEditorDirty = true;
|
||||
|
@ -91,10 +111,20 @@ class KubernetesDeployController {
|
|||
this.state.actionInProgress = true;
|
||||
|
||||
try {
|
||||
const method = this.state.BuildMethod === this.BuildMethods.GIT ? KubernetesDeployRequestMethods.REPOSITORY : KubernetesDeployRequestMethods.STRING;
|
||||
let method = KubernetesDeployRequestMethods.STRING;
|
||||
let composeFormat = this.state.DeployType === this.ManifestDeployTypes.COMPOSE;
|
||||
|
||||
switch (this.state.BuildMethod) {
|
||||
case KubernetesDeployBuildMethods.GIT:
|
||||
method = KubernetesDeployRequestMethods.REPOSITORY;
|
||||
break;
|
||||
case KubernetesDeployBuildMethods.CUSTOM_TEMPLATE:
|
||||
composeFormat = false;
|
||||
break;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
ComposeFormat: this.state.DeployType === this.ManifestDeployTypes.COMPOSE,
|
||||
ComposeFormat: composeFormat,
|
||||
Namespace: this.formValues.Namespace,
|
||||
};
|
||||
|
||||
|
@ -157,20 +187,27 @@ class KubernetesDeployController {
|
|||
return this.ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
async onInit() {
|
||||
await this.getNamespaces();
|
||||
|
||||
this.state.viewReady = true;
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
if (this.formValues.EditorContent && this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
return this.$async(this.onInit);
|
||||
return this.$async(async () => {
|
||||
await this.getNamespaces();
|
||||
|
||||
if (this.$state.params.templateId) {
|
||||
const templateId = parseInt(this.$state.params.templateId, 10);
|
||||
if (templateId && !Number.isNaN(templateId)) {
|
||||
this.state.BuildMethod = KubernetesDeployBuildMethods.CUSTOM_TEMPLATE;
|
||||
this.onChangeTemplateId(templateId);
|
||||
}
|
||||
}
|
||||
|
||||
this.state.viewReady = true;
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
if (this.formValues.EditorContent && this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue