@@ -12,8 +12,11 @@
-
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/controls/info-box/info-box.component.ts b/AzureFunctions.AngularClient/src/app/controls/info-box/info-box.component.ts
index 5912085c51..277738afec 100644
--- a/AzureFunctions.AngularClient/src/app/controls/info-box/info-box.component.ts
+++ b/AzureFunctions.AngularClient/src/app/controls/info-box/info-box.component.ts
@@ -10,6 +10,8 @@ export class InfoBoxComponent {
@Input() infoText: string = null;
@Input() infoLink: string = null;
+ @Input() infoActionFn: () => void = null;
+ @Input() infoActionIcon: string = null;
public typeClass = 'info';
public iconPath = 'image/info.svg';
@@ -32,13 +34,19 @@ export class InfoBoxComponent {
}
onClick(event: any) {
- if (!!this.infoLink) {
- window.open(this.infoLink, '_blank');
- }
+ this._invoke();
}
onKeyPress(event: KeyboardEvent) {
- if (!!this.infoLink && event.keyCode === KeyCodes.enter) {
+ if (event.keyCode === KeyCodes.enter) {
+ this._invoke();
+ }
+ }
+
+ private _invoke() {
+ if (!!this.infoActionFn) {
+ this.infoActionFn();
+ } else if (!!this.infoLink) {
window.open(this.infoLink, '_blank');
}
}
diff --git a/AzureFunctions.AngularClient/src/app/controls/textbox/textbox.component.html b/AzureFunctions.AngularClient/src/app/controls/textbox/textbox.component.html
index bc7a976b1c..30c8936f95 100644
--- a/AzureFunctions.AngularClient/src/app/controls/textbox/textbox.component.html
+++ b/AzureFunctions.AngularClient/src/app/controls/textbox/textbox.component.html
@@ -2,12 +2,20 @@
diff --git a/AzureFunctions.AngularClient/src/app/controls/textbox/textbox.component.ts b/AzureFunctions.AngularClient/src/app/controls/textbox/textbox.component.ts
index a27e1cebc2..ea3aff9f0a 100644
--- a/AzureFunctions.AngularClient/src/app/controls/textbox/textbox.component.ts
+++ b/AzureFunctions.AngularClient/src/app/controls/textbox/textbox.component.ts
@@ -1,5 +1,6 @@
import { FormControl } from '@angular/forms';
-import { Component, OnInit, Input, ViewChild } from '@angular/core';
+import { Component, Input, OnInit, Output, ViewChild } from '@angular/core';
+import { Subject } from 'rxjs/Subject';
@Component({
selector: 'textbox',
@@ -11,12 +12,19 @@ export class TextboxComponent implements OnInit {
@Input() control: FormControl;
@Input() placeholder = '';
@Input() highlightDirty: boolean;
+ @Input() readonly: boolean;
+ @Input() disabled: boolean;
+
+ @Output() change: Subject;
+ @Output() value: Subject;
@ViewChild('textboxInput') textboxInput: any;
public Obj = Object;
constructor() {
+ this.change = new Subject();
+ this.value = new Subject();
}
ngOnInit() {
@@ -27,4 +35,12 @@ export class TextboxComponent implements OnInit {
this.textboxInput.nativeElement.focus();
}
}
+
+ onChange(value: string) {
+ this.change.next(value);
+ }
+
+ onKeyUp(value: string) {
+ this.value.next(value);
+ }
}
diff --git a/AzureFunctions.AngularClient/src/app/function-quickstart/function-quickstart.component.html b/AzureFunctions.AngularClient/src/app/function-quickstart/function-quickstart.component.html
index c0867274e6..3ca1e7efa9 100644
--- a/AzureFunctions.AngularClient/src/app/function-quickstart/function-quickstart.component.html
+++ b/AzureFunctions.AngularClient/src/app/function-quickstart/function-quickstart.component.html
@@ -55,11 +55,11 @@ {{ 'intro_chooseLanguage' | translate }}
-
-
+
+
-
+
{{ 'intro_ifYou' | translate }}
();
templateTypeOptions: TemplateType[] = ['HttpTrigger', 'TimerTrigger', 'QueueTrigger'];
+ runtimeVersion: string;
private functionsNode: FunctionsNode;
private _viewInfoStream = new Subject>();
@@ -69,15 +71,18 @@ export class FunctionQuickstartComponent extends FunctionAppContextComponent {
return this.viewInfoEvents
.switchMap(r => {
this.functionsNode = r.node as FunctionsNode;
- return this._functionAppService.getFunctions(this.context);
+ return Observable.zip(
+ this._functionAppService.getFunctions(this.context),
+ this._functionAppService.getRuntimeGeneration(this.context));
})
.do(null, e => {
this._aiService.trackException(e, '/errors/function-quickstart');
console.error(e);
})
- .subscribe(fcs => {
+ .subscribe(tuple => {
this._globalStateService.clearBusyState();
- this.functionsInfo = fcs.result;
+ this.functionsInfo = tuple[0].result;
+ this.runtimeVersion = tuple[1];
});
}
@@ -166,9 +171,9 @@ export class FunctionQuickstartComponent extends FunctionAppContextComponent {
}
this._globalStateService.clearBusyState();
},
- () => {
- this._globalStateService.clearBusyState();
- });
+ () => {
+ this._globalStateService.clearBusyState();
+ });
} catch (e) {
this.showComponentError({
message: this._translateService.instant(PortalResources.functionCreateErrorDetails, { error: JSON.stringify(e) }),
diff --git a/AzureFunctions.AngularClient/src/app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.component.html b/AzureFunctions.AngularClient/src/app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.component.html
new file mode 100644
index 0000000000..9b5a07330d
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.component.html
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.component.scss b/AzureFunctions.AngularClient/src/app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/AzureFunctions.AngularClient/src/app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.component.spec.ts b/AzureFunctions.AngularClient/src/app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.component.spec.ts
new file mode 100644
index 0000000000..9fe685c9cd
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.component.spec.ts
@@ -0,0 +1,25 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { DeploymentSlotsShellComponent } from './deployment-slots-shell.component';
+
+describe('DeploymentSlotsShellComponent', () => {
+ let component: DeploymentSlotsShellComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ declarations: [DeploymentSlotsShellComponent]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(DeploymentSlotsShellComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should be created', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/AzureFunctions.AngularClient/src/app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.component.ts b/AzureFunctions.AngularClient/src/app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.component.ts
new file mode 100644
index 0000000000..d3d063a2d8
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.component.ts
@@ -0,0 +1,42 @@
+import { DashboardType } from 'app/tree-view/models/dashboard-type';
+import { TreeViewInfo, SiteData } from './../../tree-view/models/tree-view-info';
+import { Component, OnDestroy } from '@angular/core';
+import { TranslateService } from '@ngx-translate/core';
+import { ActivatedRoute } from '@angular/router';
+import { Subject } from 'rxjs/Subject';
+
+@Component({
+ selector: 'app-deployment-slots-shell',
+ templateUrl: './deployment-slots-shell.component.html',
+ styleUrls: ['./deployment-slots-shell.component.scss']
+})
+export class DeploymentSlotsShellComponent implements OnDestroy {
+ viewInfo: TreeViewInfo;
+ swapMode: boolean;
+ ngUnsubscribe: Subject;
+
+ constructor(translateService: TranslateService, route: ActivatedRoute) {
+ this.ngUnsubscribe = new Subject();
+
+ route.params
+ .takeUntil(this.ngUnsubscribe)
+ .subscribe(x => {
+ this.viewInfo = {
+ resourceId: `/subscriptions/${x['subscriptionId']}/resourceGroups/${x[
+ 'resourceGroup'
+ ]}/providers/Microsoft.Web/sites/${x['site']}` + (x['slot'] ? `/slots/${x['slot']}` : ``),
+ dashboardType: DashboardType.none,
+ node: null,
+ data: null
+ };
+
+ if (x['action'] && x['action'] === 'swap') {
+ this.swapMode = true;
+ }
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.ngUnsubscribe.next();
+ }
+}
diff --git a/AzureFunctions.AngularClient/src/app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.module.ts b/AzureFunctions.AngularClient/src/app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.module.ts
new file mode 100644
index 0000000000..5c9704b163
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.module.ts
@@ -0,0 +1,19 @@
+import { NgModule, ModuleWithProviders } from '@angular/core';
+import { DeploymentSlotsShellComponent } from './deployment-slots-shell.component';
+import { RouterModule } from '@angular/router';
+import { DeploymentSlotsComponent } from 'app/site/deployment-slots/deployment-slots.component';
+import { SharedFunctionsModule } from 'app/shared/shared-functions.module';
+import { SharedModule } from 'app/shared/shared.module';
+import { TranslateModule } from '@ngx-translate/core';
+import { DeploymentSlotsModule } from 'app/site/deployment-slots/deployment-slots.module';
+import 'rxjs/add/operator/takeUntil';
+
+const routing: ModuleWithProviders = RouterModule.forChild([{ path: '', component: DeploymentSlotsShellComponent }]);
+
+@NgModule({
+ entryComponents: [DeploymentSlotsComponent],
+ imports: [TranslateModule.forChild(), SharedModule, SharedFunctionsModule, DeploymentSlotsModule, routing],
+ declarations: [],
+ providers: []
+})
+export class DeploymentSlotsShellModule { }
diff --git a/AzureFunctions.AngularClient/src/app/ibiza-feature/ibiza-feature.module.ts b/AzureFunctions.AngularClient/src/app/ibiza-feature/ibiza-feature.module.ts
index 95cb6f21b3..7f449b5185 100644
--- a/AzureFunctions.AngularClient/src/app/ibiza-feature/ibiza-feature.module.ts
+++ b/AzureFunctions.AngularClient/src/app/ibiza-feature/ibiza-feature.module.ts
@@ -29,7 +29,23 @@ const routing: ModuleWithProviders = RouterModule.forChild([
{
path: 'subscriptions/:subscriptionId/resourcegroups/:resourceGroup/providers/microsoft.web/sites/:site/slots/:slot/deployment',
loadChildren: 'app/ibiza-feature/deployment-shell/deployment-shell.module#DeploymentShellModule'
- }
+ },
+ {
+ path: 'subscriptions/:subscriptionId/resourcegroups/:resourceGroup/providers/microsoft.web/sites/:site/deploymentslots',
+ loadChildren: 'app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.module#DeploymentSlotsShellModule'
+ },
+ {
+ path: 'subscriptions/:subscriptionId/resourcegroups/:resourceGroup/providers/microsoft.web/sites/:site/slots/:slot/deploymentslots',
+ loadChildren: 'app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.module#DeploymentSlotsShellModule'
+ },
+ {
+ path: 'subscriptions/:subscriptionId/resourcegroups/:resourceGroup/providers/microsoft.web/sites/:site/deploymentslots/actions/:action',
+ loadChildren: 'app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.module#DeploymentSlotsShellModule'
+ },
+ {
+ path: 'subscriptions/:subscriptionId/resourcegroups/:resourceGroup/providers/microsoft.web/sites/:site/slots/:slot/deploymentslots/actions/:action',
+ loadChildren: 'app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.module#DeploymentSlotsShellModule'
+ },
]
}
]);
@@ -38,4 +54,4 @@ const routing: ModuleWithProviders = RouterModule.forChild([
imports: [TranslateModule.forChild(), SharedModule, routing],
declarations: [IbizaFeatureComponent]
})
-export class IbizaFeatureModule {}
+export class IbizaFeatureModule { }
diff --git a/AzureFunctions.AngularClient/src/app/shared/components/config-save-component.ts b/AzureFunctions.AngularClient/src/app/shared/components/config-save-component.ts
new file mode 100644
index 0000000000..cdb300ff1b
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/shared/components/config-save-component.ts
@@ -0,0 +1,223 @@
+import { Injector, OnDestroy } from '@angular/core';
+import { ArmObj, ResourceId } from 'app/shared/models/arm/arm-obj';
+import { ApplicationSettings } from 'app/shared/models/arm/application-settings';
+import { ConnectionStrings } from 'app/shared/models/arm/connection-strings';
+import { Site } from 'app/shared/models/arm/site';
+import { SiteConfig } from 'app/shared/models/arm/site-config';
+import { SlotConfigNames } from 'app/shared/models/arm/slot-config-names';
+import { FeatureComponent } from './feature-component';
+import { BusyStateName } from "app/busy-state/busy-state.component";
+
+export interface ArmSaveConfigs {
+ appSettingsArm?: ArmObj;
+ connectionStringsArm?: ArmObj;
+ siteArm?: ArmObj;
+ siteConfigArm?: ArmObj;
+ slotConfigNamesArm?: ArmObj;
+}
+
+export interface ArmSaveResult {
+ success: boolean;
+ error?: string;
+ value?: ArmObj;
+}
+
+export interface ArmSaveResults {
+ appSettings?: ArmSaveResult;
+ connectionStrings?: ArmSaveResult;
+ site?: ArmSaveResult;
+ siteConfig?: ArmSaveResult;
+ slotConfigNames?: ArmSaveResult;
+}
+
+type ConfigType = 'ApplicationSettings' | 'ConnectionStrings' | 'Site' | 'SiteConfig' | 'SlotConfigNames';
+
+interface ConfigState {
+ isNeeded: boolean;
+ wasSubmitted: boolean;
+}
+
+interface ConfigStateMap {
+ [key: string]: ConfigState;
+}
+
+export abstract class ConfigSaveComponent extends FeatureComponent implements ArmSaveConfigs, OnDestroy {
+ public appSettingsArm: ArmObj;
+ public connectionStringsArm: ArmObj;
+ public siteArm: ArmObj;
+ public siteConfigArm: ArmObj;
+ public slotConfigNamesArm: ArmObj;
+
+ protected _saveFailed: boolean;
+
+ private readonly _configTypes: ConfigType[] = ['ApplicationSettings', 'ConnectionStrings', 'Site', 'SiteConfig', 'SlotConfigNames'];
+
+ private _configStates: ConfigStateMap;
+
+ protected abstract get _isPristine(): boolean;
+
+ protected abstract _getConfigsFromForms(saveConfigs: ArmSaveConfigs): ArmSaveConfigs;
+
+ constructor(componentName: string,
+ injector: Injector,
+ configTypesUsed: ConfigType[],
+ busyComponentName?: BusyStateName) {
+
+ super(componentName, injector, busyComponentName);
+
+ this._resetSubmittedStates();
+
+ configTypesUsed.forEach(t => this._configStates[t].isNeeded = true);
+ }
+
+ protected _resetConfigs() {
+ this.appSettingsArm = null;
+ this.connectionStringsArm = null;
+ this.siteArm = null;
+ this.siteConfigArm = null;
+ this.slotConfigNamesArm = null;
+ }
+
+ protected _resetSubmittedStates() {
+ this._configTypes.forEach(t => this._setSubmittedState(t, false));
+ }
+
+ private _setSubmittedState(configType: ConfigType, submitted: boolean) {
+ this._configStates = this._configStates || {};
+
+ if (!this._configStates[configType]) {
+ this._configStates[configType] = { isNeeded: submitted, wasSubmitted: submitted };
+ } else {
+ this._configStates[configType].wasSubmitted = submitted;
+ }
+ }
+
+ private _checkIfSubmitted(configType: ConfigType): boolean {
+ return this._configStates[configType] && this._configStates[configType].wasSubmitted;
+ }
+
+ private _checkIfNeeded(configType: ConfigType): boolean {
+ return this._configStates[configType] && this._configStates[configType].isNeeded;
+ }
+
+ private _checkConfigExistence(configType: ConfigType, sourceConfigs: ArmSaveConfigs): boolean {
+ switch (configType) {
+ case 'ApplicationSettings':
+ return !!sourceConfigs.appSettingsArm;
+ case 'ConnectionStrings':
+ return !!sourceConfigs.connectionStringsArm;
+ case 'Site':
+ return !!sourceConfigs.siteArm;
+ case 'SiteConfig':
+ return !!sourceConfigs.siteConfigArm;
+ case 'SlotConfigNames':
+ return !!sourceConfigs.slotConfigNamesArm;
+ default:
+ return false;
+ }
+ }
+
+ private _assignConfig(configType: ConfigType, sourceConfigs: ArmSaveConfigs, destConfigs?: ArmSaveConfigs) {
+ destConfigs = destConfigs || this;
+ switch (configType) {
+ case 'ApplicationSettings':
+ destConfigs.appSettingsArm = sourceConfigs.appSettingsArm;
+ break;
+ case 'ConnectionStrings':
+ destConfigs.connectionStringsArm = sourceConfigs.connectionStringsArm;
+ break;
+ case 'Site':
+ destConfigs.siteArm = sourceConfigs.siteArm;
+ break;
+ case 'SiteConfig':
+ destConfigs.siteConfigArm = sourceConfigs.siteConfigArm;
+ break;
+ case 'SlotConfigNames':
+ destConfigs.slotConfigNamesArm = sourceConfigs.slotConfigNamesArm;
+ break;
+ default:
+ break;
+ }
+ }
+
+ private _assignConfigFromResults(configType: ConfigType, results: ArmSaveResults, destConfigs?: ArmSaveConfigs) {
+ const configs: ArmSaveConfigs = {
+ appSettingsArm: results.appSettings ? results.appSettings.value : null,
+ connectionStringsArm: results.connectionStrings ? results.connectionStrings.value : null,
+ siteArm: results.site ? results.site.value : null,
+ siteConfigArm: results.siteConfig ? results.siteConfig.value : null,
+ slotConfigNamesArm: results.slotConfigNames ? results.slotConfigNames.value : null
+ }
+ this._assignConfig(configType, configs, destConfigs);
+ }
+
+ private _appendSaveConfig(configType: ConfigType, saveConfigs: ArmSaveConfigs, updatedConfigs: ArmSaveConfigs) {
+ if (this._checkConfigExistence(configType, updatedConfigs)) {
+ this._assignConfig(configType, updatedConfigs, saveConfigs);
+ this._setSubmittedState(configType, true);
+ }
+ }
+
+ getSaveConfigs(saveConfigs: ArmSaveConfigs) {
+ this._saveFailed = false;
+ this._resetSubmittedStates();
+
+ if (!this._isPristine) {
+ const updatedConfigs = this._getConfigsFromForms(saveConfigs);
+ this._configTypes.forEach(t => this._appendSaveConfig(t, saveConfigs, updatedConfigs));
+ }
+ }
+
+ private _checkResultSuccess(configType: ConfigType, results: ArmSaveResults): boolean {
+ let result: ArmSaveResult;
+ switch (configType) {
+ case 'ApplicationSettings':
+ result = results.appSettings;
+ break;
+ case 'ConnectionStrings':
+ result = results.connectionStrings;
+ break;
+ case 'Site':
+ result = results.site;
+ break;
+ case 'SiteConfig':
+ result = results.siteConfig;
+ break;
+ case 'SlotConfigNames':
+ result = results.slotConfigNames;
+ break;
+ default:
+ break;
+ }
+ return !!result && result.success && !!result.value;
+ }
+
+ private _processSaveResult(configType: ConfigType, results: ArmSaveResults) {
+ const isNeeded: boolean = this._checkIfNeeded(configType);
+ const wasSubmitted: boolean = this._checkIfSubmitted(configType);
+ this._setSubmittedState(configType, false);
+
+ if (isNeeded || wasSubmitted) {
+ if (this._checkResultSuccess(configType, results)) {
+ this._assignConfigFromResults(configType, results);
+ } else if (wasSubmitted) {
+ this._saveFailed = true;
+ //TODO: [andimarc] throw exception?
+ }
+ }
+ }
+
+ processSaveResults(results: ArmSaveResults) {
+ if (results) {
+ this._configTypes.forEach(t => this._processSaveResult(t, results));
+ } else {
+ //TODO: [andimarc] throw exception?
+ }
+ }
+
+ ngOnDestroy(): void {
+ super.ngOnDestroy();
+ this.clearBusy();
+ }
+
+}
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/shared/models/arm/arm-obj.ts b/AzureFunctions.AngularClient/src/app/shared/models/arm/arm-obj.ts
index c7dd1fc284..35da3ad28e 100644
--- a/AzureFunctions.AngularClient/src/app/shared/models/arm/arm-obj.ts
+++ b/AzureFunctions.AngularClient/src/app/shared/models/arm/arm-obj.ts
@@ -10,11 +10,6 @@ export interface ArmObj {
identity?: Identity;
}
-export interface ArmObjMap {
- objects: { [key: string]: ArmObj };
- error?: string;
-}
-
export interface ArmArrayResult {
value: ArmObj[];
nextLink: string;
diff --git a/AzureFunctions.AngularClient/src/app/shared/models/arm/handler-mapping.ts b/AzureFunctions.AngularClient/src/app/shared/models/arm/handler-mapping.ts
new file mode 100644
index 0000000000..220a57f16d
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/shared/models/arm/handler-mapping.ts
@@ -0,0 +1,5 @@
+export interface HandlerMapping {
+ extension: string;
+ scriptProcessor: string;
+ arguments: string;
+}
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/shared/models/arm/routing-rule.ts b/AzureFunctions.AngularClient/src/app/shared/models/arm/routing-rule.ts
new file mode 100644
index 0000000000..fe35f8ea95
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/shared/models/arm/routing-rule.ts
@@ -0,0 +1,10 @@
+export interface RoutingRule {
+ actionHostName: string;
+ reroutePercentage: number;
+ changeStep: number;
+ changeIntervalInMinutes: number;
+ minReroutePercentage: number;
+ maxReroutePercentage: number;
+ changeDecisionCallbackUrl: number;
+ name: string;
+}
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/shared/models/arm/site-config.ts b/AzureFunctions.AngularClient/src/app/shared/models/arm/site-config.ts
index 8af6d48b25..10515879a9 100644
--- a/AzureFunctions.AngularClient/src/app/shared/models/arm/site-config.ts
+++ b/AzureFunctions.AngularClient/src/app/shared/models/arm/site-config.ts
@@ -1,4 +1,8 @@
import { VirtualApplication } from './virtual-application';
+import { HandlerMapping } from './handler-mapping';
+import { RoutingRule } from './routing-rule';
+import { ConnectionStrings } from './connection-strings';
+import { ApplicationSettings } from './application-settings';
export interface SiteConfig {
scmType: string;
@@ -21,13 +25,15 @@ export interface SiteConfig {
remoteDebuggingEnabled: boolean;
remoteDebuggingVersion: string;
defaultDocuments: string[];
- handlerMappings: [{
- extension: string;
- scriptProcessor: string;
- arguments: string;
- }];
+ handlerMappings: HandlerMapping[];
linuxFxVersion: string;
appCommandLine: string;
virtualApplications: VirtualApplication[];
autoSwapSlotName: string;
+ experiments: {
+ rampUpRules: RoutingRule[];
+ }
+ siteAuthEnabled: boolean;
+ appSettings?: ApplicationSettings;
+ connectionStrings?: ConnectionStrings;
}
diff --git a/AzureFunctions.AngularClient/src/app/shared/models/arm/site.ts b/AzureFunctions.AngularClient/src/app/shared/models/arm/site.ts
index 4dc3253891..af660f2bc7 100644
--- a/AzureFunctions.AngularClient/src/app/shared/models/arm/site.ts
+++ b/AzureFunctions.AngularClient/src/app/shared/models/arm/site.ts
@@ -18,4 +18,5 @@ export interface Site {
clientCertEnabled?: boolean;
clientAffinityEnabled?: boolean;
hostingEnvironmentProfile?: HostingEnvironmentProfile;
+ name?: string;
}
diff --git a/AzureFunctions.AngularClient/src/app/shared/models/arm/slots-diff.ts b/AzureFunctions.AngularClient/src/app/shared/models/arm/slots-diff.ts
new file mode 100644
index 0000000000..235b8e91d3
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/shared/models/arm/slots-diff.ts
@@ -0,0 +1,9 @@
+export interface SlotsDiff {
+ type: string,
+ settingType: string,
+ diffRule: string,
+ settingName: string,
+ valueInCurrentSlot: string,
+ valueInTargetSlot: string,
+ description: string
+}
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/shared/models/constants.ts b/AzureFunctions.AngularClient/src/app/shared/models/constants.ts
index 6e919654ac..87348c937a 100644
--- a/AzureFunctions.AngularClient/src/app/shared/models/constants.ts
+++ b/AzureFunctions.AngularClient/src/app/shared/models/constants.ts
@@ -69,6 +69,9 @@ export class SiteTabIds {
public static readonly applicationSettings = 'appSettings';
public static readonly continuousDeployment = 'continuousDeployment';
public static readonly logicApps = 'logicApps';
+ public static readonly deploymentSlotsConfig = 'deploymentSlotsConfig';
+ public static readonly deploymentSlotsSwap = 'deploymentSlotsSwap';
+ public static readonly deploymentSlotsCreate = 'deploymentSlotsCreate';
}
export class Arm {
@@ -245,6 +248,9 @@ export class LogCategories {
public static readonly cicd = 'CICD';
public static readonly telemetry = 'Telemetry';
public static readonly featureComponent = 'FeatureComponent';
+ public static readonly deploymentSlots = 'DeploymentSlots';
+ public static readonly swapSlots = 'SwapSlots';
+ public static readonly addSlot = 'AddSlot';
}
export class KeyCodes {
diff --git a/AzureFunctions.AngularClient/src/app/shared/models/portal-resources.ts b/AzureFunctions.AngularClient/src/app/shared/models/portal-resources.ts
index 963669e45f..8ee6beee57 100644
--- a/AzureFunctions.AngularClient/src/app/shared/models/portal-resources.ts
+++ b/AzureFunctions.AngularClient/src/app/shared/models/portal-resources.ts
@@ -5,6 +5,7 @@
public static azureFunctionsRuntime: string = "azureFunctionsRuntime";
public static cancel: string = "cancel";
public static configure: string = "configure";
+ public static upgrade: string = "upgrade";
public static upgradeToEnable: string = "upgradeToEnable";
public static selectAll: string = "selectAll";
public static allItemsSelected: string = "allItemsSelected";
@@ -80,7 +81,9 @@
public static readOnlySourceControlled: string = "readOnlySourceControlled";
public static region: string = "region";
public static run: string = "run";
+ public static refresh: string = "refresh";
public static save: string = "save";
+ public static unsavedChangesWarning: string = "unsavedChangesWarning";
public static saveOperationInProgressWarning: string = "saveOperationInProgressWarning";
public static addNewSetting: string = "addNewSetting";
public static addNewConnectionString: string = "addNewConnectionString";
@@ -347,6 +350,8 @@
public static start: string = "start";
public static restart: string = "restart";
public static swap: string = "swap";
+ public static completeSwap: string = "completeSwap";
+ public static cancelSwap: string = "cancelSwap";
public static downloadProfile: string = "downloadProfile";
public static resetPubCredentials: string = "resetPubCredentials";
public static _delete: string = "_delete";
@@ -470,6 +475,7 @@
public static feature_functionSettingsInfo: string = "feature_functionSettingsInfo";
public static feature_generalSettings: string = "feature_generalSettings";
public static feature_codeDeployment: string = "feature_codeDeployment";
+ public static feature_deploymentSlotsName: string = "feature_deploymentSlotsName";
public static feature_developmentTools: string = "feature_developmentTools";
public static feature_networkingName: string = "feature_networkingName";
public static feature_networkingInfo: string = "feature_networkingInfo";
@@ -539,6 +545,7 @@
public static debug: string = "debug";
public static continuousDeployment: string = "continuousDeployment";
public static source: string = "source";
+ public static target: string = "target";
public static options: string = "options";
public static backend_error_CannotAccessFunctionApp: string = "backend_error_CannotAccessFunctionApp";
public static backend_error_CannotAccessFunctionApp_action: string = "backend_error_CannotAccessFunctionApp_action";
@@ -623,9 +630,13 @@
public static appFunctionSettings_editMode: string = "appFunctionSettings_editMode";
public static appFunctionSettings_readOnlyMode: string = "appFunctionSettings_readOnlyMode";
public static appFunctionSettings_readWriteMode: string = "appFunctionSettings_readWriteMode";
+ public static validation_decimalFormatError: string = "validation_decimalFormatError";
+ public static validation_decimalRangeValueError: string = "validation_decimalRangeValueError";
public static validation_duplicateError: string = "validation_duplicateError";
+ public static validation_error: string = "validation_error";
public static validation_linuxAppSettingNameError: string = "validation_linuxAppSettingNameError";
public static validation_requiredError: string = "validation_requiredError";
+ public static validation_routingTotalPercentError: string = "validation_routingTotalPercentError";
public static validation_siteNameMinChars: string = "validation_siteNameMinChars";
public static validation_siteNameMaxChars: string = "validation_siteNameMaxChars";
public static validation_siteNameInvalidChar: string = "validation_siteNameInvalidChar";
@@ -658,6 +669,8 @@
public static appFunctionSettings_warning_3: string = "appFunctionSettings_warning_3";
public static appFunctionSettings_warning_4: string = "appFunctionSettings_warning_4";
public static appFunctionSettings_warning_5: string = "appFunctionSettings_warning_5";
+ public static slotCreateOperationInProgressWarning: string = "slotCreateOperationInProgressWarning";
+ public static slotNew: string = "slotNew";
public static slotNew_nameLabel: string = "slotNew_nameLabel";
public static slotNew_heading: string = "slotNew_heading";
public static slotNew_desc: string = "slotNew_desc";
@@ -665,10 +678,20 @@
public static slotNew_startCreateSuccessNotifyTitle: string = "slotNew_startCreateSuccessNotifyTitle";
public static slotNew_startCreateFailureNotifyTitle: string = "slotNew_startCreateFailureNotifyTitle";
public static error_unableToLoadSlotsList: string = "error_unableToLoadSlotsList";
+ public static slotNew_quotaReached: string = "slotNew_quotaReached";
+ public static slotNew_quotaUpgrade: string = "slotNew_quotaUpgrade";
public static slotNew_noAccess: string = "slotNew_noAccess";
+ public static slots_upgrade: string = "slots_upgrade";
+ public static slots_description: string = "slots_description";
+ public static slotsDiff_settingHeader: string = "slotsDiff_settingHeader";
+ public static slotsDiff_typeHeader: string = "slotsDiff_typeHeader";
+ public static slotsDiff_oldValueHeader: string = "slotsDiff_oldValueHeader";
+ public static slotsDiff_newValueHeader: string = "slotsDiff_newValueHeader";
+ public static slotsList_noSlots: string = "slotsList_noSlots";
public static slotsList_nameHeader: string = "slotsList_nameHeader";
public static slotsList_statusHeader: string = "slotsList_statusHeader";
public static slotsList_serverfarmHeader: string = "slotsList_serverfarmHeader";
+ public static slotsList_trafficPercentHeader: string = "slotsList_trafficPercentHeader";
public static slotsList_title: string = "slotsList_title";
public static monitoring_appInsights: string = "monitoring_appInsights";
public static slotNew_nameLabel_balloonText: string = "slotNew_nameLabel_balloonText";
@@ -860,6 +883,20 @@
public static swaggerDefinition_notSupportedForBeta: string = "swaggerDefinition_notSupportedForBeta";
public static deployedSuccessfullyTo: string = "deployedSuccessfullyTo";
public static deployedFailedTo: string = "deployedFailedTo";
+ public static swapWithPreviewLabel: string = "swapWithPreviewLabel";
+ public static swapPhaseOneLabel: string = "swapPhaseOneLabel";
+ public static swapPhaseTwoLabel: string = "swapPhaseTwoLabel";
+ public static swapOperationInProgressWarning: string = "swapOperationInProgressWarning";
+ public static swapOperation: string = "swapOperation";
+ public static swapFull: string = "swapFull";
+ public static swapPhaseOne: string = "swapPhaseOne";
+ public static swapPhaseTwo: string = "swapPhaseTwo";
+ public static swapStarted: string = "swapStarted";
+ public static swapSuccess: string = "swapSuccess";
+ public static swapFailure: string = "swapFailure";
+ public static swapCancelStarted: string = "swapCancelStarted";
+ public static swapCancelSuccess: string = "swapCancelSuccess";
+ public static swapCancelFailure: string = "swapCancelFailure";
public static swappedSlotSuccess: string = "swappedSlotSuccess";
public static swappedSlotFail: string = "swappedSlotFail";
public static setupCDSuccessAndTriggerBuild: string = "setupCDSuccessAndTriggerBuild";
diff --git a/AzureFunctions.AngularClient/src/app/shared/services/cache.service.ts b/AzureFunctions.AngularClient/src/app/shared/services/cache.service.ts
index 36b6ae376c..c39474b1cb 100644
--- a/AzureFunctions.AngularClient/src/app/shared/services/cache.service.ts
+++ b/AzureFunctions.AngularClient/src/app/shared/services/cache.service.ts
@@ -58,9 +58,9 @@ export class CacheService {
return this.send(url, 'DELETE', true, null, null, invokeApi);
}
- postArm(resourceId: string, force?: boolean, apiVersion?: string): Observable {
+ postArm(resourceId: string, force?: boolean, apiVersion?: string, content?: any): Observable {
const url = this._armService.getArmUrl(resourceId, apiVersion ? apiVersion : this._armService.websiteApiVersion);
- return this.send(url, 'POST', force);
+ return this.send(url, 'POST', force, null, content);
}
putArm(resourceId: string, apiVersion?: string, content?: any) {
diff --git a/AzureFunctions.AngularClient/src/app/shared/validators/decimalRangeValidator.ts b/AzureFunctions.AngularClient/src/app/shared/validators/decimalRangeValidator.ts
new file mode 100644
index 0000000000..6f3c5b1918
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/shared/validators/decimalRangeValidator.ts
@@ -0,0 +1,42 @@
+import { ValidationErrors, Validator } from '@angular/forms';
+import { TranslateService } from '@ngx-translate/core';
+import { CustomFormControl } from 'app/controls/click-to-edit/click-to-edit.component';
+import { PortalResources } from 'app/shared/models/portal-resources';
+
+export class DecimalRangeValidator implements Validator {
+ static leftRegExp: RegExp = /^[0-9]+(\.[0-9]*)?$/; // makes sure there's at least one digit to the left of the '.' if there are none to the right
+ static rightRegExp: RegExp = /^[0-9]*(\.[0-9]+)?$/; // makes sure there's at least one digit to the right of the '.' if there are none to the left
+
+ // TODO [andimarc]: enforce limit on string length?
+ //static leftRegExp: RegExp = /^[0-9]{1,3}(\.[0-9]{0,2})?$/; // makes sure there's at least one digit to the left of the '.' if there are none to the right
+ //static rightRegExp: RegExp = /^[0-9]{0,3}(\.[0-9]{1,2})?$/; // makes sure there's at least one digit to the right of the '.' if there are none to the left
+
+ private _rangeMin: number;
+ private _rangeMax: number;
+ private _formatErrorMessage: string;
+ private _rangeErrorMessage: string;
+
+ constructor(translateService: TranslateService, rangeMin: number = 0.0, rangeMax: number = 100.0) {
+ this._rangeMin = rangeMin;
+ this._rangeMax = rangeMax;
+ this._formatErrorMessage = translateService.instant(PortalResources.validation_decimalFormatError);
+ this._rangeErrorMessage = translateService.instant(PortalResources.validation_decimalRangeValueError, { min: this._rangeMin, max: this._rangeMax });
+ }
+
+ validate(control: CustomFormControl): ValidationErrors {
+ if (control.dirty || control._msRunValidation) {
+ const stringValue = (control.value as string) || '0';
+
+ const trimmedValue = (stringValue.charAt(0) === '-') ? stringValue.substring(1) : stringValue; // trim leading '-'
+ if (!trimmedValue || (!DecimalRangeValidator.leftRegExp.test(trimmedValue) && !DecimalRangeValidator.rightRegExp.test(trimmedValue))) {
+ return { 'invalidDecimalError': this._formatErrorMessage };
+ }
+
+ const decimalValue = Number.parseFloat(stringValue);
+ if (decimalValue < this._rangeMin || decimalValue > this._rangeMax) {
+ return { 'outOfRangeError': this._rangeErrorMessage };
+ }
+ }
+ return null;
+ }
+}
diff --git a/AzureFunctions.AngularClient/src/app/shared/validators/routingSumValidator.ts b/AzureFunctions.AngularClient/src/app/shared/validators/routingSumValidator.ts
new file mode 100644
index 0000000000..5410cf4ac3
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/shared/validators/routingSumValidator.ts
@@ -0,0 +1,98 @@
+import { FormBuilder, FormControl, FormGroup, ValidationErrors, Validator } from '@angular/forms';
+import { TranslateService } from '@ngx-translate/core';
+import { PortalResources } from 'app/shared/models/portal-resources';
+
+/*
+ This validator runs on a FormGroup where the value of each child FormControl is expected to be a
+ valid decimal value between 0 and 100.
+
+ Validation will only fail in the following scenario:
+ - The value of each child FormControl is a valid decimal value between 0 and 100,
+ and the sum of these values exceeds 100.
+
+ Validation will succeed in the following scenarios:
+ - The value of each child FormControl is a valid decimal value between 0 and 100,
+ and the sum of these values is in the [0, 100] range.
+
+ - At least one child FormControl has a value that does not parse to a valid decimal. (In this
+ case, the sum cannot be computed, so we cannot determine that the sum exceeds 100.)
+
+ - At least one child FormControl has a decimal value outside the [0, 100] range. (In this case
+ the out-of-range error on the indivdual FormControl(s) supersedes any potential error on the sum.)
+
+ When validation fails, the "invalidRoutingSum" error is added to each child FormControl (if not already present).
+ When validation succeeds, the "invalidRoutingSum" error is removed from each child FormControl (if present).
+*/
+
+export class RoutingSumValidator implements Validator {
+ static REMAINDER_CONTROL_NAME = '_REMAINDER_';
+ private _remainderStringError: string;
+ private _errorMessage: string;
+
+ constructor(private _fb: FormBuilder, translateService: TranslateService) {
+ this._remainderStringError = translateService.instant(PortalResources.validation_error);
+ this._errorMessage = translateService.instant(PortalResources.validation_routingTotalPercentError);
+ }
+
+ validate(group: FormGroup): ValidationErrors {
+ let sum: number = 0.0;
+
+ let invalidFormatFound: boolean;
+ let invalidRangeFound: boolean;
+
+ const controls: FormControl[] = [];
+
+ for (const name in group.controls) {
+ const control: FormControl = (group.get(name) as FormControl);
+
+ invalidRangeFound = invalidRangeFound || control.hasError('outOfRangeError');
+ invalidFormatFound = invalidFormatFound || control.hasError('invalidDecimalError');
+
+ if (!invalidRangeFound && !invalidFormatFound) {
+ sum += (!control.value ? 0 : parseFloat(control.value));
+ }
+
+ controls.push(control);
+ }
+
+ const sumIsValid = !invalidRangeFound && !invalidFormatFound && (sum >= 0.0 && sum <= 100.0);
+
+ if (invalidRangeFound || invalidFormatFound || sumIsValid) {
+ controls.forEach(c => {
+ if (c.hasError('invalidRoutingSum')) {
+ let errors: ValidationErrors = null;
+ for (let errorKey in c.errors) {
+ if (errorKey !== 'invalidRoutingSum') {
+ errors = errors || {};
+ errors[errorKey] = c.errors[errorKey]
+ }
+ }
+ c.setErrors(errors);
+ }
+ })
+ } else {
+ controls.forEach(c => {
+ if (!c.hasError('invalidRoutingSum')) {
+ const errors: ValidationErrors = { 'invalidRoutingSum': this._errorMessage };
+ for (let errorKey in c.errors) {
+ errors[errorKey] = c.errors[errorKey]
+ }
+ c.setErrors(errors);
+ }
+ })
+ }
+
+ const parent = (group.parent as FormGroup);
+ if (parent && parent.addControl !== undefined) {
+ const remainderString = sumIsValid ? (100.0 - sum).toString() : this._remainderStringError;
+ const remainderControl = parent.get(RoutingSumValidator.REMAINDER_CONTROL_NAME);
+ if (remainderControl) {
+ remainderControl.setValue(remainderString);
+ } else {
+ parent.addControl(RoutingSumValidator.REMAINDER_CONTROL_NAME, this._fb.control({ value: remainderString, disabled: true }));
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/AzureFunctions.AngularClient/src/app/site/deployment-slots/add-slot/add-slot.component.html b/AzureFunctions.AngularClient/src/app/site/deployment-slots/add-slot/add-slot.component.html
new file mode 100644
index 0000000000..8a25a023aa
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/site/deployment-slots/add-slot/add-slot.component.html
@@ -0,0 +1,71 @@
+
+
+
+
+
{{ Resources.slotNew | translate }}
+
+
+
+ {{ Resources.slotNew_nameLabel | translate }}
+
+
+
+
+
+ {{ 'Configuration source' | translate }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ configSrcReadFailure | translate }}
+
+
+
+
+
+
+
+ {{ 'slot created' | translate }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/site/deployment-slots/add-slot/add-slot.component.scss b/AzureFunctions.AngularClient/src/app/site/deployment-slots/add-slot/add-slot.component.scss
new file mode 100644
index 0000000000..e167716ffb
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/site/deployment-slots/add-slot/add-slot.component.scss
@@ -0,0 +1,6 @@
+@import '../../../../sass/common/variables';
+
+.label-above{
+ padding-left: 5px;
+ margin-bottom: 2px;
+}
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/site/deployment-slots/add-slot/add-slot.component.ts b/AzureFunctions.AngularClient/src/app/site/deployment-slots/add-slot/add-slot.component.ts
new file mode 100644
index 0000000000..a63cc316b8
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/site/deployment-slots/add-slot/add-slot.component.ts
@@ -0,0 +1,318 @@
+import { LogCategories } from 'app/shared/models/constants';
+import { LogService } from 'app/shared/services/log.service';
+import { Component, Injector, Input, OnDestroy, Output } from '@angular/core';
+import { Observable } from 'rxjs/Observable';
+import { Subject } from 'rxjs/Subject';
+import { FormBuilder, FormGroup } from '@angular/forms';
+import { TranslateService } from '@ngx-translate/core';
+import { SlotsService } from 'app/shared/services/slots.service';
+import { AiService } from 'app/shared/services/ai.service';
+import { ArmObj, ResourceId } from 'app/shared/models/arm/arm-obj';
+import { Site } from 'app/shared/models/arm/site';
+import { PortalService } from 'app/shared/services/portal.service';
+import { RequiredValidator } from 'app/shared/validators/requiredValidator';
+import { PortalResources } from 'app/shared/models/portal-resources';
+import { SlotNameValidator } from 'app/shared/validators/slotNameValidator';
+import { errorIds } from 'app/shared/models/error-ids';
+import { AuthzService } from 'app/shared/services/authz.service';
+import { FeatureComponent } from 'app/shared/components/feature-component';
+import { ArmSiteDescriptor } from 'app/shared/resourceDescriptors';
+import { SiteService } from 'app/shared/services/site.service';
+import { SiteConfig } from 'app/shared/models/arm/site-config';
+import { DropDownElement } from 'app/shared/models/drop-down-element';
+import { CustomFormControl } from 'app/controls/click-to-edit/click-to-edit.component';
+
+// TODO [andimarc]: disable all controls when an add operation is in progress
+
+@Component({
+ selector: 'add-slot',
+ templateUrl: './add-slot.component.html',
+ styleUrls: ['./add-slot.component.scss', './../common.scss']
+})
+export class AddSlotComponent extends FeatureComponent implements OnDestroy {
+ @Input() set resourceId(resourceId: ResourceId) {
+ this.setInput(resourceId);
+ }
+
+ @Output() close: Subject;
+
+ public dirtyMessage: string;
+
+ public Resources = PortalResources;
+ public addForm: FormGroup;
+ public hasCreateAcess: boolean;
+ public slotsQuotaMessage: string;
+ public isLoading = true;
+ public creating: boolean;
+ public created: boolean;
+ public checkingConfigSrc: boolean;
+ public configSrcReadFailure: string;
+ public configSrcDropDownOptions: DropDownElement[];
+
+ private _slotConfig: SiteConfig;
+ private _siteId: string;
+ private _slotsArm: ArmObj[];
+
+ constructor(
+ private _fb: FormBuilder,
+ private _siteService: SiteService,
+ private _translateService: TranslateService,
+ private _portalService: PortalService,
+ private _aiService: AiService,
+ private _slotService: SlotsService,
+ private _logService: LogService,
+ private _authZService: AuthzService,
+ private _injector: Injector
+ ) {
+ super('AddSlotComponent', _injector, 'site-tabs');
+
+ // TODO [andimarc]
+ // For ibiza scenarios, this needs to match the deep link feature name used to load this in ibiza menu
+ this.featureName = 'deploymentslots';
+ this.isParentComponent = true;
+
+ this.close = new Subject();
+
+ const nameCtrl = this._fb.control({ value: null, disabled: true });
+ const cloneCtrl = this._fb.control({ value: false, disabled: true });
+ const configSrcCtrl = this._fb.control({ value: null, disabled: true });
+
+ this.addForm = this._fb.group({
+ name: nameCtrl,
+ clone: cloneCtrl,
+ configSrc: configSrcCtrl
+ });
+
+ cloneCtrl.valueChanges
+ .takeUntil(this.ngUnsubscribe)
+ .distinctUntilChanged()
+ .do(_ => {
+ (nameCtrl as CustomFormControl)._msRunValidation = true;
+ nameCtrl.updateValueAndValidity();
+ })
+ .subscribe(v => {
+ if (!v) {
+ configSrcCtrl.clearValidators();
+ configSrcCtrl.clearAsyncValidators();
+ configSrcCtrl.setValue(null);
+ configSrcCtrl.disable();
+ } else {
+ configSrcCtrl.enable();
+ }
+ });
+
+ configSrcCtrl.valueChanges
+ .takeUntil(this.ngUnsubscribe)
+ .distinctUntilChanged()
+ .do(_ => {
+ (nameCtrl as CustomFormControl)._msRunValidation = true;
+ nameCtrl.updateValueAndValidity();
+ })
+ .filter(v => !!v)
+ .switchMap(srcId => {
+ this.checkingConfigSrc = true;
+ this.configSrcReadFailure = null;
+ this._slotConfig = null;
+ return Observable.zip(
+ this._siteService.getSiteConfig(srcId),
+ this._siteService.getAppSettings(srcId),
+ this._siteService.getConnectionStrings(srcId));
+ })
+ .subscribe(r => {
+ const siteConfigResult = r[0];
+ const appSettingsResult = r[1];
+ const connectionStringsResult = r[2];
+
+ if (siteConfigResult.isSuccessful && appSettingsResult.isSuccessful && connectionStringsResult.isSuccessful) {
+ this._slotConfig = siteConfigResult.result.properties;
+ this._slotConfig.appSettings = appSettingsResult.result.properties;
+ this._slotConfig.connectionStrings = connectionStringsResult.result.properties;
+ } else {
+ this.configSrcReadFailure = "failure";
+ }
+
+ this.checkingConfigSrc = false;
+ });
+ }
+
+ protected setup(inputEvents: Observable) {
+ return inputEvents
+ .distinctUntilChanged()
+ .switchMap(resourceId => {
+ this.hasCreateAcess = false;
+ this.slotsQuotaMessage = null;
+ this.creating = false;
+ this.isLoading = true;
+
+ this.checkingConfigSrc = false;
+ this.configSrcReadFailure = null;
+
+ this.configSrcDropDownOptions = null;
+
+ this._slotConfig = null;
+ this._slotsArm = null;
+
+ const siteDescriptor = new ArmSiteDescriptor(resourceId);
+ this._siteId = siteDescriptor.getSiteOnlyResourceId();
+
+ return Observable.zip(
+ this._siteService.getSite(this._siteId),
+ this._siteService.getSlots(this._siteId),
+ this._authZService.hasPermission(this._siteId, [AuthzService.writeScope]),
+ this._authZService.hasReadOnlyLock(this._siteId));
+ })
+ .do(r => {
+ const siteResult = r[0];
+ const slotsResult = r[1];
+ const hasWritePermission = r[2];
+ const hasReadOnlyLock = r[3];
+
+ this.hasCreateAcess = hasWritePermission && !hasReadOnlyLock;
+
+ let success = true;
+
+ if (!siteResult.isSuccessful) {
+ this._logService.error(LogCategories.addSlot, '/add-slot', siteResult.error.result);
+ success = false;
+ }
+ if (!slotsResult.isSuccessful) {
+ this._logService.error(LogCategories.addSlot, '/add-slot', slotsResult.error.result);
+ success = false;
+ }
+
+ if (success) {
+ this._slotsArm = slotsResult.result.value;
+ this._slotsArm.unshift(siteResult.result);
+
+ const sku = siteResult.result.properties.sku.toLowerCase();
+
+ let slotsQuota = 0;
+ if (sku === 'dynamic') {
+ slotsQuota = 2;
+ } else if (sku === 'standard') {
+ slotsQuota = 5;
+ } else if (sku === 'premium') {
+ slotsQuota = 20;
+ }
+
+ if (this._slotsArm && this._slotsArm.length >= slotsQuota) {
+ this.slotsQuotaMessage = this._translateService.instant(PortalResources.slotNew_quotaReached, { quota: slotsQuota });
+ }
+ }
+
+ this._setupForm();
+ this.isLoading = false;
+ });
+ }
+
+ private _setupForm() {
+ const nameCtrl = this.addForm.get('name');
+ const cloneCtrl = this.addForm.get('clone')
+ const configSrcCtrl = this.addForm.get('configSrc')
+
+ if (!this.hasCreateAcess || !this._slotsArm) {
+
+ nameCtrl.clearValidators();
+ nameCtrl.clearAsyncValidators();
+ nameCtrl.disable();
+
+ cloneCtrl.clearValidators();
+ cloneCtrl.clearAsyncValidators();
+ cloneCtrl.setValue(false);
+ cloneCtrl.disable();
+
+ configSrcCtrl.clearValidators();
+ configSrcCtrl.clearAsyncValidators();
+ configSrcCtrl.setValue(null);
+ configSrcCtrl.disable();
+
+ } else if (this.hasCreateAcess) {
+
+ const requiredValidator = new RequiredValidator(this._translateService);
+ const slotNameValidator = new SlotNameValidator(this._injector, this._siteId);
+ nameCtrl.setValidators(requiredValidator.validate.bind(requiredValidator));
+ nameCtrl.setAsyncValidators(slotNameValidator.validate.bind(slotNameValidator));
+ nameCtrl.enable();
+
+ if (this._slotsArm && this._slotsArm.length > 0) {
+
+ cloneCtrl.enable();
+ configSrcCtrl.enable();
+
+ const options: DropDownElement[] = [];
+ this._slotsArm.forEach(s => {
+ options.push({
+ displayLabel: s.properties.name,
+ value: s.id
+ });
+ })
+ this.configSrcDropDownOptions = options;
+
+ } else {
+
+ cloneCtrl.clearValidators();
+ cloneCtrl.clearAsyncValidators();
+ cloneCtrl.setValue(false);
+ cloneCtrl.disable();
+
+ configSrcCtrl.clearValidators();
+ configSrcCtrl.clearAsyncValidators();
+ configSrcCtrl.setValue(null);
+ configSrcCtrl.disable();
+ }
+ }
+
+ }
+
+ // TODO [andimarc]: use configSrc control
+ createSlot() {
+ this.dirtyMessage = this._translateService.instant(PortalResources.slotCreateOperationInProgressWarning);
+
+ const newSlotName = this.addForm.controls['name'].value;
+ let notificationId = null;
+ this.creating = true;
+ this.setBusy();
+ // show create slot start notification
+ this._portalService.startNotification(
+ this._translateService.instant(PortalResources.slotNew_startCreateNotifyTitle).format(newSlotName),
+ this._translateService.instant(PortalResources.slotNew_startCreateNotifyTitle).format(newSlotName))
+ .first()
+ .switchMap(s => {
+ notificationId = s.id;
+ return this._slotService.createNewSlot(this._slotsArm[0].id, newSlotName, this._slotsArm[0].location, this._slotsArm[0].properties.serverFarmId);
+ })
+ .subscribe((r) => {
+ this.creating = false;
+ this.created = true;
+ this.clearBusy();
+ // TODO [andimarc]: refresh slots list
+ // update notification
+ this._portalService.stopNotification(
+ notificationId,
+ true,
+ this._translateService.instant(PortalResources.slotNew_startCreateSuccessNotifyTitle).format(newSlotName));
+ }, err => {
+ this.creating = false;
+ this.clearBusy();
+ this._portalService.stopNotification(
+ notificationId,
+ false,
+ this._translateService.instant(PortalResources.slotNew_startCreateFailureNotifyTitle).format(newSlotName));
+ this.showComponentError({
+ message: this._translateService.instant(PortalResources.slotNew_startCreateFailureNotifyTitle).format(newSlotName),
+ details: this._translateService.instant(PortalResources.slotNew_startCreateFailureNotifyTitle).format(newSlotName),
+ errorId: errorIds.failedToCreateSlot,
+ resourceId: this._slotsArm[0].id
+ });
+ this._aiService.trackEvent(errorIds.failedToCreateApp, { error: err, id: this._slotsArm[0].id });
+ });
+ }
+
+ closePanel() {
+ const close = (!this.addForm || !this.addForm.dirty || this.created) ? true : confirm('unsaved changes will be lost'); // TODO [andimarc]: add to resources
+
+ if (close) {
+ this.close.next(!!this.created);
+ }
+ }
+}
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/site/deployment-slots/common.scss b/AzureFunctions.AngularClient/src/app/site/deployment-slots/common.scss
new file mode 100644
index 0000000000..b26028d0cc
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/site/deployment-slots/common.scss
@@ -0,0 +1,81 @@
+@import '../../../sass/common/variables';
+
+.close-button{
+ position: absolute;
+ top: 0;
+ right: 0;
+ padding: 7px;
+ margin: 0;
+ text-align: center;
+ font-size: 0;
+ background-color: transparent !important;
+ border-radius: 2px;
+ transition: background-color .2s ease-in-out;
+
+ &:hover{
+ background: $error-color !important;
+ transition: background-color .2s ease-in-out;
+ }
+
+ &:disabled{
+ background: $disabled-color !important;
+ transition: none;
+ }
+
+ .icon-medium{
+ height: 18px;
+ width: 18px;
+ }
+}
+
+.pill{
+ display: inline-block;
+ margin-left: 5px;
+ padding: 0px 5px 0px 5px;
+ border-radius: 7px;
+ background: $success-color;
+ text-align: center;
+ text-transform: uppercase;
+ font-size: 10px;
+ font-weight: bold;
+ color: $body-bg-color;
+}
+
+.spinner{
+ display: inline-block;
+ border: 2px solid #f3f3f3;
+ border-radius: 50%;
+ border-top: 2px solid #3498db;
+ width: 12px;
+ height: 12px;
+ -webkit-animation: spin 2s linear infinite; /* Safari */
+ animation: spin 2s linear infinite;
+
+ &.large{
+ border-width: 10px;
+ width: 60px;
+ height: 60px;
+ }
+ }
+
+/* Safari */
+@-webkit-keyframes spin{
+ 0% { -webkit-transform: rotate(0deg); }
+ 100% { -webkit-transform: rotate(360deg); }
+}
+
+@keyframes spin{
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+.submit-spinner{
+ text-align: center;
+ margin-top: 15px;
+}
+
+:host-context(#app-root[theme=dark]){
+ .pill{
+ color: $body-bg-color-dark;
+ }
+}
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/site/deployment-slots/deployment-slots.component.html b/AzureFunctions.AngularClient/src/app/site/deployment-slots/deployment-slots.component.html
new file mode 100644
index 0000000000..a8e46adfbe
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/site/deployment-slots/deployment-slots.component.html
@@ -0,0 +1,255 @@
+
+
+
+
+ {{ Resources.slots_upgrade | translate}}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/site/deployment-slots/deployment-slots.component.scss b/AzureFunctions.AngularClient/src/app/site/deployment-slots/deployment-slots.component.scss
new file mode 100644
index 0000000000..87e4589758
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/site/deployment-slots/deployment-slots.component.scss
@@ -0,0 +1,134 @@
+@import '../../../sass/common/variables';
+
+.flyout-liner{
+ overflow: auto;
+ position: absolute;
+ padding: 15px;
+ top: 0;
+ right: 0;
+ //bottom: 10px;
+ bottom: 0;
+ left: 10px;
+ background: $body-bg-color;
+ border: 1px solid rgba($body-bg-color-dark, 0.6);
+ box-shadow: rgba($body-bg-color-dark, 0.1) -3px 2px 6px,
+ rgba($body-bg-color-dark, 0.07) -5px 4px 20px,
+ rgba($body-bg-color-dark, 0.05) -10px 8px 20px;
+}
+
+.sidebar-container{
+ height: 100%;
+ width: 100%;
+}
+
+.config-wrapper{
+ position: absolute;
+ height: calc(100% - 42px);
+ overflow-y: auto;
+}
+
+.config-container{
+ padding: 5px 20px;
+}
+
+.slots-header-container, .scale-up-container{
+ margin-top: 15px;
+ margin-bottom: 15px;
+}
+
+.scale-up-container{
+ padding: 15px;
+ text-align: center;
+ max-width: 700px;
+ margin: auto;
+
+ .scale-up-message, .slots-description{
+ margin-top: 15px;
+ margin-bottom: 15px;
+ }
+
+ .scale-up-message{
+ font-size: 20px;
+ }
+
+ .slots-description{
+ width: 70%;
+ margin-left: 15%;
+ }
+}
+
+.slots-header-container{
+ .slots-icon-container, .slots-title-container{
+ display: inline-block;
+ }
+
+ .slots-icon-container{
+ margin-left: 15px;
+ margin-right: 15px;
+ }
+
+ .icon-large{
+ height: 60px;
+ width: 60px;
+ }
+
+ .slots-title-container{
+ vertical-align: bottom;
+ }
+
+ .slots-title{
+ font-weight: 600;
+ font-size: 17px;
+ line-height: 17px;
+ letter-spacing: -.02em;
+ vertical-align: bottom;
+ }
+
+ .slots-description-container{
+ margin-bottom: 10px
+ }
+}
+
+.slots-description{
+ font-size: 14px;
+ line-height: 18px;
+}
+
+.underline{
+ display: block;
+ height: 1px;
+ border-width: 1px 0px 0px;
+ border-top-style: solid;
+ border-top-color: #cccccc;
+ padding: 0px;
+ //margin-top: 5px;
+ //margin-bottom: 10px;
+ margin: 1em 0px;
+}
+
+.pct-wrapper{
+ width: 50px;
+}
+
+.padded-col{
+ padding-left: 10px;
+ padding-right: 2px;
+}
+
+.one-quarter-col{
+ width: 25%;
+}
+
+.message-row{
+ text-align: center;
+}
+
+:host-context(#app-root[theme=dark]){
+ .flyout-liner{
+ background: $body-bg-color-dark;
+ box-shadow: rgba($body-bg-color, 0.1) -3px 0px 6px,
+ rgba($body-bg-color, 0.07) -5px 0px 20px,
+ rgba($body-bg-color, 0.05) -10px 0px 20px;
+ border-color: rgba($body-bg-color, 0.6);
+ }
+}
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/site/deployment-slots/deployment-slots.component.ts b/AzureFunctions.AngularClient/src/app/site/deployment-slots/deployment-slots.component.ts
new file mode 100644
index 0000000000..0ae9e7f4af
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/site/deployment-slots/deployment-slots.component.ts
@@ -0,0 +1,485 @@
+import { Component, Injector, Input, OnDestroy } from '@angular/core';
+import { Observable } from 'rxjs/Observable';
+import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
+import { TranslateService } from '@ngx-translate/core';
+import { TreeViewInfo, SiteData } from 'app/tree-view/models/tree-view-info';
+import { CustomFormControl } from 'app/controls/click-to-edit/click-to-edit.component';
+import { FeatureComponent } from 'app/shared/components/feature-component';
+import { ArmObj, ResourceId } from 'app/shared/models/arm/arm-obj';
+import { Site } from 'app/shared/models/arm/site';
+import { SiteConfig } from 'app/shared/models/arm/site-config';
+import { Links, LogCategories } from 'app/shared/models/constants';
+import { PortalResources } from 'app/shared/models/portal-resources';
+import { RoutingRule } from 'app/shared/models/arm/routing-rule';
+import { ArmSiteDescriptor } from 'app/shared/resourceDescriptors';
+import { AuthzService } from 'app/shared/services/authz.service';
+import { CacheService } from 'app/shared/services/cache.service';
+import { LogService } from 'app/shared/services/log.service';
+import { SiteService } from 'app/shared/services/site.service';
+import { PortalService } from 'app/shared/services/portal.service';
+import { RoutingSumValidator } from 'app/shared/validators/routingSumValidator';
+import { DecimalRangeValidator } from 'app/shared/validators/decimalRangeValidator';
+
+// TODO [andimarc]: disable all controls when the sidepanel is open
+
+@Component({
+ selector: 'deployment-slots',
+ templateUrl: './deployment-slots.component.html',
+ styleUrls: ['./deployment-slots.component.scss', './common.scss']
+})
+export class DeploymentSlotsComponent extends FeatureComponent> implements OnDestroy {
+ public Resources = PortalResources;
+ public FwdLinks = Links;
+ public SumValidator = RoutingSumValidator;
+ public viewInfo: TreeViewInfo;
+ public resourceId: ResourceId;
+
+ public loadingFailed: boolean;
+ public fetchingContent: boolean;
+ public fetchingPermissions: boolean;
+ public keepVisible: boolean;
+
+ public featureSupported: boolean;
+ public canUpgrade: boolean;
+
+ public mainForm: FormGroup;
+ public hasWriteAccess: boolean;
+ public hasSwapAccess: boolean;
+
+ public slotsQuotaMessage: string;
+ public slotsQuotaScaleUp: () => void;
+
+ public swapControlsOpen: boolean;
+ public addControlsOpen: boolean;
+
+ public dirtyMessage: string;
+
+ public siteArm: ArmObj;
+ public relativeSlotsArm: ArmObj[];
+
+ private _siteConfigArm: ArmObj;
+
+ private _isSlot: boolean;
+
+ @Input() set viewInfoInput(viewInfo: TreeViewInfo) {
+ this.setInput(viewInfo);
+ }
+
+ private _swapMode: boolean;
+ @Input() set swapMode(swapMode: boolean) {
+ // We don't expect this input to change after it is set for the first time,
+ // but here we make sure to only react the first time the input is set.
+ if (this._swapMode === undefined && swapMode !== undefined) {
+ this._swapMode = swapMode;
+ }
+ }
+
+ constructor(
+ private _authZService: AuthzService,
+ private _cacheService: CacheService,
+ private _fb: FormBuilder,
+ private _logService: LogService,
+ private _portalService: PortalService,
+ private _siteService: SiteService,
+ private _translateService: TranslateService,
+ injector: Injector) {
+
+ super('SlotsComponent', injector, 'site-tabs');
+
+ // TODO [andimarc]
+ // For ibiza scenarios, this needs to match the deep link feature name used to load this in ibiza menu
+ this.featureName = 'deploymentslots';
+ this.isParentComponent = true;
+
+ this.slotsQuotaScaleUp = () => {
+ if (this._confirmIfDirty()) {
+ this.scaleUp();
+ }
+ }
+ }
+
+ scaleUp() {
+ this.setBusy();
+
+ const inputs = {
+ aspResourceId: this.siteArm.properties.serverFarmId,
+ aseResourceId: this.siteArm.properties.hostingEnvironmentProfile
+ && this.siteArm.properties.hostingEnvironmentProfile.id
+ };
+
+ const openScaleUpBlade = this._portalService.openCollectorBladeWithInputs(
+ '',
+ inputs,
+ 'site-manage',
+ null,
+ 'WebsiteSpecPickerV3');
+
+ openScaleUpBlade
+ .first()
+ .subscribe(r => {
+ this.clearBusy();
+ this._logService.debug(LogCategories.siteConfig, `Scale up ${r ? 'succeeded' : 'cancelled'}`);
+ setTimeout(_ => { this.refresh(); });
+ },
+ e => {
+ this.clearBusy();
+ this._logService.error(LogCategories.siteConfig, '/scale-up', `Scale up failed: ${e}`);
+ });
+ }
+
+ refresh(keepVisible?: boolean) {
+ if (this._confirmIfDirty()) {
+ this.keepVisible = keepVisible;
+ const viewInfo: TreeViewInfo = JSON.parse(JSON.stringify(this.viewInfo));
+ this.setInput(viewInfo);
+ }
+ }
+
+ protected setup(inputEvents: Observable>) {
+ return inputEvents
+ .distinctUntilChanged()
+ .switchMap(viewInfo => {
+ this.viewInfo = viewInfo;
+
+ this.loadingFailed = false;
+ this.fetchingContent = true;
+ this.fetchingPermissions = true;
+
+ this.featureSupported = false;
+ this.canUpgrade = false;
+
+ this.hasWriteAccess = false;
+ this.hasSwapAccess = false;
+
+ this.slotsQuotaMessage = null;
+
+ this.swapControlsOpen = false;
+ this.addControlsOpen = false;
+
+ this.siteArm = null;
+ this.relativeSlotsArm = null;
+ this._siteConfigArm = null;
+
+ const siteDescriptor = new ArmSiteDescriptor(this.viewInfo.resourceId);
+
+ this._isSlot = !!siteDescriptor.slot;
+ this.resourceId = siteDescriptor.getTrimmedResourceId();
+
+ const siteResourceId = siteDescriptor.getSiteOnlyResourceId();
+
+ return Observable.zip(
+ this._siteService.getSite(siteResourceId),
+ this._siteService.getSlots(siteResourceId),
+ this._siteService.getSiteConfig(this.resourceId)
+ );
+ })
+ .switchMap(r => {
+ const siteResult = r[0];
+ const slotsResult = r[1];
+ const siteConfigResult = r[2];
+
+ let success = true;
+
+ // TODO [andimarc]: If only siteConfigResult fails, don't fail entire UI, just disable controls for routing rules
+ if (!siteResult.isSuccessful) {
+ this._logService.error(LogCategories.deploymentSlots, '/deployment-slots', siteResult.error.result);
+ success = false;
+ }
+ if (!slotsResult.isSuccessful) {
+ this._logService.error(LogCategories.deploymentSlots, '/deployment-slots', slotsResult.error.result);
+ success = false;
+ }
+ if (!siteConfigResult.isSuccessful) {
+ this._logService.error(LogCategories.deploymentSlots, '/deployment-slots', siteConfigResult.error.result);
+ success = false;
+ }
+
+ if (success) {
+ this._siteConfigArm = siteConfigResult.result;
+
+ if (this._isSlot) {
+ this.siteArm = slotsResult.result.value.filter(s => s.id === this.resourceId)[0];
+ this.relativeSlotsArm = slotsResult.result.value.filter(s => s.id !== this.resourceId);
+ this.relativeSlotsArm.unshift(siteResult.result);
+ } else {
+ this.siteArm = siteResult.result;
+ this.relativeSlotsArm = slotsResult.result.value;
+ }
+
+ // TODO [andimarc]: Make sure scale-up control is not showm for Dynamic SKU
+ const sku = this.siteArm.properties.sku.toLowerCase();
+
+ let slotsQuota = 0;
+ if (sku === 'dynamic') {
+ slotsQuota = 2;
+ this.canUpgrade = false;
+ } else if (sku === 'standard') {
+ slotsQuota = 5;
+ this.canUpgrade = true;
+ } else if (sku === 'premium') {
+ slotsQuota = 20;
+ this.canUpgrade = false;
+ } else {
+ this.canUpgrade = true;
+ }
+
+ this.featureSupported = !!slotsQuota;
+
+ if (this.featureSupported && this.relativeSlotsArm && (this.relativeSlotsArm.length + 1) >= slotsQuota) {
+ let quotaMessage = this._translateService.instant(PortalResources.slotNew_quotaReached, { quota: slotsQuota });
+ if (this.canUpgrade) {
+ quotaMessage = quotaMessage + ' ' + this._translateService.instant(PortalResources.slotNew_quotaUpgrade);
+ }
+ this.slotsQuotaMessage = quotaMessage;
+ }
+ }
+
+ this.loadingFailed = !success;
+ this.fetchingContent = false;
+ this.keepVisible = false;
+
+ this._setupForm();
+
+ if (this._swapMode) {
+ this._swapMode = false;
+ if (success) {
+ setTimeout(_ => { this.showSwapControls(); });
+ }
+ }
+
+ this.clearBusyEarly();
+
+ if (success) {
+ return Observable.zip(
+ this._authZService.hasPermission(this.resourceId, [AuthzService.writeScope]),
+ this._authZService.hasPermission(this.resourceId, [AuthzService.actionScope]),
+ this._authZService.hasReadOnlyLock(this.resourceId));
+ } else {
+ return Observable.zip(
+ Observable.of(false),
+ Observable.of(false),
+ Observable.of(true)
+ );
+ }
+ })
+ .do(r => {
+ const hasWritePermission = r[0];
+ const hasSwapPermission = r[1];
+ const hasReadOnlyLock = r[2];
+
+ this.hasWriteAccess = hasWritePermission && !hasReadOnlyLock;
+
+ this.hasSwapAccess = this.hasWriteAccess && hasSwapPermission;
+
+ this.fetchingPermissions = false;
+ });
+ }
+
+ private _setupForm() {
+ if (!!this.siteArm && !!this.relativeSlotsArm && !!this._siteConfigArm) {
+
+ this.mainForm = this._fb.group({});
+
+ const remainderControl = this._fb.control({ value: '', disabled: false });
+
+ const routingSumValidator = new RoutingSumValidator(this._fb, this._translateService);
+ const rulesGroup = this._fb.group({}, { validator: routingSumValidator.validate.bind(routingSumValidator) });
+
+ this.relativeSlotsArm.forEach(siteArm => {
+ const ruleControl = this._generateRuleControl(siteArm);
+ rulesGroup.addControl(siteArm.name, ruleControl);
+ })
+
+ this.mainForm.addControl(RoutingSumValidator.REMAINDER_CONTROL_NAME, remainderControl);
+
+ this.mainForm.addControl('rulesGroup', rulesGroup);
+
+ this._validateRoutingControls();
+
+ setTimeout(_ => { remainderControl.disable() });
+
+ } else {
+ this.mainForm = null;
+ }
+ }
+
+ private _generateRuleControl(siteArm: ArmObj): FormControl {
+ const rampUpRules = this._siteConfigArm.properties.experiments.rampUpRules;
+ const ruleName = siteArm.type === 'Microsoft.Web/sites' ? 'production' : this.getSegment(siteArm.name, -1);
+ const rule = !rampUpRules ? null : rampUpRules.filter(r => r.name === ruleName)[0];
+
+ const decimalRangeValidator = new DecimalRangeValidator(this._translateService);
+ return this._fb.control({ value: rule ? rule.reroutePercentage : 0, disabled: false }, decimalRangeValidator.validate.bind(decimalRangeValidator));
+ }
+
+ private _validateRoutingControls() {
+ if (this.mainForm && this.mainForm.controls['rulesGroup']) {
+ const rulesGroup = (this.mainForm.controls['rulesGroup'] as FormGroup);
+ for (const name in rulesGroup.controls) {
+ const control = (rulesGroup.controls[name] as CustomFormControl);
+ control._msRunValidation = true;
+ control.updateValueAndValidity();
+ }
+ rulesGroup.updateValueAndValidity();
+ }
+ }
+
+ save() {
+ this.dirtyMessage = this._translateService.instant(PortalResources.saveOperationInProgressWarning);
+
+ if (this.mainForm.controls['rulesGroup'] && this.mainForm.controls['rulesGroup'].valid) {
+
+ this.setBusy();
+ let notificationId = null;
+
+ this._portalService.startNotification(
+ this._translateService.instant(PortalResources.configUpdating),
+ this._translateService.instant(PortalResources.configUpdating))
+ .first()
+ .switchMap(s => {
+ notificationId = s.id;
+
+ const siteConfigArm = JSON.parse(JSON.stringify(this._siteConfigArm));
+ const rampUpRules = siteConfigArm.properties.experiments.rampUpRules as RoutingRule[];
+
+ const rulesGroup: FormGroup = (this.mainForm.controls['rulesGroup'] as FormGroup);
+ for (const name in rulesGroup.controls) {
+ const ruleControl = rulesGroup.controls[name];
+
+ if (!ruleControl.pristine) {
+ const nameParts = name.split('/');
+ const ruleName = nameParts.length === 0 ? 'production' : nameParts[1];
+ const index = rampUpRules.findIndex(r => r.name === ruleName);
+
+ if (!ruleControl.value) {
+ if (index >= 0) {
+ rampUpRules.splice(index, 1);
+ }
+ } else {
+ if (index >= 0) {
+ rampUpRules[index].reroutePercentage = ruleControl.value;
+ } else {
+ const slotArm = this.relativeSlotsArm.find(s => s.name === name);
+
+ if (slotArm) {
+ rampUpRules.push({
+ actionHostName: slotArm.properties.hostNames[0],
+ reroutePercentage: ruleControl.value,
+ changeStep: null,
+ changeIntervalInMinutes: null,
+ minReroutePercentage: null,
+ maxReroutePercentage: null,
+ changeDecisionCallbackUrl: null,
+ name: ruleName
+ });
+ }
+ }
+ }
+ }
+ }
+
+ return this._cacheService.putArm(`${this.resourceId}/config/web`, null, siteConfigArm);
+ })
+ .do(null, error => {
+ this.dirtyMessage = null;
+ this._logService.error(LogCategories.deploymentSlots, '/deployment-slots', error);
+ this.clearBusy();
+ this._portalService.stopNotification(
+ notificationId,
+ false,
+ this._translateService.instant(PortalResources.configUpdateFailure) + JSON.stringify(error));
+ })
+ .subscribe(r => {
+ this.dirtyMessage = null;
+ this.clearBusy();
+ this._portalService.stopNotification(
+ notificationId,
+ true,
+ this._translateService.instant(PortalResources.configUpdateSuccess));
+
+ this._siteConfigArm = r.json();
+ this._setupForm();
+ });
+
+ }
+ }
+
+ ngOnDestroy(): void {
+ super.ngOnDestroy();
+
+ this.clearBusy();
+ this._broadcastService.clearDirtyState('swap-slot');
+ this._broadcastService.clearDirtyState('add-slot');
+ }
+
+ private _confirmIfDirty(): boolean {
+ let proceed = true;
+
+ if (this.mainForm && this.mainForm.dirty) {
+ proceed = confirm(this._translateService.instant(PortalResources.unsavedChangesWarning));
+ if (proceed) {
+ this._discard();
+ }
+ }
+
+ return proceed;
+ }
+
+ private _discard() {
+ this._setupForm();
+ }
+
+ discard() {
+ if (this._confirmIfDirty()) {
+ this._discard();
+ }
+ }
+
+ showSwapControls() {
+ if (this._confirmIfDirty()) {
+ this.swapControlsOpen = true;
+ }
+ }
+
+ showAddControls() {
+ if (this._confirmIfDirty()) {
+ this.addControlsOpen = true;
+ }
+ }
+
+ hideControls(refresh: boolean) {
+ this.swapControlsOpen = false;
+ this.addControlsOpen = false;
+ if (refresh) {
+ // TODO [andimarc]: prompt to confirm refresh?
+ this.refresh(true);
+ }
+ }
+
+ openSlotBlade(resourceId: string) {
+ if (resourceId) {
+ this._portalService.openBlade({
+ detailBlade: 'AppsOverviewBlade',
+ detailBladeInputs: { id: resourceId }
+ },
+ 'deployment-slots'
+ );
+ }
+ }
+
+ getSegment(path: string, index: number): string {
+ let segment = null;
+
+ if (!!path) {
+ const segments = path.split('/');
+
+ index = (index < 0) ? segments.length + index : index;
+
+ if (index >= 0 && index < segments.length) {
+ segment = segments[index];
+ }
+ }
+
+ return segment;
+ }
+}
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/site/deployment-slots/deployment-slots.module.ts b/AzureFunctions.AngularClient/src/app/site/deployment-slots/deployment-slots.module.ts
new file mode 100644
index 0000000000..f3aef7c128
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/site/deployment-slots/deployment-slots.module.ts
@@ -0,0 +1,47 @@
+import { NgModule } from '@angular/core';
+import { DeploymentSlotsComponent } from 'app/site/deployment-slots/deployment-slots.component';
+import { SwapSlotsComponent } from 'app/site/deployment-slots/swap-slots/swap-slots.component';
+import { AddSlotComponent } from 'app/site/deployment-slots/add-slot/add-slot.component';
+import { DeploymentSlotsShellComponent } from 'app/ibiza-feature/deployment-slots-shell/deployment-slots-shell.component';
+import { TranslateModule } from '@ngx-translate/core';
+import { SharedModule } from 'app/shared/shared.module';
+import { SharedFunctionsModule } from 'app/shared/shared-functions.module';
+import { SidebarModule } from 'ng-sidebar';
+import 'rxjs/add/operator/catch';
+import 'rxjs/add/operator/debounceTime';
+import 'rxjs/add/operator/distinctUntilChanged';
+import 'rxjs/add/operator/do';
+import 'rxjs/add/operator/filter';
+import 'rxjs/add/operator/first';
+import 'rxjs/add/observable/forkJoin';
+import 'rxjs/add/observable/interval';
+import 'rxjs/add/operator/map';
+import 'rxjs/add/operator/merge';
+import 'rxjs/add/operator/mergeMap';
+import 'rxjs/add/observable/of';
+import 'rxjs/add/operator/retry';
+import 'rxjs/add/operator/switchMap';
+import 'rxjs/add/operator/take';
+import 'rxjs/add/operator/takeUntil';
+import 'rxjs/add/observable/timer';
+import 'rxjs/add/observable/throw';
+import 'rxjs/add/observable/zip';
+
+@NgModule({
+ entryComponents: [
+ DeploymentSlotsComponent
+ ],
+ imports: [
+ TranslateModule.forChild(), SharedModule, SharedFunctionsModule, SidebarModule
+ ],
+ declarations: [
+ DeploymentSlotsComponent,
+ SwapSlotsComponent,
+ AddSlotComponent,
+ DeploymentSlotsShellComponent
+ ],
+ exports: [
+ DeploymentSlotsComponent
+ ]
+})
+export class DeploymentSlotsModule { }
diff --git a/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/slotSwapAuthValidator.ts b/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/slotSwapAuthValidator.ts
new file mode 100644
index 0000000000..73b90c9a25
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/slotSwapAuthValidator.ts
@@ -0,0 +1,56 @@
+import { AsyncValidator, FormControl, FormGroup } from '@angular/forms';
+import { TranslateService } from '@ngx-translate/core';
+import { Observable } from 'rxjs/Observable';
+import { SiteService } from 'app/shared/services/site.service';
+import { ArmObj } from 'app/shared/models/arm/arm-obj';
+import { SiteConfig } from 'app/shared/models/arm/site-config';
+import { HttpResult } from 'app/shared/models/http-result';
+
+// This validation is needed because Swap with Preview cannot be performed if either of
+// the slots involved has authentication enabled. The validator returns an error if the
+// 'preview' control is set to true and at least one of the slots has authentication enabled.
+export class SlotSwapAuthValidator implements AsyncValidator {
+ constructor(
+ private _siteService: SiteService,
+ private _translateService: TranslateService) { }
+
+ validate(group: FormGroup) {
+ const src: FormControl = group.get('src') as FormControl;
+ const dest: FormControl = group.get('dest') as FormControl;
+ const preview: FormControl = group.get('preview') as FormControl;
+
+ if (!src || !dest || !preview) {
+ throw "Validator requires FormGroup with controls 'src' 'dest' and 'preview'";
+ }
+
+ if (!preview.value) {
+ return Promise.resolve(null);
+ } else {
+ return new Promise(resolve => {
+ Observable.zip(
+ src.value ? this._siteService.getSiteConfig(src.value) : Observable.of(null),
+ dest.value ? this._siteService.getSiteConfig(dest.value) : Observable.of(null)
+ )
+ .subscribe(r => {
+ const authEnabledSlots: string[] = [];
+ r.forEach(res => {
+ const result = (res as HttpResult>);
+ const siteConfigArm = (result && result.isSuccessful) ? result.result : null;
+ if (siteConfigArm && siteConfigArm.properties.siteAuthEnabled) {
+ authEnabledSlots.push(siteConfigArm.name);
+ }
+ })
+
+ if (authEnabledSlots.length === 0) {
+ resolve(null);
+ } else {
+ resolve({
+ // TODO [andimarc]: more to Resources
+ previewWithAuth: this._translateService.instant("The following slot(s) have authentication enabled: {{slotsList}}", { slotsList: JSON.stringify(authEnabledSlots) })
+ });
+ }
+ });
+ });
+ }
+ }
+}
diff --git a/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/slotSwapPermissionsValidator.ts b/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/slotSwapPermissionsValidator.ts
new file mode 100644
index 0000000000..9afc101fc0
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/slotSwapPermissionsValidator.ts
@@ -0,0 +1,42 @@
+import { TranslateService } from '@ngx-translate/core';
+import { AuthzService } from 'app/shared/services/authz.service';
+//import { PortalResources } from 'app/shared/models/portal-resources';
+import { AsyncValidator, FormControl } from '@angular/forms';
+import { Observable } from 'rxjs/Observable';
+
+export class SlotSwapPermissionsValidator implements AsyncValidator {
+ constructor(
+ private _authZService: AuthzService,
+ private _translateService: TranslateService) { }
+
+
+ validate(control: FormControl) {
+ const resourceId: string = control.value as string;
+
+ if (!resourceId) {
+ return Promise.resolve(null);
+ } else {
+ return new Promise(resolve => {
+ Observable.zip(
+ this._authZService.hasPermission(resourceId, [AuthzService.writeScope]),
+ this._authZService.hasPermission(resourceId, [AuthzService.actionScope]),
+ this._authZService.hasReadOnlyLock(resourceId)
+ )
+ .subscribe(r => {
+ const hasWritePermission = r[1];
+ const hasSwapPermission = r[2];
+ const hasReadOnlyLock = r[3];
+
+ if (hasSwapPermission && hasWritePermission && !hasReadOnlyLock) {
+ resolve(null);
+ } else {
+ resolve({
+ noSwapAcess: this._translateService.instant("No swap access for slot")
+ });
+ }
+
+ })
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/slotSwapUniqueValidator.ts b/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/slotSwapUniqueValidator.ts
new file mode 100644
index 0000000000..14e4b9dca3
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/slotSwapUniqueValidator.ts
@@ -0,0 +1,25 @@
+import { FormControl, FormGroup, Validator } from '@angular/forms';
+import { TranslateService } from '@ngx-translate/core';
+//import { PortalResources } from 'app/shared/models/portal-resources';
+
+
+export class SlotSwapUniqueValidator implements Validator {
+ constructor(private _translateService: TranslateService) { }
+
+ validate(group: FormGroup) {
+ let error = null;
+
+ const src: FormControl = group.controls['src'] as FormControl;
+ const dest: FormControl = group.controls['dest'] as FormControl;
+
+ if (!src || !dest) {
+ throw "Validator requires FormGroup with controls 'src' and 'dest'";
+ }
+
+ if (src.value === dest.value) {
+ error = { notUnique: this._translateService.instant('Source and Destination cannot be the same slot') };
+ }
+
+ return error;
+ }
+}
diff --git a/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/swap-slots.component.html b/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/swap-slots.component.html
new file mode 100644
index 0000000000..6b619181ab
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/swap-slots.component.html
@@ -0,0 +1,194 @@
+
+
+
+
+
Swap slots
+
+
+
+
+
+ {{ Resources.source | translate }}
+
{{ Resources.production | translate }}
+
+
+
+
+
+ No swap access on the selected slot.
+
+
+
+
+
+
+
+
+ {{ Resources.target | translate }}
+
{{ Resources.production | translate }}
+
+
+
+
+
+ No swap access on the selected slot.
+
+
+
+
+
+
+ Slots must be different
+
+
+
+
+
+ Cannnot perform multiphase swap because the following slots have authentication enabled:
+
+ {{ slot }}
+
+
+
+
+
+
+
+
+
+
+ {{ Resources.swapPhaseOneLabel | translate }}
+
+
+ {{ Resources.swapPhaseTwoLabel | translate }}
+
+
+
+
+
+
+
+
+
Preview changes
+
+
+
+
+
+
+
+
+ |
+
+
+
+ {{ diff.settingName }} |
+ {{ diff.settingType}} |
+ {{ diffsPreviewSlot==='source' ? diff.valueInCurrentSlot : diff.valueInTargetSlot }} |
+ {{ diffsPreviewSlot==='source' ? diff.valueInTargetSlot : diff.valueInCurrentSlot }} |
+
+
+
+
+
+
+
+
Swap Or Cancel In Progress
+
+
+
+
+ {{ successMessage }}
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/swap-slots.component.scss b/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/swap-slots.component.scss
new file mode 100644
index 0000000000..cd73778b23
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/swap-slots.component.scss
@@ -0,0 +1,268 @@
+@import '../../../../sass/common/variables';
+
+.slot-drop-downs-container {
+ width: 100%;
+ margin-top: 10px;
+}
+
+.slot-drop-down-container{
+ float: left;
+ width: 200px;
+ margin-right: calc(50% - 200px);
+}
+
+.drop-down-label{
+ padding-left: 5px;
+ margin-bottom: 2px;
+}
+
+.bullet{
+ height: 10px;
+ width: 10px;
+ display: inline-block;
+ border-radius: 50%;
+ margin-right: 3px;
+
+ &.src{
+ background-color: aqua;
+ }
+
+ &.dest{
+ background-color: pink;
+ }
+}
+
+$markersHeight: 22px;
+$markersWidth: $markersHeight;
+$markersShadowWidth: 4px;
+$markersDistance: 90px;
+
+.phase-markers{
+ position: relative;
+ height: ($markersHeight + $markersShadowWidth*2);
+ margin-top: 10px;
+
+ .left-marker, .right-marker, .phase-connector{
+ position: absolute;
+ }
+
+ .left-marker, .right-marker{
+ top: 0px;
+ height: $markersHeight;
+ width: $markersHeight;
+ margin-top: $markersShadowWidth;
+ margin-bottom: $markersShadowWidth;
+ bottom: $markersShadowWidth;
+ border-radius: 50%;
+ text-align: center;
+ background-color: gray;
+
+ &.phase-current{
+ box-shadow: 0px 0px 0px $markersShadowWidth lightblue;
+ background-color: blue;
+ }
+
+ &.phase-complete{
+ background-color: green;
+ }
+ }
+
+ .left-marker{
+ left: $markersShadowWidth;
+
+ &.phase-current{
+ margin-right: $markersShadowWidth;
+ }
+ }
+
+ .right-marker{
+ left: ($markersWidth + $markersShadowWidth*3 + $markersDistance);
+
+ &.phase-current{
+ left: ($markersWidth + $markersShadowWidth*2 + $markersDistance);
+ margin-left: $markersShadowWidth;
+ }
+ }
+
+ .phase-connector{
+ left: ($markersWidth + $markersShadowWidth);
+ top: ($markersHeight + $markersShadowWidth*2 - 2px)/2;
+ height: 2px;
+ width: ($markersDistance + $markersShadowWidth*2);
+ background-color: #cccccc;
+
+ &.contract-left, &.contract-right {
+ width: ($markersDistance + $markersShadowWidth);
+ }
+
+ &.contract-left {
+ left: ($markersWidth + $markersShadowWidth*2);
+ }
+ }
+}
+
+.phase-labels{
+ position: relative;
+ height: 22px;
+ margin-bottom: 10px;
+
+ .left-label, .right-label{
+ position: absolute;
+ top: 0px;
+ }
+
+ .left-label{
+ left: 0px;
+ }
+
+ .right-label{
+ left: ($markersWidth + $markersShadowWidth*2 + $markersDistance);
+ }
+}
+
+/*
+.phase-markers{
+ position: relative;
+ height: 30px;
+ margin-top: 10px;
+
+ .left-marker, .right-marker, .phase-connector{
+ position: absolute;
+ }
+
+ .left-marker, .right-marker{
+ top: 0px;
+ height: 22px;
+ width: 22px;
+ margin-top: 4px;
+ margin-bottom: 4px;
+ bottom: 4px;
+ border-radius: 50%;
+ text-align: center;
+ background-color: gray;
+
+ &.phase-current{
+ box-shadow: 0px 0px 0px 4px lightblue;
+ background-color: blue;
+ }
+
+ &.phase-complete{
+ background-color: green;
+ }
+ }
+
+ .left-marker{
+ left: 4px;
+
+ &.phase-current{
+ margin-right: 4px;
+ }
+ }
+
+ .right-marker{
+ left: 124px;
+
+ &.phase-current{
+ left: 120px;
+ margin-left: 4px;
+ }
+ }
+
+ .phase-connector{
+ left: 26px;
+ top: 14px;
+ height: 2px;
+ width: 98px;
+ background-color: #cccccc;
+
+ &.contract-left, &.contract-right {
+ width: 94px;
+ }
+
+ &.contract-left {
+ left: 30px;
+ }
+ }
+}
+*/
+
+.spinner-row{
+ text-align: center;
+ padding: 5px;
+
+ .spinner{
+ border-width: 5px;
+ width: 30px;
+ height: 30px;
+ }
+}
+
+
+td, th{
+ min-width: 0px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ span{
+ font-size: 16px;
+
+ &:hover{
+ color: $error-color;
+ }
+
+ }
+
+ span.delete:hover{
+ color: $error-color;
+ }
+}
+
+.padded-col{
+ padding-left: 10px;
+ padding-right: 2px;
+}
+
+.one-quarter-col{
+ width: 25%;
+}
+
+
+.preview-changes-container{
+ border: 1px solid black;
+
+ .preview-toggle-container{
+
+ .preview-toggle-button{
+ width: 50%;
+ float: left;
+ background-color: #d8d8ff;
+ border-bottom: 1px solid black;
+ padding: 3px 0 3px 5px;
+
+ &.src{
+ border-right: 1px solid black;
+ }
+
+ &.dest{
+ border-left: 1px solid black;
+ }
+
+ &.selected{
+ background-color: transparent;
+ border: none;
+ outline: none;
+ }
+ }
+ }
+}
+
+.buttons-container{
+ margin-top: 15px;
+}
+
+.custom-button{
+ float: left;
+ margin-left: 0px;
+ margin-right: 15px;
+ padding: 5px 15px 5px 15px
+}
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/swap-slots.component.ts b/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/swap-slots.component.ts
new file mode 100644
index 0000000000..673dc398f6
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/app/site/deployment-slots/swap-slots/swap-slots.component.ts
@@ -0,0 +1,469 @@
+import { Component, Injector, Input, OnDestroy, Output } from '@angular/core';
+import { Response } from '@angular/http';
+import { Observable } from 'rxjs/Observable';
+import { Subject } from 'rxjs/Subject';
+import { FormBuilder, FormGroup } from '@angular/forms';
+import { TranslateService } from '@ngx-translate/core';
+import { FeatureComponent } from 'app/shared/components/feature-component';
+import { ArmObj, ResourceId, ArmArrayResult } from 'app/shared/models/arm/arm-obj';
+import { Site } from 'app/shared/models/arm/site';
+import { SlotsDiff } from 'app/shared/models/arm/slots-diff';
+import { LogCategories } from 'app/shared/models/constants';
+import { DropDownElement } from 'app/shared/models/drop-down-element';
+import { PortalResources } from 'app/shared/models/portal-resources';
+import { ArmSiteDescriptor } from 'app/shared/resourceDescriptors';
+import { AuthzService } from 'app/shared/services/authz.service';
+import { CacheService } from 'app/shared/services/cache.service';
+import { LogService } from 'app/shared/services/log.service';
+import { SiteService } from 'app/shared/services/site.service';
+import { PortalService } from 'app/shared/services/portal.service';
+
+// TODO [andimarc]: disable all controls when a swap operation is in progress
+// TODO [andimarc]: disable controls when in phaseTwo or complete
+
+interface SlotInfo {
+ siteArm: ArmObj,
+ hasSiteAuth?: boolean,
+ hasSwapAccess?: boolean
+}
+
+@Component({
+ selector: 'swap-slots',
+ templateUrl: './swap-slots.component.html',
+ styleUrls: ['./swap-slots.component.scss', './../common.scss']
+})
+export class SwapSlotsComponent extends FeatureComponent implements OnDestroy {
+ @Input() set resourceIdInput(resourceId: ResourceId) {
+ this._resourceId = resourceId;
+ this.setInput(resourceId);
+ }
+
+ @Output() close: Subject;
+
+ public dirtyMessage: string;
+
+ public Resources = PortalResources;
+ public srcDropDownOptions: DropDownElement[];
+ public destDropDownOptions: DropDownElement[];
+ public siteResourceId: ResourceId;
+
+ public slotsNotUnique: boolean;
+ public srcNotSelected: boolean;
+ public srcNoSwapAccess: boolean;
+ public destNotSelected: boolean;
+ public destNoSwapAccess: boolean;
+ public siteAuthConflicts: string[];
+ public isValid: boolean;
+
+ public previewLink: string;
+ public phase: null | 'phaseOne' | 'phaseTwo' | 'complete';
+
+ public successMessage = null;
+
+ public checkingSrc: boolean;
+ public checkingDest: boolean;
+ public loadingDiffs: boolean;
+ public swapping: boolean;
+
+ public slotsDiffs: SlotsDiff[];
+ public diffsPreviewSlot: 'source' | 'target' = 'source';
+
+ public swapForm: FormGroup;
+
+ private _swappedOrCancelled: boolean;
+
+ private _diffSubject: Subject;
+
+ private _slotsMap: { [key: string]: SlotInfo };
+
+ private _resourceId: ResourceId;
+
+ constructor(
+ private _authZService: AuthzService,
+ private _cacheService: CacheService,
+ private _fb: FormBuilder,
+ private _logService: LogService,
+ private _portalService: PortalService,
+ private _siteService: SiteService,
+ private _translateService: TranslateService,
+ injector: Injector
+ ) {
+ super('SwapSlotsComponent', injector, 'site-tabs');
+
+ this.close = new Subject();
+
+ // TODO [andimarc]
+ // For ibiza scenarios, this needs to match the deep link feature name used to load this in ibiza menu
+ this.featureName = 'deploymentslots';
+ this.isParentComponent = true;
+
+ this._setupSubscriptions();
+ }
+
+ protected setup(inputEvents: Observable) {
+ //TODO [andimarc]: detect if we are already in phase two of a swap
+
+ return inputEvents
+ .distinctUntilChanged()
+ .switchMap(resourceId => {
+ this._resourceId = resourceId;
+
+ this.previewLink = null;
+
+ this.successMessage = null;
+
+ this.swapping = false;
+
+ this.srcDropDownOptions = [];
+ this.destDropDownOptions = [];
+ this._slotsMap = {};
+
+ this._swappedOrCancelled = false;
+
+ const siteDescriptor = new ArmSiteDescriptor(resourceId);
+ this.siteResourceId = siteDescriptor.getSiteOnlyResourceId();
+
+ return Observable.zip(
+ this._siteService.getSite(this.siteResourceId),
+ this._siteService.getSlots(this.siteResourceId),
+ this._siteService.getSlotConfigNames(this.siteResourceId),
+ this._siteService.getSiteConfig(this._resourceId)
+ );
+ })
+ .do(r => {
+ const siteResult = r[0];
+ const slotsResult = r[1];
+ const slotConfigNamesResult = r[2];
+ const siteConfigResult = r[3];
+
+ let success = true;
+
+ if (!siteResult.isSuccessful) {
+ this._logService.error(LogCategories.swapSlots, '/swap-slots', siteResult.error.result);
+ success = false;
+ }
+ if (!slotsResult.isSuccessful) {
+ this._logService.error(LogCategories.swapSlots, '/swap-slots', slotsResult.error.result);
+ success = false;
+ }
+ if (!slotConfigNamesResult.isSuccessful) {
+ this._logService.error(LogCategories.swapSlots, '/swap-slots', slotConfigNamesResult.error.result);
+ success = false;
+ }
+ if (!siteConfigResult.isSuccessful) {
+ this._logService.error(LogCategories.swapSlots, '/swap-slots', siteConfigResult.error.result);
+ success = false;
+ }
+
+ if (success) {
+ const options: DropDownElement[] = [];
+
+ [siteResult.result, ...slotsResult.result.value].forEach(s => {
+ this._slotsMap[s.id] = { siteArm: s };
+ options.push({
+ displayLabel: s.properties.name,
+ value: s.id
+ });
+ })
+
+ this._slotsMap[this._resourceId].hasSiteAuth = siteConfigResult.result.properties.siteAuthEnabled;
+
+ const slotConfigNames = slotConfigNamesResult.result.properties;
+ const hasStickySettings = slotConfigNames.appSettingNames.length > 0 || slotConfigNames.connectionStringNames.length > 0;
+
+ this.swapForm.controls['src'].setValue(this._resourceId);
+ this.swapForm.controls['dest'].setValue(null);
+
+ const multiPhaseCtrl = this.swapForm.controls['multiPhase'];
+ multiPhaseCtrl.setValue(false);
+ if (hasStickySettings) {
+ multiPhaseCtrl.enable();
+ } else {
+ multiPhaseCtrl.disable();
+ }
+
+ this.srcDropDownOptions = JSON.parse(JSON.stringify(options));
+ this.srcDropDownOptions.forEach(o => o.default = o.value === this._resourceId);
+
+ this.destDropDownOptions = JSON.parse(JSON.stringify(options));
+ }
+ });
+ }
+
+ private _setupSubscriptions() {
+ this._diffSubject = new Subject();
+ this._diffSubject
+ .takeUntil(this.ngUnsubscribe)
+ .distinctUntilChanged()
+ .switchMap(s => {
+ this.loadingDiffs = true;
+ const list = s.split(',');
+ return this._getSlotsDiffs(list[0], list[1]);
+ })
+ .subscribe(r => {
+ this.loadingDiffs = false;
+ this.slotsDiffs = !r ? null : r.value.map(o => o.properties);
+ });
+
+ const srcCtrl = this._fb.control({ value: null, disabled: false });
+ const destCtrl = this._fb.control({ value: null, disabled: false });
+ const multiPhaseCtrl = this._fb.control({ value: false, disabled: false });
+
+ this.swapForm = this._fb.group({
+ src: srcCtrl,
+ dest: destCtrl,
+ multiPhase: multiPhaseCtrl
+ });
+
+ srcCtrl.valueChanges
+ .takeUntil(this.ngUnsubscribe)
+ .distinctUntilChanged()
+ .switchMap(v => {
+ this.checkingSrc = true;
+ return this._getSlotInfo(srcCtrl.value);
+ })
+ .subscribe(slotInfo => {
+ if (slotInfo) {
+ this._slotsMap[slotInfo.siteArm.id] = slotInfo;
+ this._validateAndDiff();
+ }
+ this.checkingSrc = false;
+ });
+
+ destCtrl.valueChanges
+ .takeUntil(this.ngUnsubscribe)
+ .distinctUntilChanged()
+ .switchMap(v => {
+ this.checkingDest = true;
+ return this._getSlotInfo(destCtrl.value);
+ })
+ .subscribe(slotInfo => {
+ if (slotInfo) {
+ this._slotsMap[slotInfo.siteArm.id] = slotInfo;
+ this._validateAndDiff();
+ }
+ this.checkingDest = false;
+ });
+ }
+
+ private _validateAndDiff() {
+ // TODO [andimarc]: user form validation instaed
+
+ const src = this.swapForm ? this.swapForm.controls['src'].value : null;
+ const dest = this.swapForm ? this.swapForm.controls['dest'].value : null;
+ const multiPhase = this.swapForm ? this.swapForm.controls['multiPhase'].value : false;
+
+ this.slotsNotUnique = src === dest;
+ this.srcNotSelected = !src;
+ this.destNotSelected = !dest;
+ this.srcNoSwapAccess = !!src && this._slotsMap[src] && !this._slotsMap[src].hasSwapAccess;
+ this.destNoSwapAccess = !!dest && this._slotsMap[dest] && !this._slotsMap[dest].hasSwapAccess;
+
+ const siteAuthConflicts: string[] = [];
+ if (multiPhase) {
+ [src, dest].forEach(r => {
+ if (!!r && this._slotsMap[r] && this._slotsMap[r].hasSiteAuth) {
+ siteAuthConflicts.push(r);
+ }
+ })
+
+ }
+ this.siteAuthConflicts = siteAuthConflicts.length === 0 ? null : siteAuthConflicts;
+
+ // TODO [andimarc]: make sure neither src or dest slot have targetSwapSlot set (unless this is phase two of a swap and the value match up)
+
+ this.isValid = !this.slotsNotUnique
+ && !this.srcNotSelected
+ && !this.destNotSelected
+ && !this.srcNoSwapAccess
+ && !this.destNoSwapAccess
+ && !this.siteAuthConflicts;
+
+ if (this.isValid) {
+ this._diffSubject.next(`${src},${dest}`);
+ }
+ }
+
+ private _getSlotInfo(resourceId: ResourceId, force?: boolean): Observable {
+ const slotInfo: SlotInfo = resourceId ? this._slotsMap[resourceId] : null;
+
+ const needsFetch = slotInfo && (slotInfo.hasSiteAuth === undefined || slotInfo.hasSwapAccess === undefined);
+
+ if (needsFetch || force) {
+ return Observable.zip(
+ this._authZService.hasPermission(slotInfo.siteArm.id, [AuthzService.writeScope]),
+ this._authZService.hasPermission(slotInfo.siteArm.id, [AuthzService.actionScope]),
+ this._authZService.hasReadOnlyLock(slotInfo.siteArm.id),
+ this._siteService.getSiteConfig(slotInfo.siteArm.id))
+ .mergeMap(r => {
+ const hasWritePermission = r[0];
+ const hasSwapPermission = r[1];
+ const hasReadOnlyLock = r[2];
+ const siteConfigResult = r[3];
+
+ const hasSwapAccess = hasWritePermission && hasSwapPermission && !hasReadOnlyLock;
+ const hasSiteAuth = siteConfigResult.result && siteConfigResult.result.properties.siteAuthEnabled;
+ return Observable.of({
+ siteArm: slotInfo.siteArm,
+ hasSiteAuth: hasSiteAuth,
+ hasSwapAccess: hasSwapAccess
+ });
+ });
+ } else {
+ return Observable.of(slotInfo);
+ }
+ }
+
+ closePanel() {
+ const close = (!this.swapForm || !this.swapForm.dirty || this.phase === 'complete') ? true : confirm('unsaved changes will be lost'); // TODO [andimarc]: add to resources
+
+ if (close) {
+ this.close.next(!!this._swappedOrCancelled);
+ }
+ }
+
+ private _getSlotsDiffs(src: string, dest: string): Observable> {
+ const srcDescriptor: ArmSiteDescriptor = new ArmSiteDescriptor(src);
+ const path = srcDescriptor.getTrimmedResourceId() + '/slotsdiffs';
+
+ const destDescriptor: ArmSiteDescriptor = new ArmSiteDescriptor(dest);
+ const content = {
+ targetSlot: destDescriptor.slot || 'production'
+ }
+
+ return this._cacheService.postArm(path, null, null, content)
+ .mergeMap(r => {
+ return Observable.of(r.json());
+ })
+ .catch(e => {
+ return Observable.of(null);
+ });
+ }
+
+ swap() {
+ this._submit('swap');
+ }
+
+ cancelMultiPhaseSwap() {
+ this._submit('cancel');
+ }
+
+ private _submit(operationType: 'swap' | 'cancel') {
+ this.dirtyMessage = this._translateService.instant(PortalResources.swapOperationInProgressWarning);
+
+ if (this.isValid) { // TODO [andimarc]: check if form is valid
+ if (this.swapForm.controls['multiPhase'].value && !this.phase) {
+ this.phase = operationType === 'swap' ? 'phaseOne' : 'phaseTwo';
+ }
+
+ const srcId = this.swapForm.controls['src'].value;
+ const destId = this.swapForm.controls['dest'].value;
+
+ const srcDescriptor: ArmSiteDescriptor = new ArmSiteDescriptor(srcId);
+ const destDescriptor: ArmSiteDescriptor = new ArmSiteDescriptor(destId);
+
+ const srcName = srcDescriptor.slot || 'production';
+ const destName = destDescriptor.slot || 'production';
+
+ let path = srcDescriptor.getTrimmedResourceId();
+ let content = null;
+ let swapType = this._translateService.instant(PortalResources.swapFull);
+
+ if (operationType === 'swap') {
+ path += (this.phase === 'phaseOne' ? '/applyslotconfig' : '/slotsswap');
+
+ content = { targetSlot: destName };
+
+ if (this.phase === 'phaseOne') {
+ swapType = this._translateService.instant(PortalResources.swapPhaseOne);
+ } else if (this.phase === 'phaseTwo') {
+ swapType = this._translateService.instant(PortalResources.swapPhaseTwo);
+ }
+ } else {
+ path += '/resetSlotConfig';
+ }
+
+ const operation = this._translateService.instant(PortalResources.swapOperation, { swapType: swapType, srcSlot: srcName, destSlot: destName });
+
+ const startMessageResourceString = operationType === 'swap' ? PortalResources.swapStarted : PortalResources.swapCancelStarted;
+ const startMessage = this._translateService.instant(startMessageResourceString, { operation: operation });
+
+ this.setBusy();
+ this.swapping = true;
+ let notificationId = null;
+
+ this._portalService.startNotification(
+ startMessage,
+ startMessage)
+ .first()
+ .switchMap(s => {
+ notificationId = s.id;
+
+ return this._cacheService.postArm(path, null, null, content)
+ .mergeMap(r => {
+ if (operationType === 'swap' && (!this.phase || this.phase === 'phaseTwo')) {
+ const location = r.headers.get('Location');
+ if (!location) {
+ return Observable.of({ success: false, error: 'no location header' });
+ } else {
+ return Observable.interval(1000)
+ .concatMap(_ => this._cacheService.get(location))
+ .map((r: Response) => r.status)
+ .take(30 * 60 * 1000)
+ .filter(s => s !== 202)
+ .map(r => { return { success: true, error: null } })
+ .catch(e => Observable.of({ success: false, error: e }))
+ .take(1);
+ }
+ } else {
+ return Observable.of({ success: true, error: null });
+ }
+ })
+ .catch(e => {
+ return Observable.of({ success: false, error: e })
+ })
+ })
+ .subscribe(r => {
+ this.dirtyMessage = null;
+ this.clearBusy();
+ this.swapping = false;
+
+ let resultMessage;
+
+ if (!r.success) {
+ this._logService.error(LogCategories.deploymentSlots, '/deployment-slots', r.error);
+
+ const failureResourceString = operationType === 'swap' ? PortalResources.swapFailure : PortalResources.swapCancelFailure;
+ resultMessage = this._translateService.instant(failureResourceString, { operation: operation, error: JSON.stringify(r.error) });
+
+ // TODO [andimarc]: display error message in an error info box?
+
+ } else {
+ if (operationType === 'swap') {
+ resultMessage = this._translateService.instant(PortalResources.swapSuccess, { operation: operation })
+
+ if (!this.phase || this.phase === 'phaseTwo') {
+ this.phase = 'complete';
+ this.successMessage = resultMessage;
+ } else {
+ this.phase = 'phaseTwo';
+ this.previewLink = 'https://' + (this._slotsMap[srcId] as SlotInfo).siteArm.properties.hostNames[0];
+ }
+ } else {
+ resultMessage = this._translateService.instant(PortalResources.swapCancelSuccess, { operation: operation })
+ this.phase = 'phaseOne';
+ }
+
+ this._swappedOrCancelled = true;
+
+ // TODO [andimarc]: refresh the _slotsMap entries for the slot(s) involved in the swap
+ }
+
+ this._portalService.stopNotification(
+ notificationId,
+ r.success,
+ resultMessage);
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/app/site/site-config/app-settings/app-settings.component.ts b/AzureFunctions.AngularClient/src/app/site/site-config/app-settings/app-settings.component.ts
index e675ee48db..fb9611a07d 100644
--- a/AzureFunctions.AngularClient/src/app/site/site-config/app-settings/app-settings.component.ts
+++ b/AzureFunctions.AngularClient/src/app/site/site-config/app-settings/app-settings.component.ts
@@ -1,19 +1,14 @@
-import { Injector } from '@angular/core';
-import { FeatureComponent } from 'app/shared/components/feature-component';
-import { Response } from '@angular/http';
-import { Component, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
+import { ConfigSaveComponent, ArmSaveConfigs } from 'app/shared/components/config-save-component';
+import { Component, Injector, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
import { FormArray, FormBuilder, FormGroup, Validators, ValidatorFn } from '@angular/forms';
import { Observable } from 'rxjs/Observable';
import { TranslateService } from '@ngx-translate/core';
-import { ApplicationSettings } from './../../../shared/models/arm/application-settings';
import { SlotConfigNames } from './../../../shared/models/arm/slot-config-names';
-import { SaveOrValidationResult } from './../site-config.component';
+import { ApplicationSettings } from './../../../shared/models/arm/application-settings';
import { LogService } from './../../../shared/services/log.service';
import { PortalResources } from './../../../shared/models/portal-resources';
-import { BusyStateScopeManager } from './../../../busy-state/busy-state-scope-manager';
import { CustomFormControl, CustomFormGroup } from './../../../controls/click-to-edit/click-to-edit.component';
-import { ArmObj, ArmObjMap, ResourceId } from './../../../shared/models/arm/arm-obj';
-import { CacheService } from './../../../shared/services/cache.service';
+import { ArmObj, ResourceId } from './../../../shared/models/arm/arm-obj';
import { ArmSiteDescriptor } from 'app/shared/resourceDescriptors';
import { UniqueValidator } from 'app/shared/validators/uniqueValidator';
import { RequiredValidator } from 'app/shared/validators/requiredValidator';
@@ -28,7 +23,7 @@ import { LogCategories } from 'app/shared/models/constants';
templateUrl: './app-settings.component.html',
styleUrls: ['./../site-config.component.scss']
})
-export class AppSettingsComponent extends FeatureComponent implements OnChanges, OnDestroy {
+export class AppSettingsComponent extends ConfigSaveComponent implements OnChanges, OnDestroy {
@Input() mainForm: FormGroup;
@Input() resourceId: ResourceId;
@@ -42,27 +37,21 @@ export class AppSettingsComponent extends FeatureComponent implement
public newItem: CustomFormGroup;
public originalItemsDeleted: number;
- private busyManager: BusyStateScopeManager;
- private _saveError: string;
private _validatorFns: ValidatorFn[];
private _requiredValidator: RequiredValidator;
private _uniqueAppSettingValidator: UniqueValidator;
private _linuxAppSettingNameValidator: LinuxAppSettingNameValidator;
private _isLinux: boolean;
- private _appSettingsArm: ArmObj;
- private _slotConfigNamesArm: ArmObj;
private _slotConfigNamesArmPath: string;
constructor(
- private _cacheService: CacheService,
private _fb: FormBuilder,
private _translateService: TranslateService,
private _logService: LogService,
private _siteService: SiteService,
injector: Injector
) {
- super('AppSettingsComponent', injector);
- this.busyManager = new BusyStateScopeManager(this._broadcastService, 'site-tabs');
+ super('AppSettingsComponent', injector, ['ApplicationSettings', 'SlotConfigNames'], 'site-tabs');
this._resetPermissionsAndLoadingState();
@@ -70,15 +59,17 @@ export class AppSettingsComponent extends FeatureComponent implement
this.originalItemsDeleted = 0;
}
+ protected get _isPristine() {
+ return this.groupArray && this.groupArray.pristine;
+ }
+
protected setup(inputEvents: Observable) {
return inputEvents
.distinctUntilChanged()
.switchMap(() => {
-
- this.busyManager.setBusy();
- this._saveError = null;
- this._appSettingsArm = null;
- this._slotConfigNamesArm = null;
+ this._saveFailed = false;
+ this._resetSubmittedStates();
+ this._resetConfigs();
this.groupArray = null;
this.newItem = null;
this.originalItemsDeleted = 0;
@@ -110,15 +101,15 @@ export class AppSettingsComponent extends FeatureComponent implement
}
if (asResult.isSuccessful) {
- this._appSettingsArm = asResult.result;
+ this.appSettingsArm = asResult.result;
}
if (slotNamesResult.isSuccessful) {
- this._slotConfigNamesArm = slotNamesResult.result;
+ this.slotConfigNamesArm = slotNamesResult.result;
}
- if (this._appSettingsArm && this._slotConfigNamesArm) {
- this._setupForm(this._appSettingsArm, this._slotConfigNamesArm);
+ if (this.appSettingsArm && this.slotConfigNamesArm) {
+ this._setupForm(this.appSettingsArm, this.slotConfigNamesArm);
}
const failedRequest = results.find(r => !r.isSuccessful);
@@ -133,7 +124,6 @@ export class AppSettingsComponent extends FeatureComponent implement
this.loadingMessage = null;
this.showPermissionsMessage = true;
- this.busyManager.clearBusy();
});
}
@@ -142,15 +132,10 @@ export class AppSettingsComponent extends FeatureComponent implement
this.setInput(this.resourceId);
}
if (changes['mainForm'] && !changes['resourceId']) {
- this._setupForm(this._appSettingsArm, this._slotConfigNamesArm);
+ this._setupForm(this.appSettingsArm, this.slotConfigNamesArm);
}
}
- ngOnDestroy(): void {
- super.ngOnDestroy();
- this.busyManager.clearBusy();
- }
-
private _resetPermissionsAndLoadingState() {
this.hasWritePermissions = true;
this.permissionsMessage = '';
@@ -171,10 +156,9 @@ export class AppSettingsComponent extends FeatureComponent implement
this.hasWritePermissions = writePermission && !readOnlyLock;
}
-
private _setupForm(appSettingsArm: ArmObj, slotConfigNamesArm: ArmObj) {
if (!!appSettingsArm && !!slotConfigNamesArm) {
- if (!this._saveError || !this.groupArray) {
+ if (!this._saveFailed || !this.groupArray) {
this.newItem = null;
this.originalItemsDeleted = 0;
this.groupArray = this._fb.array([]);
@@ -232,10 +216,11 @@ export class AppSettingsComponent extends FeatureComponent implement
}
}
- this._saveError = null;
+ this._saveFailed = false;
+ this._resetSubmittedStates();
}
- validate(): SaveOrValidationResult {
+ validate() {
const groups = this.groupArray.controls;
// Purge any added entries that were never modified
@@ -250,11 +235,6 @@ export class AppSettingsComponent extends FeatureComponent implement
}
this._validateAllControls(groups as CustomFormGroup[]);
-
- return {
- success: this.groupArray.valid,
- error: this.groupArray.valid ? null : this._validationFailureMessage()
- };
}
private _validateAllControls(groups: CustomFormGroup[]) {
@@ -268,110 +248,59 @@ export class AppSettingsComponent extends FeatureComponent implement
});
}
- getConfigForSave(): ArmObjMap {
- // Prevent unnecessary PUT call if these settings haven't been changed
- if (this.groupArray.pristine) {
- return null;
- } else {
- const configObjects: ArmObjMap = {
- objects: {}
- };
-
- if (this.mainForm.contains('appSettings') && this.mainForm.controls['appSettings'].valid) {
- const appSettingsArm: ArmObj = JSON.parse(JSON.stringify(this._appSettingsArm));
- appSettingsArm.id = `${this.resourceId}/config/appSettings`;
- appSettingsArm.properties = {};
-
- this._slotConfigNamesArm.id = this._slotConfigNamesArmPath;
- const slotConfigNamesArm: ArmObj = JSON.parse(JSON.stringify(this._slotConfigNamesArm));
- slotConfigNamesArm.properties.appSettingNames = slotConfigNamesArm.properties.appSettingNames || [];
-
- const appSettings = appSettingsArm.properties;
- const appSettingNames = slotConfigNamesArm.properties.appSettingNames;
-
- let appSettingsPristine = true;
- let appSettingNamesPristine = true;
-
- this.groupArray.controls.forEach(group => {
- if ((group as CustomFormGroup).msExistenceState !== 'deleted') {
- const controls = (group as CustomFormGroup).controls;
-
- const name = controls['name'].value;
-
- appSettings[name] = controls['value'].value;
-
- if (appSettingsPristine && !group.pristine) {
- appSettingsPristine = controls['name'].pristine && controls['value'].pristine;
- }
-
- if (group.value.isSlotSetting) {
- if (appSettingNames.indexOf(name) === -1) {
- appSettingNames.push(name);
- appSettingNamesPristine = false;
- }
- } else {
- const index = appSettingNames.indexOf(name);
- if (index !== -1) {
- appSettingNames.splice(index, 1);
- appSettingNamesPristine = false;
- }
- }
- } else {
- appSettingsPristine = false;
- }
- })
+ protected _getConfigsFromForms(saveConfigs: ArmSaveConfigs): ArmSaveConfigs {
+ const appSettingsArm: ArmObj = (saveConfigs && saveConfigs.appSettingsArm) ?
+ JSON.parse(JSON.stringify(saveConfigs.appSettingsArm)) : // TODO: [andimarc] not valid scenario - should never be already set
+ JSON.parse(JSON.stringify(this.appSettingsArm));
+ appSettingsArm.id = `${this.resourceId}/config/appSettings`;
+ appSettingsArm.properties = {};
- if (!appSettingNamesPristine) {
- configObjects['slotConfigNames'] = slotConfigNamesArm;
- }
- if (!appSettingsPristine) {
- configObjects['appSettings'] = appSettingsArm;
- }
- } else {
- configObjects.error = this._validationFailureMessage();
- }
+ const slotConfigNamesArm: ArmObj = (saveConfigs && saveConfigs.slotConfigNamesArm) ?
+ JSON.parse(JSON.stringify(saveConfigs.slotConfigNamesArm)) :
+ JSON.parse(JSON.stringify(this.slotConfigNamesArm));
+ slotConfigNamesArm.id = this._slotConfigNamesArmPath;
+ slotConfigNamesArm.properties.appSettingNames = slotConfigNamesArm.properties.appSettingNames || [];
- return configObjects;
- }
- }
+ const appSettings: ApplicationSettings = appSettingsArm.properties;
+ const appSettingNames: string[] = slotConfigNamesArm.properties.appSettingNames;
- save(
- appSettingsArm: ArmObj,
- slotConfigNamesResponse: Response): Observable {
+ let appSettingsPristine = true;
+ let appSettingNamesPristine = true;
- // Don't make unnecessary PUT call if these settings haven't been changed
- if (this.groupArray.pristine) {
- return Observable.of({
- success: true,
- error: null
- });
- } else {
- return Observable.zip(
- appSettingsArm ? this._cacheService.putArm(`${this.resourceId}/config/appSettings`, null, appSettingsArm) : Observable.of(null),
- Observable.of(slotConfigNamesResponse),
- (a, s) => ({ appSettingsResponse: a, slotConfigNamesResponse: s })
- )
- .map(r => {
- this._appSettingsArm = r.appSettingsResponse ? r.appSettingsResponse.json() : this._appSettingsArm;
- this._slotConfigNamesArm = r.slotConfigNamesResponse ? r.slotConfigNamesResponse.json() : this._slotConfigNamesArm;
- return {
- success: true,
- error: null
- };
- })
- .catch(error => {
- this._saveError = error._body;
- return Observable.of({
- success: false,
- error: error._body
- });
- });
- }
- }
+ this.groupArray.controls.forEach(group => {
+ if ((group as CustomFormGroup).msExistenceState !== 'deleted') {
+ const controls = (group as CustomFormGroup).controls;
+
+ const name = controls['name'].value;
+
+ appSettings[name] = controls['value'].value;
+
+ if (appSettingsPristine && !group.pristine) {
+ appSettingsPristine = controls['name'].pristine && controls['value'].pristine;
+ }
- private _validationFailureMessage(): string {
- const configGroupName = this._translateService.instant(PortalResources.feature_applicationSettingsName);
- return this._translateService.instant(PortalResources.configUpdateFailureInvalidInput, { configGroupName: configGroupName });
+ if (group.value.isSlotSetting) {
+ if (appSettingNames.indexOf(name) === -1) {
+ appSettingNames.push(name);
+ appSettingNamesPristine = false;
+ }
+ } else {
+ const index = appSettingNames.indexOf(name);
+ if (index !== -1) {
+ appSettingNames.splice(index, 1);
+ appSettingNamesPristine = false;
+ }
+ }
+ }
+ else {
+ appSettingsPristine = false;
+ }
+ });
+
+ return {
+ appSettingsArm: appSettingsPristine ? null : appSettingsArm,
+ slotConfigNamesArm: appSettingNamesPristine ? null : slotConfigNamesArm
+ };
}
deleteItem(group: FormGroup) {
diff --git a/AzureFunctions.AngularClient/src/app/site/site-config/connection-strings/connection-strings.component.ts b/AzureFunctions.AngularClient/src/app/site/site-config/connection-strings/connection-strings.component.ts
index 674c6dcd1f..3104dd59b3 100644
--- a/AzureFunctions.AngularClient/src/app/site/site-config/connection-strings/connection-strings.component.ts
+++ b/AzureFunctions.AngularClient/src/app/site/site-config/connection-strings/connection-strings.component.ts
@@ -1,22 +1,18 @@
+import { ConfigSaveComponent, ArmSaveConfigs } from 'app/shared/components/config-save-component';
import { LogService } from 'app/shared/services/log.service';
import { LogCategories } from './../../../shared/models/constants';
import { errorIds } from 'app/shared/models/error-ids';
-import { Injector } from '@angular/core';
-import { FeatureComponent } from 'app/shared/components/feature-component';
-import { Response } from '@angular/http';
-import { Component, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
+import { Component, Injector, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Observable } from 'rxjs/Observable';
import { TranslateService } from '@ngx-translate/core';
import { SlotConfigNames } from './../../../shared/models/arm/slot-config-names';
import { ConnectionStringType, ConnectionStrings } from './../../../shared/models/arm/connection-strings';
import { EnumEx } from './../../../shared/Utilities/enumEx';
-import { SaveOrValidationResult } from './../site-config.component';
import { PortalResources } from './../../../shared/models/portal-resources';
import { DropDownElement } from './../../../shared/models/drop-down-element';
import { CustomFormControl, CustomFormGroup } from './../../../controls/click-to-edit/click-to-edit.component';
-import { ArmObj, ArmObjMap, ResourceId } from './../../../shared/models/arm/arm-obj';
-import { CacheService } from './../../../shared/services/cache.service';
+import { ArmObj, ResourceId } from './../../../shared/models/arm/arm-obj';
import { ArmSiteDescriptor } from 'app/shared/resourceDescriptors';
import { UniqueValidator } from 'app/shared/validators/uniqueValidator';
import { RequiredValidator } from 'app/shared/validators/requiredValidator';
@@ -27,7 +23,7 @@ import { SiteService } from 'app/shared/services/site.service';
templateUrl: './connection-strings.component.html',
styleUrls: ['./../site-config.component.scss']
})
-export class ConnectionStringsComponent extends FeatureComponent implements OnChanges, OnDestroy {
+export class ConnectionStringsComponent extends ConfigSaveComponent implements OnChanges, OnDestroy {
@Input() mainForm: FormGroup;
@Input() resourceId: ResourceId;
@@ -42,22 +38,18 @@ export class ConnectionStringsComponent extends FeatureComponent imp
public newItem: CustomFormGroup;
public originalItemsDeleted: number;
- private _saveError: string;
private _requiredValidator: RequiredValidator;
private _uniqueCsValidator: UniqueValidator;
- private _connectionStringsArm: ArmObj;
- private _slotConfigNamesArm: ArmObj;
private _slotConfigNamesArmPath: string;
constructor(
- private _cacheService: CacheService,
private _fb: FormBuilder,
private _translateService: TranslateService,
private _siteService: SiteService,
private _logService: LogService,
injector: Injector
) {
- super('ConnectionStringsComponent', injector, 'site-tabs');
+ super('ConnectionStringsComponent', injector, ['ConnectionStrings', 'SlotConfigNames'], 'site-tabs');
this._resetPermissionsAndLoadingState();
@@ -65,13 +57,17 @@ export class ConnectionStringsComponent extends FeatureComponent imp
this.originalItemsDeleted = 0;
}
+ protected get _isPristine() {
+ return this.groupArray && this.groupArray.pristine;
+ }
+
protected setup(inputEvents: Observable) {
return inputEvents
.distinctUntilChanged()
.switchMap(() => {
- this._saveError = null;
- this._connectionStringsArm = null;
- this._slotConfigNamesArm = null;
+ this._saveFailed = false;
+ this._resetSubmittedStates();
+ this._resetConfigs();
this.groupArray = null;
this.newItem = null;
this.originalItemsDeleted = 0;
@@ -103,9 +99,9 @@ export class ConnectionStringsComponent extends FeatureComponent imp
this.loadingMessage = null;
this.showPermissionsMessage = true;
} else {
- this._connectionStringsArm = csResult.result;
- this._slotConfigNamesArm = slotNamesResult.result;
- this._setupForm(this._connectionStringsArm, this._slotConfigNamesArm);
+ this.connectionStringsArm = csResult.result;
+ this.slotConfigNamesArm = slotNamesResult.result;
+ this._setupForm(this.connectionStringsArm, this.slotConfigNamesArm);
}
}
@@ -119,15 +115,10 @@ export class ConnectionStringsComponent extends FeatureComponent imp
this.setInput(this.resourceId);
}
if (changes['mainForm'] && !changes['resourceId']) {
- this._setupForm(this._connectionStringsArm, this._slotConfigNamesArm);
+ this._setupForm(this.connectionStringsArm, this.slotConfigNamesArm);
}
}
- ngOnDestroy(): void {
- super.ngOnDestroy();
- this.clearBusy();
- }
-
private _resetPermissionsAndLoadingState() {
this.hasWritePermissions = true;
this.permissionsMessage = '';
@@ -150,7 +141,7 @@ export class ConnectionStringsComponent extends FeatureComponent imp
private _setupForm(connectionStringsArm: ArmObj, slotConfigNamesArm: ArmObj) {
if (!!connectionStringsArm && !!slotConfigNamesArm) {
- if (!this._saveError || !this.groupArray) {
+ if (!this._saveFailed || !this.groupArray) {
this.newItem = null;
this.originalItemsDeleted = 0;
this.groupArray = this._fb.array([]);
@@ -207,10 +198,11 @@ export class ConnectionStringsComponent extends FeatureComponent imp
}
}
- this._saveError = null;
+ this._saveFailed = false;
+ this._resetSubmittedStates();
}
- validate(): SaveOrValidationResult {
+ validate() {
const groups = this.groupArray.controls;
// Purge any added entries that were never modified
@@ -225,11 +217,6 @@ export class ConnectionStringsComponent extends FeatureComponent imp
}
this._validateAllControls(groups as CustomFormGroup[]);
-
- return {
- success: this.groupArray.valid,
- error: this.groupArray.valid ? null : this._validationFailureMessage()
- };
}
private _validateAllControls(groups: CustomFormGroup[]) {
@@ -243,113 +230,62 @@ export class ConnectionStringsComponent extends FeatureComponent imp
});
}
- getConfigForSave(): ArmObjMap {
- // Prevent unnecessary PUT call if these settings haven't been changed
- if (this.groupArray.pristine) {
- return null;
- } else {
- const configObjects: ArmObjMap = {
- objects: {}
- };
-
- if (this.mainForm.contains('connectionStrings') && this.mainForm.controls['connectionStrings'].valid) {
- const connectionStringsArm: ArmObj = JSON.parse(JSON.stringify(this._connectionStringsArm));
- connectionStringsArm.id = `${this.resourceId}/config/connectionStrings`;
- connectionStringsArm.properties = {};
-
- this._slotConfigNamesArm.id = this._slotConfigNamesArmPath;
- const slotConfigNamesArm: ArmObj = JSON.parse(JSON.stringify(this._slotConfigNamesArm));
- slotConfigNamesArm.properties.connectionStringNames = slotConfigNamesArm.properties.connectionStringNames || [];
-
- const connectionStrings: ConnectionStrings = connectionStringsArm.properties;
- const connectionStringNames: string[] = slotConfigNamesArm.properties.connectionStringNames;
-
- let connectionStringsPristine = true;
- let connectionStringNamesPristine = true;
-
- this.groupArray.controls.forEach(group => {
- if ((group as CustomFormGroup).msExistenceState !== 'deleted') {
- const controls = (group as CustomFormGroup).controls;
-
- const name = controls['name'].value;
-
- connectionStrings[name] = {
- value: controls['value'].value,
- type: controls['type'].value
- };
-
- if (connectionStringsPristine && !group.pristine) {
- connectionStringsPristine = controls['name'].pristine && controls['value'].pristine && controls['type'].pristine;
- }
-
- if (group.value.isSlotSetting) {
- if (connectionStringNames.indexOf(name) === -1) {
- connectionStringNames.push(name);
- connectionStringNamesPristine = false;
- }
- } else {
- const index = connectionStringNames.indexOf(name);
- if (index !== -1) {
- connectionStringNames.splice(index, 1);
- connectionStringNamesPristine = false;
- }
- }
- } else {
- connectionStringsPristine = false;
- }
- })
+ protected _getConfigsFromForms(saveConfigs: ArmSaveConfigs): ArmSaveConfigs {
+ const connectionStringsArm: ArmObj = (saveConfigs && saveConfigs.connectionStringsArm) ?
+ JSON.parse(JSON.stringify(saveConfigs.connectionStringsArm)) : // TODO: [andimarc] not valid scenario - should never be already set
+ JSON.parse(JSON.stringify(this.connectionStringsArm));
+ connectionStringsArm.id = `${this.resourceId}/config/connectionStrings`;
+ connectionStringsArm.properties = {};
- if (!connectionStringNamesPristine) {
- configObjects['slotConfigNames'] = slotConfigNamesArm;
- }
- if (!connectionStringsPristine) {
- configObjects['connectionStrings'] = connectionStringsArm;
- }
- } else {
- configObjects.error = this._validationFailureMessage();
- }
+ const slotConfigNamesArm: ArmObj = (saveConfigs && saveConfigs.slotConfigNamesArm) ?
+ JSON.parse(JSON.stringify(saveConfigs.slotConfigNamesArm)) :
+ JSON.parse(JSON.stringify(this.slotConfigNamesArm));
+ slotConfigNamesArm.id = this._slotConfigNamesArmPath;
+ slotConfigNamesArm.properties.connectionStringNames = slotConfigNamesArm.properties.connectionStringNames || [];
- return configObjects;
- }
- }
+ const connectionStrings: ConnectionStrings = connectionStringsArm.properties;
+ const connectionStringNames: string[] = slotConfigNamesArm.properties.connectionStringNames;
- save(
- connectionStringsArm: ArmObj,
- slotConfigNamesResponse: Response): Observable {
+ let connectionStringsPristine = true;
+ let connectionStringNamesPristine = true;
- // Don't make unnecessary PUT call if these settings haven't been changed
- if (this.groupArray.pristine) {
- return Observable.of({
- success: true,
- error: null
- });
- } else {
- return Observable.zip(
- connectionStringsArm ? this._cacheService.putArm(`${this.resourceId}/config/connectionstrings`, null, connectionStringsArm) : Observable.of(null),
- Observable.of(slotConfigNamesResponse),
- (c, s) => ({ connectionStringsResponse: c, slotConfigNamesResponse: s })
- )
- .map(r => {
- this._connectionStringsArm = r.connectionStringsResponse ? r.connectionStringsResponse.json() : this._connectionStringsArm;
- this._slotConfigNamesArm = r.slotConfigNamesResponse ? r.slotConfigNamesResponse.json() : this._slotConfigNamesArm;
- return {
- success: true,
- error: null
- };
- })
- .catch(error => {
- this._saveError = error._body;
- return Observable.of({
- success: false,
- error: error._body
- });
- });
- }
- }
+ this.groupArray.controls.forEach(group => {
+ if ((group as CustomFormGroup).msExistenceState !== 'deleted') {
+ const controls = (group as CustomFormGroup).controls;
+
+ const name = controls['name'].value;
- private _validationFailureMessage(): string {
- const configGroupName = this._translateService.instant(PortalResources.connectionStrings);
- return this._translateService.instant(PortalResources.configUpdateFailureInvalidInput, { configGroupName: configGroupName });
+ connectionStrings[name] = {
+ value: controls['value'].value,
+ type: controls['type'].value
+ };
+
+ if (connectionStringsPristine && !group.pristine) {
+ connectionStringsPristine = controls['name'].pristine && controls['value'].pristine && controls['type'].pristine;
+ }
+
+ if (group.value.isSlotSetting) {
+ if (connectionStringNames.indexOf(name) === -1) {
+ connectionStringNames.push(name);
+ connectionStringNamesPristine = false;
+ }
+ } else {
+ const index = connectionStringNames.indexOf(name);
+ if (index !== -1) {
+ connectionStringNames.splice(index, 1);
+ connectionStringNamesPristine = false;
+ }
+ }
+ }
+ else {
+ connectionStringsPristine = false;
+ }
+ });
+
+ return {
+ connectionStringsArm: connectionStringsPristine ? null : connectionStringsArm,
+ slotConfigNamesArm: connectionStringNamesPristine ? null : slotConfigNamesArm
+ };
}
deleteItem(group: FormGroup) {
diff --git a/AzureFunctions.AngularClient/src/app/site/site-config/default-documents/default-documents.component.ts b/AzureFunctions.AngularClient/src/app/site/site-config/default-documents/default-documents.component.ts
index a56a3c05c9..202e462395 100644
--- a/AzureFunctions.AngularClient/src/app/site/site-config/default-documents/default-documents.component.ts
+++ b/AzureFunctions.AngularClient/src/app/site/site-config/default-documents/default-documents.component.ts
@@ -1,18 +1,15 @@
+import { ConfigSaveComponent, ArmSaveConfigs } from 'app/shared/components/config-save-component';
import { LogCategories } from './../../../shared/models/constants';
import { LogService } from './../../../shared/services/log.service';
import { SiteService } from 'app/shared/services/site.service';
-import { FeatureComponent } from 'app/shared/components/feature-component';
-import { BroadcastService } from './../../../shared/services/broadcast.service';
-import { Component, Input, OnChanges, OnDestroy, SimpleChanges, Injector } from '@angular/core';
+import { Component, Injector, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Observable } from 'rxjs/Observable';
import { TranslateService } from '@ngx-translate/core';
import { SiteConfig } from './../../../shared/models/arm/site-config';
-import { SaveOrValidationResult } from './../site-config.component';
import { PortalResources } from './../../../shared/models/portal-resources';
import { CustomFormControl, CustomFormGroup } from './../../../controls/click-to-edit/click-to-edit.component';
import { ArmObj, ResourceId } from './../../../shared/models/arm/arm-obj';
-import { CacheService } from './../../../shared/services/cache.service';
import { AuthzService } from './../../../shared/services/authz.service';
import { UniqueValidator } from 'app/shared/validators/uniqueValidator';
import { RequiredValidator } from 'app/shared/validators/requiredValidator';
@@ -22,7 +19,7 @@ import { RequiredValidator } from 'app/shared/validators/requiredValidator';
templateUrl: './default-documents.component.html',
styleUrls: ['./../site-config.component.scss']
})
-export class DefaultDocumentsComponent extends FeatureComponent implements OnChanges, OnDestroy {
+export class DefaultDocumentsComponent extends ConfigSaveComponent implements OnChanges, OnDestroy {
@Input() mainForm: FormGroup;
@Input() resourceId: ResourceId;
@@ -37,22 +34,18 @@ export class DefaultDocumentsComponent extends FeatureComponent impl
public newItem: CustomFormGroup;
public originalItemsDeleted: number;
- private _saveError: string;
private _requiredValidator: RequiredValidator;
private _uniqueDocumentValidator: UniqueValidator;
- private _webConfigArm: ArmObj;
constructor(
- private _cacheService: CacheService,
private _fb: FormBuilder,
private _translateService: TranslateService,
private _logService: LogService,
private _authZService: AuthzService,
private _siteService: SiteService,
- broadcastService: BroadcastService,
injector: Injector
) {
- super('DefaultDocumentComponent', injector, 'site-tabs');
+ super('DefaultDocumentComponent', injector, ['SiteConfig'], 'site-tabs');
this._resetPermissionsAndLoadingState();
@@ -60,12 +53,17 @@ export class DefaultDocumentsComponent extends FeatureComponent impl
this.originalItemsDeleted = 0;
}
+ protected get _isPristine() {
+ return this.groupArray && this.groupArray.pristine;
+ }
+
protected setup(inputEvents: Observable) {
return inputEvents
.distinctUntilChanged()
.switchMap(() => {
- this._saveError = null;
- this._webConfigArm = null;
+ this._saveFailed = false;
+ this._resetSubmittedStates();
+ this._resetConfigs();
this.groupArray = null;
this.newItem = null;
this.originalItemsDeleted = 0;
@@ -78,9 +76,9 @@ export class DefaultDocumentsComponent extends FeatureComponent impl
})
.do(results => {
if (results[0].isSuccessful) {
- this._webConfigArm = results[0].result;
+ this.siteConfigArm = results[0].result;
this._setPermissions(results[1], results[2]);
- this._setupForm(this._webConfigArm);
+ this._setupForm(this.siteConfigArm);
} else {
this._logService.error(LogCategories.defaultDocuments, '/default-documents', results[0].error.result);
this._setupForm(null);
@@ -97,15 +95,10 @@ export class DefaultDocumentsComponent extends FeatureComponent impl
this.setInput(this.resourceId);
}
if (changes['mainForm'] && !changes['resourceId']) {
- this._setupForm(this._webConfigArm);
+ this._setupForm(this.siteConfigArm);
}
}
- ngOnDestroy(): void {
- super.ngOnDestroy();
- this.clearBusy();
- }
-
private _resetPermissionsAndLoadingState() {
this.hasWritePermissions = true;
this.permissionsMessage = '';
@@ -127,10 +120,9 @@ export class DefaultDocumentsComponent extends FeatureComponent impl
this.hasWritePermissions = writePermission && !readOnlyLock;
}
-
- private _setupForm(webConfigArm: ArmObj) {
- if (!!webConfigArm) {
- if (!this._saveError || !this.groupArray) {
+ private _setupForm(siteConfigArm: ArmObj) {
+ if (!!siteConfigArm) {
+ if (!this._saveFailed || !this.groupArray) {
this.newItem = null;
this.originalItemsDeleted = 0;
this.groupArray = this._fb.array([]);
@@ -141,8 +133,8 @@ export class DefaultDocumentsComponent extends FeatureComponent impl
this.groupArray,
this._translateService.instant(PortalResources.validation_duplicateError));
- if (webConfigArm.properties.defaultDocuments) {
- webConfigArm.properties.defaultDocuments.forEach(document => {
+ if (siteConfigArm.properties.defaultDocuments) {
+ siteConfigArm.properties.defaultDocuments.forEach(document => {
const group = this._fb.group({
name: [
{ value: document, disabled: !this.hasWritePermissions },
@@ -173,10 +165,11 @@ export class DefaultDocumentsComponent extends FeatureComponent impl
}
}
- this._saveError = null;
+ this._saveFailed = false;
+ this._resetSubmittedStates();
}
- validate(): SaveOrValidationResult {
+ validate() {
const groups = this.groupArray.controls;
// Purge any added entries that were never modified
@@ -191,11 +184,6 @@ export class DefaultDocumentsComponent extends FeatureComponent impl
}
this._validateAllControls(groups as CustomFormGroup[]);
-
- return {
- success: this.groupArray.valid,
- error: this.groupArray.valid ? null : this._validationFailureMessage()
- };
}
private _validateAllControls(groups: CustomFormGroup[]) {
@@ -209,54 +197,24 @@ export class DefaultDocumentsComponent extends FeatureComponent impl
});
}
- save(): Observable {
- // Don't make unnecessary PATCH call if these settings haven't been changed
- if (this.groupArray.pristine) {
- return Observable.of({
- success: true,
- error: null
- });
- } else if (this.mainForm.contains('defaultDocs') && this.mainForm.controls['defaultDocs'].valid) {
- const defaultDocGroups = this.groupArray.controls;
+ protected _getConfigsFromForms(saveConfigs: ArmSaveConfigs): ArmSaveConfigs {
+ const siteConfigArm: ArmObj = (saveConfigs && saveConfigs.siteConfigArm) ?
+ JSON.parse(JSON.stringify(saveConfigs.siteConfigArm)) :
+ JSON.parse(JSON.stringify(this.siteConfigArm));
+ siteConfigArm.id = `${this.resourceId}/config/web`;
+ siteConfigArm.properties.defaultDocuments = [];
- const webConfigArm: ArmObj = JSON.parse(JSON.stringify(this._webConfigArm));
- webConfigArm.properties = {};
+ const defaultDocuments: string[] = siteConfigArm.properties.defaultDocuments;
- webConfigArm.properties.defaultDocuments = [];
- defaultDocGroups.forEach(group => {
- if ((group as CustomFormGroup).msExistenceState !== 'deleted') {
- webConfigArm.properties.defaultDocuments.push((group as FormGroup).controls['name'].value);
- }
- });
-
- return this._cacheService.patchArm(`${this.resourceId}/config/web`, null, webConfigArm)
- .map(webConfigResponse => {
- this._webConfigArm = webConfigResponse.json();
- return {
- success: true,
- error: null
- };
- })
- .catch(error => {
- this._saveError = error._body;
- return Observable.of({
- success: false,
- error: error._body
- });
- });
- } else {
- const failureMessage = this._validationFailureMessage();
- this._saveError = failureMessage;
- return Observable.of({
- success: false,
- error: failureMessage
- });
- }
- }
+ this.groupArray.controls.forEach(group => {
+ if ((group as CustomFormGroup).msExistenceState !== 'deleted') {
+ defaultDocuments.push((group as FormGroup).controls['name'].value);
+ }
+ });
- private _validationFailureMessage(): string {
- const configGroupName = this._translateService.instant(PortalResources.feature_defaultDocumentsName);
- return this._translateService.instant(PortalResources.configUpdateFailureInvalidInput, { configGroupName: configGroupName });
+ return {
+ siteConfigArm: siteConfigArm
+ };
}
deleteItem(group: FormGroup) {
diff --git a/AzureFunctions.AngularClient/src/app/site/site-config/general-settings/general-settings.component.ts b/AzureFunctions.AngularClient/src/app/site/site-config/general-settings/general-settings.component.ts
index 00637fc37c..6b47956f09 100644
--- a/AzureFunctions.AngularClient/src/app/site/site-config/general-settings/general-settings.component.ts
+++ b/AzureFunctions.AngularClient/src/app/site/site-config/general-settings/general-settings.component.ts
@@ -1,11 +1,10 @@
-import { FeatureComponent } from 'app/shared/components/feature-component';
+import { ConfigSaveComponent, ArmSaveConfigs } from 'app/shared/components/config-save-component';
import { Links, LogCategories } from './../../../shared/models/constants';
import { PortalService } from './../../../shared/services/portal.service';
-import { Component, Input, OnChanges, OnDestroy, SimpleChanges, Injector } from '@angular/core';
+import { Component, Injector, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Observable } from 'rxjs/Observable';
import { TranslateService } from '@ngx-translate/core';
-import { SaveOrValidationResult } from '../site-config.component';
import { Site } from 'app/shared/models/arm/site';
import { SiteConfig } from 'app/shared/models/arm/site-config';
import { AvailableStack, AvailableStackNames, AvailableStacksOsType, MajorVersion, LinuxConstants } from 'app/shared/models/arm/stacks';
@@ -16,7 +15,6 @@ import { LogService } from './../../../shared/services/log.service';
import { PortalResources } from './../../../shared/models/portal-resources';
import { CustomFormControl } from './../../../controls/click-to-edit/click-to-edit.component';
import { ArmObj, ArmArrayResult, ResourceId } from './../../../shared/models/arm/arm-obj';
-import { CacheService } from './../../../shared/services/cache.service';
import { AuthzService } from './../../../shared/services/authz.service';
import { ArmSiteDescriptor } from 'app/shared/resourceDescriptors';
@@ -29,7 +27,7 @@ import { SiteService } from 'app/shared/services/site.service';
templateUrl: './general-settings.component.html',
styleUrls: ['./../site-config.component.scss']
})
-export class GeneralSettingsComponent extends FeatureComponent implements OnChanges, OnDestroy {
+export class GeneralSettingsComponent extends ConfigSaveComponent implements OnChanges, OnDestroy {
@Input() mainForm: FormGroup;
@Input() resourceId: ResourceId;
@@ -39,7 +37,6 @@ export class GeneralSettingsComponent extends FeatureComponent imple
public permissionsMessage: string;
public showPermissionsMessage: boolean;
public showReadOnlySettingsMessage: string;
- public siteArm: ArmObj;
public loadingFailureMessage: string;
public loadingMessage: string;
public FwLinks = Links;
@@ -71,8 +68,6 @@ export class GeneralSettingsComponent extends FeatureComponent imple
public linuxRuntimeSupported = false;
public linuxFxVersionOptions: DropDownGroupElement[];
- private _saveError: string;
- private _webConfigArm: ArmObj;
private _sku: string;
private _dropDownOptionsMapClean: { [key: string]: DropDownElement[] };
@@ -85,7 +80,6 @@ export class GeneralSettingsComponent extends FeatureComponent imple
private _ignoreChildEvents = true;
constructor(
- private _cacheService: CacheService,
private _fb: FormBuilder,
private _translateService: TranslateService,
private _logService: LogService,
@@ -94,7 +88,7 @@ export class GeneralSettingsComponent extends FeatureComponent imple
private _siteService: SiteService,
injector: Injector
) {
- super('GeneralSettingsComponent', injector, 'site-tabs');
+ super('GeneralSettingsComponent', injector, ['Site', 'SiteConfig'], 'site-tabs');
this._resetSlotsInfo();
@@ -103,13 +97,17 @@ export class GeneralSettingsComponent extends FeatureComponent imple
this._generateRadioOptions();
}
+ protected get _isPristine() {
+ return this.group && this.group.pristine;
+ }
+
protected setup(inputEvents: Observable) {
return inputEvents
.distinctUntilChanged()
.switchMap(() => {
- this._saveError = null;
- this.siteArm = null;
- this._webConfigArm = null;
+ this._saveFailed = false;
+ this._resetSubmittedStates();
+ this._resetConfigs();
this.group = null;
this.dropDownOptionsMap = null;
this._ignoreChildEvents = true;
@@ -158,7 +156,7 @@ export class GeneralSettingsComponent extends FeatureComponent imple
} else {
this.siteArm = siteResult.result;
- this._webConfigArm = configResult.result;
+ this.siteConfigArm = configResult.result;
this._setPermissions(hasWritePermission, hasReadonlyLock);
@@ -170,8 +168,8 @@ export class GeneralSettingsComponent extends FeatureComponent imple
this._parseLinuxBuiltInStacks(stacksResultLinux.result);
}
- this._processSupportedControls(this.siteArm, this._webConfigArm);
- this._setupForm(this._webConfigArm, this.siteArm);
+ this._processSupportedControls(this.siteArm, this.siteConfigArm);
+ this._setupForm(this.siteConfigArm, this.siteArm);
}
this.loadingMessage = null;
@@ -184,15 +182,10 @@ export class GeneralSettingsComponent extends FeatureComponent imple
this.setInput(this.resourceId);
}
if (changes['mainForm'] && !changes['resourceId']) {
- this._setupForm(this._webConfigArm, this.siteArm);
+ this._setupForm(this.siteConfigArm, this.siteArm);
}
}
- ngOnDestroy(): void {
- super.ngOnDestroy();
- this.clearBusy();
- }
-
scaleUp() {
this.setBusy();
@@ -266,8 +259,8 @@ export class GeneralSettingsComponent extends FeatureComponent imple
this.linuxRuntimeSupported = false;
}
- private _processSupportedControls(siteConfigArm: ArmObj, webConfigArm: ArmObj) {
- if (!!siteConfigArm) {
+ private _processSupportedControls(siteArm: ArmObj, siteConfigArm: ArmObj) {
+ if (!!siteArm) {
let netFrameworkSupported = true;
let phpSupported = true;
let pythonSupported = true;
@@ -281,9 +274,9 @@ export class GeneralSettingsComponent extends FeatureComponent imple
let autoSwapSupported = true;
let linuxRuntimeSupported = false;
- this._sku = siteConfigArm.properties.sku;
+ this._sku = siteArm.properties.sku;
- if (ArmUtil.isLinuxApp(siteConfigArm)) {
+ if (ArmUtil.isLinuxApp(siteArm)) {
netFrameworkSupported = false;
phpSupported = false;
pythonSupported = false;
@@ -293,14 +286,14 @@ export class GeneralSettingsComponent extends FeatureComponent imple
classicPipelineModeSupported = false;
remoteDebuggingSupported = false;
- if ((webConfigArm.properties.linuxFxVersion || '').indexOf(LinuxConstants.dockerPrefix) === -1) {
+ if ((siteConfigArm.properties.linuxFxVersion || '').indexOf(LinuxConstants.dockerPrefix) === -1) {
linuxRuntimeSupported = true;
}
autoSwapSupported = false;
}
- if (ArmUtil.isFunctionApp(siteConfigArm)) {
+ if (ArmUtil.isFunctionApp(siteArm)) {
netFrameworkSupported = false;
pythonSupported = false;
javaSupported = false;
@@ -333,23 +326,23 @@ export class GeneralSettingsComponent extends FeatureComponent imple
}
}
- private _setupForm(webConfigArm: ArmObj, siteConfigArm: ArmObj) {
- if (!!webConfigArm && !!siteConfigArm) {
+ private _setupForm(siteConfigArm: ArmObj, siteArm: ArmObj) {
+ if (!!siteConfigArm && !!siteArm) {
this._ignoreChildEvents = true;
- if (!this._saveError || !this.group) {
+ if (!this._saveFailed || !this.group) {
const group = this._fb.group({});
const dropDownOptionsMap: { [key: string]: DropDownElement[] } = {};
const linuxFxVersionOptions: DropDownGroupElement[] = [];
- this._setupNetFramworkVersion(group, dropDownOptionsMap, webConfigArm.properties.netFrameworkVersion);
- this._setupPhpVersion(group, dropDownOptionsMap, webConfigArm.properties.phpVersion);
- this._setupPythonVersion(group, dropDownOptionsMap, webConfigArm.properties.pythonVersion);
- this._setupJava(group, dropDownOptionsMap, webConfigArm.properties.javaVersion, webConfigArm.properties.javaContainer, webConfigArm.properties.javaContainerVersion);
- this._setupGeneralSettings(group, webConfigArm, siteConfigArm);
- this._setupAutoSwapSettings(group, dropDownOptionsMap, webConfigArm.properties.autoSwapSlotName);
- this._setupLinux(group, linuxFxVersionOptions, webConfigArm.properties.linuxFxVersion, webConfigArm.properties.appCommandLine);
+ this._setupNetFramworkVersion(group, dropDownOptionsMap, siteConfigArm.properties.netFrameworkVersion);
+ this._setupPhpVersion(group, dropDownOptionsMap, siteConfigArm.properties.phpVersion);
+ this._setupPythonVersion(group, dropDownOptionsMap, siteConfigArm.properties.pythonVersion);
+ this._setupJava(group, dropDownOptionsMap, siteConfigArm.properties.javaVersion, siteConfigArm.properties.javaContainer, siteConfigArm.properties.javaContainerVersion);
+ this._setupGeneralSettings(group, siteConfigArm, siteArm);
+ this._setupAutoSwapSettings(group, dropDownOptionsMap, siteConfigArm.properties.autoSwapSlotName);
+ this._setupLinux(group, linuxFxVersionOptions, siteConfigArm.properties.linuxFxVersion, siteConfigArm.properties.appCommandLine);
this.group = group;
this.dropDownOptionsMap = dropDownOptionsMap;
@@ -377,7 +370,8 @@ export class GeneralSettingsComponent extends FeatureComponent imple
}
- this._saveError = null;
+ this._saveFailed = null;
+ this._resetSubmittedStates();
}
private _setControlsEnabledState(names: string[], enabled: boolean) {
@@ -447,26 +441,26 @@ export class GeneralSettingsComponent extends FeatureComponent imple
{ displayLabel: onString, value: true }];
}
- private _setupGeneralSettings(group: FormGroup, webConfigArm: ArmObj, siteConfigArm: ArmObj) {
+ private _setupGeneralSettings(group: FormGroup, siteConfigArm: ArmObj, siteArm: ArmObj) {
if (this.platform64BitSupported) {
- group.addControl('use32BitWorkerProcess', this._fb.control({ value: webConfigArm.properties.use32BitWorkerProcess, disabled: !this.hasWritePermissions }));
+ group.addControl('use32BitWorkerProcess', this._fb.control({ value: siteConfigArm.properties.use32BitWorkerProcess, disabled: !this.hasWritePermissions }));
}
if (this.webSocketsSupported) {
- group.addControl('webSocketsEnabled', this._fb.control({ value: webConfigArm.properties.webSocketsEnabled, disabled: !this.hasWritePermissions }));
+ group.addControl('webSocketsEnabled', this._fb.control({ value: siteConfigArm.properties.webSocketsEnabled, disabled: !this.hasWritePermissions }));
}
if (this.alwaysOnSupported) {
- group.addControl('alwaysOn', this._fb.control({ value: webConfigArm.properties.alwaysOn, disabled: !this.hasWritePermissions }));
+ group.addControl('alwaysOn', this._fb.control({ value: siteConfigArm.properties.alwaysOn, disabled: !this.hasWritePermissions }));
}
if (this.classicPipelineModeSupported) {
- group.addControl('managedPipelineMode', this._fb.control({ value: webConfigArm.properties.managedPipelineMode, disabled: !this.hasWritePermissions }));
+ group.addControl('managedPipelineMode', this._fb.control({ value: siteConfigArm.properties.managedPipelineMode, disabled: !this.hasWritePermissions }));
}
if (this.clientAffinitySupported) {
- group.addControl('clientAffinityEnabled', this._fb.control({ value: siteConfigArm.properties.clientAffinityEnabled, disabled: !this.hasWritePermissions }));
+ group.addControl('clientAffinityEnabled', this._fb.control({ value: siteArm.properties.clientAffinityEnabled, disabled: !this.hasWritePermissions }));
}
if (this.remoteDebuggingSupported) {
- group.addControl('remoteDebuggingEnabled', this._fb.control({ value: webConfigArm.properties.remoteDebuggingEnabled, disabled: !this.hasWritePermissions }));
- group.addControl('remoteDebuggingVersion', this._fb.control({ value: webConfigArm.properties.remoteDebuggingVersion, disabled: !this.hasWritePermissions }));
- setTimeout(() => { this._setControlsEnabledState(['remoteDebuggingVersion'], webConfigArm.properties.remoteDebuggingEnabled && this.hasWritePermissions); }, 0);
+ group.addControl('remoteDebuggingEnabled', this._fb.control({ value: siteConfigArm.properties.remoteDebuggingEnabled, disabled: !this.hasWritePermissions }));
+ group.addControl('remoteDebuggingVersion', this._fb.control({ value: siteConfigArm.properties.remoteDebuggingVersion, disabled: !this.hasWritePermissions }));
+ setTimeout(() => { this._setControlsEnabledState(['remoteDebuggingVersion'], siteConfigArm.properties.remoteDebuggingEnabled && this.hasWritePermissions); }, 0);
}
}
@@ -725,7 +719,6 @@ export class GeneralSettingsComponent extends FeatureComponent imple
}
const javaWebContainerControl = this._fb.control({ value: defaultJavaWebContainer, disabled: !this.hasWritePermissions });
-
group.addControl('javaVersion', javaVersionControl);
group.addControl('javaMinorVersion', javaMinorVersionControl);
group.addControl('javaWebContainer', javaWebContainerControl);
@@ -750,7 +743,6 @@ export class GeneralSettingsComponent extends FeatureComponent imple
let defaultJavaWebContainer: string;
let javaWebContainerNeedsUpdate = false;
-
if (!javaVersion) {
if (previousJavaVersionSelection) {
javaMinorVersionOptions = [];
@@ -1041,117 +1033,99 @@ export class GeneralSettingsComponent extends FeatureComponent imple
}
}
- validate(): SaveOrValidationResult {
+ validate() {
let controls = this.group.controls;
for (const controlName in controls) {
const control = controls[controlName];
control._msRunValidation = true;
control.updateValueAndValidity();
}
-
- return {
- success: this.group.valid,
- error: this.group.valid ? null : this._validationFailureMessage()
- };
}
- save(): Observable {
- // Don't make unnecessary PATCH call if these settings haven't been changed
- if (this.group.pristine) {
- return Observable.of({
- success: true,
- error: null
- });
- } else if (this.mainForm.contains('generalSettings') && this.mainForm.controls['generalSettings'].valid) {
- const generalSettingsControls = this.group.controls;
+ protected _getConfigsFromForms(saveConfigs: ArmSaveConfigs): ArmSaveConfigs {
+ const generalSettingsControls = this.group.controls;
- // level: site
- const siteConfigArm: ArmObj = JSON.parse(JSON.stringify(this.siteArm));
+ let sitePristine = true;
+ let siteConfigPristine = true;
+
+ for (let name in generalSettingsControls) {
+ if (generalSettingsControls[name].dirty) {
+ if (name === 'clientAffinityEnabled') {
+ sitePristine = false;
+ } else {
+ siteConfigPristine = false;
+ }
+ }
+ }
+
+ let siteArm: ArmObj = null;
+
+ if (!sitePristine) {
+ siteArm = (saveConfigs && saveConfigs.siteArm) ?
+ JSON.parse(JSON.stringify(saveConfigs.siteArm)) :
+ JSON.parse(JSON.stringify(this.siteArm));
+ siteArm.id = this.resourceId;
if (this.clientAffinitySupported) {
- const clientAffinityEnabled = (generalSettingsControls['clientAffinityEnabled'].value);
- siteConfigArm.properties.clientAffinityEnabled = clientAffinityEnabled;
+ siteArm.properties.clientAffinityEnabled = (generalSettingsControls['clientAffinityEnabled'].value);;
}
+ }
- // level: site/config/web
- const webConfigArm: ArmObj = JSON.parse(JSON.stringify(this._webConfigArm));
- webConfigArm.properties = {};
+ let siteConfigArm: ArmObj = null;
+
+ if (!siteConfigPristine) {
+ siteConfigArm = (saveConfigs && saveConfigs.siteConfigArm) ?
+ JSON.parse(JSON.stringify(saveConfigs.siteConfigArm)) :
+ JSON.parse(JSON.stringify(this.siteConfigArm));
+ siteConfigArm.id = `${this.resourceId}/config/web`;
// -- non-stack settings --
if (this.platform64BitSupported) {
- webConfigArm.properties.use32BitWorkerProcess = (generalSettingsControls['use32BitWorkerProcess'].value);
+ siteConfigArm.properties.use32BitWorkerProcess = (generalSettingsControls['use32BitWorkerProcess'].value);
}
if (this.webSocketsSupported) {
- webConfigArm.properties.webSocketsEnabled = (generalSettingsControls['webSocketsEnabled'].value);
+ siteConfigArm.properties.webSocketsEnabled = (generalSettingsControls['webSocketsEnabled'].value);
}
if (this.alwaysOnSupported) {
- webConfigArm.properties.alwaysOn = (generalSettingsControls['alwaysOn'].value);
+ siteConfigArm.properties.alwaysOn = (generalSettingsControls['alwaysOn'].value);
}
if (this.classicPipelineModeSupported) {
- webConfigArm.properties.managedPipelineMode = (generalSettingsControls['managedPipelineMode'].value);
+ siteConfigArm.properties.managedPipelineMode = (generalSettingsControls['managedPipelineMode'].value);
}
if (this.remoteDebuggingSupported) {
- webConfigArm.properties.remoteDebuggingEnabled = (generalSettingsControls['remoteDebuggingEnabled'].value);
- webConfigArm.properties.remoteDebuggingVersion = (generalSettingsControls['remoteDebuggingVersion'].value);
+ siteConfigArm.properties.remoteDebuggingEnabled = (generalSettingsControls['remoteDebuggingEnabled'].value);
+ siteConfigArm.properties.remoteDebuggingVersion = (generalSettingsControls['remoteDebuggingVersion'].value);
}
if (this.autoSwapSupported) {
const autoSwapEnabled = (generalSettingsControls['autoSwapEnabled'].value);
- webConfigArm.properties.autoSwapSlotName = autoSwapEnabled ? (generalSettingsControls['autoSwapSlotName'].value) : '';
+ siteConfigArm.properties.autoSwapSlotName = autoSwapEnabled ? (generalSettingsControls['autoSwapSlotName'].value) : '';
}
// -- stacks settings --
if (this.netFrameworkSupported) {
- webConfigArm.properties.netFrameworkVersion = (generalSettingsControls['netFrameworkVersion'].value);
+ siteConfigArm.properties.netFrameworkVersion = (generalSettingsControls['netFrameworkVersion'].value);
}
if (this.phpSupported) {
- webConfigArm.properties.phpVersion = (generalSettingsControls['phpVersion'].value);
+ siteConfigArm.properties.phpVersion = (generalSettingsControls['phpVersion'].value);
}
if (this.pythonSupported) {
- webConfigArm.properties.pythonVersion = (generalSettingsControls['pythonVersion'].value);
+ siteConfigArm.properties.pythonVersion = (generalSettingsControls['pythonVersion'].value);
}
if (this.javaSupported) {
- webConfigArm.properties.javaVersion = (generalSettingsControls['javaMinorVersion'].value) || (generalSettingsControls['javaVersion'].value) || '';
+ siteConfigArm.properties.javaVersion = (generalSettingsControls['javaMinorVersion'].value) || (generalSettingsControls['javaVersion'].value) || '';
const javaWebContainerProperties: JavaWebContainerProperties = JSON.parse((generalSettingsControls['javaWebContainer'].value));
- webConfigArm.properties.javaContainer = !webConfigArm.properties.javaVersion ? '' : (javaWebContainerProperties.container || '');
- webConfigArm.properties.javaContainerVersion = !webConfigArm.properties.javaVersion ? '' : (javaWebContainerProperties.containerMinorVersion || javaWebContainerProperties.containerMajorVersion || '');
+ siteConfigArm.properties.javaContainer = !siteConfigArm.properties.javaVersion ? '' : (javaWebContainerProperties.container || '');
+ siteConfigArm.properties.javaContainerVersion = !siteConfigArm.properties.javaVersion ? '' : (javaWebContainerProperties.containerMinorVersion || javaWebContainerProperties.containerMajorVersion || '');
}
if (this.linuxRuntimeSupported) {
- webConfigArm.properties.linuxFxVersion = (generalSettingsControls['linuxFxVersion'].value);
- webConfigArm.properties.appCommandLine = (generalSettingsControls['appCommandLine'].value);
+ siteConfigArm.properties.linuxFxVersion = (generalSettingsControls['linuxFxVersion'].value);
+ siteConfigArm.properties.appCommandLine = (generalSettingsControls['appCommandLine'].value);
}
-
- return Observable.zip(
- this._cacheService.putArm(`${this.resourceId}`, null, siteConfigArm),
- this._cacheService.patchArm(`${this.resourceId}/config/web`, null, webConfigArm),
- (c, w) => ({ siteConfigResponse: c, webConfigResponse: w })
- )
- .map(r => {
- this.siteArm = r.siteConfigResponse.json();
- this._webConfigArm = r.webConfigResponse.json();
- return {
- success: true,
- error: null
- };
- })
- .catch(error => {
- this._saveError = error._body;
- return Observable.of({
- success: false,
- error: error._body
- });
- });
- } else {
- const failureMessage = this._validationFailureMessage();
- this._saveError = failureMessage;
- return Observable.of({
- success: false,
- error: failureMessage
- });
}
- }
- private _validationFailureMessage(): string {
- const configGroupName = this._translateService.instant(PortalResources.feature_generalSettingsName);
- return this._translateService.instant(PortalResources.configUpdateFailureInvalidInput, { configGroupName: configGroupName });
+ return {
+ siteArm: siteArm,
+ siteConfigArm: siteConfigArm
+ };
}
}
diff --git a/AzureFunctions.AngularClient/src/app/site/site-config/handler-mappings/handler-mappings.component.ts b/AzureFunctions.AngularClient/src/app/site/site-config/handler-mappings/handler-mappings.component.ts
index 2cca0d2735..194a8b8a69 100644
--- a/AzureFunctions.AngularClient/src/app/site/site-config/handler-mappings/handler-mappings.component.ts
+++ b/AzureFunctions.AngularClient/src/app/site/site-config/handler-mappings/handler-mappings.component.ts
@@ -1,19 +1,16 @@
+import { ConfigSaveComponent, ArmSaveConfigs } from 'app/shared/components/config-save-component';
import { LogService } from './../../../shared/services/log.service';
import { LogCategories } from './../../../shared/models/constants';
import { SiteService } from './../../../shared/services/site.service';
-import { Injector } from '@angular/core';
-import { FeatureComponent } from 'app/shared/components/feature-component';
-import { BroadcastService } from './../../../shared/services/broadcast.service';
-import { Component, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
+import { Component, Injector, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
import { FormArray, FormBuilder, FormGroup } from '@angular/forms';
import { Observable } from 'rxjs/Observable';
import { TranslateService } from '@ngx-translate/core';
+import { HandlerMapping } from './../../../shared/models/arm/handler-mapping';
import { SiteConfig } from './../../../shared/models/arm/site-config';
-import { SaveOrValidationResult } from './../site-config.component';
import { PortalResources } from './../../../shared/models/portal-resources';
import { CustomFormControl, CustomFormGroup } from './../../../controls/click-to-edit/click-to-edit.component';
import { ArmObj, ResourceId } from './../../../shared/models/arm/arm-obj';
-import { CacheService } from './../../../shared/services/cache.service';
import { AuthzService } from './../../../shared/services/authz.service';
import { RequiredValidator } from 'app/shared/validators/requiredValidator';
@@ -22,7 +19,7 @@ import { RequiredValidator } from 'app/shared/validators/requiredValidator';
templateUrl: './handler-mappings.component.html',
styleUrls: ['./../site-config.component.scss']
})
-export class HandlerMappingsComponent extends FeatureComponent implements OnChanges, OnDestroy {
+export class HandlerMappingsComponent extends ConfigSaveComponent implements OnChanges, OnDestroy {
@Input() mainForm: FormGroup;
@Input() resourceId: ResourceId;
@@ -37,21 +34,17 @@ export class HandlerMappingsComponent extends FeatureComponent imple
public newItem: CustomFormGroup;
public originalItemsDeleted: number;
- private _saveError: string;
private _requiredValidator: RequiredValidator;
- private _webConfigArm: ArmObj;
constructor(
- private _cacheService: CacheService,
private _fb: FormBuilder,
private _translateService: TranslateService,
private _logService: LogService,
private _authZService: AuthzService,
- broadcastService: BroadcastService,
private _siteService: SiteService,
injector: Injector
) {
- super('HandlerMappingsComponent', injector, 'site-tabs');
+ super('HandlerMappingsComponent', injector, ['SiteConfig'], 'site-tabs');
this._resetPermissionsAndLoadingState();
@@ -62,8 +55,9 @@ export class HandlerMappingsComponent extends FeatureComponent imple
protected setup(inputEvents: Observable) {
return inputEvents.distinctUntilChanged()
.switchMap(() => {
- this._saveError = null;
- this._webConfigArm = null;
+ this._saveFailed = false;
+ this._resetSubmittedStates();
+ this._resetConfigs();
this.groupArray = null;
this.newItem = null;
this.originalItemsDeleted = 0;
@@ -79,9 +73,9 @@ export class HandlerMappingsComponent extends FeatureComponent imple
this._setupForm(null);
this.loadingFailureMessage = this._translateService.instant(PortalResources.configLoadFailure);
} else {
- this._webConfigArm = results[0].result;
+ this.siteConfigArm = results[0].result;
this._setPermissions(results[1], results[2]);
- this._setupForm(this._webConfigArm);
+ this._setupForm(this.siteConfigArm);
}
this.loadingMessage = null;
@@ -89,20 +83,19 @@ export class HandlerMappingsComponent extends FeatureComponent imple
});
}
+ protected get _isPristine() {
+ return this.groupArray && this.groupArray.pristine;
+ }
+
ngOnChanges(changes: SimpleChanges) {
if (changes['resourceId']) {
this.setInput(this.resourceId);
}
if (changes['mainForm'] && !changes['resourceId']) {
- this._setupForm(this._webConfigArm);
+ this._setupForm(this.siteConfigArm);
}
}
- ngOnDestroy(): void {
- super.ngOnDestroy();
- this.clearBusy();
- }
-
private _resetPermissionsAndLoadingState() {
this.hasWritePermissions = true;
this.permissionsMessage = '';
@@ -124,18 +117,17 @@ export class HandlerMappingsComponent extends FeatureComponent imple
this.hasWritePermissions = writePermission && !readOnlyLock;
}
-
- private _setupForm(webConfigArm: ArmObj) {
- if (!!webConfigArm) {
- if (!this._saveError || !this.groupArray) {
+ private _setupForm(siteConfigArm: ArmObj) {
+ if (!!siteConfigArm) {
+ if (!this._saveFailed || !this.groupArray) {
this.newItem = null;
this.originalItemsDeleted = 0;
this.groupArray = this._fb.array([]);
this._requiredValidator = new RequiredValidator(this._translateService);
- if (webConfigArm.properties.handlerMappings) {
- webConfigArm.properties.handlerMappings.forEach(mapping => {
+ if (siteConfigArm.properties.handlerMappings) {
+ siteConfigArm.properties.handlerMappings.forEach(mapping => {
const group = this._fb.group({
extension: [{ value: mapping.extension, disabled: !this.hasWritePermissions }, this._requiredValidator.validate.bind(this._requiredValidator)],
scriptProcessor: [{ value: mapping.scriptProcessor, disabled: !this.hasWritePermissions }, this._requiredValidator.validate.bind(this._requiredValidator)],
@@ -164,10 +156,11 @@ export class HandlerMappingsComponent extends FeatureComponent imple
}
}
- this._saveError = null;
+ this._saveFailed = false;
+ this._resetSubmittedStates();
}
- validate(): SaveOrValidationResult {
+ validate() {
const groups = this.groupArray.controls;
// Purge any added entries that were never modified
@@ -182,11 +175,6 @@ export class HandlerMappingsComponent extends FeatureComponent imple
}
this._validateAllControls(groups as CustomFormGroup[]);
-
- return {
- success: this.groupArray.valid,
- error: this.groupArray.valid ? null : this._validationFailureMessage()
- };
}
private _validateAllControls(groups: CustomFormGroup[]) {
@@ -200,59 +188,29 @@ export class HandlerMappingsComponent extends FeatureComponent imple
});
}
- save(): Observable {
- // Don't make unnecessary PATCH call if these settings haven't been changed
- if (this.groupArray.pristine) {
- return Observable.of({
- success: true,
- error: null
- });
- } else if (this.mainForm.contains('handlerMappings') && this.mainForm.controls['handlerMappings'].valid) {
- const handlerMappingGroups = this.groupArray.controls;
-
- const webConfigArm: ArmObj = JSON.parse(JSON.stringify(this._webConfigArm));
- webConfigArm.properties = {};
-
- webConfigArm.properties.handlerMappings = [];
- handlerMappingGroups.forEach(group => {
- if ((group as CustomFormGroup).msExistenceState !== 'deleted') {
- const formGroup: FormGroup = group as FormGroup;
- webConfigArm.properties.handlerMappings.push({
- extension: formGroup.controls['extension'].value,
- scriptProcessor: formGroup.controls['scriptProcessor'].value,
- arguments: formGroup.controls['arguments'].value,
- });
- }
- });
-
- return this._cacheService.patchArm(`${this.resourceId}/config/web`, null, webConfigArm)
- .map(webConfigResponse => {
- this._webConfigArm = webConfigResponse.json();
- return {
- success: true,
- error: null
- };
- })
- .catch(error => {
- this._saveError = error._body;
- return Observable.of({
- success: false,
- error: error._body
- });
+ protected _getConfigsFromForms(saveConfigs: ArmSaveConfigs): ArmSaveConfigs {
+ const siteConfigArm: ArmObj = (saveConfigs && saveConfigs.siteConfigArm) ?
+ JSON.parse(JSON.stringify(saveConfigs.siteConfigArm)) :
+ JSON.parse(JSON.stringify(this.siteConfigArm));
+ siteConfigArm.id = `${this.resourceId}/config/web`;
+ siteConfigArm.properties.handlerMappings = [];
+
+ const handlerMappings: HandlerMapping[] = siteConfigArm.properties.handlerMappings;
+
+ this.groupArray.controls.forEach(group => {
+ if ((group as CustomFormGroup).msExistenceState !== 'deleted') {
+ const formGroup: FormGroup = group as FormGroup;
+ handlerMappings.push({
+ extension: formGroup.controls['extension'].value,
+ scriptProcessor: formGroup.controls['scriptProcessor'].value,
+ arguments: formGroup.controls['arguments'].value,
});
- } else {
- const failureMessage = this._validationFailureMessage();
- this._saveError = failureMessage;
- return Observable.of({
- success: false,
- error: failureMessage
- });
- }
- }
+ }
+ });
- private _validationFailureMessage(): string {
- const configGroupName = this._translateService.instant(PortalResources.feature_handlerMappingsName);
- return this._translateService.instant(PortalResources.configUpdateFailureInvalidInput, { configGroupName: configGroupName });
+ return {
+ siteConfigArm: siteConfigArm
+ };
}
deleteItem(group: FormGroup) {
diff --git a/AzureFunctions.AngularClient/src/app/site/site-config/site-config.component.ts b/AzureFunctions.AngularClient/src/app/site/site-config/site-config.component.ts
index 68aa567246..00e41c5af6 100644
--- a/AzureFunctions.AngularClient/src/app/site/site-config/site-config.component.ts
+++ b/AzureFunctions.AngularClient/src/app/site/site-config/site-config.component.ts
@@ -1,13 +1,9 @@
import { SiteService } from 'app/shared/services/site.service';
-import { Injector } from '@angular/core';
import { Observable } from 'rxjs/Rx';
import { CacheService } from 'app/shared/services/cache.service';
import { Site } from './../../shared/models/arm/site';
-import { ApplicationSettings } from './../../shared/models/arm/application-settings';
-import { ConnectionStrings } from './../../shared/models/arm/connection-strings';
-import { SlotConfigNames } from './../../shared/models/arm/slot-config-names';
-import { ArmObj, ArmObjMap } from './../../shared/models/arm/arm-obj';
-import { Component, Input, OnDestroy, ViewChild } from '@angular/core';
+import { ArmObj } from './../../shared/models/arm/arm-obj';
+import { Component, Injector, Input, OnDestroy, ViewChild } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Subscription as RxSubscription } from 'rxjs/Subscription';
import { TranslateService } from '@ngx-translate/core';
@@ -25,6 +21,7 @@ import { LogCategories, SiteTabIds } from './../../shared/models/constants';
import { LogService } from './../../shared/services/log.service';
import { ArmUtil } from 'app/shared/Utilities/arm-utils';
import { FeatureComponent } from 'app/shared/components/feature-component';
+import { ArmSaveConfigs, ArmSaveResult, ArmSaveResults } from 'app/shared/components/config-save-component';
export interface SaveOrValidationResult {
success: boolean;
@@ -162,7 +159,21 @@ export class SiteConfigComponent extends FeatureComponent
this.setBusy();
let notificationId = null;
- let saveAttempted = false;
+
+ const saveConfigs: ArmSaveConfigs = {};
+
+ this.generalSettings.getSaveConfigs(saveConfigs);
+ this.appSettings.getSaveConfigs(saveConfigs);
+ this.connectionStrings.getSaveConfigs(saveConfigs);
+ if (this.defaultDocumentsSupported) {
+ this.defaultDocuments.getSaveConfigs(saveConfigs);
+ }
+ if (this.handlerMappingsSupported) {
+ this.handlerMappings.getSaveConfigs(saveConfigs);
+ }
+ if (this.virtualDirectoriesSupported) {
+ this.virtualDirectories.getSaveConfigs(saveConfigs);
+ }
this._portalService.startNotification(
this._translateService.instant(PortalResources.configUpdating),
@@ -171,122 +182,50 @@ export class SiteConfigComponent extends FeatureComponent
.switchMap(s => {
notificationId = s.id;
- // This is a temporary workaround for merging the slotConfigNames config from AppSettingsModule and ConnectionStringsModule.
- // Adding a proper solution (for all config APIs) is tracked here: /~https://github.com/Azure/azure-functions-ux/issues/1856
- const asConfig: ArmObjMap = this.appSettings.getConfigForSave();
- const csConfig: ArmObjMap = this.connectionStrings.getConfigForSave();
-
- let appSettingsArm: ArmObj = null;
- let asSlotConfigNamesArm: ArmObj = null;
-
- let connectionStringsArm: ArmObj = null;
- let csSlotConfigNamesArm: ArmObj = null;
-
- let slotConfigNamesArm: ArmObj = null;
-
- const errors: string[] = [];
-
- // asConfig will be null if neither /config/appSettings or /config/slotConfigNames.appSettingNames have changes to be saved
- if (asConfig) {
- if (asConfig['appSettings']) {
- // there are changes to be saved for /config/appSettings
- appSettingsArm = asConfig['appSettings'];
- }
- if (asConfig['slotConfigNames']) {
- // there are changes to be saved for /config/slotConfigNames.appSettingNames
- asSlotConfigNamesArm = JSON.parse(JSON.stringify(asConfig['slotConfigNames']));
- }
- if (asConfig.error) {
- errors.push(asConfig.error);
- }
- }
-
- // csConfig will be null if neither /config/connectionStrings or /config/slotConfigNames.connectionStringNames have changes to be saved
- if (csConfig) {
- if (csConfig['connectionStrings']) {
- // there are changes to be saved for /config/appSettings
- connectionStringsArm = csConfig['connectionStrings'];
- }
- if (csConfig['slotConfigNames']) {
- // there are changes to be saved for /config/slotConfigNames.connectionStringNames
- csSlotConfigNamesArm = JSON.parse(JSON.stringify(csConfig['slotConfigNames']));
- }
- if (csConfig.error) {
- errors.push(csConfig.error);
- }
- }
-
- if (errors.length > 0) {
- return Observable.throw(errors);
- }
-
- if (asSlotConfigNamesArm && csSlotConfigNamesArm) {
- // If there are changes to both /config/slotConfigNames.appSettingNames and /config/slotConfigNames.connectionStringNames,
- // so merge the changes into a single /config/slotConfigNames payload.
- slotConfigNamesArm = asSlotConfigNamesArm;
- slotConfigNamesArm.properties.connectionStringNames = csSlotConfigNamesArm.properties.connectionStringNames;
- } else {
- // At most one of the /config/slotConfigNames.* properties has changes. Select the config that has changes (or null if neither has changes).
- slotConfigNamesArm = asSlotConfigNamesArm || csSlotConfigNamesArm;
- }
-
- return Observable.zip(
- // Don't make the PUT call if there are no /config/slotConfigNames to submit.
- slotConfigNamesArm ? this._cacheService.putArm(slotConfigNamesArm.id, null, slotConfigNamesArm) : Observable.of(null),
- Observable.of(appSettingsArm),
- Observable.of(connectionStringsArm),
- (sc, a, c) => ({ slotConfigNamesResult: sc, appSettingsArm: a, connectionStringsArm: c })
- );
- })
- .mergeMap(r => {
- saveAttempted = true;
return Observable.zip(
- this.generalSettings.save(),
- this.appSettings.save(r.appSettingsArm, r.slotConfigNamesResult),
- this.connectionStrings.save(r.connectionStringsArm, r.slotConfigNamesResult),
- this.defaultDocumentsSupported ? this.defaultDocuments.save() : Observable.of({ success: true }),
- this.handlerMappingsSupported ? this.handlerMappings.save() : Observable.of({ success: true }),
- this.virtualDirectoriesSupported ? this.virtualDirectories.save() : Observable.of({ success: true }),
- (g, a, c, d, h, v) => ({
- generalSettingsResult: g,
- appSettingsResult: a,
- connectionStringsResult: c,
- defaultDocumentsResult: d,
- handlerMappingsResult: h,
- virtualDirectoriesResult: v
- })
+ this._putArm(saveConfigs.appSettingsArm),
+ this._putArm(saveConfigs.connectionStringsArm),
+ this._putArm(saveConfigs.siteArm),
+ this._putArm(saveConfigs.siteConfigArm),
+ this._putArm(saveConfigs.slotConfigNamesArm)
);
})
- .do(null, error => {
+ .subscribe(results => {
this.dirtyMessage = null;
- this._logService.error(LogCategories.siteConfig, '/site-config', error);
this.clearBusy();
- if (saveAttempted) {
- this._setupForm(true /*retain dirty state*/);
- this.mainForm.markAsDirty();
+
+ const saveResults: ArmSaveResults = {
+ appSettings: results[0],
+ connectionStrings: results[1],
+ site: results[2],
+ siteConfig: results[3],
+ slotConfigNames: results[4]
+ }
+
+ this.generalSettings.processSaveResults(saveResults);
+ this.appSettings.processSaveResults(saveResults);
+ this.connectionStrings.processSaveResults(saveResults);
+ if (this.defaultDocumentsSupported) {
+ this.defaultDocuments.processSaveResults(saveResults);
+ }
+ if (this.handlerMappingsSupported) {
+ this.handlerMappings.processSaveResults(saveResults);
+ }
+ if (this.virtualDirectoriesSupported) {
+ this.virtualDirectories.processSaveResults(saveResults);
}
- this._portalService.stopNotification(
- notificationId,
- false,
- this._translateService.instant(PortalResources.configUpdateFailure) + JSON.stringify(error));
- })
- .subscribe(r => {
- this.dirtyMessage = null;
- this.clearBusy();
- const saveResults: SaveOrValidationResult[] = [
- r.generalSettingsResult,
- r.appSettingsResult,
- r.connectionStringsResult,
- r.defaultDocumentsResult,
- r.handlerMappingsResult,
- r.virtualDirectoriesResult
- ];
- const saveFailures: string[] = saveResults.filter(res => !res.success).map(res => res.error);
- const saveSuccess: boolean = saveFailures.length === 0;
+ const saveErrors: string[] = [];
+ results.forEach(result => {
+ if (result && !result.success) {
+ saveErrors.push(result.error);
+ this._logService.error(LogCategories.siteConfig, '/site-config', result.error);
+ }
+ });
+ const saveSuccess: boolean = !saveErrors || saveErrors.length === 0;
const saveNotification = saveSuccess ?
this._translateService.instant(PortalResources.configUpdateSuccess) :
- this._translateService.instant(PortalResources.configUpdateFailure) + JSON.stringify(saveFailures);
+ this._translateService.instant(PortalResources.configUpdateFailure) + JSON.stringify(saveErrors);
// Even if the save failed, we still need to regenerate mainForm since each child component is saves independently, maintaining its own save state.
// Here we regenerate mainForm (and mark it as dirty on failure), which triggers _setupForm() to run on the child components. In _setupForm(), the child components
@@ -301,7 +240,30 @@ export class SiteConfigComponent extends FeatureComponent
}
}
+ private _putArm(armObj: ArmObj): Observable> {
+ if (!armObj) {
+ return Observable.of(null);
+ }
+
+ return this._cacheService.putArm(armObj.id, null, armObj)
+ .map(res => {
+ return {
+ success: true,
+ value: res.json()
+ };
+ })
+ .catch(error => {
+ return Observable.of({
+ success: false,
+ error: error._body
+ });
+ });
+ }
+
discard() {
- this._setupForm();
+ const proceed = confirm(this._translateService.instant(PortalResources.unsavedChangesWarning));
+ if (proceed) {
+ this._setupForm();
+ }
}
}
diff --git a/AzureFunctions.AngularClient/src/app/site/site-config/virtual-directories/virtual-directories.component.ts b/AzureFunctions.AngularClient/src/app/site/site-config/virtual-directories/virtual-directories.component.ts
index a2050d3779..1c8ea5b2e5 100644
--- a/AzureFunctions.AngularClient/src/app/site/site-config/virtual-directories/virtual-directories.component.ts
+++ b/AzureFunctions.AngularClient/src/app/site/site-config/virtual-directories/virtual-directories.component.ts
@@ -1,20 +1,16 @@
+import { ConfigSaveComponent, ArmSaveConfigs } from 'app/shared/components/config-save-component';
import { LogService } from './../../../shared/services/log.service';
import { LogCategories } from './../../../shared/models/constants';
import { SiteService } from 'app/shared/services/site.service';
-import { Injector } from '@angular/core';
-import { FeatureComponent } from 'app/shared/components/feature-component';
-import { BroadcastService } from './../../../shared/services/broadcast.service';
-import { Component, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
+import { Component, Injector, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Observable } from 'rxjs/Observable';
import { TranslateService } from '@ngx-translate/core';
import { VirtualApplication, VirtualDirectory } from './../../../shared/models/arm/virtual-application';
import { SiteConfig } from './../../../shared/models/arm/site-config';
-import { SaveOrValidationResult } from './../site-config.component';
import { PortalResources } from './../../../shared/models/portal-resources';
import { CustomFormControl, CustomFormGroup } from './../../../controls/click-to-edit/click-to-edit.component';
import { ArmObj, ResourceId } from './../../../shared/models/arm/arm-obj';
-import { CacheService } from './../../../shared/services/cache.service';
import { AuthzService } from './../../../shared/services/authz.service';
import { UniqueValidator } from 'app/shared/validators/uniqueValidator';
import { RequiredValidator } from 'app/shared/validators/requiredValidator';
@@ -24,7 +20,7 @@ import { RequiredValidator } from 'app/shared/validators/requiredValidator';
templateUrl: './virtual-directories.component.html',
styleUrls: ['./../site-config.component.scss']
})
-export class VirtualDirectoriesComponent extends FeatureComponent implements OnChanges, OnDestroy {
+export class VirtualDirectoriesComponent extends ConfigSaveComponent implements OnChanges, OnDestroy {
@Input() mainForm: FormGroup;
@Input() resourceId: ResourceId;
@@ -39,22 +35,18 @@ export class VirtualDirectoriesComponent extends FeatureComponent im
public newItem: CustomFormGroup;
public originalItemsDeleted: number;
- private _saveError: string;
private _requiredValidator: RequiredValidator;
private _uniqueValidator: UniqueValidator;
- private _webConfigArm: ArmObj;
constructor(
- private _cacheService: CacheService,
private _fb: FormBuilder,
private _translateService: TranslateService,
private _logService: LogService,
private _authZService: AuthzService,
private _siteService: SiteService,
- broadcastService: BroadcastService,
injector: Injector
) {
- super('VirtualDirectoriesComponent', injector, 'site-tabs');
+ super('VirtualDirectoriesComponent', injector, ['SiteConfig'], 'site-tabs');
this._resetPermissionsAndLoadingState();
@@ -62,12 +54,17 @@ export class VirtualDirectoriesComponent extends FeatureComponent im
this.originalItemsDeleted = 0;
}
+ protected get _isPristine() {
+ return this.groupArray && this.groupArray.pristine;
+ }
+
protected setup(inputEvents: Observable) {
return inputEvents
.distinctUntilChanged()
.switchMap(() => {
- this._saveError = null;
- this._webConfigArm = null;
+ this._saveFailed = false;
+ this._resetSubmittedStates();
+ this._resetConfigs();
this.groupArray = null;
this.newItem = null;
this.originalItemsDeleted = 0;
@@ -84,9 +81,9 @@ export class VirtualDirectoriesComponent extends FeatureComponent im
this._setupForm(null);
this.loadingFailureMessage = this._translateService.instant(PortalResources.configLoadFailure);
} else {
- this._webConfigArm = results[0].result;
+ this.siteConfigArm = results[0].result;
this._setPermissions(results[1], results[2]);
- this._setupForm(this._webConfigArm);
+ this._setupForm(this.siteConfigArm);
}
this.loadingMessage = null;
@@ -99,15 +96,10 @@ export class VirtualDirectoriesComponent extends FeatureComponent im
this.setInput(this.resourceId);
}
if (changes['mainForm'] && !changes['resourceId']) {
- this._setupForm(this._webConfigArm);
+ this._setupForm(this.siteConfigArm);
}
}
- ngOnDestroy(): void {
- super.ngOnDestroy();
- this.clearBusy();
- }
-
private _resetPermissionsAndLoadingState() {
this.hasWritePermissions = true;
this.permissionsMessage = '';
@@ -129,9 +121,9 @@ export class VirtualDirectoriesComponent extends FeatureComponent im
this.hasWritePermissions = writePermission && !readOnlyLock;
}
- private _setupForm(webConfigArm: ArmObj) {
- if (!!webConfigArm) {
- if (!this._saveError || !this.groupArray) {
+ private _setupForm(siteConfigArm: ArmObj) {
+ if (!!siteConfigArm) {
+ if (!this._saveFailed || !this.groupArray) {
this.newItem = null;
this.originalItemsDeleted = 0;
this.groupArray = this._fb.array([]);
@@ -143,8 +135,8 @@ export class VirtualDirectoriesComponent extends FeatureComponent im
this._translateService.instant(PortalResources.validation_duplicateError),
this._getNormalizedVirtualPath);
- if (webConfigArm.properties.virtualApplications) {
- webConfigArm.properties.virtualApplications.forEach(virtualApplication => {
+ if (siteConfigArm.properties.virtualApplications) {
+ siteConfigArm.properties.virtualApplications.forEach(virtualApplication => {
this._addVDirToGroup(
virtualApplication.virtualPath,
virtualApplication.physicalPath,
@@ -179,7 +171,8 @@ export class VirtualDirectoriesComponent extends FeatureComponent im
}
}
- this._saveError = null;
+ this._saveFailed = false;
+ this._resetSubmittedStates();
}
private _getNormalizedVirtualPath(virtualPath: string): string {
@@ -219,7 +212,7 @@ export class VirtualDirectoriesComponent extends FeatureComponent im
return basePathAdjusted + subPathAdjusted;
}
- validate(): SaveOrValidationResult {
+ validate() {
const groups = this.groupArray.controls;
// Purge any added entries that were never modified
@@ -234,11 +227,6 @@ export class VirtualDirectoriesComponent extends FeatureComponent im
}
this._validateAllControls(groups as CustomFormGroup[]);
-
- return {
- success: this.groupArray.valid,
- error: this.groupArray.valid ? null : this._validationFailureMessage()
- };
}
private _validateAllControls(groups: CustomFormGroup[]) {
@@ -252,88 +240,56 @@ export class VirtualDirectoriesComponent extends FeatureComponent im
});
}
- save(): Observable {
- // Don't make unnecessary PATCH call if these settings haven't been changed
- if (this.groupArray.pristine) {
- return Observable.of({
- success: true,
- error: null
- });
- } else if (this.mainForm.contains('virtualDirectories') && this.mainForm.controls['virtualDirectories'].valid) {
- const virtualDirGroups = this.groupArray.controls;
-
- const webConfigArm: ArmObj = JSON.parse(JSON.stringify(this._webConfigArm));
- webConfigArm.properties = {};
-
- const virtualApplications: VirtualApplication[] = [];
- const virtualDirectories: VirtualDirectory[] = [];
-
- virtualDirGroups.forEach(group => {
- if ((group as CustomFormGroup).msExistenceState !== 'deleted') {
- const formGroup = (group as FormGroup);
- if (formGroup.controls['isApplication'].value) {
- virtualApplications.push({
- virtualPath: this._getNormalizedVirtualPath(formGroup.controls['virtualPath'].value),
- physicalPath: formGroup.controls['physicalPath'].value,
- virtualDirectories: []
- });
- } else {
- virtualDirectories.push({
- virtualPath: this._getNormalizedVirtualPath(formGroup.controls['virtualPath'].value),
- physicalPath: formGroup.controls['physicalPath'].value
- });
- }
+ protected _getConfigsFromForms(saveConfigs: ArmSaveConfigs): ArmSaveConfigs {
+ const siteConfigArm: ArmObj = (saveConfigs && saveConfigs.siteConfigArm) ?
+ JSON.parse(JSON.stringify(saveConfigs.siteConfigArm)) :
+ JSON.parse(JSON.stringify(this.siteConfigArm));
+ siteConfigArm.id = `${this.resourceId}/config/web`;
+ siteConfigArm.properties.virtualApplications = [];
+
+ const virtualApplications: VirtualApplication[] = siteConfigArm.properties.virtualApplications;
+ const virtualDirectories: VirtualDirectory[] = [];
+
+ this.groupArray.controls.forEach(group => {
+ if ((group as CustomFormGroup).msExistenceState !== 'deleted') {
+ const formGroup = (group as FormGroup);
+ if (formGroup.controls['isApplication'].value) {
+ virtualApplications.push({
+ virtualPath: this._getNormalizedVirtualPath(formGroup.controls['virtualPath'].value),
+ physicalPath: formGroup.controls['physicalPath'].value,
+ virtualDirectories: []
+ });
+ } else {
+ virtualDirectories.push({
+ virtualPath: this._getNormalizedVirtualPath(formGroup.controls['virtualPath'].value),
+ physicalPath: formGroup.controls['physicalPath'].value
+ });
}
- });
+ }
+ });
- // TODO: Prevent savinng config with no applictions defined
- // if (virtualApplications.length === 0) { //DO SOMETHING - MAYBE HANDLE IN FRORM VALIDATION }
- virtualApplications.sort((a, b) => { return b.virtualPath.length - a.virtualPath.length; });
-
- virtualDirectories.forEach(virtualDirectory => {
- let appFound = false;
- const dirPathLen = virtualDirectory.virtualPath.length;
- for (let i = 0; i < virtualApplications.length && !appFound; i++) {
- const appPathLen = virtualApplications[i].virtualPath.length;
- if (appPathLen < dirPathLen && virtualDirectory.virtualPath.startsWith(virtualApplications[i].virtualPath)) {
- appFound = true;
- virtualDirectory.virtualPath = virtualDirectory.virtualPath.substring(appPathLen);
- virtualApplications[i].virtualDirectories.push(virtualDirectory);
- }
+ // TODO: Prevent savinng config with no applictions defined
+ // if (virtualApplications.length === 0) { //DO SOMETHING - MAYBE HANDLE IN FRORM VALIDATION }
+ virtualApplications.sort((a, b) => { return b.virtualPath.length - a.virtualPath.length; });
+
+ virtualDirectories.forEach(virtualDirectory => {
+ let appFound = false;
+ const dirPathLen = virtualDirectory.virtualPath.length;
+ for (let i = 0; i < virtualApplications.length && !appFound; i++) {
+ const appPathLen = virtualApplications[i].virtualPath.length;
+ if (appPathLen < dirPathLen && virtualDirectory.virtualPath.startsWith(virtualApplications[i].virtualPath)) {
+ appFound = true;
+ virtualDirectory.virtualPath = virtualDirectory.virtualPath.substring(appPathLen);
+ virtualApplications[i].virtualDirectories.push(virtualDirectory);
}
- // TODO: Prevent saving config with "orphan" virtual directory
- // if (!parentFound) { // DO SOMETHING }
- });
-
- webConfigArm.properties.virtualApplications = virtualApplications;
- return this._cacheService.patchArm(`${this.resourceId}/config/web`, null, webConfigArm)
- .map(webConfigResponse => {
- this._webConfigArm = webConfigResponse.json();
- return {
- success: true,
- error: null
- };
- })
- .catch(error => {
- this._saveError = error._body;
- return Observable.of({
- success: false,
- error: error._body
- });
- });
- } else {
- const failureMessage = this._validationFailureMessage();
- this._saveError = failureMessage;
- return Observable.of({
- success: false,
- error: failureMessage
- });
- }
- }
+ }
+ // TODO: Prevent saving config with "orphan" virtual directory
+ // if (!parentFound) { // DO SOMETHING }
+ });
- private _validationFailureMessage(): string {
- const configGroupName = this._translateService.instant(PortalResources.feature_virtualDirectoriesName);
- return this._translateService.instant(PortalResources.configUpdateFailureInvalidInput, { configGroupName: configGroupName });
+ return {
+ siteConfigArm: siteConfigArm
+ };
}
deleteItem(group: FormGroup) {
diff --git a/AzureFunctions.AngularClient/src/image/slots.svg b/AzureFunctions.AngularClient/src/image/slots.svg
new file mode 100644
index 0000000000..c812db17ec
--- /dev/null
+++ b/AzureFunctions.AngularClient/src/image/slots.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/AzureFunctions.AngularClient/src/sass/pages/_sidebar.scss b/AzureFunctions.AngularClient/src/sass/pages/_sidebar.scss
index 4a0b587dcb..d7221b58a1 100644
--- a/AzureFunctions.AngularClient/src/sass/pages/_sidebar.scss
+++ b/AzureFunctions.AngularClient/src/sass/pages/_sidebar.scss
@@ -1,4 +1,5 @@
@import '../common/variables';
+
.sidebar {
background-color: $body-bg-color;
@@ -10,21 +11,50 @@
}
.sidebar-new-function {
- width: 30vw;
background-color: $body-bg-color;
z-index: 11 !important;
}
+.sidebar-new-function {
+ width: 30vw;
+}
+
+.sidebar-swap-slot, .sidebar-add-slot {
+ //top: 42px !important;
+ top: 20px !important;
+ overflow: hidden;
+ background-color: transparent;
+}
+
+.sidebar-swap-slot {
+ //width: 60vw;
+ //min-width: 380px !important;
+ width: 500px !important;
+}
+
+.sidebar-add-slot {
+ //width: 30vw;
+ //min-width: 190px !important;
+ width: 350px !important;
+}
+
+.sidebar-backdrop-deployment-slots {
+ background: rgba($body-bg-color-dark, 0.07) !important;
+}
+
.ng-sidebar__backdrop {
z-index: 10 !important;
}
#app-root[theme=dark]{
- .sidebar-new-function {
+ .sidebar{
background-color: $body-bg-color-dark;
}
- .sidebar{
+ .sidebar-new-function {
background-color: $body-bg-color-dark;
}
+ .sidebar-backdrop-deployment-slots {
+ background: rgba($body-bg-color, 0.07) !important;
+ }
}
diff --git a/AzureFunctions/ResourcesPortal/Resources.Designer.cs b/AzureFunctions/ResourcesPortal/Resources.Designer.cs
index 3ee63caf28..b2fa7be5d4 100644
--- a/AzureFunctions/ResourcesPortal/Resources.Designer.cs
+++ b/AzureFunctions/ResourcesPortal/Resources.Designer.cs
@@ -3246,6 +3246,15 @@ internal static string feature_deploymentSourceName {
}
}
+ ///
+ /// Looks up a localized string similar to Deployment Slots.
+ ///
+ internal static string feature_deploymentSlotsName {
+ get {
+ return ResourceManager.GetString("feature_deploymentSlotsName", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Development tools.
///
@@ -6162,6 +6171,15 @@ internal static string runtimeVersion {
}
}
+ ///
+ /// Looks up a localized string similar to Refresh.
+ ///
+ internal static string refresh {
+ get {
+ return ResourceManager.GetString("refresh", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Save.
///
@@ -6171,6 +6189,15 @@ internal static string save {
}
}
+ ///
+ /// Looks up a localized string similar to Unsaved changes will be discarded..
+ ///
+ internal static string unsavedChangesWarning {
+ get {
+ return ResourceManager.GetString("unsavedChangesWarning", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to A save operation is currently in progress. Navigating away may cause some changes to be lost..
///
@@ -6648,6 +6675,24 @@ internal static string slot {
}
}
+ ///
+ /// Looks up a localized string similar to A slot create operation is currently in progress. After navigating away, you will no longer be able to check operation status..
+ ///
+ internal static string slotCreateOperationInProgressWarning {
+ get {
+ return ResourceManager.GetString("slotCreateOperationInProgressWarning", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Add Slot.
+ ///
+ internal static string slotNew {
+ get {
+ return ResourceManager.GetString("slotNew", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Deployment slots let you deploy different versions of your function app to different URLs. You can test a certain version and then swap content and configuration between slots..
///
@@ -6693,6 +6738,24 @@ internal static string slotNew_nameLabel_balloonText {
}
}
+ ///
+ /// Looks up a localized string similar to You have reached the slots quota limit ({{quota}}) for the current plan..
+ ///
+ internal static string slotNew_quotaReached {
+ get {
+ return ResourceManager.GetString("slotNew_quotaReached", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Please upgrade your plan..
+ ///
+ internal static string slotNew_quotaUpgrade {
+ get {
+ return ResourceManager.GetString("slotNew_quotaUpgrade", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to No access to create a slot. Please ensure you have the right RBAC access for the function app and do not have read locks enabled either..
///
@@ -6738,6 +6801,69 @@ internal static string slots_warningOff {
}
}
+ ///
+ /// Looks up a localized string similar to Upgrade to a standard or premium plan to add slots..
+ ///
+ internal static string slots_upgrade {
+ get {
+ return ResourceManager.GetString("slots_upgrade", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Deployment slots are live apps with their own hostnames. App content and configurations elements can be swapped between two deployment slots, including the production slot..
+ ///
+ internal static string slots_description {
+ get {
+ return ResourceManager.GetString("slots_description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Setting.
+ ///
+ internal static string slotsDiff_settingHeader {
+ get {
+ return ResourceManager.GetString("slotsDiff_settingHeader", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Type.
+ ///
+ internal static string slotsDiff_typeHeader {
+ get {
+ return ResourceManager.GetString("slotsDiff_typeHeader", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Old Value.
+ ///
+ internal static string slotsDiff_oldValueHeader {
+ get {
+ return ResourceManager.GetString("slotsDiff_oldValueHeader", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to New Value.
+ ///
+ internal static string slotsDiff_newValueHeader {
+ get {
+ return ResourceManager.GetString("slotsDiff_newValueHeader", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to You haven't added any deployment slots. Click 'Add Slot' to get started..
+ ///
+ internal static string slotsList_noSlots {
+ get {
+ return ResourceManager.GetString("slotsList_noSlots", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Name.
///
@@ -6765,6 +6891,15 @@ internal static string slotsList_statusHeader {
}
}
+ ///
+ /// Looks up a localized string similar to Traffic %.
+ ///
+ internal static string slotsList_trafficPercentHeader {
+ get {
+ return ResourceManager.GetString("slotsList_trafficPercentHeader", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Slots (preview).
///
@@ -6783,6 +6918,15 @@ internal static string source {
}
}
+ ///
+ /// Looks up a localized string similar to Target.
+ ///
+ internal static string target {
+ get {
+ return ResourceManager.GetString("target", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Source Control.
///
@@ -7335,6 +7479,132 @@ internal static string swaggerDefinition_useAPIdefinition {
}
}
+ ///
+ /// Looks up a localized string similar to Perform swap with preview.
+ ///
+ internal static string swapWithPreviewLabel {
+ get {
+ return ResourceManager.GetString("swapWithPreviewLabel", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Start the swap.
+ ///
+ internal static string swapPhaseOneLabel {
+ get {
+ return ResourceManager.GetString("swapPhaseOneLabel", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Review + complete the swap.
+ ///
+ internal static string swapPhaseTwoLabel {
+ get {
+ return ResourceManager.GetString("swapPhaseTwoLabel", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to A swap operation is currently in progress. After navigating away, you will no longer be able to check operation status..
+ ///
+ internal static string swapOperationInProgressWarning {
+ get {
+ return ResourceManager.GetString("swapOperationInProgressWarning", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {{swapType}} between slot {{srcSlot}} and slot {{destSlot}}.
+ ///
+ internal static string swapOperation {
+ get {
+ return ResourceManager.GetString("swapOperation", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to swap.
+ ///
+ internal static string swapFull {
+ get {
+ return ResourceManager.GetString("swapFull", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to phase one of swap.
+ ///
+ internal static string swapPhaseOne {
+ get {
+ return ResourceManager.GetString("swapPhaseOne", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to phase two of swap.
+ ///
+ internal static string swapPhaseTwo {
+ get {
+ return ResourceManager.GetString("swapPhaseTwo", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Performing {{operation}}.
+ ///
+ internal static string swapStarted {
+ get {
+ return ResourceManager.GetString("swapStarted", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Successfully completed {{operation}}.
+ ///
+ internal static string swapSuccess {
+ get {
+ return ResourceManager.GetString("swapSuccess", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Failed to complete {{operation}}. Error: {{error}}.
+ ///
+ internal static string swapFailure {
+ get {
+ return ResourceManager.GetString("swapFailure", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Cancelling {{operation}}.
+ ///
+ internal static string swapCancelStarted {
+ get {
+ return ResourceManager.GetString("swapCancelStarted", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Successfully cancelled {{operation}}.
+ ///
+ internal static string swapCancelSuccess {
+ get {
+ return ResourceManager.GetString("swapCancelSuccess", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Failed to cancel {{operation}}. Error: {{error}}.
+ ///
+ internal static string swapCancelFailure {
+ get {
+ return ResourceManager.GetString("swapCancelFailure", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Swap.
///
@@ -7344,6 +7614,24 @@ internal static string swap {
}
}
+ ///
+ /// Looks up a localized string similar to Complete Swap.
+ ///
+ internal static string completeSwap {
+ get {
+ return ResourceManager.GetString("completeSwap", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Cancel Swap.
+ ///
+ internal static string cancelSwap {
+ get {
+ return ResourceManager.GetString("cancelSwap", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Failed to swap slot {0} with {1}.
///
@@ -8019,6 +8307,15 @@ internal static string update {
}
}
+ ///
+ /// Looks up a localized string similar to Upgrade.
+ ///
+ internal static string upgrade {
+ get {
+ return ResourceManager.GetString("upgrade", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Upgrade to enable.
///
@@ -8055,6 +8352,24 @@ internal static string use32BitWorkerProcessUpsell {
}
}
+ ///
+ /// Looks up a localized string similar to Please enter a valid decimal value with format 'ddd.dd'.
+ ///
+ internal static string validation_decimalFormatError {
+ get {
+ return ResourceManager.GetString("validation_decimalFormatError", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Value must be an decimal in the range of {{min}} to {{max}}.
+ ///
+ internal static string validation_decimalRangeValueError {
+ get {
+ return ResourceManager.GetString("validation_decimalRangeValueError", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Duplicate values are not allowed.
///
@@ -8064,6 +8379,15 @@ internal static string validation_duplicateError {
}
}
+ ///
+ /// Looks up a localized string similar to ERROR.
+ ///
+ internal static string validation_error {
+ get {
+ return ResourceManager.GetString("validation_error", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to This field can only contain letters, numbers (0-9), periods ("."), and underscores ("_").
///
@@ -8082,6 +8406,15 @@ internal static string validation_requiredError {
}
}
+ ///
+ /// Looks up a localized string similar to Sum of routing percentage value of all rules must be less than or equal to 100.0.
+ ///
+ internal static string validation_routingTotalPercentError {
+ get {
+ return ResourceManager.GetString("validation_routingTotalPercentError", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to '{0}' is an invalid character.
///
diff --git a/AzureFunctions/ResourcesPortal/Resources.resx b/AzureFunctions/ResourcesPortal/Resources.resx
index cf061bbc5b..2a770cf8ae 100644
--- a/AzureFunctions/ResourcesPortal/Resources.resx
+++ b/AzureFunctions/ResourcesPortal/Resources.resx
@@ -129,6 +129,9 @@
Configure
+
+ Upgrade
+
Upgrade to enable
@@ -355,9 +358,15 @@
Run
+
+ Refresh
+
Save
+
+ Unsaved changes will be discarded.
+
A save operation is currently in progress. Navigating away may cause some changes to be lost.
@@ -1156,6 +1165,12 @@
Swap
+
+ Complete Swap
+
+
+ Cancel Swap
+
Download publish profile
@@ -1525,6 +1540,9 @@
Code Deployment
+
+ Deployment Slots
+
Development tools
@@ -1732,6 +1750,9 @@
Source
+
+ Target
+
Options
@@ -1987,15 +2008,27 @@ Set to "External URL" to use an API definition that is hosted elsewhere.
Read/Write
+
+ Please enter a valid decimal value with format 'ddd.dd'.
+
+
+ Value must be an decimal in the range of {{min}} to {{max}}
+
Duplicate values are not allowed
+
+ ERROR
+
This field can only contain letters, numbers (0-9), periods ("."), and underscores ("_")
This field is required
+
+ Sum of routing percentage value of all rules must be less than or equal to 100.0
+
The name must be at least 2 characters
@@ -2083,6 +2116,12 @@ Set to "External URL" to use an API definition that is hosted elsewhere.
node for each function.
+
+ A slot create operation is currently in progress. After navigating away, you will no longer be able to check operation status.
+
+
+ Add Slot
+
Name
@@ -2104,9 +2143,36 @@ Set to "External URL" to use an API definition that is hosted elsewhere.
Unable to upate the list of slots
+
+ You have reached the slots quota limit ({{quota}}) for the current plan.
+
+
+ Please upgrade your plan.
+
No access to create a slot. Please ensure you have the right RBAC access for the function app and do not have read locks enabled either.
+
+ Upgrade to a standard or premium plan to add slots.
+
+
+ Deployment slots are live apps with their own hostnames. App content and configurations elements can be swapped between two deployment slots, including the production slot.
+
+
+ Setting
+
+
+ Type
+
+
+ Old Value
+
+
+ New Value
+
+
+ You haven't added any deployment slots. Click 'Add Slot' to get started.
+
Name
@@ -2116,6 +2182,9 @@ Set to "External URL" to use an API definition that is hosted elsewhere.
App service plan
+
+ Traffic %
+
Slots (preview)
@@ -2698,6 +2767,48 @@ Set to "External URL" to use an API definition that is hosted elsewhere.
Failed to deploy to {0}
+
+ Perform swap with preview
+
+
+ Start the swap
+
+
+ Review + complete the swap
+
+
+ A swap operation is currently in progress. After navigating away, you will no longer be able to check operation status.
+
+
+ {{swapType}} between slot {{srcSlot}} and slot {{destSlot}}
+
+
+ swap
+
+
+ phase one of swap
+
+
+ phase two of swap
+
+
+ Performing {{operation}}
+
+
+ Successfully completed {{operation}}
+
+
+ Failed to complete {{operation}}. Error: {{error}}
+
+
+ Cancelling {{operation}}
+
+
+ Successfully cancelled {{operation}}
+
+
+ Failed to cancel {{operation}}. Error: {{error}}
+
Swapped slot {0} with {1}
diff --git a/server/src/actions/proxy.ts b/server/src/actions/proxy.ts
index e172c8b10a..d9aed7cbc8 100644
--- a/server/src/actions/proxy.ts
+++ b/server/src/actions/proxy.ts
@@ -20,7 +20,7 @@ export function proxy(req: Request, res: Response) {
.then(r => res.send(r.data))
.catch(e => {
if (e.response && e.response.status) {
- res.status(e.response.status).send();
+ res.status(e.response.status).send(e.response);
} else if (e.request) {
res.status(400).send({
reason: 'PassThrough',