diff --git a/AzureFunctions.AngularClient/src/app/controls/info-box/info-box.component.html b/AzureFunctions.AngularClient/src/app/controls/info-box/info-box.component.html index 354705fa31..b4252a4ada 100644 --- a/AzureFunctions.AngularClient/src/app/controls/info-box/info-box.component.html +++ b/AzureFunctions.AngularClient/src/app/controls/info-box/info-box.component.html @@ -1,9 +1,9 @@
@@ -12,8 +12,11 @@
{{ infoText }}
- \ 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 }} +
+
+ +
+
+
Create In Progress
+
+ +
+
+ {{ '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}} +
+
+ {{ Resources.slots_description | translate }} + + {{ Resources.topBar_learnMore | translate }} + +
+ + +
+ + + +
+ + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + +
+
+
+
+
+
{{ Resources.feature_deploymentSlotsName | translate }}
+
+
+
+ +
+
{{ Resources.slots_description | translate }}
+
+
+ + + + + + {{ Resources.slotsList_nameHeader | translate }} + + + {{ Resources.slotsList_statusHeader | translate }} + + + {{ Resources.slotsList_serverfarmHeader | translate }} + + + {{ Resources.slotsList_trafficPercentHeader | translate }} + + + + + + + {{ siteArm ? siteArm.properties.name : '-' }} +
+ {{ Resources.production | translate }} +
+ + + + {{ siteArm ? siteArm.properties.state : '-' }} + + + + {{ siteArm ? getSegment(siteArm.properties.serverFarmId, 8) : '-' }} + + + +
+ + +
+ + + + + + + + + {{ relativeSlotArm.properties.name }} + +
+ {{ Resources.production | translate }} +
+ + + + {{ relativeSlotArm.properties.state }} + + + + {{ getSegment(relativeSlotArm.properties.serverFarmId, 8) }} + + + +
+ + +
+ + + + + + + + + + {{ Resources.loading | translate }} + + + + {{ (!siteArm ? Resources.error_unableToLoadSlotsList : Resources.slotsList_noSlots) | 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

+
+
+
+ + + +
+ No swap access on the selected slot. +
+
+
+ +
+
+ + + +
+ No swap access on the selected slot. +
+
+
+
+ +
+ Slots must be different +
+ +
+ +
+ Cannnot perform multiphase swap because the following slots have authentication enabled: +
+ {{ slot }} +
+
+
+ +
+ +
+
+ 1 +
+
+
+
+ 2 +
+
+ +
+
+ {{ Resources.swapPhaseOneLabel | translate }} +
+
+ {{ Resources.swapPhaseTwoLabel | translate }} +
+
+ + + +
+ +
+

Preview changes

+
+
+
+ +
+ Source Changes + +
+
+ +
+ Target Changes + +
+
+ + + {{ Resources.slotsDiff_settingHeader | translate }} + {{ Resources.slotsDiff_typeHeader | translate }} + {{ Resources.slotsDiff_oldValueHeader | translate }} + {{ Resources.slotsDiff_newValueHeader | translate }} + + + + +
+ + + + + {{ 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',