Disable checking for conflicts when copying saved objects (#83575)

This commit is contained in:
Joe Portner 2020-12-03 11:08:25 -05:00 committed by GitHub
parent ac71d2e941
commit c39d14fef4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
72 changed files with 1769 additions and 191 deletions

View file

@ -51,9 +51,17 @@ You can request to overwrite any objects that already exist in the target space
(Optional, boolean) When set to `true`, all saved objects related to the specified saved objects will also be copied into the target
spaces. The default value is `false`.
`createNewCopies`::
(Optional, boolean) Creates new copies of saved objects, regenerates each object ID, and resets the origin. When used, potential conflict
errors are avoided. The default value is `true`.
+
NOTE: This cannot be used with the `overwrite` option.
`overwrite`::
(Optional, boolean) When set to `true`, all conflicts are automatically overidden. When a saved object with a matching `type` and `id`
exists in the target space, that version is replaced with the version from the source space. The default value is `false`.
+
NOTE: This cannot be used with the `createNewCopies` option.
[role="child_attributes"]
[[spaces-api-copy-saved-objects-response-body]]
@ -128,8 +136,7 @@ $ curl -X POST api/spaces/_copy_saved_objects
"id": "my-dashboard"
}],
"spaces": ["marketing"],
"includeReferences": true,
"createNewcopies": true
"includeReferences": true
}
----
// KIBANA
@ -193,7 +200,8 @@ $ curl -X POST api/spaces/_copy_saved_objects
"id": "my-dashboard"
}],
"spaces": ["marketing"],
"includeReferences": true
"includeReferences": true,
"createNewCopies": false
}
----
// KIBANA
@ -254,7 +262,8 @@ $ curl -X POST api/spaces/_copy_saved_objects
"id": "my-dashboard"
}],
"spaces": ["marketing", "sales"],
"includeReferences": true
"includeReferences": true,
"createNewCopies": false
}
----
// KIBANA
@ -405,7 +414,8 @@ $ curl -X POST api/spaces/_copy_saved_objects
"id": "my-dashboard"
}],
"spaces": ["marketing"],
"includeReferences": true
"includeReferences": true,
"createNewCopies": false
}
----
// KIBANA

View file

@ -45,6 +45,10 @@ Execute the <<spaces-api-copy-saved-objects,copy saved objects to space API>>, w
`includeReferences`::
(Optional, boolean) When set to `true`, all saved objects related to the specified saved objects are copied into the target spaces. The `includeReferences` must be the same values used during the failed <<spaces-api-copy-saved-objects, copy saved objects to space API>> operation. The default value is `false`.
`createNewCopies`::
(Optional, boolean) Creates new copies of the saved objects, regenerates each object ID, and resets the origin. When enabled during the
initial copy, also enable when resolving copy errors. The default value is `true`.
`retries`::
(Required, object) The retry operations to attempt, which can specify how to resolve different types of errors. Object keys represent the
target space IDs.
@ -148,6 +152,7 @@ $ curl -X POST api/spaces/_resolve_copy_saved_objects_errors
"id": "my-dashboard"
}],
"includeReferences": true,
"createNewCopies": false,
"retries": {
"sales": [
{
@ -246,6 +251,7 @@ $ curl -X POST api/spaces/_resolve_copy_saved_objects_errors
"id": "my-dashboard"
}],
"includeReferences": true,
"createNewCopies": false,
"retries": {
"marketing": [
{

View file

@ -9,7 +9,7 @@ Increments all the specified counter fields by one. Creates the document if one
<b>Signature:</b>
```typescript
incrementCounter(type: string, id: string, counterFieldNames: string[], options?: SavedObjectsIncrementCounterOptions): Promise<SavedObject>;
incrementCounter<T = unknown>(type: string, id: string, counterFieldNames: string[], options?: SavedObjectsIncrementCounterOptions): Promise<SavedObject<T>>;
```
## Parameters
@ -23,7 +23,7 @@ incrementCounter(type: string, id: string, counterFieldNames: string[], options?
<b>Returns:</b>
`Promise<SavedObject>`
`Promise<SavedObject<T>>`
The saved object after the specified fields were incremented

View file

@ -0,0 +1,24 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/** @internal */
export const CORE_USAGE_STATS_TYPE = 'core-usage-stats';
/** @internal */
export const CORE_USAGE_STATS_ID = 'core-usage-stats';

View file

@ -20,7 +20,16 @@
import { PublicMethodsOf } from '@kbn/utility-types';
import { BehaviorSubject } from 'rxjs';
import { CoreUsageDataService } from './core_usage_data_service';
import { CoreUsageData, CoreUsageDataStart } from './types';
import { coreUsageStatsClientMock } from './core_usage_stats_client.mock';
import { CoreUsageData, CoreUsageDataSetup, CoreUsageDataStart } from './types';
const createSetupContractMock = (usageStatsClient = coreUsageStatsClientMock.create()) => {
const setupContract: jest.Mocked<CoreUsageDataSetup> = {
registerType: jest.fn(),
getClient: jest.fn().mockReturnValue(usageStatsClient),
};
return setupContract;
};
const createStartContractMock = () => {
const startContract: jest.Mocked<CoreUsageDataStart> = {
@ -140,7 +149,7 @@ const createStartContractMock = () => {
const createMock = () => {
const mocked: jest.Mocked<PublicMethodsOf<CoreUsageDataService>> = {
setup: jest.fn(),
setup: jest.fn().mockReturnValue(createSetupContractMock()),
start: jest.fn().mockReturnValue(createStartContractMock()),
stop: jest.fn(),
};
@ -149,5 +158,6 @@ const createMock = () => {
export const coreUsageDataServiceMock = {
create: createMock,
createSetupContract: createSetupContractMock,
createStartContract: createStartContractMock,
};

View file

@ -34,6 +34,9 @@ import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service.
import { CoreUsageDataService } from './core_usage_data_service';
import { elasticsearchServiceMock } from '../elasticsearch/elasticsearch_service.mock';
import { typeRegistryMock } from '../saved_objects/saved_objects_type_registry.mock';
import { CORE_USAGE_STATS_TYPE } from './constants';
import { CoreUsageStatsClient } from './core_usage_stats_client';
describe('CoreUsageDataService', () => {
const getTestScheduler = () =>
@ -63,11 +66,67 @@ describe('CoreUsageDataService', () => {
service = new CoreUsageDataService(coreContext);
});
describe('setup', () => {
it('creates internal repository', async () => {
const metrics = metricsServiceMock.createInternalSetupContract();
const savedObjectsStartPromise = Promise.resolve(
savedObjectsServiceMock.createStartContract()
);
service.setup({ metrics, savedObjectsStartPromise });
const savedObjects = await savedObjectsStartPromise;
expect(savedObjects.createInternalRepository).toHaveBeenCalledTimes(1);
expect(savedObjects.createInternalRepository).toHaveBeenCalledWith([CORE_USAGE_STATS_TYPE]);
});
describe('#registerType', () => {
it('registers core usage stats type', async () => {
const metrics = metricsServiceMock.createInternalSetupContract();
const savedObjectsStartPromise = Promise.resolve(
savedObjectsServiceMock.createStartContract()
);
const coreUsageData = service.setup({
metrics,
savedObjectsStartPromise,
});
const typeRegistry = typeRegistryMock.create();
coreUsageData.registerType(typeRegistry);
expect(typeRegistry.registerType).toHaveBeenCalledTimes(1);
expect(typeRegistry.registerType).toHaveBeenCalledWith({
name: CORE_USAGE_STATS_TYPE,
hidden: true,
namespaceType: 'agnostic',
mappings: expect.anything(),
});
});
});
describe('#getClient', () => {
it('returns client', async () => {
const metrics = metricsServiceMock.createInternalSetupContract();
const savedObjectsStartPromise = Promise.resolve(
savedObjectsServiceMock.createStartContract()
);
const coreUsageData = service.setup({
metrics,
savedObjectsStartPromise,
});
const usageStatsClient = coreUsageData.getClient();
expect(usageStatsClient).toBeInstanceOf(CoreUsageStatsClient);
});
});
});
describe('start', () => {
describe('getCoreUsageData', () => {
it('returns core metrics for default config', () => {
it('returns core metrics for default config', async () => {
const metrics = metricsServiceMock.createInternalSetupContract();
service.setup({ metrics });
const savedObjectsStartPromise = Promise.resolve(
savedObjectsServiceMock.createStartContract()
);
service.setup({ metrics, savedObjectsStartPromise });
const elasticsearch = elasticsearchServiceMock.createStart();
elasticsearch.client.asInternalUser.cat.indices.mockResolvedValueOnce({
body: [
@ -243,8 +302,11 @@ describe('CoreUsageDataService', () => {
observables.push(newObservable);
return newObservable as Observable<any>;
});
const savedObjectsStartPromise = Promise.resolve(
savedObjectsServiceMock.createStartContract()
);
service.setup({ metrics });
service.setup({ metrics, savedObjectsStartPromise });
// Use the stopTimer$ to delay calling stop() until the third frame
const stopTimer$ = cold('---a|');

View file

@ -21,20 +21,29 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { CoreService } from 'src/core/types';
import { SavedObjectsServiceStart } from 'src/core/server';
import { Logger, SavedObjectsServiceStart, SavedObjectTypeRegistry } from 'src/core/server';
import { CoreContext } from '../core_context';
import { ElasticsearchConfigType } from '../elasticsearch/elasticsearch_config';
import { HttpConfigType } from '../http';
import { LoggingConfigType } from '../logging';
import { SavedObjectsConfigType } from '../saved_objects/saved_objects_config';
import { CoreServicesUsageData, CoreUsageData, CoreUsageDataStart } from './types';
import {
CoreServicesUsageData,
CoreUsageData,
CoreUsageDataStart,
CoreUsageDataSetup,
} from './types';
import { isConfigured } from './is_configured';
import { ElasticsearchServiceStart } from '../elasticsearch';
import { KibanaConfigType } from '../kibana_config';
import { coreUsageStatsType } from './core_usage_stats';
import { CORE_USAGE_STATS_TYPE } from './constants';
import { CoreUsageStatsClient } from './core_usage_stats_client';
import { MetricsServiceSetup, OpsMetrics } from '..';
export interface SetupDeps {
metrics: MetricsServiceSetup;
savedObjectsStartPromise: Promise<SavedObjectsServiceStart>;
}
export interface StartDeps {
@ -60,7 +69,8 @@ const kibanaOrTaskManagerIndex = (index: string, kibanaConfigIndex: string) => {
return index === kibanaConfigIndex ? '.kibana' : '.kibana_task_manager';
};
export class CoreUsageDataService implements CoreService<void, CoreUsageDataStart> {
export class CoreUsageDataService implements CoreService<CoreUsageDataSetup, CoreUsageDataStart> {
private logger: Logger;
private elasticsearchConfig?: ElasticsearchConfigType;
private configService: CoreContext['configService'];
private httpConfig?: HttpConfigType;
@ -69,8 +79,10 @@ export class CoreUsageDataService implements CoreService<void, CoreUsageDataStar
private stop$: Subject<void>;
private opsMetrics?: OpsMetrics;
private kibanaConfig?: KibanaConfigType;
private coreUsageStatsClient?: CoreUsageStatsClient;
constructor(core: CoreContext) {
this.logger = core.logger.get('core-usage-stats-service');
this.configService = core.configService;
this.stop$ = new Subject();
}
@ -130,8 +142,15 @@ export class CoreUsageDataService implements CoreService<void, CoreUsageDataStar
throw new Error('Unable to read config values. Ensure that setup() has completed.');
}
if (!this.coreUsageStatsClient) {
throw new Error(
'Core usage stats client is not initialized. Ensure that setup() has completed.'
);
}
const es = this.elasticsearchConfig;
const soUsageData = await this.getSavedObjectIndicesUsageData(savedObjects, elasticsearch);
const coreUsageStatsData = await this.coreUsageStatsClient.getUsageStats();
const http = this.httpConfig;
return {
@ -225,10 +244,11 @@ export class CoreUsageDataService implements CoreService<void, CoreUsageDataStar
services: {
savedObjects: soUsageData,
},
...coreUsageStatsData,
};
}
setup({ metrics }: SetupDeps) {
setup({ metrics, savedObjectsStartPromise }: SetupDeps) {
metrics
.getOpsMetrics$()
.pipe(takeUntil(this.stop$))
@ -268,6 +288,24 @@ export class CoreUsageDataService implements CoreService<void, CoreUsageDataStar
.subscribe((config) => {
this.kibanaConfig = config;
});
const internalRepositoryPromise = savedObjectsStartPromise.then((savedObjects) =>
savedObjects.createInternalRepository([CORE_USAGE_STATS_TYPE])
);
const registerType = (typeRegistry: SavedObjectTypeRegistry) => {
typeRegistry.registerType(coreUsageStatsType);
};
const getClient = () => {
const debugLogger = (message: string) => this.logger.debug(message);
return new CoreUsageStatsClient(debugLogger, internalRepositoryPromise);
};
this.coreUsageStatsClient = getClient();
return { registerType, getClient } as CoreUsageDataSetup;
}
start({ savedObjects, elasticsearch }: StartDeps) {

View file

@ -0,0 +1,32 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SavedObjectsType } from '../saved_objects';
import { CORE_USAGE_STATS_TYPE } from './constants';
/** @internal */
export const coreUsageStatsType: SavedObjectsType = {
name: CORE_USAGE_STATS_TYPE,
hidden: true,
namespaceType: 'agnostic',
mappings: {
dynamic: false, // we aren't querying or aggregating over this data, so we don't need to specify any fields
properties: {},
},
};

View file

@ -0,0 +1,32 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { CoreUsageStatsClient } from '.';
const createUsageStatsClientMock = () =>
(({
getUsageStats: jest.fn().mockResolvedValue({}),
incrementSavedObjectsImport: jest.fn().mockResolvedValue(null),
incrementSavedObjectsResolveImportErrors: jest.fn().mockResolvedValue(null),
incrementSavedObjectsExport: jest.fn().mockResolvedValue(null),
} as unknown) as jest.Mocked<CoreUsageStatsClient>);
export const coreUsageStatsClientMock = {
create: createUsageStatsClientMock,
};

View file

@ -0,0 +1,227 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { savedObjectsRepositoryMock } from '../mocks';
import { CORE_USAGE_STATS_TYPE, CORE_USAGE_STATS_ID } from './constants';
import {
IncrementSavedObjectsImportOptions,
IncrementSavedObjectsResolveImportErrorsOptions,
IncrementSavedObjectsExportOptions,
IMPORT_STATS_PREFIX,
RESOLVE_IMPORT_STATS_PREFIX,
EXPORT_STATS_PREFIX,
} from './core_usage_stats_client';
import { CoreUsageStatsClient } from '.';
describe('CoreUsageStatsClient', () => {
const setup = () => {
const debugLoggerMock = jest.fn();
const repositoryMock = savedObjectsRepositoryMock.create();
const usageStatsClient = new CoreUsageStatsClient(
debugLoggerMock,
Promise.resolve(repositoryMock)
);
return { usageStatsClient, debugLoggerMock, repositoryMock };
};
const firstPartyRequestHeaders = { 'kbn-version': 'a', origin: 'b', referer: 'c' }; // as long as these three header fields are truthy, this will be treated like a first-party request
const incrementOptions = { refresh: false };
describe('#getUsageStats', () => {
it('returns empty object when encountering a repository error', async () => {
const { usageStatsClient, repositoryMock } = setup();
repositoryMock.get.mockRejectedValue(new Error('Oh no!'));
const result = await usageStatsClient.getUsageStats();
expect(result).toEqual({});
});
it('returns object attributes when usage stats exist', async () => {
const { usageStatsClient, repositoryMock } = setup();
const usageStats = { foo: 'bar' };
repositoryMock.incrementCounter.mockResolvedValue({
type: CORE_USAGE_STATS_TYPE,
id: CORE_USAGE_STATS_ID,
attributes: usageStats,
references: [],
});
const result = await usageStatsClient.getUsageStats();
expect(result).toEqual(usageStats);
});
});
describe('#incrementSavedObjectsImport', () => {
it('does not throw an error if repository incrementCounter operation fails', async () => {
const { usageStatsClient, repositoryMock } = setup();
repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!'));
await expect(
usageStatsClient.incrementSavedObjectsImport({} as IncrementSavedObjectsImportOptions)
).resolves.toBeUndefined();
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
});
it('handles falsy options appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup();
await usageStatsClient.incrementSavedObjectsImport({} as IncrementSavedObjectsImportOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
`${IMPORT_STATS_PREFIX}.total`,
`${IMPORT_STATS_PREFIX}.kibanaRequest.no`,
`${IMPORT_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${IMPORT_STATS_PREFIX}.overwriteEnabled.no`,
],
incrementOptions
);
});
it('handles truthy options appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup();
await usageStatsClient.incrementSavedObjectsImport({
headers: firstPartyRequestHeaders,
createNewCopies: true,
overwrite: true,
} as IncrementSavedObjectsImportOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
`${IMPORT_STATS_PREFIX}.total`,
`${IMPORT_STATS_PREFIX}.kibanaRequest.yes`,
`${IMPORT_STATS_PREFIX}.createNewCopiesEnabled.yes`,
`${IMPORT_STATS_PREFIX}.overwriteEnabled.yes`,
],
incrementOptions
);
});
});
describe('#incrementSavedObjectsResolveImportErrors', () => {
it('does not throw an error if repository incrementCounter operation fails', async () => {
const { usageStatsClient, repositoryMock } = setup();
repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!'));
await expect(
usageStatsClient.incrementSavedObjectsResolveImportErrors(
{} as IncrementSavedObjectsResolveImportErrorsOptions
)
).resolves.toBeUndefined();
expect(repositoryMock.incrementCounter).toHaveBeenCalled();
});
it('handles falsy options appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup();
await usageStatsClient.incrementSavedObjectsResolveImportErrors(
{} as IncrementSavedObjectsResolveImportErrorsOptions
);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
`${RESOLVE_IMPORT_STATS_PREFIX}.total`,
`${RESOLVE_IMPORT_STATS_PREFIX}.kibanaRequest.no`,
`${RESOLVE_IMPORT_STATS_PREFIX}.createNewCopiesEnabled.no`,
],
incrementOptions
);
});
it('handles truthy options appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup();
await usageStatsClient.incrementSavedObjectsResolveImportErrors({
headers: firstPartyRequestHeaders,
createNewCopies: true,
} as IncrementSavedObjectsResolveImportErrorsOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
`${RESOLVE_IMPORT_STATS_PREFIX}.total`,
`${RESOLVE_IMPORT_STATS_PREFIX}.kibanaRequest.yes`,
`${RESOLVE_IMPORT_STATS_PREFIX}.createNewCopiesEnabled.yes`,
],
incrementOptions
);
});
});
describe('#incrementSavedObjectsExport', () => {
it('does not throw an error if repository incrementCounter operation fails', async () => {
const { usageStatsClient, repositoryMock } = setup();
repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!'));
await expect(
usageStatsClient.incrementSavedObjectsExport({} as IncrementSavedObjectsExportOptions)
).resolves.toBeUndefined();
expect(repositoryMock.incrementCounter).toHaveBeenCalled();
});
it('handles falsy options appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup();
await usageStatsClient.incrementSavedObjectsExport({
types: undefined,
supportedTypes: ['foo', 'bar'],
} as IncrementSavedObjectsExportOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
`${EXPORT_STATS_PREFIX}.total`,
`${EXPORT_STATS_PREFIX}.kibanaRequest.no`,
`${EXPORT_STATS_PREFIX}.allTypesSelected.no`,
],
incrementOptions
);
});
it('handles truthy options appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup();
await usageStatsClient.incrementSavedObjectsExport({
headers: firstPartyRequestHeaders,
types: ['foo', 'bar'],
supportedTypes: ['foo', 'bar'],
} as IncrementSavedObjectsExportOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
`${EXPORT_STATS_PREFIX}.total`,
`${EXPORT_STATS_PREFIX}.kibanaRequest.yes`,
`${EXPORT_STATS_PREFIX}.allTypesSelected.yes`,
],
incrementOptions
);
});
});
});

View file

@ -0,0 +1,154 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { CORE_USAGE_STATS_TYPE, CORE_USAGE_STATS_ID } from './constants';
import { CoreUsageStats } from './types';
import {
Headers,
ISavedObjectsRepository,
SavedObjectsImportOptions,
SavedObjectsResolveImportErrorsOptions,
SavedObjectsExportOptions,
} from '..';
interface BaseIncrementOptions {
headers?: Headers;
}
/** @internal */
export type IncrementSavedObjectsImportOptions = BaseIncrementOptions &
Pick<SavedObjectsImportOptions, 'createNewCopies' | 'overwrite'>;
/** @internal */
export type IncrementSavedObjectsResolveImportErrorsOptions = BaseIncrementOptions &
Pick<SavedObjectsResolveImportErrorsOptions, 'createNewCopies'>;
/** @internal */
export type IncrementSavedObjectsExportOptions = BaseIncrementOptions &
Pick<SavedObjectsExportOptions, 'types'> & { supportedTypes: string[] };
export const IMPORT_STATS_PREFIX = 'apiCalls.savedObjectsImport';
export const RESOLVE_IMPORT_STATS_PREFIX = 'apiCalls.savedObjectsResolveImportErrors';
export const EXPORT_STATS_PREFIX = 'apiCalls.savedObjectsExport';
const ALL_COUNTER_FIELDS = [
`${IMPORT_STATS_PREFIX}.total`,
`${IMPORT_STATS_PREFIX}.kibanaRequest.yes`,
`${IMPORT_STATS_PREFIX}.kibanaRequest.no`,
`${IMPORT_STATS_PREFIX}.createNewCopiesEnabled.yes`,
`${IMPORT_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${IMPORT_STATS_PREFIX}.overwriteEnabled.yes`,
`${IMPORT_STATS_PREFIX}.overwriteEnabled.no`,
`${RESOLVE_IMPORT_STATS_PREFIX}.total`,
`${RESOLVE_IMPORT_STATS_PREFIX}.kibanaRequest.yes`,
`${RESOLVE_IMPORT_STATS_PREFIX}.kibanaRequest.no`,
`${RESOLVE_IMPORT_STATS_PREFIX}.createNewCopiesEnabled.yes`,
`${RESOLVE_IMPORT_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${EXPORT_STATS_PREFIX}.total`,
`${EXPORT_STATS_PREFIX}.kibanaRequest.yes`,
`${EXPORT_STATS_PREFIX}.kibanaRequest.no`,
`${EXPORT_STATS_PREFIX}.allTypesSelected.yes`,
`${EXPORT_STATS_PREFIX}.allTypesSelected.no`,
];
/** @internal */
export class CoreUsageStatsClient {
constructor(
private readonly debugLogger: (message: string) => void,
private readonly repositoryPromise: Promise<ISavedObjectsRepository>
) {}
public async getUsageStats() {
this.debugLogger('getUsageStats() called');
let coreUsageStats: CoreUsageStats = {};
try {
const repository = await this.repositoryPromise;
const result = await repository.incrementCounter<CoreUsageStats>(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
ALL_COUNTER_FIELDS,
{ initialize: true } // set all counter fields to 0 if they don't exist
);
coreUsageStats = result.attributes;
} catch (err) {
// do nothing
}
return coreUsageStats;
}
public async incrementSavedObjectsImport({
headers,
createNewCopies,
overwrite,
}: IncrementSavedObjectsImportOptions) {
const isKibanaRequest = getIsKibanaRequest(headers);
const counterFieldNames = [
'total',
`kibanaRequest.${isKibanaRequest ? 'yes' : 'no'}`,
`createNewCopiesEnabled.${createNewCopies ? 'yes' : 'no'}`,
`overwriteEnabled.${overwrite ? 'yes' : 'no'}`,
];
await this.updateUsageStats(counterFieldNames, IMPORT_STATS_PREFIX);
}
public async incrementSavedObjectsResolveImportErrors({
headers,
createNewCopies,
}: IncrementSavedObjectsResolveImportErrorsOptions) {
const isKibanaRequest = getIsKibanaRequest(headers);
const counterFieldNames = [
'total',
`kibanaRequest.${isKibanaRequest ? 'yes' : 'no'}`,
`createNewCopiesEnabled.${createNewCopies ? 'yes' : 'no'}`,
];
await this.updateUsageStats(counterFieldNames, RESOLVE_IMPORT_STATS_PREFIX);
}
public async incrementSavedObjectsExport({
headers,
types,
supportedTypes,
}: IncrementSavedObjectsExportOptions) {
const isKibanaRequest = getIsKibanaRequest(headers);
const isAllTypesSelected = !!types && supportedTypes.every((x) => types.includes(x));
const counterFieldNames = [
'total',
`kibanaRequest.${isKibanaRequest ? 'yes' : 'no'}`,
`allTypesSelected.${isAllTypesSelected ? 'yes' : 'no'}`,
];
await this.updateUsageStats(counterFieldNames, EXPORT_STATS_PREFIX);
}
private async updateUsageStats(counterFieldNames: string[], prefix: string) {
const options = { refresh: false };
try {
const repository = await this.repositoryPromise;
await repository.incrementCounter(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
counterFieldNames.map((x) => `${prefix}.${x}`),
options
);
} catch (err) {
// do nothing
}
}
}
function getIsKibanaRequest(headers?: Headers) {
// The presence of these three request headers gives us a good indication that this is a first-party request from the Kibana client.
// We can't be 100% certain, but this is a reasonable attempt.
return headers && headers['kbn-version'] && headers.origin && headers.referer;
}

View file

@ -16,16 +16,24 @@
* specific language governing permissions and limitations
* under the License.
*/
export { CoreUsageDataStart } from './types';
export { CoreUsageDataSetup, CoreUsageDataStart } from './types';
export { CoreUsageDataService } from './core_usage_data_service';
export { CoreUsageStatsClient } from './core_usage_stats_client';
// Because of #79265 we need to explicity import, then export these types for
// scripts/telemetry_check.js to work as expected
import {
CoreUsageStats,
CoreUsageData,
CoreConfigUsageData,
CoreEnvironmentUsageData,
CoreServicesUsageData,
} from './types';
export { CoreUsageData, CoreConfigUsageData, CoreEnvironmentUsageData, CoreServicesUsageData };
export {
CoreUsageStats,
CoreUsageData,
CoreConfigUsageData,
CoreEnvironmentUsageData,
CoreServicesUsageData,
};

View file

@ -17,11 +17,40 @@
* under the License.
*/
import { CoreUsageStatsClient } from './core_usage_stats_client';
import { ISavedObjectTypeRegistry, SavedObjectTypeRegistry } from '..';
/**
* @internal
*
* CoreUsageStats are collected over time while Kibana is running. This is related to CoreUsageData, which is a superset of this that also
* includes point-in-time configuration information.
* */
export interface CoreUsageStats {
'apiCalls.savedObjectsImport.total'?: number;
'apiCalls.savedObjectsImport.kibanaRequest.yes'?: number;
'apiCalls.savedObjectsImport.kibanaRequest.no'?: number;
'apiCalls.savedObjectsImport.createNewCopiesEnabled.yes'?: number;
'apiCalls.savedObjectsImport.createNewCopiesEnabled.no'?: number;
'apiCalls.savedObjectsImport.overwriteEnabled.yes'?: number;
'apiCalls.savedObjectsImport.overwriteEnabled.no'?: number;
'apiCalls.savedObjectsResolveImportErrors.total'?: number;
'apiCalls.savedObjectsResolveImportErrors.kibanaRequest.yes'?: number;
'apiCalls.savedObjectsResolveImportErrors.kibanaRequest.no'?: number;
'apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.yes'?: number;
'apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.no'?: number;
'apiCalls.savedObjectsExport.total'?: number;
'apiCalls.savedObjectsExport.kibanaRequest.yes'?: number;
'apiCalls.savedObjectsExport.kibanaRequest.no'?: number;
'apiCalls.savedObjectsExport.allTypesSelected.yes'?: number;
'apiCalls.savedObjectsExport.allTypesSelected.no'?: number;
}
/**
* Type describing Core's usage data payload
* @internal
*/
export interface CoreUsageData {
export interface CoreUsageData extends CoreUsageStats {
config: CoreConfigUsageData;
services: CoreServicesUsageData;
environment: CoreEnvironmentUsageData;
@ -141,6 +170,14 @@ export interface CoreConfigUsageData {
// };
}
/** @internal */
export interface CoreUsageDataSetup {
registerType(
typeRegistry: ISavedObjectTypeRegistry & Pick<SavedObjectTypeRegistry, 'registerType'>
): void;
getClient(): CoreUsageStatsClient;
}
/**
* Internal API for getting Core's usage data payload.
*

View file

@ -69,13 +69,20 @@ import { I18nServiceSetup } from './i18n';
// Because of #79265 we need to explicity import, then export these types for
// scripts/telemetry_check.js to work as expected
import {
CoreUsageStats,
CoreUsageData,
CoreConfigUsageData,
CoreEnvironmentUsageData,
CoreServicesUsageData,
} from './core_usage_data';
export { CoreUsageData, CoreConfigUsageData, CoreEnvironmentUsageData, CoreServicesUsageData };
export {
CoreUsageStats,
CoreUsageData,
CoreConfigUsageData,
CoreEnvironmentUsageData,
CoreServicesUsageData,
};
export { bootstrap } from './bootstrap';
export { Capabilities, CapabilitiesProvider, CapabilitiesSwitcher } from './capabilities';

View file

@ -22,11 +22,20 @@ import stringify from 'json-stable-stringify';
import { createPromiseFromStreams, createMapStream, createConcatStream } from '@kbn/utils';
import { IRouter } from '../../http';
import { CoreUsageDataSetup } from '../../core_usage_data';
import { SavedObjectConfig } from '../saved_objects_config';
import { exportSavedObjectsToStream } from '../export';
import { validateTypes, validateObjects } from './utils';
export const registerExportRoute = (router: IRouter, config: SavedObjectConfig) => {
interface RouteDependencies {
config: SavedObjectConfig;
coreUsageData: CoreUsageDataSetup;
}
export const registerExportRoute = (
router: IRouter,
{ config, coreUsageData }: RouteDependencies
) => {
const { maxImportExportSize } = config;
const referenceSchema = schema.object({
@ -95,6 +104,12 @@ export const registerExportRoute = (router: IRouter, config: SavedObjectConfig)
}
}
const { headers } = req;
const usageStatsClient = coreUsageData.getClient();
usageStatsClient
.incrementSavedObjectsExport({ headers, types, supportedTypes })
.catch(() => {});
const exportStream = await exportSavedObjectsToStream({
savedObjectsClient,
types,

View file

@ -21,17 +21,26 @@ import { Readable } from 'stream';
import { extname } from 'path';
import { schema } from '@kbn/config-schema';
import { IRouter } from '../../http';
import { CoreUsageDataSetup } from '../../core_usage_data';
import { importSavedObjectsFromStream } from '../import';
import { SavedObjectConfig } from '../saved_objects_config';
import { createSavedObjectsStreamFromNdJson } from './utils';
interface RouteDependencies {
config: SavedObjectConfig;
coreUsageData: CoreUsageDataSetup;
}
interface FileStream extends Readable {
hapi: {
filename: string;
};
}
export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) => {
export const registerImportRoute = (
router: IRouter,
{ config, coreUsageData }: RouteDependencies
) => {
const { maxImportExportSize, maxImportPayloadBytes } = config;
router.post(
@ -65,6 +74,13 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig)
},
router.handleLegacyErrors(async (context, req, res) => {
const { overwrite, createNewCopies } = req.query;
const { headers } = req;
const usageStatsClient = coreUsageData.getClient();
usageStatsClient
.incrementSavedObjectsImport({ headers, createNewCopies, overwrite })
.catch(() => {});
const file = req.body.file as FileStream;
const fileExtension = extname(file.hapi.filename).toLowerCase();
if (fileExtension !== '.ndjson') {

View file

@ -18,6 +18,7 @@
*/
import { InternalHttpServiceSetup } from '../../http';
import { CoreUsageDataSetup } from '../../core_usage_data';
import { Logger } from '../../logging';
import { SavedObjectConfig } from '../saved_objects_config';
import { IKibanaMigrator } from '../migrations';
@ -37,11 +38,13 @@ import { registerMigrateRoute } from './migrate';
export function registerRoutes({
http,
coreUsageData,
logger,
config,
migratorPromise,
}: {
http: InternalHttpServiceSetup;
coreUsageData: CoreUsageDataSetup;
logger: Logger;
config: SavedObjectConfig;
migratorPromise: Promise<IKibanaMigrator>;
@ -57,9 +60,9 @@ export function registerRoutes({
registerBulkCreateRoute(router);
registerBulkUpdateRoute(router);
registerLogLegacyImportRoute(router, logger);
registerExportRoute(router, config);
registerImportRoute(router, config);
registerResolveImportErrorsRoute(router, config);
registerExportRoute(router, { config, coreUsageData });
registerImportRoute(router, { config, coreUsageData });
registerResolveImportErrorsRoute(router, { config, coreUsageData });
const internalRouter = http.createRouter('/internal/saved_objects/');

View file

@ -25,6 +25,9 @@ import * as exportMock from '../../export';
import supertest from 'supertest';
import type { UnwrapPromise } from '@kbn/utility-types';
import { createListStream } from '@kbn/utils';
import { CoreUsageStatsClient } from '../../../core_usage_data';
import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock';
import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock';
import { SavedObjectConfig } from '../../saved_objects_config';
import { registerExportRoute } from '../export';
import { setupServer, createExportableType } from '../test_utils';
@ -36,6 +39,7 @@ const config = {
maxImportPayloadBytes: 26214400,
maxImportExportSize: 10000,
} as SavedObjectConfig;
let coreUsageStatsClient: jest.Mocked<CoreUsageStatsClient>;
describe('POST /api/saved_objects/_export', () => {
let server: SetupServerReturn['server'];
@ -49,7 +53,10 @@ describe('POST /api/saved_objects/_export', () => {
);
const router = httpSetup.createRouter('/api/saved_objects/');
registerExportRoute(router, config);
coreUsageStatsClient = coreUsageStatsClientMock.create();
coreUsageStatsClient.incrementSavedObjectsExport.mockRejectedValue(new Error('Oh no!')); // this error is intentionally swallowed so the export does not fail
const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient);
registerExportRoute(router, { config, coreUsageData });
await server.start();
});
@ -59,7 +66,7 @@ describe('POST /api/saved_objects/_export', () => {
await server.stop();
});
it('formats successful response', async () => {
it('formats successful response and records usage stats', async () => {
const sortedObjects = [
{
id: '1',
@ -110,5 +117,10 @@ describe('POST /api/saved_objects/_export', () => {
types: ['search'],
})
);
expect(coreUsageStatsClient.incrementSavedObjectsExport).toHaveBeenCalledWith({
headers: expect.anything(),
types: ['search'],
supportedTypes: ['index-pattern', 'search'],
});
});
});

View file

@ -22,6 +22,9 @@ import supertest from 'supertest';
import { UnwrapPromise } from '@kbn/utility-types';
import { registerImportRoute } from '../import';
import { savedObjectsClientMock } from '../../../../../core/server/mocks';
import { CoreUsageStatsClient } from '../../../core_usage_data';
import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock';
import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock';
import { SavedObjectConfig } from '../../saved_objects_config';
import { setupServer, createExportableType } from '../test_utils';
import { SavedObjectsErrorHelpers } from '../..';
@ -31,6 +34,7 @@ type SetupServerReturn = UnwrapPromise<ReturnType<typeof setupServer>>;
const { v4: uuidv4 } = jest.requireActual('uuid');
const allowedTypes = ['index-pattern', 'visualization', 'dashboard'];
const config = { maxImportPayloadBytes: 26214400, maxImportExportSize: 10000 } as SavedObjectConfig;
let coreUsageStatsClient: jest.Mocked<CoreUsageStatsClient>;
const URL = '/internal/saved_objects/_import';
describe(`POST ${URL}`, () => {
@ -71,7 +75,10 @@ describe(`POST ${URL}`, () => {
savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] });
const router = httpSetup.createRouter('/internal/saved_objects/');
registerImportRoute(router, config);
coreUsageStatsClient = coreUsageStatsClientMock.create();
coreUsageStatsClient.incrementSavedObjectsImport.mockRejectedValue(new Error('Oh no!')); // this error is intentionally swallowed so the import does not fail
const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient);
registerImportRoute(router, { config, coreUsageData });
await server.start();
});
@ -80,7 +87,7 @@ describe(`POST ${URL}`, () => {
await server.stop();
});
it('formats successful response', async () => {
it('formats successful response and records usage stats', async () => {
const result = await supertest(httpSetup.server.listener)
.post(URL)
.set('content-Type', 'multipart/form-data; boundary=BOUNDARY')
@ -98,6 +105,11 @@ describe(`POST ${URL}`, () => {
expect(result.body).toEqual({ success: true, successCount: 0 });
expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created
expect(coreUsageStatsClient.incrementSavedObjectsImport).toHaveBeenCalledWith({
headers: expect.anything(),
createNewCopies: false,
overwrite: false,
});
});
it('defaults migrationVersion to empty object', async () => {

View file

@ -22,6 +22,9 @@ import supertest from 'supertest';
import { UnwrapPromise } from '@kbn/utility-types';
import { registerResolveImportErrorsRoute } from '../resolve_import_errors';
import { savedObjectsClientMock } from '../../../../../core/server/mocks';
import { CoreUsageStatsClient } from '../../../core_usage_data';
import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock';
import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock';
import { setupServer, createExportableType } from '../test_utils';
import { SavedObjectConfig } from '../../saved_objects_config';
@ -30,6 +33,7 @@ type SetupServerReturn = UnwrapPromise<ReturnType<typeof setupServer>>;
const { v4: uuidv4 } = jest.requireActual('uuid');
const allowedTypes = ['index-pattern', 'visualization', 'dashboard'];
const config = { maxImportPayloadBytes: 26214400, maxImportExportSize: 10000 } as SavedObjectConfig;
let coreUsageStatsClient: jest.Mocked<CoreUsageStatsClient>;
const URL = '/api/saved_objects/_resolve_import_errors';
describe(`POST ${URL}`, () => {
@ -76,7 +80,12 @@ describe(`POST ${URL}`, () => {
savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] });
const router = httpSetup.createRouter('/api/saved_objects/');
registerResolveImportErrorsRoute(router, config);
coreUsageStatsClient = coreUsageStatsClientMock.create();
coreUsageStatsClient.incrementSavedObjectsResolveImportErrors.mockRejectedValue(
new Error('Oh no!') // this error is intentionally swallowed so the export does not fail
);
const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient);
registerResolveImportErrorsRoute(router, { config, coreUsageData });
await server.start();
});
@ -85,7 +94,7 @@ describe(`POST ${URL}`, () => {
await server.stop();
});
it('formats successful response', async () => {
it('formats successful response and records usage stats', async () => {
const result = await supertest(httpSetup.server.listener)
.post(URL)
.set('content-Type', 'multipart/form-data; boundary=BOUNDARY')
@ -107,6 +116,10 @@ describe(`POST ${URL}`, () => {
expect(result.body).toEqual({ success: true, successCount: 0 });
expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created
expect(coreUsageStatsClient.incrementSavedObjectsResolveImportErrors).toHaveBeenCalledWith({
headers: expect.anything(),
createNewCopies: false,
});
});
it('defaults migrationVersion to empty object', async () => {

View file

@ -21,17 +21,26 @@ import { extname } from 'path';
import { Readable } from 'stream';
import { schema } from '@kbn/config-schema';
import { IRouter } from '../../http';
import { CoreUsageDataSetup } from '../../core_usage_data';
import { resolveSavedObjectsImportErrors } from '../import';
import { SavedObjectConfig } from '../saved_objects_config';
import { createSavedObjectsStreamFromNdJson } from './utils';
interface RouteDependencies {
config: SavedObjectConfig;
coreUsageData: CoreUsageDataSetup;
}
interface FileStream extends Readable {
hapi: {
filename: string;
};
}
export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedObjectConfig) => {
export const registerResolveImportErrorsRoute = (
router: IRouter,
{ config, coreUsageData }: RouteDependencies
) => {
const { maxImportExportSize, maxImportPayloadBytes } = config;
router.post(
@ -72,6 +81,14 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO
},
},
router.handleLegacyErrors(async (context, req, res) => {
const { createNewCopies } = req.query;
const { headers } = req;
const usageStatsClient = coreUsageData.getClient();
usageStatsClient
.incrementSavedObjectsResolveImportErrors({ headers, createNewCopies })
.catch(() => {});
const file = req.body.file as FileStream;
const fileExtension = extname(file.hapi.filename).toLowerCase();
if (fileExtension !== '.ndjson') {
@ -93,7 +110,7 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO
readStream,
retries: req.body.retries,
objectLimit: maxImportExportSize,
createNewCopies: req.query.createNewCopies,
createNewCopies,
});
return res.ok({ body: result });

View file

@ -33,6 +33,7 @@ import { Env } from '../config';
import { configServiceMock } from '../mocks';
import { elasticsearchServiceMock } from '../elasticsearch/elasticsearch_service.mock';
import { elasticsearchClientMock } from '../elasticsearch/client/mocks';
import { coreUsageDataServiceMock } from '../core_usage_data/core_usage_data_service.mock';
import { httpServiceMock } from '../http/http_service.mock';
import { httpServerMock } from '../http/http_server.mocks';
import { SavedObjectsClientFactoryProvider } from './service/lib';
@ -64,6 +65,7 @@ describe('SavedObjectsService', () => {
return {
http: httpServiceMock.createInternalSetupContract(),
elasticsearch: elasticsearchMock,
coreUsageData: coreUsageDataServiceMock.createSetupContract(),
};
};

View file

@ -27,6 +27,7 @@ import {
} from './';
import { KibanaMigrator, IKibanaMigrator } from './migrations';
import { CoreContext } from '../core_context';
import { CoreUsageDataSetup } from '../core_usage_data';
import {
ElasticsearchClient,
IClusterClient,
@ -253,6 +254,7 @@ export interface SavedObjectsRepositoryFactory {
export interface SavedObjectsSetupDeps {
http: InternalHttpServiceSetup;
elasticsearch: InternalElasticsearchServiceSetup;
coreUsageData: CoreUsageDataSetup;
}
interface WrappedClientFactoryWrapper {
@ -288,6 +290,7 @@ export class SavedObjectsService
this.logger.debug('Setting up SavedObjects service');
this.setupDeps = setupDeps;
const { http, elasticsearch, coreUsageData } = setupDeps;
const savedObjectsConfig = await this.coreContext.configService
.atPath<SavedObjectsConfigType>('savedObjects')
@ -299,8 +302,11 @@ export class SavedObjectsService
.toPromise();
this.config = new SavedObjectConfig(savedObjectsConfig, savedObjectsMigrationConfig);
coreUsageData.registerType(this.typeRegistry);
registerRoutes({
http: setupDeps.http,
http,
coreUsageData,
logger: this.logger,
config: this.config,
migratorPromise: this.migrator$.pipe(first()).toPromise(),
@ -309,7 +315,7 @@ export class SavedObjectsService
return {
status$: calculateStatus$(
this.migrator$.pipe(switchMap((migrator) => migrator.getStatus$())),
setupDeps.elasticsearch.status$
elasticsearch.status$
),
setClientFactoryProvider: (provider) => {
if (this.started) {

View file

@ -1562,12 +1562,12 @@ export class SavedObjectsRepository {
* @param options - {@link SavedObjectsIncrementCounterOptions}
* @returns The saved object after the specified fields were incremented
*/
async incrementCounter(
async incrementCounter<T = unknown>(
type: string,
id: string,
counterFieldNames: string[],
options: SavedObjectsIncrementCounterOptions = {}
): Promise<SavedObject> {
): Promise<SavedObject<T>> {
if (typeof type !== 'string') {
throw new Error('"type" argument must be a string');
}

View file

@ -521,7 +521,7 @@ export interface CoreStatus {
}
// @internal
export interface CoreUsageData {
export interface CoreUsageData extends CoreUsageStats {
// (undocumented)
config: CoreConfigUsageData;
// (undocumented)
@ -535,6 +535,44 @@ export interface CoreUsageDataStart {
getCoreUsageData(): Promise<CoreUsageData>;
}
// @internal
export interface CoreUsageStats {
// (undocumented)
'apiCalls.savedObjectsExport.allTypesSelected.no'?: number;
// (undocumented)
'apiCalls.savedObjectsExport.allTypesSelected.yes'?: number;
// (undocumented)
'apiCalls.savedObjectsExport.kibanaRequest.no'?: number;
// (undocumented)
'apiCalls.savedObjectsExport.kibanaRequest.yes'?: number;
// (undocumented)
'apiCalls.savedObjectsExport.total'?: number;
// (undocumented)
'apiCalls.savedObjectsImport.createNewCopiesEnabled.no'?: number;
// (undocumented)
'apiCalls.savedObjectsImport.createNewCopiesEnabled.yes'?: number;
// (undocumented)
'apiCalls.savedObjectsImport.kibanaRequest.no'?: number;
// (undocumented)
'apiCalls.savedObjectsImport.kibanaRequest.yes'?: number;
// (undocumented)
'apiCalls.savedObjectsImport.overwriteEnabled.no'?: number;
// (undocumented)
'apiCalls.savedObjectsImport.overwriteEnabled.yes'?: number;
// (undocumented)
'apiCalls.savedObjectsImport.total'?: number;
// (undocumented)
'apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.no'?: number;
// (undocumented)
'apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.yes'?: number;
// (undocumented)
'apiCalls.savedObjectsResolveImportErrors.kibanaRequest.no'?: number;
// (undocumented)
'apiCalls.savedObjectsResolveImportErrors.kibanaRequest.yes'?: number;
// (undocumented)
'apiCalls.savedObjectsResolveImportErrors.total'?: number;
}
// @public (undocumented)
export interface CountResponse {
// (undocumented)
@ -2448,7 +2486,7 @@ export class SavedObjectsRepository {
// (undocumented)
find<T = unknown>(options: SavedObjectsFindOptions): Promise<SavedObjectsFindResponse<T>>;
get<T = unknown>(type: string, id: string, options?: SavedObjectsBaseOptions): Promise<SavedObject<T>>;
incrementCounter(type: string, id: string, counterFieldNames: string[], options?: SavedObjectsIncrementCounterOptions): Promise<SavedObject>;
incrementCounter<T = unknown>(type: string, id: string, counterFieldNames: string[], options?: SavedObjectsIncrementCounterOptions): Promise<SavedObject<T>>;
removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise<SavedObjectsRemoveReferencesToResponse>;
update<T = unknown>(type: string, id: string, attributes: Partial<T>, options?: SavedObjectsUpdateOptions): Promise<SavedObjectsUpdateResponse<T>>;
}

View file

@ -185,6 +185,7 @@ test(`doesn't setup core services if config validation fails`, async () => {
expect(mockElasticsearchService.setup).not.toHaveBeenCalled();
expect(mockPluginsService.setup).not.toHaveBeenCalled();
expect(mockLegacyService.setup).not.toHaveBeenCalled();
expect(mockSavedObjectsService.stop).not.toHaveBeenCalled();
expect(mockUiSettingsService.setup).not.toHaveBeenCalled();
expect(mockRenderingService.setup).not.toHaveBeenCalled();
expect(mockMetricsService.setup).not.toHaveBeenCalled();

View file

@ -31,7 +31,7 @@ import { LegacyService, ensureValidConfiguration } from './legacy';
import { Logger, LoggerFactory, LoggingService, ILoggingSystem } from './logging';
import { UiSettingsService } from './ui_settings';
import { PluginsService, config as pluginsConfig } from './plugins';
import { SavedObjectsService } from './saved_objects';
import { SavedObjectsService, SavedObjectsServiceStart } from './saved_objects';
import { MetricsService, opsConfig } from './metrics';
import { CapabilitiesService } from './capabilities';
import { EnvironmentService, config as pidConfig } from './environment';
@ -78,6 +78,9 @@ export class Server {
private readonly coreUsageData: CoreUsageDataService;
private readonly i18n: I18nService;
private readonly savedObjectsStartPromise: Promise<SavedObjectsServiceStart>;
private resolveSavedObjectsStartPromise?: (value: SavedObjectsServiceStart) => void;
#pluginsInitialized?: boolean;
private coreStart?: InternalCoreStart;
private readonly logger: LoggerFactory;
@ -109,6 +112,10 @@ export class Server {
this.logging = new LoggingService(core);
this.coreUsageData = new CoreUsageDataService(core);
this.i18n = new I18nService(core);
this.savedObjectsStartPromise = new Promise((resolve) => {
this.resolveSavedObjectsStartPromise = resolve;
});
}
public async setup() {
@ -155,9 +162,17 @@ export class Server {
http: httpSetup,
});
const metricsSetup = await this.metrics.setup({ http: httpSetup });
const coreUsageDataSetup = this.coreUsageData.setup({
metrics: metricsSetup,
savedObjectsStartPromise: this.savedObjectsStartPromise,
});
const savedObjectsSetup = await this.savedObjects.setup({
http: httpSetup,
elasticsearch: elasticsearchServiceSetup,
coreUsageData: coreUsageDataSetup,
});
const uiSettingsSetup = await this.uiSettings.setup({
@ -165,8 +180,6 @@ export class Server {
savedObjects: savedObjectsSetup,
});
const metricsSetup = await this.metrics.setup({ http: httpSetup });
const statusSetup = await this.status.setup({
elasticsearch: elasticsearchServiceSetup,
pluginDependencies: pluginTree.asNames,
@ -191,8 +204,6 @@ export class Server {
loggingSystem: this.loggingSystem,
});
this.coreUsageData.setup({ metrics: metricsSetup });
const coreSetup: InternalCoreSetup = {
capabilities: capabilitiesSetup,
context: contextServiceSetup,
@ -235,6 +246,8 @@ export class Server {
elasticsearch: elasticsearchStart,
pluginsInitialized: this.#pluginsInitialized,
});
await this.resolveSavedObjectsStartPromise!(savedObjectsStart);
soStartSpan?.end();
const capabilitiesStart = this.capabilities.start();
const uiSettingsStart = await this.uiSettings.start();

View file

@ -115,6 +115,23 @@ export function getCoreUsageCollector(
},
},
},
'apiCalls.savedObjectsImport.total': { type: 'long' },
'apiCalls.savedObjectsImport.kibanaRequest.yes': { type: 'long' },
'apiCalls.savedObjectsImport.kibanaRequest.no': { type: 'long' },
'apiCalls.savedObjectsImport.createNewCopiesEnabled.yes': { type: 'long' },
'apiCalls.savedObjectsImport.createNewCopiesEnabled.no': { type: 'long' },
'apiCalls.savedObjectsImport.overwriteEnabled.yes': { type: 'long' },
'apiCalls.savedObjectsImport.overwriteEnabled.no': { type: 'long' },
'apiCalls.savedObjectsResolveImportErrors.total': { type: 'long' },
'apiCalls.savedObjectsResolveImportErrors.kibanaRequest.yes': { type: 'long' },
'apiCalls.savedObjectsResolveImportErrors.kibanaRequest.no': { type: 'long' },
'apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.yes': { type: 'long' },
'apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.no': { type: 'long' },
'apiCalls.savedObjectsExport.total': { type: 'long' },
'apiCalls.savedObjectsExport.kibanaRequest.yes': { type: 'long' },
'apiCalls.savedObjectsExport.kibanaRequest.no': { type: 'long' },
'apiCalls.savedObjectsExport.allTypesSelected.yes': { type: 'long' },
'apiCalls.savedObjectsExport.allTypesSelected.no': { type: 'long' },
},
fetch() {
return getCoreUsageDataService().getCoreUsageData();

View file

@ -570,11 +570,17 @@ exports[`Flyout should render import step 1`] = `
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Select a file to import"
id="savedObjectsManagement.objectsTable.flyout.selectFileToImportFormRowLabel"
values={Object {}}
/>
<EuiTitle
size="xs"
>
<span>
<FormattedMessage
defaultMessage="Select a file to import"
id="savedObjectsManagement.objectsTable.flyout.selectFileToImportFormRowLabel"
values={Object {}}
/>
</span>
</EuiTitle>
}
labelType="label"
>

View file

@ -758,10 +758,14 @@ export class Flyout extends Component<FlyoutProps, FlyoutState> {
<EuiFormRow
fullWidth
label={
<FormattedMessage
id="savedObjectsManagement.objectsTable.flyout.selectFileToImportFormRowLabel"
defaultMessage="Select a file to import"
/>
<EuiTitle size="xs">
<span>
<FormattedMessage
id="savedObjectsManagement.objectsTable.flyout.selectFileToImportFormRowLabel"
defaultMessage="Select a file to import"
/>
</span>
</EuiTitle>
}
>
<EuiFilePicker

View file

@ -120,7 +120,7 @@ export const ImportModeControl = ({
options={[overwriteEnabled, overwriteDisabled]}
idSelected={overwrite ? overwriteEnabled.id : overwriteDisabled.id}
onChange={(id: string) => onChange({ overwrite: id === overwriteEnabled.id })}
disabled={createNewCopies}
disabled={createNewCopies && !isLegacyFile}
data-test-subj={'savedObjectsManagement-importModeControl-overwriteRadioGroup'}
/>
);

View file

@ -1516,6 +1516,57 @@
}
}
}
},
"apiCalls.savedObjectsImport.total": {
"type": "long"
},
"apiCalls.savedObjectsImport.kibanaRequest.yes": {
"type": "long"
},
"apiCalls.savedObjectsImport.kibanaRequest.no": {
"type": "long"
},
"apiCalls.savedObjectsImport.createNewCopiesEnabled.yes": {
"type": "long"
},
"apiCalls.savedObjectsImport.createNewCopiesEnabled.no": {
"type": "long"
},
"apiCalls.savedObjectsImport.overwriteEnabled.yes": {
"type": "long"
},
"apiCalls.savedObjectsImport.overwriteEnabled.no": {
"type": "long"
},
"apiCalls.savedObjectsResolveImportErrors.total": {
"type": "long"
},
"apiCalls.savedObjectsResolveImportErrors.kibanaRequest.yes": {
"type": "long"
},
"apiCalls.savedObjectsResolveImportErrors.kibanaRequest.no": {
"type": "long"
},
"apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.yes": {
"type": "long"
},
"apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.no": {
"type": "long"
},
"apiCalls.savedObjectsExport.total": {
"type": "long"
},
"apiCalls.savedObjectsExport.kibanaRequest.yes": {
"type": "long"
},
"apiCalls.savedObjectsExport.kibanaRequest.no": {
"type": "long"
},
"apiCalls.savedObjectsExport.allTypesSelected.yes": {
"type": "long"
},
"apiCalls.savedObjectsExport.allTypesSelected.no": {
"type": "long"
}
}
},

View file

@ -10,7 +10,7 @@ import { mountWithIntl } from '@kbn/test/jest';
import { CopyModeControl, CopyModeControlProps } from './copy_mode_control';
describe('CopyModeControl', () => {
const initialValues = { createNewCopies: false, overwrite: true }; // some test cases below make assumptions based on these initial values
const initialValues = { createNewCopies: true, overwrite: true }; // some test cases below make assumptions based on these initial values
const updateSelection = jest.fn();
const getOverwriteRadio = (wrapper: ReactWrapper) =>
@ -34,21 +34,23 @@ describe('CopyModeControl', () => {
const wrapper = mountWithIntl(<CopyModeControl {...props} />);
expect(updateSelection).not.toHaveBeenCalled();
const { createNewCopies } = initialValues;
// need to disable `createNewCopies` first
getCreateNewCopiesDisabled(wrapper).simulate('change');
const createNewCopies = false;
getOverwriteDisabled(wrapper).simulate('change');
expect(updateSelection).toHaveBeenNthCalledWith(1, { createNewCopies, overwrite: false });
expect(updateSelection).toHaveBeenNthCalledWith(2, { createNewCopies, overwrite: false });
getOverwriteEnabled(wrapper).simulate('change');
expect(updateSelection).toHaveBeenNthCalledWith(2, { createNewCopies, overwrite: true });
expect(updateSelection).toHaveBeenNthCalledWith(3, { createNewCopies, overwrite: true });
});
it('should disable the Overwrite switch when `createNewCopies` is enabled', async () => {
it('should enable the Overwrite switch when `createNewCopies` is disabled', async () => {
const wrapper = mountWithIntl(<CopyModeControl {...props} />);
expect(getOverwriteRadio(wrapper).prop('disabled')).toBe(false);
getCreateNewCopiesEnabled(wrapper).simulate('change');
expect(getOverwriteRadio(wrapper).prop('disabled')).toBe(true);
getCreateNewCopiesDisabled(wrapper).simulate('change');
expect(getOverwriteRadio(wrapper).prop('disabled')).toBe(false);
});
it('should allow the user to toggle `createNewCopies`', async () => {
@ -57,10 +59,10 @@ describe('CopyModeControl', () => {
expect(updateSelection).not.toHaveBeenCalled();
const { overwrite } = initialValues;
getCreateNewCopiesEnabled(wrapper).simulate('change');
expect(updateSelection).toHaveBeenNthCalledWith(1, { createNewCopies: true, overwrite });
getCreateNewCopiesDisabled(wrapper).simulate('change');
expect(updateSelection).toHaveBeenNthCalledWith(2, { createNewCopies: false, overwrite });
expect(updateSelection).toHaveBeenNthCalledWith(1, { createNewCopies: false, overwrite });
getCreateNewCopiesEnabled(wrapper).simulate('change');
expect(updateSelection).toHaveBeenNthCalledWith(2, { createNewCopies: true, overwrite });
});
});

View file

@ -126,6 +126,15 @@ export const CopyModeControl = ({ initialValues, updateSelection }: CopyModeCont
),
}}
>
<EuiCheckableCard
id={createNewCopiesEnabled.id}
label={createLabel(createNewCopiesEnabled)}
checked={createNewCopies}
onChange={() => onChange({ createNewCopies: true })}
/>
<EuiSpacer size="s" />
<EuiCheckableCard
id={createNewCopiesDisabled.id}
label={createLabel(createNewCopiesDisabled)}
@ -140,15 +149,6 @@ export const CopyModeControl = ({ initialValues, updateSelection }: CopyModeCont
data-test-subj={'cts-copyModeControl-overwriteRadioGroup'}
/>
</EuiCheckableCard>
<EuiSpacer size="s" />
<EuiCheckableCard
id={createNewCopiesEnabled.id}
label={createLabel(createNewCopiesEnabled)}
checked={createNewCopies}
onChange={() => onChange({ createNewCopies: true })}
/>
</EuiFormFieldset>
<EuiSpacer size="m" />

View file

@ -12,6 +12,7 @@ import { EuiLoadingSpinner, EuiEmptyPrompt } from '@elastic/eui';
import { Space } from '../../../common/model/space';
import { findTestSubject } from '@kbn/test/jest';
import { SelectableSpacesControl } from './selectable_spaces_control';
import { CopyModeControl } from './copy_mode_control';
import { act } from '@testing-library/react';
import { ProcessingCopyToSpace } from './processing_copy_to_space';
import { spacesManagerMock } from '../../spaces_manager/mocks';
@ -289,7 +290,7 @@ describe('CopyToSpaceFlyout', () => {
[{ type: savedObjectToCopy.type, id: savedObjectToCopy.id }],
['space-1', 'space-2'],
true,
false,
true, // `createNewCopies` is enabled by default
true
);
@ -376,14 +377,25 @@ describe('CopyToSpaceFlyout', () => {
spaceSelector.props().onChange(['space-1', 'space-2']);
});
const startButton = findTestSubject(wrapper, 'cts-initiate-button');
// Change copy mode to check for conflicts
const copyModeControl = wrapper.find(CopyModeControl);
copyModeControl.find('input[id="createNewCopiesDisabled"]').simulate('change');
await act(async () => {
const startButton = findTestSubject(wrapper, 'cts-initiate-button');
startButton.simulate('click');
await nextTick();
wrapper.update();
});
expect(mockSpacesManager.copySavedObjects).toHaveBeenCalledWith(
[{ type: savedObjectToCopy.type, id: savedObjectToCopy.id }],
['space-1', 'space-2'],
true,
false, // `createNewCopies` is disabled
true
);
expect(wrapper.find(CopyToSpaceForm)).toHaveLength(0);
expect(wrapper.find(ProcessingCopyToSpace)).toHaveLength(1);
@ -429,7 +441,7 @@ describe('CopyToSpaceFlyout', () => {
],
},
true,
false
false // `createNewCopies` is disabled
);
expect(onClose).toHaveBeenCalledTimes(1);
@ -545,7 +557,7 @@ describe('CopyToSpaceFlyout', () => {
],
},
true,
false
true // `createNewCopies` is enabled by default
);
expect(onClose).toHaveBeenCalledTimes(1);

View file

@ -42,7 +42,7 @@ interface Props {
}
const INCLUDE_RELATED_DEFAULT = true;
const CREATE_NEW_COPIES_DEFAULT = false;
const CREATE_NEW_COPIES_DEFAULT = true;
const OVERWRITE_ALL_DEFAULT = true;
export const CopySavedObjectsToSpaceFlyout = (props: Props) => {

View file

@ -5,7 +5,7 @@
*/
import React from 'react';
import { EuiSpacer, EuiFormRow } from '@elastic/eui';
import { EuiSpacer, EuiTitle, EuiFormRow } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { CopyOptions } from '../types';
import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public';
@ -45,14 +45,18 @@ export const CopyToSpaceForm = (props: Props) => {
updateSelection={(newValues: CopyMode) => changeCopyMode(newValues)}
/>
<EuiSpacer />
<EuiSpacer size="m" />
<EuiFormRow
label={
<FormattedMessage
id="xpack.spaces.management.copyToSpace.selectSpacesLabel"
defaultMessage="Select spaces"
/>
<EuiTitle size="xs">
<span>
<FormattedMessage
id="xpack.spaces.management.copyToSpace.selectSpacesLabel"
defaultMessage="Select spaces"
/>
</span>
</EuiTitle>
}
fullWidth
>

View file

@ -72,7 +72,7 @@ export const SelectableSpacesControl = (props: Props) => {
className: 'spcCopyToSpace__spacesList',
'data-test-subj': 'cts-form-space-selector',
}}
searchable
searchable={options.length > 6}
>
{(list, search) => {
return (

View file

@ -175,7 +175,7 @@ export const SelectableSpacesControl = (props: Props) => {
'data-test-subj': 'sts-form-space-selector',
}}
height={ROW_HEIGHT * 3.5}
searchable
searchable={options.length > 6}
>
{(list, search) => {
return (

View file

@ -38,7 +38,7 @@ export const ShareToSpaceForm = (props: Props) => {
title={
<FormattedMessage
id="xpack.spaces.management.shareToSpace.shareWarningTitle"
defaultMessage="Editing a shared object applies the changes in all spaces"
defaultMessage="Editing a shared object applies the changes in every space"
/>
}
color="warning"

View file

@ -91,7 +91,8 @@ export class SpacesManager {
objects,
spaces,
includeReferences,
...(createNewCopies ? { createNewCopies } : { overwrite }),
createNewCopies,
...(createNewCopies ? { overwrite: false } : { overwrite }), // ignore the overwrite option if createNewCopies is enabled
}),
});
}

View file

@ -20,8 +20,8 @@ import {
import { LicensingPluginSetup } from '../../licensing/server';
import { createSpacesTutorialContextFactory } from './lib/spaces_tutorial_context_factory';
import { registerSpacesUsageCollector } from './usage_collection';
import { SpacesService, SpacesServiceStart } from './spaces_service';
import { SpacesServiceSetup } from './spaces_service';
import { SpacesService, SpacesServiceSetup, SpacesServiceStart } from './spaces_service';
import { UsageStatsService } from './usage_stats';
import { ConfigType } from './config';
import { initSpacesRequestInterceptors } from './lib/request_interceptors';
import { initExternalSpacesApi } from './routes/api/external';
@ -99,6 +99,10 @@ export class Plugin {
return this.spacesServiceStart;
};
const usageStatsServicePromise = new UsageStatsService(this.log).setup({
getStartServices: core.getStartServices,
});
const savedObjectsService = new SpacesSavedObjectsService();
savedObjectsService.setup({ core, getSpacesService });
@ -126,6 +130,7 @@ export class Plugin {
getStartServices: core.getStartServices,
getImportExportObjectLimit: core.savedObjects.getImportExportObjectLimit,
getSpacesService,
usageStatsServicePromise,
});
const internalRouter = core.http.createRouter();
@ -148,6 +153,7 @@ export class Plugin {
kibanaIndexConfig$: this.kibanaIndexConfig$,
features: plugins.features,
licensing: plugins.licensing,
usageStatsServicePromise,
});
}

View file

@ -22,6 +22,8 @@ import {
coreMock,
} from 'src/core/server/mocks';
import { SpacesService } from '../../../spaces_service';
import { usageStatsClientMock } from '../../../usage_stats/usage_stats_client.mock';
import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock';
import { initCopyToSpacesApi } from './copy_to_space';
import { spacesConfig } from '../../../lib/__fixtures__';
import { ObjectType } from '@kbn/config-schema';
@ -82,6 +84,11 @@ describe('copy to space', () => {
basePath: httpService.basePath,
});
const usageStatsClient = usageStatsClientMock.create();
const usageStatsServicePromise = Promise.resolve(
usageStatsServiceMock.createSetupContract(usageStatsClient)
);
const clientServiceStart = clientService.start(coreStart);
const spacesServiceStart = service.start({
@ -95,6 +102,7 @@ describe('copy to space', () => {
getImportExportObjectLimit: () => 1000,
log,
getSpacesService: () => spacesServiceStart,
usageStatsServicePromise,
});
const [
@ -113,6 +121,7 @@ describe('copy to space', () => {
routeHandler: resolveRouteHandler,
},
savedObjectsRepositoryMock,
usageStatsClient,
};
};
@ -136,6 +145,27 @@ describe('copy to space', () => {
});
});
it(`records usageStats data`, async () => {
const createNewCopies = Symbol();
const overwrite = Symbol();
const payload = { spaces: ['a-space'], objects: [], createNewCopies, overwrite };
const { copyToSpace, usageStatsClient } = await setup();
const request = httpServerMock.createKibanaRequest({
body: payload,
method: 'post',
});
await copyToSpace.routeHandler(mockRouteContext, request, kibanaResponseFactory);
expect(usageStatsClient.incrementCopySavedObjects).toHaveBeenCalledWith({
headers: request.headers,
createNewCopies,
overwrite,
});
});
it(`uses a Saved Objects Client instance without the spaces wrapper`, async () => {
const payload = {
spaces: ['a-space'],
@ -272,6 +302,25 @@ describe('copy to space', () => {
});
});
it(`records usageStats data`, async () => {
const createNewCopies = Symbol();
const payload = { retries: {}, objects: [], createNewCopies };
const { resolveConflicts, usageStatsClient } = await setup();
const request = httpServerMock.createKibanaRequest({
body: payload,
method: 'post',
});
await resolveConflicts.routeHandler(mockRouteContext, request, kibanaResponseFactory);
expect(usageStatsClient.incrementResolveCopySavedObjectsErrors).toHaveBeenCalledWith({
headers: request.headers,
createNewCopies,
});
});
it(`uses a Saved Objects Client instance without the spaces wrapper`, async () => {
const payload = {
retries: {

View file

@ -21,7 +21,14 @@ const areObjectsUnique = (objects: SavedObjectIdentifier[]) =>
_.uniqBy(objects, (o: SavedObjectIdentifier) => `${o.type}:${o.id}`).length === objects.length;
export function initCopyToSpacesApi(deps: ExternalRouteDeps) {
const { externalRouter, getSpacesService, getImportExportObjectLimit, getStartServices } = deps;
const {
externalRouter,
getSpacesService,
usageStatsServicePromise,
getImportExportObjectLimit,
getStartServices,
} = deps;
const usageStatsClientPromise = usageStatsServicePromise.then(({ getClient }) => getClient());
externalRouter.post(
{
@ -63,7 +70,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) {
),
includeReferences: schema.boolean({ defaultValue: false }),
overwrite: schema.boolean({ defaultValue: false }),
createNewCopies: schema.boolean({ defaultValue: false }),
createNewCopies: schema.boolean({ defaultValue: true }),
},
{
validate: (object) => {
@ -77,12 +84,6 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) {
},
createLicensedRouteHandler(async (context, request, response) => {
const [startServices] = await getStartServices();
const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory(
startServices.savedObjects,
getImportExportObjectLimit,
request
);
const {
spaces: destinationSpaceIds,
objects,
@ -90,6 +91,17 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) {
overwrite,
createNewCopies,
} = request.body;
const { headers } = request;
usageStatsClientPromise.then((usageStatsClient) =>
usageStatsClient.incrementCopySavedObjects({ headers, createNewCopies, overwrite })
);
const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory(
startServices.savedObjects,
getImportExportObjectLimit,
request
);
const sourceSpaceId = getSpacesService().getSpaceId(request);
const copyResponse = await copySavedObjectsToSpaces(sourceSpaceId, destinationSpaceIds, {
objects,
@ -142,19 +154,24 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) {
}
),
includeReferences: schema.boolean({ defaultValue: false }),
createNewCopies: schema.boolean({ defaultValue: false }),
createNewCopies: schema.boolean({ defaultValue: true }),
}),
},
},
createLicensedRouteHandler(async (context, request, response) => {
const [startServices] = await getStartServices();
const { objects, includeReferences, retries, createNewCopies } = request.body;
const { headers } = request;
usageStatsClientPromise.then((usageStatsClient) =>
usageStatsClient.incrementResolveCopySavedObjectsErrors({ headers, createNewCopies })
);
const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory(
startServices.savedObjects,
getImportExportObjectLimit,
request
);
const { objects, includeReferences, retries, createNewCopies } = request.body;
const sourceSpaceId = getSpacesService().getSpaceId(request);
const resolveConflictsResponse = await resolveCopySavedObjectsToSpacesConflicts(
sourceSpaceId,

View file

@ -27,6 +27,7 @@ import { initDeleteSpacesApi } from './delete';
import { spacesConfig } from '../../../lib/__fixtures__';
import { ObjectType } from '@kbn/config-schema';
import { SpacesClientService } from '../../../spaces_client';
import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock';
describe('Spaces Public API', () => {
const spacesSavedObjects = createSpaces();
@ -51,6 +52,8 @@ describe('Spaces Public API', () => {
basePath: httpService.basePath,
});
const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract());
const clientServiceStart = clientService.start(coreStart);
const spacesServiceStart = service.start({
@ -64,6 +67,7 @@ describe('Spaces Public API', () => {
getImportExportObjectLimit: () => 1000,
log,
getSpacesService: () => spacesServiceStart,
usageStatsServicePromise,
});
const [routeDefinition, routeHandler] = router.delete.mock.calls[0];

View file

@ -21,6 +21,7 @@ import {
import { SpacesService } from '../../../spaces_service';
import { spacesConfig } from '../../../lib/__fixtures__';
import { SpacesClientService } from '../../../spaces_client';
import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock';
describe('GET space', () => {
const spacesSavedObjects = createSpaces();
@ -46,6 +47,8 @@ describe('GET space', () => {
basePath: httpService.basePath,
});
const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract());
const clientServiceStart = clientService.start(coreStart);
const spacesServiceStart = service.start({
@ -59,6 +62,7 @@ describe('GET space', () => {
getImportExportObjectLimit: () => 1000,
log,
getSpacesService: () => spacesServiceStart,
usageStatsServicePromise,
});
return {

View file

@ -22,6 +22,7 @@ import { initGetAllSpacesApi } from './get_all';
import { spacesConfig } from '../../../lib/__fixtures__';
import { ObjectType } from '@kbn/config-schema';
import { SpacesClientService } from '../../../spaces_client';
import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock';
describe('GET /spaces/space', () => {
const spacesSavedObjects = createSpaces();
@ -47,6 +48,8 @@ describe('GET /spaces/space', () => {
basePath: httpService.basePath,
});
const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract());
const clientServiceStart = clientService.start(coreStart);
const spacesServiceStart = service.start({
@ -60,6 +63,7 @@ describe('GET /spaces/space', () => {
getImportExportObjectLimit: () => 1000,
log,
getSpacesService: () => spacesServiceStart,
usageStatsServicePromise,
});
return {

View file

@ -10,7 +10,8 @@ import { initGetSpaceApi } from './get';
import { initGetAllSpacesApi } from './get_all';
import { initPostSpacesApi } from './post';
import { initPutSpacesApi } from './put';
import { SpacesServiceStart } from '../../../spaces_service/spaces_service';
import { SpacesServiceStart } from '../../../spaces_service';
import { UsageStatsServiceSetup } from '../../../usage_stats';
import { initCopyToSpacesApi } from './copy_to_space';
import { initShareToSpacesApi } from './share_to_space';
@ -19,6 +20,7 @@ export interface ExternalRouteDeps {
getStartServices: CoreSetup['getStartServices'];
getImportExportObjectLimit: () => number;
getSpacesService: () => SpacesServiceStart;
usageStatsServicePromise: Promise<UsageStatsServiceSetup>;
log: Logger;
}

View file

@ -22,6 +22,7 @@ import { initPostSpacesApi } from './post';
import { spacesConfig } from '../../../lib/__fixtures__';
import { ObjectType } from '@kbn/config-schema';
import { SpacesClientService } from '../../../spaces_client';
import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock';
describe('Spaces Public API', () => {
const spacesSavedObjects = createSpaces();
@ -46,6 +47,8 @@ describe('Spaces Public API', () => {
basePath: httpService.basePath,
});
const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract());
const clientServiceStart = clientService.start(coreStart);
const spacesServiceStart = service.start({
@ -59,6 +62,7 @@ describe('Spaces Public API', () => {
getImportExportObjectLimit: () => 1000,
log,
getSpacesService: () => spacesServiceStart,
usageStatsServicePromise,
});
const [routeDefinition, routeHandler] = router.post.mock.calls[0];

View file

@ -23,6 +23,7 @@ import { initPutSpacesApi } from './put';
import { spacesConfig } from '../../../lib/__fixtures__';
import { ObjectType } from '@kbn/config-schema';
import { SpacesClientService } from '../../../spaces_client';
import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock';
describe('PUT /api/spaces/space', () => {
const spacesSavedObjects = createSpaces();
@ -47,6 +48,8 @@ describe('PUT /api/spaces/space', () => {
basePath: httpService.basePath,
});
const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract());
const clientServiceStart = clientService.start(coreStart);
const spacesServiceStart = service.start({
@ -60,6 +63,7 @@ describe('PUT /api/spaces/space', () => {
getImportExportObjectLimit: () => 1000,
log,
getSpacesService: () => spacesServiceStart,
usageStatsServicePromise,
});
const [routeDefinition, routeHandler] = router.put.mock.calls[0];

View file

@ -23,6 +23,7 @@ import { initShareToSpacesApi } from './share_to_space';
import { spacesConfig } from '../../../lib/__fixtures__';
import { ObjectType } from '@kbn/config-schema';
import { SpacesClientService } from '../../../spaces_client';
import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock';
describe('share to space', () => {
const spacesSavedObjects = createSpaces();
@ -47,6 +48,8 @@ describe('share to space', () => {
basePath: httpService.basePath,
});
const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract());
const clientServiceStart = clientService.start(coreStart);
const spacesServiceStart = service.start({
@ -59,6 +62,7 @@ describe('share to space', () => {
getImportExportObjectLimit: () => 1000,
log,
getSpacesService: () => spacesServiceStart,
usageStatsServicePromise,
});
const [

View file

@ -38,3 +38,8 @@ export const SpacesSavedObjectMappings = deepFreeze({
},
},
});
export const UsageStatsMappings = deepFreeze({
dynamic: false as false, // we aren't querying or aggregating over this data, so we don't need to specify any fields
properties: {},
});

View file

@ -5,6 +5,7 @@
*/
import { coreMock } from 'src/core/server/mocks';
import { SPACES_USAGE_STATS_TYPE } from '../usage_stats';
import { spacesServiceMock } from '../spaces_service/spaces_service.mock';
import { SpacesSavedObjectsService } from './saved_objects_service';
@ -17,51 +18,15 @@ describe('SpacesSavedObjectsService', () => {
const service = new SpacesSavedObjectsService();
service.setup({ core, getSpacesService: () => spacesService });
expect(core.savedObjects.registerType).toHaveBeenCalledTimes(1);
expect(core.savedObjects.registerType.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"hidden": true,
"mappings": Object {
"properties": Object {
"_reserved": Object {
"type": "boolean",
},
"color": Object {
"type": "keyword",
},
"description": Object {
"type": "text",
},
"disabledFeatures": Object {
"type": "keyword",
},
"imageUrl": Object {
"index": false,
"type": "text",
},
"initials": Object {
"type": "keyword",
},
"name": Object {
"fields": Object {
"keyword": Object {
"ignore_above": 2048,
"type": "keyword",
},
},
"type": "text",
},
},
},
"migrations": Object {
"6.6.0": [Function],
},
"name": "space",
"namespaceType": "agnostic",
},
]
`);
expect(core.savedObjects.registerType).toHaveBeenCalledTimes(2);
expect(core.savedObjects.registerType).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ name: 'space' })
);
expect(core.savedObjects.registerType).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ name: SPACES_USAGE_STATS_TYPE })
);
});
it('registers the client wrapper', () => {

View file

@ -5,10 +5,11 @@
*/
import { CoreSetup } from 'src/core/server';
import { SpacesSavedObjectMappings } from './mappings';
import { SpacesSavedObjectMappings, UsageStatsMappings } from './mappings';
import { migrateToKibana660 } from './migrations';
import { spacesSavedObjectsClientWrapperFactory } from './saved_objects_client_wrapper_factory';
import { SpacesServiceStart } from '../spaces_service';
import { SPACES_USAGE_STATS_TYPE } from '../usage_stats';
interface SetupDeps {
core: Pick<CoreSetup, 'savedObjects' | 'getStartServices'>;
@ -27,6 +28,13 @@ export class SpacesSavedObjectsService {
},
});
core.savedObjects.registerType({
name: SPACES_USAGE_STATS_TYPE,
hidden: true,
namespaceType: 'agnostic',
mappings: UsageStatsMappings,
});
core.savedObjects.addClientWrapper(
Number.MIN_SAFE_INTEGER,
'spaces',

View file

@ -4,11 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { getSpacesUsageCollector, UsageStats } from './spaces_usage_collector';
import { getSpacesUsageCollector, UsageData } from './spaces_usage_collector';
import * as Rx from 'rxjs';
import { PluginsSetup } from '../plugin';
import { KibanaFeature } from '../../../features/server';
import { ILicense, LicensingPluginSetup } from '../../../licensing/server';
import { UsageStats } from '../usage_stats';
import { usageStatsClientMock } from '../usage_stats/usage_stats_client.mock';
import { usageStatsServiceMock } from '../usage_stats/usage_stats_service.mock';
import { pluginInitializerContextConfigMock } from 'src/core/server/mocks';
import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks';
@ -17,6 +20,21 @@ interface SetupOpts {
features?: KibanaFeature[];
}
const MOCK_USAGE_STATS: UsageStats = {
'apiCalls.copySavedObjects.total': 5,
'apiCalls.copySavedObjects.kibanaRequest.yes': 5,
'apiCalls.copySavedObjects.kibanaRequest.no': 0,
'apiCalls.copySavedObjects.createNewCopiesEnabled.yes': 2,
'apiCalls.copySavedObjects.createNewCopiesEnabled.no': 3,
'apiCalls.copySavedObjects.overwriteEnabled.yes': 1,
'apiCalls.copySavedObjects.overwriteEnabled.no': 4,
'apiCalls.resolveCopySavedObjectsErrors.total': 13,
'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.yes': 13,
'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.no': 0,
'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.yes': 6,
'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.no': 7,
};
function setup({
license = { isAvailable: true },
features = [{ id: 'feature1' } as KibanaFeature, { id: 'feature2' } as KibanaFeature],
@ -41,12 +59,18 @@ function setup({
getKibanaFeatures: jest.fn().mockReturnValue(features),
} as unknown) as PluginsSetup['features'];
const usageStatsClient = usageStatsClientMock.create();
usageStatsClient.getUsageStats.mockResolvedValue(MOCK_USAGE_STATS);
const usageStatsService = usageStatsServiceMock.createSetupContract(usageStatsClient);
return {
licensing,
features: featuresSetup,
usageCollection: {
makeUsageCollector: (options: any) => new MockUsageCollector(options),
},
usageStatsService,
usageStatsClient,
};
}
@ -77,26 +101,28 @@ const getMockFetchContext = (mockedCallCluster: jest.Mock) => {
describe('error handling', () => {
it('handles a 404 when searching for space usage', async () => {
const { features, licensing, usageCollection } = setup({
const { features, licensing, usageCollection, usageStatsService } = setup({
license: { isAvailable: true, type: 'basic' },
});
const collector = getSpacesUsageCollector(usageCollection as any, {
kibanaIndexConfig$: Rx.of({ kibana: { index: '.kibana' } }),
features,
licensing,
usageStatsServicePromise: Promise.resolve(usageStatsService),
});
await collector.fetch(getMockFetchContext(jest.fn().mockRejectedValue({ status: 404 })));
});
it('throws error for a non-404', async () => {
const { features, licensing, usageCollection } = setup({
const { features, licensing, usageCollection, usageStatsService } = setup({
license: { isAvailable: true, type: 'basic' },
});
const collector = getSpacesUsageCollector(usageCollection as any, {
kibanaIndexConfig$: Rx.of({ kibana: { index: '.kibana' } }),
features,
licensing,
usageStatsServicePromise: Promise.resolve(usageStatsService),
});
const statusCodes = [401, 402, 403, 500];
@ -110,17 +136,19 @@ describe('error handling', () => {
});
describe('with a basic license', () => {
let usageStats: UsageStats;
let usageData: UsageData;
const { features, licensing, usageCollection, usageStatsService, usageStatsClient } = setup({
license: { isAvailable: true, type: 'basic' },
});
beforeAll(async () => {
const { features, licensing, usageCollection } = setup({
license: { isAvailable: true, type: 'basic' },
});
const collector = getSpacesUsageCollector(usageCollection as any, {
kibanaIndexConfig$: pluginInitializerContextConfigMock({}).legacy.globalConfig$,
features,
licensing,
usageStatsServicePromise: Promise.resolve(usageStatsService),
});
usageStats = await collector.fetch(getMockFetchContext(defaultCallClusterMock));
usageData = await collector.fetch(getMockFetchContext(defaultCallClusterMock));
expect(defaultCallClusterMock).toHaveBeenCalledWith('search', {
body: {
@ -138,87 +166,111 @@ describe('with a basic license', () => {
});
test('sets enabled to true', () => {
expect(usageStats.enabled).toBe(true);
expect(usageData.enabled).toBe(true);
});
test('sets available to true', () => {
expect(usageStats.available).toBe(true);
expect(usageData.available).toBe(true);
});
test('sets the number of spaces', () => {
expect(usageStats.count).toBe(2);
expect(usageData.count).toBe(2);
});
test('calculates feature control usage', () => {
expect(usageStats.usesFeatureControls).toBe(true);
expect(usageStats).toHaveProperty('disabledFeatures');
expect(usageStats.disabledFeatures).toEqual({
expect(usageData.usesFeatureControls).toBe(true);
expect(usageData).toHaveProperty('disabledFeatures');
expect(usageData.disabledFeatures).toEqual({
feature1: 1,
feature2: 0,
});
});
test('fetches usageStats data', () => {
expect(usageStatsService.getClient).toHaveBeenCalledTimes(1);
expect(usageStatsClient.getUsageStats).toHaveBeenCalledTimes(1);
expect(usageData).toEqual(expect.objectContaining(MOCK_USAGE_STATS));
});
});
describe('with no license', () => {
let usageStats: UsageStats;
let usageData: UsageData;
const { features, licensing, usageCollection, usageStatsService, usageStatsClient } = setup({
license: { isAvailable: false },
});
beforeAll(async () => {
const { features, licensing, usageCollection } = setup({ license: { isAvailable: false } });
const collector = getSpacesUsageCollector(usageCollection as any, {
kibanaIndexConfig$: pluginInitializerContextConfigMock({}).legacy.globalConfig$,
features,
licensing,
usageStatsServicePromise: Promise.resolve(usageStatsService),
});
usageStats = await collector.fetch(getMockFetchContext(defaultCallClusterMock));
usageData = await collector.fetch(getMockFetchContext(defaultCallClusterMock));
});
test('sets enabled to false', () => {
expect(usageStats.enabled).toBe(false);
expect(usageData.enabled).toBe(false);
});
test('sets available to false', () => {
expect(usageStats.available).toBe(false);
expect(usageData.available).toBe(false);
});
test('does not set the number of spaces', () => {
expect(usageStats.count).toBeUndefined();
expect(usageData.count).toBeUndefined();
});
test('does not set feature control usage', () => {
expect(usageStats.usesFeatureControls).toBeUndefined();
expect(usageData.usesFeatureControls).toBeUndefined();
});
test('does not fetch usageStats data', () => {
expect(usageStatsService.getClient).not.toHaveBeenCalled();
expect(usageStatsClient.getUsageStats).not.toHaveBeenCalled();
expect(usageData).not.toEqual(expect.objectContaining(MOCK_USAGE_STATS));
});
});
describe('with platinum license', () => {
let usageStats: UsageStats;
let usageData: UsageData;
const { features, licensing, usageCollection, usageStatsService, usageStatsClient } = setup({
license: { isAvailable: true, type: 'platinum' },
});
beforeAll(async () => {
const { features, licensing, usageCollection } = setup({
license: { isAvailable: true, type: 'platinum' },
});
const collector = getSpacesUsageCollector(usageCollection as any, {
kibanaIndexConfig$: pluginInitializerContextConfigMock({}).legacy.globalConfig$,
features,
licensing,
usageStatsServicePromise: Promise.resolve(usageStatsService),
});
usageStats = await collector.fetch(getMockFetchContext(defaultCallClusterMock));
usageData = await collector.fetch(getMockFetchContext(defaultCallClusterMock));
});
test('sets enabled to true', () => {
expect(usageStats.enabled).toBe(true);
expect(usageData.enabled).toBe(true);
});
test('sets available to true', () => {
expect(usageStats.available).toBe(true);
expect(usageData.available).toBe(true);
});
test('sets the number of spaces', () => {
expect(usageStats.count).toBe(2);
expect(usageData.count).toBe(2);
});
test('calculates feature control usage', () => {
expect(usageStats.usesFeatureControls).toBe(true);
expect(usageStats.disabledFeatures).toEqual({
expect(usageData.usesFeatureControls).toBe(true);
expect(usageData.disabledFeatures).toEqual({
feature1: 1,
feature2: 0,
});
});
test('fetches usageStats data', () => {
expect(usageStatsService.getClient).toHaveBeenCalledTimes(1);
expect(usageStatsClient.getUsageStats).toHaveBeenCalledTimes(1);
expect(usageData).toEqual(expect.objectContaining(MOCK_USAGE_STATS));
});
});

View file

@ -9,6 +9,7 @@ import { take } from 'rxjs/operators';
import { CollectorFetchContext, UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { Observable } from 'rxjs';
import { PluginsSetup } from '../plugin';
import { UsageStats, UsageStatsServiceSetup } from '../usage_stats';
type CallCluster = <T = unknown>(
endpoint: string,
@ -33,7 +34,7 @@ interface SpacesAggregationResponse {
* @param {string} kibanaIndex
* @param {PluginsSetup['features']} features
* @param {boolean} spacesAvailable
* @return {UsageStats}
* @return {UsageData}
*/
async function getSpacesUsage(
callCluster: CallCluster,
@ -109,10 +110,22 @@ async function getSpacesUsage(
count,
usesFeatureControls,
disabledFeatures,
} as UsageStats;
} as UsageData;
}
export interface UsageStats {
async function getUsageStats(
usageStatsServicePromise: Promise<UsageStatsServiceSetup>,
spacesAvailable: boolean
) {
if (!spacesAvailable) {
return null;
}
const usageStatsClient = await usageStatsServicePromise.then(({ getClient }) => getClient());
return usageStatsClient.getUsageStats();
}
export interface UsageData extends UsageStats {
available: boolean;
enabled: boolean;
count?: number;
@ -143,6 +156,7 @@ interface CollectorDeps {
kibanaIndexConfig$: Observable<{ kibana: { index: string } }>;
features: PluginsSetup['features'];
licensing: PluginsSetup['licensing'];
usageStatsServicePromise: Promise<UsageStatsServiceSetup>;
}
/*
@ -153,7 +167,7 @@ export function getSpacesUsageCollector(
usageCollection: UsageCollectionSetup,
deps: CollectorDeps
) {
return usageCollection.makeUsageCollector<UsageStats>({
return usageCollection.makeUsageCollector<UsageData>({
type: 'spaces',
isReady: () => true,
schema: {
@ -181,20 +195,35 @@ export function getSpacesUsageCollector(
available: { type: 'boolean' },
enabled: { type: 'boolean' },
count: { type: 'long' },
'apiCalls.copySavedObjects.total': { type: 'long' },
'apiCalls.copySavedObjects.kibanaRequest.yes': { type: 'long' },
'apiCalls.copySavedObjects.kibanaRequest.no': { type: 'long' },
'apiCalls.copySavedObjects.createNewCopiesEnabled.yes': { type: 'long' },
'apiCalls.copySavedObjects.createNewCopiesEnabled.no': { type: 'long' },
'apiCalls.copySavedObjects.overwriteEnabled.yes': { type: 'long' },
'apiCalls.copySavedObjects.overwriteEnabled.no': { type: 'long' },
'apiCalls.resolveCopySavedObjectsErrors.total': { type: 'long' },
'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.yes': { type: 'long' },
'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.no': { type: 'long' },
'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.yes': { type: 'long' },
'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.no': { type: 'long' },
},
fetch: async ({ callCluster }: CollectorFetchContext) => {
const license = await deps.licensing.license$.pipe(take(1)).toPromise();
const { licensing, kibanaIndexConfig$, features, usageStatsServicePromise } = deps;
const license = await licensing.license$.pipe(take(1)).toPromise();
const available = license.isAvailable; // some form of spaces is available for all valid licenses
const kibanaIndex = (await deps.kibanaIndexConfig$.pipe(take(1)).toPromise()).kibana.index;
const kibanaIndex = (await kibanaIndexConfig$.pipe(take(1)).toPromise()).kibana.index;
const usageStats = await getSpacesUsage(callCluster, kibanaIndex, deps.features, available);
const usageData = await getSpacesUsage(callCluster, kibanaIndex, features, available);
const usageStats = await getUsageStats(usageStatsServicePromise, available);
return {
available,
enabled: available,
...usageData,
...usageStats,
} as UsageStats;
} as UsageData;
},
});
}

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const SPACES_USAGE_STATS_TYPE = 'spaces-usage-stats';
export const SPACES_USAGE_STATS_ID = 'spaces-usage-stats';

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { SPACES_USAGE_STATS_TYPE } from './constants';
export { UsageStatsService, UsageStatsServiceSetup } from './usage_stats_service';
export { UsageStats } from './types';

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export interface UsageStats {
'apiCalls.copySavedObjects.total'?: number;
'apiCalls.copySavedObjects.kibanaRequest.yes'?: number;
'apiCalls.copySavedObjects.kibanaRequest.no'?: number;
'apiCalls.copySavedObjects.createNewCopiesEnabled.yes'?: number;
'apiCalls.copySavedObjects.createNewCopiesEnabled.no'?: number;
'apiCalls.copySavedObjects.overwriteEnabled.yes'?: number;
'apiCalls.copySavedObjects.overwriteEnabled.no'?: number;
'apiCalls.resolveCopySavedObjectsErrors.total'?: number;
'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.yes'?: number;
'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.no'?: number;
'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.yes'?: number;
'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.no'?: number;
}

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { UsageStatsClient } from './usage_stats_client';
const createUsageStatsClientMock = () =>
(({
getUsageStats: jest.fn().mockResolvedValue({}),
incrementCopySavedObjects: jest.fn().mockResolvedValue(null),
incrementResolveCopySavedObjectsErrors: jest.fn().mockResolvedValue(null),
} as unknown) as jest.Mocked<UsageStatsClient>);
export const usageStatsClientMock = {
create: createUsageStatsClientMock,
};

View file

@ -0,0 +1,181 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { savedObjectsRepositoryMock } from 'src/core/server/mocks';
import { SPACES_USAGE_STATS_TYPE, SPACES_USAGE_STATS_ID } from './constants';
import {
UsageStatsClient,
IncrementCopySavedObjectsOptions,
IncrementResolveCopySavedObjectsErrorsOptions,
COPY_STATS_PREFIX,
RESOLVE_COPY_STATS_PREFIX,
} from './usage_stats_client';
describe('UsageStatsClient', () => {
const setup = () => {
const debugLoggerMock = jest.fn();
const repositoryMock = savedObjectsRepositoryMock.create();
const usageStatsClient = new UsageStatsClient(debugLoggerMock, Promise.resolve(repositoryMock));
return { usageStatsClient, debugLoggerMock, repositoryMock };
};
const firstPartyRequestHeaders = { 'kbn-version': 'a', origin: 'b', referer: 'c' }; // as long as these three header fields are truthy, this will be treated like a first-party request
const incrementOptions = { refresh: false };
describe('#getUsageStats', () => {
it('calls repository.incrementCounter and initializes fields', async () => {
const { usageStatsClient, repositoryMock } = setup();
await usageStatsClient.getUsageStats();
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
SPACES_USAGE_STATS_TYPE,
SPACES_USAGE_STATS_ID,
[
`${COPY_STATS_PREFIX}.total`,
`${COPY_STATS_PREFIX}.kibanaRequest.yes`,
`${COPY_STATS_PREFIX}.kibanaRequest.no`,
`${COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`,
`${COPY_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${COPY_STATS_PREFIX}.overwriteEnabled.yes`,
`${COPY_STATS_PREFIX}.overwriteEnabled.no`,
`${RESOLVE_COPY_STATS_PREFIX}.total`,
`${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.yes`,
`${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.no`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.no`,
],
{ initialize: true }
);
});
it('returns empty object when encountering a repository error', async () => {
const { usageStatsClient, repositoryMock } = setup();
repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!'));
const result = await usageStatsClient.getUsageStats();
expect(result).toEqual({});
});
it('returns object attributes when usageStats data exists', async () => {
const { usageStatsClient, repositoryMock } = setup();
const usageStats = { foo: 'bar' };
repositoryMock.incrementCounter.mockResolvedValue({
type: SPACES_USAGE_STATS_TYPE,
id: SPACES_USAGE_STATS_ID,
attributes: usageStats,
references: [],
});
const result = await usageStatsClient.getUsageStats();
expect(result).toEqual(usageStats);
});
});
describe('#incrementCopySavedObjects', () => {
it('does not throw an error if repository incrementCounter operation fails', async () => {
const { usageStatsClient, repositoryMock } = setup();
repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!'));
await expect(
usageStatsClient.incrementCopySavedObjects({} as IncrementCopySavedObjectsOptions)
).resolves.toBeUndefined();
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
});
it('handles falsy options appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup();
await usageStatsClient.incrementCopySavedObjects({} as IncrementCopySavedObjectsOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
SPACES_USAGE_STATS_TYPE,
SPACES_USAGE_STATS_ID,
[
`${COPY_STATS_PREFIX}.total`,
`${COPY_STATS_PREFIX}.kibanaRequest.no`,
`${COPY_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${COPY_STATS_PREFIX}.overwriteEnabled.no`,
],
incrementOptions
);
});
it('handles truthy options appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup();
await usageStatsClient.incrementCopySavedObjects({
headers: firstPartyRequestHeaders,
createNewCopies: true,
overwrite: true,
} as IncrementCopySavedObjectsOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
SPACES_USAGE_STATS_TYPE,
SPACES_USAGE_STATS_ID,
[
`${COPY_STATS_PREFIX}.total`,
`${COPY_STATS_PREFIX}.kibanaRequest.yes`,
`${COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`,
`${COPY_STATS_PREFIX}.overwriteEnabled.yes`,
],
incrementOptions
);
});
});
describe('#incrementResolveCopySavedObjectsErrors', () => {
it('does not throw an error if repository create operation fails', async () => {
const { usageStatsClient, repositoryMock } = setup();
repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!'));
await expect(
usageStatsClient.incrementResolveCopySavedObjectsErrors(
{} as IncrementResolveCopySavedObjectsErrorsOptions
)
).resolves.toBeUndefined();
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
});
it('handles falsy options appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup();
await usageStatsClient.incrementResolveCopySavedObjectsErrors(
{} as IncrementResolveCopySavedObjectsErrorsOptions
);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
SPACES_USAGE_STATS_TYPE,
SPACES_USAGE_STATS_ID,
[
`${RESOLVE_COPY_STATS_PREFIX}.total`,
`${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.no`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.no`,
],
incrementOptions
);
});
it('handles truthy options appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup();
await usageStatsClient.incrementResolveCopySavedObjectsErrors({
headers: firstPartyRequestHeaders,
createNewCopies: true,
} as IncrementResolveCopySavedObjectsErrorsOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
SPACES_USAGE_STATS_TYPE,
SPACES_USAGE_STATS_ID,
[
`${RESOLVE_COPY_STATS_PREFIX}.total`,
`${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.yes`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`,
],
incrementOptions
);
});
});
});

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ISavedObjectsRepository, Headers } from 'src/core/server';
import { SPACES_USAGE_STATS_TYPE, SPACES_USAGE_STATS_ID } from './constants';
import { CopyOptions, ResolveConflictsOptions } from '../lib/copy_to_spaces/types';
import { UsageStats } from './types';
interface BaseIncrementOptions {
headers?: Headers;
}
export type IncrementCopySavedObjectsOptions = BaseIncrementOptions &
Pick<CopyOptions, 'createNewCopies' | 'overwrite'>;
export type IncrementResolveCopySavedObjectsErrorsOptions = BaseIncrementOptions &
Pick<ResolveConflictsOptions, 'createNewCopies'>;
export const COPY_STATS_PREFIX = 'apiCalls.copySavedObjects';
export const RESOLVE_COPY_STATS_PREFIX = 'apiCalls.resolveCopySavedObjectsErrors';
const ALL_COUNTER_FIELDS = [
`${COPY_STATS_PREFIX}.total`,
`${COPY_STATS_PREFIX}.kibanaRequest.yes`,
`${COPY_STATS_PREFIX}.kibanaRequest.no`,
`${COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`,
`${COPY_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${COPY_STATS_PREFIX}.overwriteEnabled.yes`,
`${COPY_STATS_PREFIX}.overwriteEnabled.no`,
`${RESOLVE_COPY_STATS_PREFIX}.total`,
`${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.yes`,
`${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.no`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.no`,
];
export class UsageStatsClient {
constructor(
private readonly debugLogger: (message: string) => void,
private readonly repositoryPromise: Promise<ISavedObjectsRepository>
) {}
public async getUsageStats() {
this.debugLogger('getUsageStats() called');
let usageStats: UsageStats = {};
try {
const repository = await this.repositoryPromise;
const result = await repository.incrementCounter<UsageStats>(
SPACES_USAGE_STATS_TYPE,
SPACES_USAGE_STATS_ID,
ALL_COUNTER_FIELDS,
{ initialize: true }
);
usageStats = result.attributes;
} catch (err) {
// do nothing
}
return usageStats;
}
public async incrementCopySavedObjects({
headers,
createNewCopies,
overwrite,
}: IncrementCopySavedObjectsOptions) {
const isKibanaRequest = getIsKibanaRequest(headers);
const counterFieldNames = [
'total',
`kibanaRequest.${isKibanaRequest ? 'yes' : 'no'}`,
`createNewCopiesEnabled.${createNewCopies ? 'yes' : 'no'}`,
`overwriteEnabled.${overwrite ? 'yes' : 'no'}`,
];
await this.updateUsageStats(counterFieldNames, COPY_STATS_PREFIX);
}
public async incrementResolveCopySavedObjectsErrors({
headers,
createNewCopies,
}: IncrementResolveCopySavedObjectsErrorsOptions) {
const isKibanaRequest = getIsKibanaRequest(headers);
const counterFieldNames = [
'total',
`kibanaRequest.${isKibanaRequest ? 'yes' : 'no'}`,
`createNewCopiesEnabled.${createNewCopies ? 'yes' : 'no'}`,
];
await this.updateUsageStats(counterFieldNames, RESOLVE_COPY_STATS_PREFIX);
}
private async updateUsageStats(counterFieldNames: string[], prefix: string) {
const options = { refresh: false };
try {
const repository = await this.repositoryPromise;
await repository.incrementCounter(
SPACES_USAGE_STATS_TYPE,
SPACES_USAGE_STATS_ID,
counterFieldNames.map((x) => `${prefix}.${x}`),
options
);
} catch (err) {
// do nothing
}
}
}
function getIsKibanaRequest(headers?: Headers) {
// The presence of these three request headers gives us a good indication that this is a first-party request from the Kibana client.
// We can't be 100% certain, but this is a reasonable attempt.
return headers && headers['kbn-version'] && headers.origin && headers.referer;
}

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { usageStatsClientMock } from './usage_stats_client.mock';
import { UsageStatsServiceSetup } from './usage_stats_service';
const createSetupContractMock = (usageStatsClient = usageStatsClientMock.create()) => {
const setupContract: jest.Mocked<UsageStatsServiceSetup> = {
getClient: jest.fn().mockReturnValue(usageStatsClient),
};
return setupContract;
};
export const usageStatsServiceMock = {
createSetupContract: createSetupContractMock,
};

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { coreMock, loggingSystemMock } from 'src/core/server/mocks';
import { UsageStatsService } from '.';
import { UsageStatsClient } from './usage_stats_client';
import { SPACES_USAGE_STATS_TYPE } from './constants';
describe('UsageStatsService', () => {
const mockLogger = loggingSystemMock.createLogger();
describe('#setup', () => {
const setup = async () => {
const core = coreMock.createSetup();
const usageStatsService = await new UsageStatsService(mockLogger).setup(core);
return { core, usageStatsService };
};
it('creates internal repository', async () => {
const { core } = await setup();
const [{ savedObjects }] = await core.getStartServices();
expect(savedObjects.createInternalRepository).toHaveBeenCalledTimes(1);
expect(savedObjects.createInternalRepository).toHaveBeenCalledWith([SPACES_USAGE_STATS_TYPE]);
});
describe('#getClient', () => {
it('returns client', async () => {
const { usageStatsService } = await setup();
const usageStatsClient = usageStatsService.getClient();
expect(usageStatsClient).toBeInstanceOf(UsageStatsClient);
});
});
});
});

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Logger, CoreSetup } from '../../../../../src/core/server';
import { UsageStatsClient } from './usage_stats_client';
import { SPACES_USAGE_STATS_TYPE } from './constants';
export interface UsageStatsServiceSetup {
getClient(): UsageStatsClient;
}
interface UsageStatsServiceDeps {
getStartServices: CoreSetup['getStartServices'];
}
export class UsageStatsService {
constructor(private readonly log: Logger) {}
public async setup({ getStartServices }: UsageStatsServiceDeps): Promise<UsageStatsServiceSetup> {
const internalRepositoryPromise = getStartServices().then(([coreStart]) =>
coreStart.savedObjects.createInternalRepository([SPACES_USAGE_STATS_TYPE])
);
const getClient = () => {
const debugLogger = (message: string) => this.log.debug(message);
return new UsageStatsClient(debugLogger, internalRepositoryPromise);
};
return { getClient };
}
public async stop() {}
}

View file

@ -3381,6 +3381,42 @@
},
"count": {
"type": "long"
},
"apiCalls.copySavedObjects.total": {
"type": "long"
},
"apiCalls.copySavedObjects.kibanaRequest.yes": {
"type": "long"
},
"apiCalls.copySavedObjects.kibanaRequest.no": {
"type": "long"
},
"apiCalls.copySavedObjects.createNewCopiesEnabled.yes": {
"type": "long"
},
"apiCalls.copySavedObjects.createNewCopiesEnabled.no": {
"type": "long"
},
"apiCalls.copySavedObjects.overwriteEnabled.yes": {
"type": "long"
},
"apiCalls.copySavedObjects.overwriteEnabled.no": {
"type": "long"
},
"apiCalls.resolveCopySavedObjectsErrors.total": {
"type": "long"
},
"apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.yes": {
"type": "long"
},
"apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.no": {
"type": "long"
},
"apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.yes": {
"type": "long"
},
"apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.no": {
"type": "long"
}
}
},

View file

@ -19330,7 +19330,6 @@
"xpack.spaces.management.shareToSpace.shareToSpacesButton": "保存して閉じる",
"xpack.spaces.management.shareToSpace.shareWarningBody": "1つのスペースでのみ編集するには、{makeACopyLink}してください。",
"xpack.spaces.management.shareToSpace.shareWarningLink": "コピーを作成",
"xpack.spaces.management.shareToSpace.shareWarningTitle": "共有オブジェクトの編集は、すべてのスペースで変更を適用します。",
"xpack.spaces.management.shareToSpace.showLessSpacesLink": "縮小表示",
"xpack.spaces.management.shareToSpace.showMoreSpacesLink": "他{count}件",
"xpack.spaces.management.shareToSpace.spacesLoadErrorTitle": "利用可能なスペースを読み込み中にエラーが発生",

View file

@ -19349,7 +19349,6 @@
"xpack.spaces.management.shareToSpace.shareToSpacesButton": "保存并关闭",
"xpack.spaces.management.shareToSpace.shareWarningBody": "要仅在一个工作区中编辑,请改为{makeACopyLink}。",
"xpack.spaces.management.shareToSpace.shareWarningLink": "创建副本",
"xpack.spaces.management.shareToSpace.shareWarningTitle": "编辑共享对象会在所有工作区中应用更改",
"xpack.spaces.management.shareToSpace.showLessSpacesLink": "显示更少",
"xpack.spaces.management.shareToSpace.showMoreSpacesLink": "另外 {count} 个",
"xpack.spaces.management.shareToSpace.spacesLoadErrorTitle": "加载可用工作区时出错",

View file

@ -52,6 +52,7 @@ export default function spaceSelectorFunctonalTests({
await PageObjects.copySavedObjectsToSpace.openCopyToSpaceFlyoutForObject('A Dashboard');
await PageObjects.copySavedObjectsToSpace.setupForm({
createNewCopies: false,
overwrite: true,
destinationSpaceId,
});
@ -80,6 +81,7 @@ export default function spaceSelectorFunctonalTests({
await PageObjects.copySavedObjectsToSpace.openCopyToSpaceFlyoutForObject('A Dashboard');
await PageObjects.copySavedObjectsToSpace.setupForm({
createNewCopies: false,
overwrite: false,
destinationSpaceId,
});
@ -116,12 +118,42 @@ export default function spaceSelectorFunctonalTests({
await PageObjects.copySavedObjectsToSpace.finishCopy();
});
it('avoids conflicts when createNewCopies is enabled', async () => {
const destinationSpaceId = 'sales';
await PageObjects.copySavedObjectsToSpace.openCopyToSpaceFlyoutForObject('A Dashboard');
await PageObjects.copySavedObjectsToSpace.setupForm({
createNewCopies: true,
overwrite: false,
destinationSpaceId,
});
await PageObjects.copySavedObjectsToSpace.startCopy();
// Wait for successful copy
await testSubjects.waitForDeleted(`cts-summary-indicator-loading-${destinationSpaceId}`);
await testSubjects.existOrFail(`cts-summary-indicator-success-${destinationSpaceId}`);
const summaryCounts = await PageObjects.copySavedObjectsToSpace.getSummaryCounts();
expect(summaryCounts).to.eql({
success: 3,
pending: 0,
skipped: 0,
errors: 0,
});
await PageObjects.copySavedObjectsToSpace.finishCopy();
});
it('allows a dashboard to be copied to the marketing space, with circular references', async () => {
const destinationSpaceId = 'marketing';
await PageObjects.copySavedObjectsToSpace.openCopyToSpaceFlyoutForObject('Dashboard Foo');
await PageObjects.copySavedObjectsToSpace.setupForm({
createNewCopies: false,
overwrite: true,
destinationSpaceId,
});

View file

@ -28,12 +28,24 @@ export function CopySavedObjectsToSpacePageProvider({
},
async setupForm({
createNewCopies,
overwrite,
destinationSpaceId,
}: {
createNewCopies?: boolean;
overwrite?: boolean;
destinationSpaceId: string;
}) {
if (createNewCopies && overwrite) {
throw new Error('createNewCopies and overwrite options cannot be used together');
}
if (!createNewCopies) {
const form = await testSubjects.find('copy-to-space-form');
// a radio button consists of a div tag that contains an input, a div, and a label
// we can't click the input directly, need to click the label
const label = await form.findByCssSelector('label[for="createNewCopiesDisabled"]');
await label.click();
}
if (!overwrite) {
const radio = await testSubjects.find('cts-copyModeControl-overwriteRadioGroup');
// a radio button consists of a div tag that contains an input, a div, and a label

View file

@ -607,6 +607,7 @@ export function copyToSpaceTestSuiteFactory(
objects: [dashboardObject],
spaces: [destination],
includeReferences: false,
createNewCopies: false,
overwrite: false,
})
.expect(tests.noConflictsWithoutReferences.statusCode)
@ -625,6 +626,7 @@ export function copyToSpaceTestSuiteFactory(
objects: [dashboardObject],
spaces: [destination],
includeReferences: true,
createNewCopies: false,
overwrite: false,
})
.expect(tests.noConflictsWithReferences.statusCode)
@ -643,6 +645,7 @@ export function copyToSpaceTestSuiteFactory(
objects: [dashboardObject],
spaces: [destination],
includeReferences: true,
createNewCopies: false,
overwrite: true,
})
.expect(tests.withConflictsOverwriting.statusCode)
@ -661,6 +664,7 @@ export function copyToSpaceTestSuiteFactory(
objects: [dashboardObject],
spaces: [destination],
includeReferences: true,
createNewCopies: false,
overwrite: false,
})
.expect(tests.withConflictsWithoutOverwriting.statusCode)
@ -678,6 +682,7 @@ export function copyToSpaceTestSuiteFactory(
objects: [dashboardObject],
spaces: [conflictDestination, noConflictDestination],
includeReferences: true,
createNewCopies: false,
overwrite: true,
})
.expect(tests.multipleSpaces.statusCode)
@ -710,6 +715,7 @@ export function copyToSpaceTestSuiteFactory(
objects: [dashboardObject],
spaces: ['non_existent_space'],
includeReferences: false,
createNewCopies: false,
overwrite: true,
})
.expect(tests.nonExistentSpace.statusCode)
@ -720,6 +726,7 @@ export function copyToSpaceTestSuiteFactory(
[false, true].forEach((overwrite) => {
const spaces = ['space_2'];
const includeReferences = false;
const createNewCopies = false;
describe(`multi-namespace types with overwrite=${overwrite}`, () => {
before(() => esArchiver.load('saved_objects/spaces'));
after(() => esArchiver.unload('saved_objects/spaces'));
@ -730,7 +737,7 @@ export function copyToSpaceTestSuiteFactory(
return supertest
.post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`)
.auth(user.username, user.password)
.send({ objects, spaces, includeReferences, overwrite })
.send({ objects, spaces, includeReferences, createNewCopies, overwrite })
.expect(statusCode)
.then(response);
});

View file

@ -442,6 +442,7 @@ export function resolveCopyToSpaceConflictsSuite(
.send({
objects: [dashboardObject],
includeReferences: true,
createNewCopies: false,
retries: { [destination]: [{ ...visualizationObject, overwrite: false }] },
})
.expect(tests.withReferencesNotOverwriting.statusCode)
@ -457,6 +458,7 @@ export function resolveCopyToSpaceConflictsSuite(
.send({
objects: [dashboardObject],
includeReferences: true,
createNewCopies: false,
retries: { [destination]: [{ ...visualizationObject, overwrite: true }] },
})
.expect(tests.withReferencesOverwriting.statusCode)
@ -472,6 +474,7 @@ export function resolveCopyToSpaceConflictsSuite(
.send({
objects: [dashboardObject],
includeReferences: false,
createNewCopies: false,
retries: { [destination]: [{ ...dashboardObject, overwrite: true }] },
})
.expect(tests.withoutReferencesOverwriting.statusCode)
@ -487,6 +490,7 @@ export function resolveCopyToSpaceConflictsSuite(
.send({
objects: [dashboardObject],
includeReferences: false,
createNewCopies: false,
retries: { [destination]: [{ ...dashboardObject, overwrite: false }] },
})
.expect(tests.withoutReferencesNotOverwriting.statusCode)
@ -502,6 +506,7 @@ export function resolveCopyToSpaceConflictsSuite(
.send({
objects: [dashboardObject],
includeReferences: false,
createNewCopies: false,
retries: { [destination]: [{ ...dashboardObject, overwrite: true }] },
})
.expect(tests.nonExistentSpace.statusCode)
@ -510,6 +515,7 @@ export function resolveCopyToSpaceConflictsSuite(
});
const includeReferences = false;
const createNewCopies = false;
describe(`multi-namespace types with "overwrite" retry`, () => {
before(() => esArchiver.load('saved_objects/spaces'));
after(() => esArchiver.unload('saved_objects/spaces'));
@ -520,7 +526,7 @@ export function resolveCopyToSpaceConflictsSuite(
return supertestWithoutAuth
.post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`)
.auth(user.username, user.password)
.send({ objects, includeReferences, retries })
.send({ objects, includeReferences, createNewCopies, retries })
.expect(statusCode)
.then(response);
});