[maps] vector tile inspector (#131565)

* [maps] Vector tile inspector adapter

* empty prompt

* add layer select

* tile request view

* show gridTile es path and body

* show error message

* hits request

* tab with editor

* clean up

* open in console

* do not track same tile multiple times

* remove layer from vector tile inspector when layer is removed

* refactor tile request generation

* show path in inspector

* requests view callout

* remove duplicated server side code

* remove unused files

* fix map_actions test

* open requests view when getting requests from inspector

* only show view when adapter is present

* fix open in console link not matching tile request

* tslint

* fix search sessions functional test

* update trouble shooting docs

* use bold in docs

* fix tiles at zoom level 0

* revert changes to mb_map

* include path when copying to clipboard

* clear inspector when layer type changes

* tslint fix

* clean-up

* update callout copy

* empty prompt copy

* copy updates

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2022-05-11 15:04:33 -06:00 committed by GitHub
parent 03e1233408
commit 72ec630f91
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1098 additions and 283 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

View file

@ -12,10 +12,13 @@ Use the information in this section to inspect Elasticsearch requests and find s
[float]
=== Inspect Elasticsearch requests
Maps uses the {ref}/search-search.html[{es} search API] to get documents and aggregation results from {es}. To troubleshoot these requests, open the Inspector, which shows the most recent requests for each layer. You can switch between different requests using the *Request* dropdown.
Maps uses the {ref}/search-vector-tile-api.html[{es} vector tile search API] and the {ref}/search-search.html[{es} search API] to get documents and aggregation results from {es}. Use *Vector tiles* inspector to view {es} vector tile search API requests. Use *Requests* inspector to view {es} search API requests.
[role="screenshot"]
image::maps/images/inspector.png[]
image::maps/images/vector_tile_inspector.png[]
[role="screenshot"]
image::maps/images/requests_inspector.png[]
[float]
=== Solutions to common problems

View file

@ -20,7 +20,7 @@ export const getRequestsViewDescription = (): InspectorViewDescription => ({
}),
order: 20,
help: i18n.translate('inspector.requests.requestsDescriptionTooltip', {
defaultMessage: 'View the requests that collected the data',
defaultMessage: 'View the search requests used to collect the data',
}),
shouldShow(adapters: Adapters) {
return Boolean(adapters.requests);

View file

@ -6,9 +6,14 @@
*/
import { isUndefined, omitBy } from 'lodash';
import type { KibanaExecutionContext } from '@kbn/core/public';
import { APP_ID } from './constants';
export function makeExecutionContext(context: { id?: string; url?: string; description?: string }) {
export function makeExecutionContext(context: {
id?: string;
url?: string;
description?: string;
}): KibanaExecutionContext {
return omitBy(
{
name: APP_ID,

View file

@ -7,6 +7,7 @@
import type { RisonValue } from 'rison-node';
import rison from 'rison-node';
import { RENDER_AS } from './constants';
export function decodeMvtResponseBody(encodedRequestBody: string): object {
return rison.decode(decodeURIComponent(encodedRequestBody)) as object;
@ -15,3 +16,107 @@ export function decodeMvtResponseBody(encodedRequestBody: string): object {
export function encodeMvtResponseBody(unencodedRequestBody: object): string {
return encodeURIComponent(rison.encode(unencodedRequestBody as RisonValue));
}
export function getAggsTileRequest({
encodedRequestBody,
geometryFieldName,
gridPrecision,
index,
renderAs = RENDER_AS.POINT,
x,
y,
z,
}: {
encodedRequestBody: string;
geometryFieldName: string;
gridPrecision: number;
index: string;
renderAs: RENDER_AS;
x: number;
y: number;
z: number;
}) {
const requestBody = decodeMvtResponseBody(encodedRequestBody) as any;
return {
path: `/${encodeURIComponent(index)}/_mvt/${geometryFieldName}/${z}/${x}/${y}`,
body: {
size: 0, // no hits
grid_precision: gridPrecision,
exact_bounds: false,
extent: 4096, // full resolution,
query: requestBody.query,
grid_agg: renderAs === RENDER_AS.HEX ? 'geohex' : 'geotile',
grid_type: renderAs === RENDER_AS.GRID || renderAs === RENDER_AS.HEX ? 'grid' : 'centroid',
aggs: requestBody.aggs,
fields: requestBody.fields,
runtime_mappings: requestBody.runtime_mappings,
},
};
}
export function getHitsTileRequest({
encodedRequestBody,
geometryFieldName,
index,
x,
y,
z,
}: {
encodedRequestBody: string;
geometryFieldName: string;
index: string;
x: number;
y: number;
z: number;
}) {
const requestBody = decodeMvtResponseBody(encodedRequestBody) as any;
return {
path: `/${encodeURIComponent(index)}/_mvt/${geometryFieldName}/${z}/${x}/${y}`,
body: {
grid_precision: 0, // no aggs
exact_bounds: true,
extent: 4096, // full resolution,
query: requestBody.query,
fields: mergeFields(
[
requestBody.docvalue_fields as Field[] | undefined,
requestBody.stored_fields as Field[] | undefined,
],
[geometryFieldName]
),
runtime_mappings: requestBody.runtime_mappings,
track_total_hits: typeof requestBody.size === 'number' ? requestBody.size + 1 : false,
},
};
}
// can not use "import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey"
// SearchRequest is incorrectly typed and does not support Field as object
// https://github.com/elastic/elasticsearch-js/issues/1615
type Field =
| string
| {
field: string;
format: string;
};
function mergeFields(fieldsList: Array<Field[] | undefined>, excludeNames: string[]): Field[] {
const fieldNames: string[] = [];
const mergedFields: Field[] = [];
fieldsList.forEach((fields) => {
if (!fields) {
return;
}
fields.forEach((field) => {
const fieldName = typeof field === 'string' ? field : field.field;
if (!excludeNames.includes(fieldName) && !fieldNames.includes(fieldName)) {
fieldNames.push(fieldName);
mergedFields.push(field);
}
});
});
return mergedFields;
}

View file

@ -22,7 +22,7 @@ import {
getSelectedLayerId,
} from '../selectors/map_selectors';
import { FLYOUT_STATE } from '../reducers/ui';
import { cancelRequest } from '../reducers/non_serializable_instances';
import { cancelRequest, getInspectorAdapters } from '../reducers/non_serializable_instances';
import { setDrawMode, updateFlyout } from './ui_actions';
import {
ADD_LAYER,
@ -451,6 +451,9 @@ function updateLayerType(layerId: string, newLayerType: string) {
return;
}
dispatch(clearDataRequests(layer));
if (layer.getSource().isESSource()) {
getInspectorAdapters(getState()).vectorTiles?.removeLayer(layerId);
}
dispatch({
type: UPDATE_LAYER_PROP,
id: layerId,
@ -587,6 +590,9 @@ function removeLayerFromLayerList(layerId: string) {
});
dispatch(updateTooltipStateForLayer(layerGettingRemoved));
layerGettingRemoved.destroy();
if (layerGettingRemoved.getSource().isESSource()) {
getInspectorAdapters(getState())?.vectorTiles.removeLayer(layerId);
}
dispatch({
type: REMOVE_LAYER,
id: layerId,

View file

@ -8,6 +8,7 @@
/* eslint @typescript-eslint/no-var-requires: 0 */
jest.mock('../selectors/map_selectors', () => ({}));
jest.mock('../reducers/non_serializable_instances', () => ({}));
jest.mock('./data_request_actions', () => {
return {
syncDataForAllLayers: () => {},
@ -25,6 +26,9 @@ import { mapExtentChanged, setMouseCoordinates, setQuery } from './map_actions';
const getStoreMock = jest.fn();
const dispatchMock = jest.fn();
const vectorTileAdapterMock = {
setTiles: jest.fn(),
};
describe('map_actions', () => {
afterEach(() => {
@ -43,6 +47,12 @@ describe('map_actions', () => {
require('../selectors/map_selectors').getLayerList = () => {
return [];
};
require('../reducers/non_serializable_instances').getInspectorAdapters = () => {
return {
vectorTiles: vectorTileAdapterMock,
};
};
});
it('should set buffer', () => {
@ -61,6 +71,8 @@ describe('map_actions', () => {
});
action(dispatchMock, getStoreMock);
expect(vectorTileAdapterMock.setTiles.mock.calls[0]).toEqual([[{ x: 24, y: 15, z: 5 }]]);
expect(dispatchMock.mock.calls[0]).toEqual([
{
mapViewContext: {
@ -101,6 +113,12 @@ describe('map_actions', () => {
minLon: 92.5,
},
};
require('../reducers/non_serializable_instances').getInspectorAdapters = () => {
return {
vectorTiles: vectorTileAdapterMock,
};
};
};
});
@ -120,6 +138,8 @@ describe('map_actions', () => {
});
action(dispatchMock, getStoreMock);
expect(vectorTileAdapterMock.setTiles.mock.calls.length).toBe(0);
expect(dispatchMock.mock.calls[0]).toEqual([
{
mapViewContext: {
@ -162,6 +182,8 @@ describe('map_actions', () => {
});
action(dispatchMock, getStoreMock);
expect(vectorTileAdapterMock.setTiles.mock.calls.length).toBe(1);
expect(dispatchMock.mock.calls[0]).toEqual([
{
mapViewContext: {
@ -204,6 +226,8 @@ describe('map_actions', () => {
});
action(dispatchMock, getStoreMock);
expect(vectorTileAdapterMock.setTiles.mock.calls.length).toBe(1);
expect(dispatchMock.mock.calls[0]).toEqual([
{
mapViewContext: {

View file

@ -17,6 +17,7 @@ import { Geometry, Position } from 'geojson';
import { asyncForEach, asyncMap } from '@kbn/std';
import { DRAW_MODE, DRAW_SHAPE, LAYER_STYLE_TYPE } from '../../common/constants';
import type { MapExtentState, MapViewContext } from '../reducers/map/types';
import { getInspectorAdapters } from '../reducers/non_serializable_instances';
import { MapStoreState } from '../reducers/store';
import { IVectorStyle } from '../classes/styles/vector/vector_style';
import {
@ -73,7 +74,7 @@ import { INITIAL_LOCATION } from '../../common/constants';
import { updateTooltipStateForLayer } from './tooltip_actions';
import { isVectorLayer, IVectorLayer } from '../classes/layers/vector_layer';
import { SET_DRAW_MODE, pushDeletedFeatureId, clearDeletedFeatureIds } from './ui_actions';
import { expandToTileBoundaries } from '../classes/util/geo_tile_utils';
import { expandToTileBoundaries, getTilesForExtent } from '../classes/util/geo_tile_utils';
import { getToasts } from '../kibana_services';
import { getDeletedFeatureIds } from '../selectors/ui_selectors';
@ -217,14 +218,18 @@ export function mapExtentChanged(mapExtentState: MapExtentState) {
doesPrevBufferContainNextExtent = turfBooleanContains(bufferGeometry, extentGeometry);
}
const requiresNewBuffer =
!prevBuffer || !doesPrevBufferContainNextExtent || prevZoom !== nextZoom;
if (requiresNewBuffer) {
getInspectorAdapters(getState()).vectorTiles.setTiles(getTilesForExtent(nextZoom, extent));
}
dispatch({
type: MAP_EXTENT_CHANGED,
mapViewContext: {
...mapExtentState,
buffer:
!prevBuffer || !doesPrevBufferContainNextExtent || prevZoom !== nextZoom
? expandToTileBoundaries(extent, Math.ceil(nextZoom))
: prevBuffer,
buffer: requiresNewBuffer
? expandToTileBoundaries(extent, Math.ceil(nextZoom))
: prevBuffer,
} as MapViewContext,
});

View file

@ -94,6 +94,7 @@ export class HeatmapLayer extends AbstractLayer {
async syncData(syncContext: DataRequestContext) {
await syncMvtSourceData({
layerId: this.getId(),
layerName: await this.getDisplayName(),
prevDataRequest: this.getSourceDataRequest(),
requestMeta: buildVectorRequestMeta(
this.getSource(),

View file

@ -42,6 +42,9 @@ const mockSource = {
isGeoGridPrecisionAware: () => {
return false;
},
isESSource: () => {
return false;
},
} as unknown as IMvtVectorSource;
describe('syncMvtSourceData', () => {
@ -50,6 +53,7 @@ describe('syncMvtSourceData', () => {
await syncMvtSourceData({
layerId: 'layer1',
layerName: 'my layer',
prevDataRequest: undefined,
requestMeta: {
...syncContext.dataFilters,
@ -96,6 +100,7 @@ describe('syncMvtSourceData', () => {
await syncMvtSourceData({
layerId: 'layer1',
layerName: 'my layer',
prevDataRequest: {
getMeta: () => {
return prevRequestMeta;
@ -138,6 +143,7 @@ describe('syncMvtSourceData', () => {
await syncMvtSourceData({
layerId: 'layer1',
layerName: 'my layer',
prevDataRequest: {
getMeta: () => {
return prevRequestMeta;
@ -177,6 +183,7 @@ describe('syncMvtSourceData', () => {
await syncMvtSourceData({
layerId: 'layer1',
layerName: 'my layer',
prevDataRequest: {
getMeta: () => {
return prevRequestMeta;
@ -224,6 +231,7 @@ describe('syncMvtSourceData', () => {
await syncMvtSourceData({
layerId: 'layer1',
layerName: 'my layer',
prevDataRequest: {
getMeta: () => {
return prevRequestMeta;
@ -263,6 +271,7 @@ describe('syncMvtSourceData', () => {
await syncMvtSourceData({
layerId: 'layer1',
layerName: 'my layer',
prevDataRequest: {
getMeta: () => {
return prevRequestMeta;
@ -302,6 +311,7 @@ describe('syncMvtSourceData', () => {
await syncMvtSourceData({
layerId: 'layer1',
layerName: 'my layer',
prevDataRequest: {
getMeta: () => {
return prevRequestMeta;
@ -325,4 +335,38 @@ describe('syncMvtSourceData', () => {
// @ts-expect-error
sinon.assert.calledOnce(syncContext.stopLoading);
});
test('Should add layer to vector tile inspector when source is synced', async () => {
const syncContext = new MockSyncContext({ dataFilters: {} });
const mockVectorTileAdapter = {
addLayer: sinon.spy(),
};
await syncMvtSourceData({
layerId: 'layer1',
layerName: 'my layer',
prevDataRequest: undefined,
requestMeta: {
...syncContext.dataFilters,
applyGlobalQuery: true,
applyGlobalTime: true,
applyForceRefresh: true,
fieldNames: [],
sourceMeta: {},
isForceRefresh: false,
isFeatureEditorOpenForLayer: false,
},
source: {
...mockSource,
isESSource: () => {
return true;
},
getInspectorAdapters: () => {
return { vectorTiles: mockVectorTileAdapter };
},
},
syncContext,
});
sinon.assert.calledOnce(mockVectorTileAdapter.addLayer);
});
});

View file

@ -24,12 +24,14 @@ export interface MvtSourceData {
export async function syncMvtSourceData({
layerId,
layerName,
prevDataRequest,
requestMeta,
source,
syncContext,
}: {
layerId: string;
layerName: string;
prevDataRequest: DataRequest | undefined;
requestMeta: VectorSourceRequestMeta;
source: IMvtVectorSource;
@ -71,6 +73,9 @@ export async function syncMvtSourceData({
: prevData.refreshToken;
const tileUrl = await source.getTileUrl(requestMeta, refreshToken);
if (source.isESSource()) {
source.getInspectorAdapters()?.vectorTiles.addLayer(layerId, layerName, tileUrl);
}
const sourceData = {
tileUrl,
tileSourceLayer: source.getTileSourceLayer(),

View file

@ -220,6 +220,7 @@ export class MvtVectorLayer extends AbstractVectorLayer {
await syncMvtSourceData({
layerId: this.getId(),
layerName: await this.getDisplayName(),
prevDataRequest: this.getSourceDataRequest(),
requestMeta: await this._getVectorSourceRequestMeta(
syncContext.isForceRefresh,

View file

@ -9,10 +9,11 @@ import {
getTileKey,
parseTileKey,
getTileBoundingBox,
getTilesForExtent,
expandToTileBoundaries,
} from './geo_tile_utils';
it('Should parse tile key', () => {
test('Should parse tile key', () => {
expect(parseTileKey('15/23423/1867')).toEqual({
zoom: 15,
x: 23423,
@ -21,11 +22,76 @@ it('Should parse tile key', () => {
});
});
it('Should get tile key', () => {
test('Should get tiles for extent', () => {
const extent = {
minLon: -132.19235,
minLat: 12.05834,
maxLon: -83.6593,
maxLat: 30.03121,
};
expect(getTilesForExtent(4.74, extent)).toEqual([
{ x: 2, y: 6, z: 4 },
{ x: 2, y: 7, z: 4 },
{ x: 3, y: 6, z: 4 },
{ x: 3, y: 7, z: 4 },
{ x: 4, y: 6, z: 4 },
{ x: 4, y: 7, z: 4 },
]);
});
test('Should get tiles for extent that crosses dateline', () => {
const extent = {
minLon: -267.34624,
minLat: 10,
maxLon: 33.8355,
maxLat: 79.16772,
};
expect(getTilesForExtent(2.12, extent)).toEqual([
{ x: 3, y: 0, z: 2 },
{ x: 3, y: 1, z: 2 },
{ x: 0, y: 0, z: 2 },
{ x: 0, y: 1, z: 2 },
{ x: 1, y: 0, z: 2 },
{ x: 1, y: 1, z: 2 },
{ x: 2, y: 0, z: 2 },
{ x: 2, y: 1, z: 2 },
]);
});
test('Should get tiles for extent that crosses dateline and not add tiles in between right and left', () => {
const extent = {
minLon: -183.25917,
minLat: 50.10446,
maxLon: -176.63722,
maxLat: 53.06071,
};
expect(getTilesForExtent(6.8, extent)).toEqual([
{ x: 63, y: 20, z: 6 },
{ x: 63, y: 21, z: 6 },
{ x: 0, y: 20, z: 6 },
{ x: 0, y: 21, z: 6 },
]);
});
test('Should return single tile for zoom level 0', () => {
const extent = {
minLon: -180.39426,
minLat: -85.05113,
maxLon: 270.66456,
maxLat: 85.05113,
};
expect(getTilesForExtent(0, extent)).toEqual([{ x: 0, y: 0, z: 0 }]);
});
test('Should get tile key', () => {
expect(getTileKey(45, 120, 10)).toEqual('10/853/368');
});
it('Should convert tile key to geojson Polygon', () => {
test('Should convert tile key to geojson Polygon', () => {
const geometry = getTileBoundingBox('15/23423/1867');
expect(geometry).toEqual({
top: 82.92546,
@ -35,7 +101,7 @@ it('Should convert tile key to geojson Polygon', () => {
});
});
it('Should convert tile key to geojson Polygon with extra precision', () => {
test('Should convert tile key to geojson Polygon with extra precision', () => {
const geometry = getTileBoundingBox('26/19762828/25222702');
expect(geometry).toEqual({
top: 40.7491508,
@ -45,7 +111,7 @@ it('Should convert tile key to geojson Polygon with extra precision', () => {
});
});
it('Should expand extent to align boundaries with tile boundaries', () => {
test('Should expand extent to align boundaries with tile boundaries', () => {
const extent = {
maxLat: 12.5,
maxLon: 102.5,

View file

@ -60,6 +60,31 @@ export function parseTileKey(tileKey: string): {
return { x, y, zoom, tileCount };
}
export function getTilesForExtent(
zoom: number,
extent: MapExtent
): Array<{ x: number; y: number; z: number }> {
const tileCount = getTileCount(Math.floor(zoom));
const minX = longitudeToTile(extent.minLon, tileCount);
const maxX = longitudeToTile(extent.maxLon, tileCount);
const minY = latitudeToTile(extent.maxLat, tileCount);
const maxY = latitudeToTile(extent.minLat, tileCount);
const tiles: Array<{ x: number; y: number; z: number }> = [];
for (let x = 0; x < tileCount && minX + x <= maxX; x++) {
const tileX = minX + x;
for (let y = 0; y < tileCount && minY + y <= maxY; y++) {
const tileY = minY + y;
tiles.push({
x: tileX < 0 ? tileCount - Math.abs(tileX) : tileX,
y: tileY < 0 ? tileCount - Math.abs(tileY) : tileY,
z: Math.floor(zoom),
});
}
}
return tiles;
}
export function getTileKey(lat: number, lon: number, zoom: number): string {
const tileCount = getTileCount(zoom);

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { MapAdapter, MapInspectorView } from './map_adapter';
export { VectorTileAdapter, VectorTileInspectorView } from './vector_tile_adapter';

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { MapAdapter } from './map_adapter';
export { MapInspectorView } from './map_inspector_view';

View file

@ -8,7 +8,7 @@
import React, { lazy } from 'react';
import type { Adapters } from '@kbn/inspector-plugin/public';
import { i18n } from '@kbn/i18n';
import { LazyWrapper } from '../lazy_wrapper';
import { LazyWrapper } from '../../lazy_wrapper';
const getLazyComponent = () => {
return lazy(() => import('./map_view_component'));

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiEmptyPrompt } from '@elastic/eui';
export function EmptyPrompt() {
return (
<EuiEmptyPrompt
title={
<h2>
<FormattedMessage
id="xpack.maps.inspector.vectorTile.noRequestsLoggedTitle"
defaultMessage="No requests logged for vector tiles"
/>
</h2>
}
body={
<React.Fragment>
<p>
<FormattedMessage
id="xpack.maps.inspector.vectorTile.noRequestsLoggedDescription.mapHasNotLoggedAnyRequestsText"
defaultMessage="This map does not have any vector tile search results."
/>
</p>
</React.Fragment>
}
/>
);
}

View file

@ -0,0 +1,123 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getTileRequest } from './get_tile_request';
test('Should return elasticsearch vector tile request for aggs tiles', () => {
expect(
getTileRequest({
layerId: '1',
tileUrl: `/pof/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=geo.coordinates&index=kibana_sample_data_logs&gridPrecision=8&requestBody=(_source%3A(excludes%3A!())%2Caggs%3A()%2Cfields%3A!((field%3A'%40timestamp'%2Cformat%3Adate_time)%2C(field%3Atimestamp%2Cformat%3Adate_time)%2C(field%3Autc_time%2Cformat%3Adate_time))%2Cquery%3A(bool%3A(filter%3A!((match_phrase%3A(machine.os.keyword%3Aios))%2C(range%3A(timestamp%3A(format%3Astrict_date_optional_time%2Cgte%3A'2022-04-22T16%3A46%3A00.744Z'%2Clte%3A'2022-04-29T16%3A46%3A05.345Z'))))%2Cmust%3A!()%2Cmust_not%3A!()%2Cshould%3A!()))%2Cruntime_mappings%3A(hour_of_day%3A(script%3A(source%3A'emit(doc%5B!'timestamp!'%5D.value.getHour())%3B')%2Ctype%3Along))%2Cscript_fields%3A()%2Csize%3A0%2Cstored_fields%3A!('*'))&renderAs=heatmap&token=e8bff005-ccea-464a-ae56-2061b4f8ce68`,
x: 3,
y: 0,
z: 2,
})
).toEqual({
path: '/kibana_sample_data_logs/_mvt/geo.coordinates/2/3/0',
body: {
size: 0,
grid_precision: 8,
exact_bounds: false,
extent: 4096,
query: {
bool: {
filter: [
{
match_phrase: {
'machine.os.keyword': 'ios',
},
},
{
range: {
timestamp: {
format: 'strict_date_optional_time',
gte: '2022-04-22T16:46:00.744Z',
lte: '2022-04-29T16:46:05.345Z',
},
},
},
],
must: [],
must_not: [],
should: [],
},
},
grid_agg: 'geotile',
grid_type: 'centroid',
aggs: {},
fields: [
{
field: '@timestamp',
format: 'date_time',
},
{
field: 'timestamp',
format: 'date_time',
},
{
field: 'utc_time',
format: 'date_time',
},
],
runtime_mappings: {
hour_of_day: {
script: {
source: "emit(doc['timestamp'].value.getHour());",
},
type: 'long',
},
},
},
});
});
test('Should return elasticsearch vector tile request for hits tiles', () => {
expect(
getTileRequest({
layerId: '1',
tileUrl: `http://localhost:5601/pof/api/maps/mvt/getTile/{z}/{x}/{y}.pbf?geometryFieldName=geo.coordinates&index=kibana_sample_data_logs&requestBody=(_source%3A!f%2Cdocvalue_fields%3A!()%2Cquery%3A(bool%3A(filter%3A!((range%3A(timestamp%3A(format%3Astrict_date_optional_time%2Cgte%3A%272022-04-22T16%3A46%3A00.744Z%27%2Clte%3A%272022-04-29T16%3A46%3A05.345Z%27))))%2Cmust%3A!()%2Cmust_not%3A!()%2Cshould%3A!()))%2Cruntime_mappings%3A(hour_of_day%3A(script%3A(source%3A%27emit(doc%5B!%27timestamp!%27%5D.value.getHour())%3B%27)%2Ctype%3Along))%2Cscript_fields%3A()%2Csize%3A10000%2Cstored_fields%3A!(geo.coordinates))&token=415049b6-bb0a-444a-a7b9-89717db5183c`,
x: 0,
y: 0,
z: 2,
})
).toEqual({
path: '/kibana_sample_data_logs/_mvt/geo.coordinates/2/0/0',
body: {
grid_precision: 0,
exact_bounds: true,
extent: 4096,
query: {
bool: {
filter: [
{
range: {
timestamp: {
format: 'strict_date_optional_time',
gte: '2022-04-22T16:46:00.744Z',
lte: '2022-04-29T16:46:05.345Z',
},
},
},
],
must: [],
must_not: [],
should: [],
},
},
fields: [],
runtime_mappings: {
hour_of_day: {
script: {
source: "emit(doc['timestamp'].value.getHour());",
},
type: 'long',
},
},
track_total_hits: 10001,
},
});
});

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
MVT_GETGRIDTILE_API_PATH,
MVT_GETTILE_API_PATH,
RENDER_AS,
} from '../../../../common/constants';
import { getAggsTileRequest, getHitsTileRequest } from '../../../../common/mvt_request_body';
import type { TileRequest } from '../types';
function getSearchParams(url: string): URLSearchParams {
const split = url.split('?');
const queryString = split.length <= 1 ? '' : split[1];
return new URLSearchParams(queryString);
}
export function getTileRequest(tileRequest: TileRequest): { path?: string; body?: object } {
const searchParams = getSearchParams(tileRequest.tileUrl);
const encodedRequestBody = searchParams.has('requestBody')
? (searchParams.get('requestBody') as string)
: '()';
if (!searchParams.has('index')) {
throw new Error(`Required query parameter 'index' not provided.`);
}
const index = searchParams.get('index') as string;
if (!searchParams.has('geometryFieldName')) {
throw new Error(`Required query parameter 'geometryFieldName' not provided.`);
}
const geometryFieldName = searchParams.get('geometryFieldName') as string;
if (tileRequest.tileUrl.includes(MVT_GETGRIDTILE_API_PATH)) {
return getAggsTileRequest({
encodedRequestBody,
geometryFieldName,
gridPrecision: parseInt(searchParams.get('gridPrecision') as string, 10),
index,
renderAs: searchParams.get('renderAs') as RENDER_AS,
x: tileRequest.x,
y: tileRequest.y,
z: tileRequest.z,
});
}
if (tileRequest.tileUrl.includes(MVT_GETTILE_API_PATH)) {
return getHitsTileRequest({
encodedRequestBody,
geometryFieldName,
index,
x: tileRequest.x,
y: tileRequest.y,
z: tileRequest.z,
});
}
throw new Error('Unexpected path');
}

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiCallOut } from '@elastic/eui';
export function RequestsViewCallout() {
return (
<EuiCallOut
size="s"
title={i18n.translate('xpack.maps.inspector.vectorTile.requestsView', {
defaultMessage: `You're viewing vector tile search requests. To view requests submitted to the search API, set View to Requests.`,
})}
iconType="iInCircle"
/>
);
}

View file

@ -0,0 +1,134 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import {
EuiButtonEmpty,
EuiCallOut,
EuiCopy,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
} from '@elastic/eui';
import { XJsonLang } from '@kbn/monaco';
import { CodeEditor } from '@kbn/kibana-react-plugin/public';
import { compressToEncodedURIComponent } from 'lz-string';
import {
getDevToolsCapabilities,
getNavigateToUrl,
getShareService,
} from '../../../kibana_services';
import type { TileRequest } from '../types';
import { getTileRequest } from './get_tile_request';
interface Props {
tileRequest: TileRequest;
}
export function TileRequestTab(props: Props) {
try {
const { path, body } = getTileRequest(props.tileRequest);
const consoleRequest = `POST ${path}\n${JSON.stringify(body, null, 2)}`;
let consoleHref: string | undefined;
if (getDevToolsCapabilities().show) {
const devToolsDataUri = compressToEncodedURIComponent(consoleRequest);
consoleHref = getShareService()
.url.locators.get('CONSOLE_APP_LOCATOR')
?.useUrl({ loadFrom: `data:text/plain,${devToolsDataUri}` });
}
return (
<EuiFlexGroup
direction="column"
gutterSize="s"
wrap={false}
responsive={true}
style={{ height: '100%' }}
>
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="flexEnd" gutterSize="m" wrap>
<EuiFlexItem grow={false}>
<div>
<EuiCopy textToCopy={consoleRequest}>
{(copy) => (
<EuiButtonEmpty size="xs" flush="right" iconType="copyClipboard" onClick={copy}>
{i18n.translate(
'xpack.maps.inspector.vectorTileRequest.copyToClipboardLabel',
{
defaultMessage: 'Copy to clipboard',
}
)}
</EuiButtonEmpty>
)}
</EuiCopy>
</div>
</EuiFlexItem>
{consoleHref !== undefined && (
<EuiFlexItem grow={false}>
<div>
<EuiButtonEmpty
size="xs"
flush="right"
onClick={() => {
const navigateToUrl = getNavigateToUrl();
navigateToUrl(consoleHref!);
}}
iconType="wrench"
>
{i18n.translate('xpack.maps.inspector.vectorTileRequest.openInConsoleLabel', {
defaultMessage: 'Open in Console',
})}
</EuiButtonEmpty>
</div>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={true}>
<CodeEditor
languageId={XJsonLang.ID}
value={consoleRequest}
options={{
readOnly: true,
lineNumbers: 'off',
fontSize: 12,
minimap: {
enabled: false,
},
folding: true,
scrollBeyondLastLine: false,
wordWrap: 'on',
wrappingIndent: 'indent',
automaticLayout: true,
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
} catch (e) {
return (
<EuiCallOut
title={i18n.translate('xpack.maps.inspector.vectorTileRequest.errorMessage', {
defaultMessage: 'Unable to create Elasticsearch vector tile search request',
})}
color="warning"
iconType="help"
>
<p>
{i18n.translate('xpack.maps.inspector.vectorTileRequest.errorTitle', {
defaultMessage: `Could not convert tile request, '{tileUrl}', to Elasticesarch vector tile search request, error: {error}`,
values: {
tileUrl: props.tileRequest.tileUrl,
error: e.message,
},
})}
</p>
</EuiCallOut>
);
}
}

View file

@ -0,0 +1,178 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import _ from 'lodash';
import React, { Component } from 'react';
import { i18n } from '@kbn/i18n';
import type { Adapters } from '@kbn/inspector-plugin/public';
import { EuiComboBox, EuiComboBoxOptionOption, EuiSpacer, EuiTabs, EuiTab } from '@elastic/eui';
import { EmptyPrompt } from './empty_prompt';
import type { TileRequest } from '../types';
import { TileRequestTab } from './tile_request_tab';
import { RequestsViewCallout } from './requests_view_callout';
interface Props {
adapters: Adapters;
}
interface State {
selectedLayer: EuiComboBoxOptionOption<string> | null;
selectedTileRequest: TileRequest | null;
tileRequests: TileRequest[];
layerOptions: Array<EuiComboBoxOptionOption<string>>;
}
class VectorTileInspector extends Component<Props, State> {
private _isMounted = false;
state: State = {
selectedLayer: null,
selectedTileRequest: null,
tileRequests: [],
layerOptions: [],
};
componentDidMount() {
this._isMounted = true;
this._onAdapterChange();
this.props.adapters.vectorTiles.on('change', this._debouncedOnAdapterChange);
}
componentWillUnmount() {
this._isMounted = false;
this.props.adapters.vectorTiles.removeListener('change', this._debouncedOnAdapterChange);
}
_onAdapterChange = () => {
const layerOptions = this.props.adapters.vectorTiles.getLayerOptions() as Array<
EuiComboBoxOptionOption<string>
>;
if (layerOptions.length === 0) {
this.setState({
selectedLayer: null,
selectedTileRequest: null,
tileRequests: [],
layerOptions: [],
});
return;
}
const selectedLayer =
this.state.selectedLayer &&
layerOptions.some((layerOption) => {
return this.state.selectedLayer?.value === layerOption.value;
})
? this.state.selectedLayer
: layerOptions[0];
const tileRequests = this.props.adapters.vectorTiles.getTileRequests(selectedLayer.value);
const selectedTileRequest =
this.state.selectedTileRequest &&
tileRequests.some((tileRequest: TileRequest) => {
return (
this.state.selectedTileRequest?.layerId === tileRequest.layerId &&
this.state.selectedTileRequest?.x === tileRequest.x &&
this.state.selectedTileRequest?.y === tileRequest.y &&
this.state.selectedTileRequest?.z === tileRequest.z
);
})
? this.state.selectedTileRequest
: tileRequests.length
? tileRequests[0]
: null;
this.setState({
selectedLayer,
selectedTileRequest,
tileRequests,
layerOptions,
});
};
_debouncedOnAdapterChange = _.debounce(() => {
if (this._isMounted) {
this._onAdapterChange();
}
}, 256);
_onLayerSelect = (selectedOptions: Array<EuiComboBoxOptionOption<string>>) => {
if (selectedOptions.length === 0) {
this.setState({
selectedLayer: null,
selectedTileRequest: null,
tileRequests: [],
});
return;
}
const selectedLayer = selectedOptions[0];
const tileRequests = this.props.adapters.vectorTiles.getTileRequests(selectedLayer.value);
this.setState({
selectedLayer,
selectedTileRequest: tileRequests.length ? tileRequests[0] : null,
tileRequests,
});
};
renderTabs() {
return this.state.tileRequests.map((tileRequest) => {
const tileLabel = `${tileRequest.z}/${tileRequest.x}/${tileRequest.y}`;
return (
<EuiTab
key={`${tileRequest.layerId}${tileLabel}`}
onClick={() => {
this.setState({ selectedTileRequest: tileRequest });
}}
isSelected={
tileRequest.layerId === this.state.selectedTileRequest?.layerId &&
tileRequest.x === this.state.selectedTileRequest?.x &&
tileRequest.y === this.state.selectedTileRequest?.y &&
tileRequest.z === this.state.selectedTileRequest?.z
}
>
{tileLabel}
</EuiTab>
);
});
}
render() {
return this.state.layerOptions.length === 0 ? (
<>
<RequestsViewCallout />
<EmptyPrompt />
</>
) : (
<>
<RequestsViewCallout />
<EuiSpacer />
<EuiComboBox
singleSelection={true}
options={this.state.layerOptions}
selectedOptions={this.state.selectedLayer ? [this.state.selectedLayer] : []}
onChange={this._onLayerSelect}
isClearable={false}
prepend={i18n.translate('xpack.maps.inspector.vectorTile.layerSelectPrepend', {
defaultMessage: 'Layer',
})}
/>
<EuiSpacer />
<EuiTabs size="s">{this.renderTabs()}</EuiTabs>
<EuiSpacer size="s" />
{this.state.selectedTileRequest && (
<TileRequestTab
key={`${this.state.selectedTileRequest.layerId}${this.state.selectedTileRequest.x}${this.state.selectedTileRequest.y}${this.state.selectedTileRequest.z}`}
tileRequest={this.state.selectedTileRequest}
/>
)}
</>
);
}
}
// default export required for React.Lazy
// eslint-disable-next-line import/no-default-export
export default VectorTileInspector;

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { VectorTileAdapter } from './vector_tile_adapter';
export { VectorTileInspectorView } from './vector_tile_inspector_view';

View file

@ -5,8 +5,10 @@
* 2.0.
*/
import { errors } from '@elastic/elasticsearch';
export function isAbortError(error: Error) {
return error instanceof errors.RequestAbortedError;
export interface TileRequest {
layerId: string;
tileUrl: string;
x: number;
y: number;
z: number;
}

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EventEmitter } from 'events';
import { TileRequest } from './types';
export class VectorTileAdapter extends EventEmitter {
private _layers: Record<string, { label: string; tileUrl: string }> = {};
private _tiles: Array<{ x: number; y: number; z: number }> = [];
addLayer(layerId: string, label: string, tileUrl: string) {
this._layers[layerId] = { label, tileUrl };
this._onChange();
}
removeLayer(layerId: string) {
delete this._layers[layerId];
this._onChange();
}
setTiles(tiles: Array<{ x: number; y: number; z: number }>) {
this._tiles = tiles;
this._onChange();
}
getLayerOptions(): Array<{ value: string; label: string }> {
return Object.keys(this._layers).map((layerId) => {
return {
value: layerId,
label: this._layers[layerId].label,
};
});
}
getTileRequests(layerId: string): TileRequest[] {
if (!this._layers[layerId]) {
return [];
}
const { tileUrl } = this._layers[layerId];
return this._tiles.map((tile) => {
return {
layerId,
tileUrl,
...tile,
};
});
}
_onChange() {
this.emit('change');
}
}

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { lazy } from 'react';
import type { Adapters } from '@kbn/inspector-plugin/public';
import { i18n } from '@kbn/i18n';
import { LazyWrapper } from '../../lazy_wrapper';
const getLazyComponent = () => {
return lazy(() => import('./components/vector_tile_inspector'));
};
export const VectorTileInspectorView = {
title: i18n.translate('xpack.maps.inspector.vectorTileViewTitle', {
defaultMessage: 'Vector tiles',
}),
order: 10,
help: i18n.translate('xpack.maps.inspector.vectorTileViewHelpText', {
defaultMessage: 'View the vector tile search requests used to collect the data',
}),
shouldShow(adapters: Adapters) {
return Boolean(adapters.vectorTiles);
},
component: (props: { adapters: Adapters }) => {
return <LazyWrapper getLazyComponent={getLazyComponent} lazyComponentProps={props} />;
},
};

View file

@ -46,6 +46,7 @@ export const getTimeFilter = () => pluginsStart.data.query.timefilter.timefilter
export const getToasts = () => coreStart.notifications.toasts;
export const getSavedObjectsClient = () => coreStart.savedObjects.client;
export const getCoreChrome = () => coreStart.chrome;
export const getDevToolsCapabilities = () => coreStart.application.capabilities.dev_tools;
export const getMapsCapabilities = () => coreStart.application.capabilities.maps;
export const getVisualizeCapabilities = () => coreStart.application.capabilities.visualize;
export const getDocLinks = () => coreStart.docLinks;
@ -58,6 +59,7 @@ export const getCoreI18n = () => coreStart.i18n;
export const getSearchService = () => pluginsStart.data.search;
export const getEmbeddableService = () => pluginsStart.embeddable;
export const getNavigateToApp = () => coreStart.application.navigateToApp;
export const getNavigateToUrl = () => coreStart.application.navigateToUrl;
export const getSavedObjectsTagging = () => pluginsStart.savedObjectsTagging;
export const getPresentationUtilContext = () => pluginsStart.presentationUtil.ContextProvider;
export const getSecurityService = () => pluginsStart.security;

View file

@ -74,7 +74,7 @@ import { APP_ICON_SOLUTION, APP_ID, MAP_SAVED_OBJECT_TYPE } from '../common/cons
import { getMapsVisTypeAlias } from './maps_vis_type_alias';
import { featureCatalogueEntry } from './feature_catalogue_entry';
import { setIsCloudEnabled, setMapAppConfig, setStartServices } from './kibana_services';
import { MapInspectorView } from './inspector/map_inspector_view';
import { MapInspectorView, VectorTileInspectorView } from './inspector';
import { setupLensChoroplethChart } from './lens';
@ -172,6 +172,7 @@ export class MapsPlugin
})
);
plugins.inspector.registerView(VectorTileInspectorView);
plugins.inspector.registerView(MapInspectorView);
if (plugins.home) {
plugins.home.featureCatalogue.register(featureCatalogueEntry);

View file

@ -6,7 +6,7 @@
*/
import { RequestAdapter } from '@kbn/inspector-plugin/common/adapters/request';
import { MapAdapter } from '../inspector/map_adapter';
import { MapAdapter, VectorTileAdapter } from '../inspector';
import { getShowMapsInspectorAdapter } from '../kibana_services';
const REGISTER_CANCEL_CALLBACK = 'REGISTER_CANCEL_CALLBACK';
@ -17,6 +17,7 @@ const SET_CHARTS_PALETTE_SERVICE_GET_COLOR = 'SET_CHARTS_PALETTE_SERVICE_GET_COL
function createInspectorAdapters() {
const inspectorAdapters = {
requests: new RequestAdapter(),
vectorTiles: new VectorTileAdapter(),
};
if (getShowMapsInspectorAdapter()) {
inspectorAdapters.map = new MapAdapter();

View file

@ -1,96 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CoreStart, Logger } from '@kbn/core/server';
import type { DataRequestHandlerContext } from '@kbn/data-plugin/server';
import { IncomingHttpHeaders } from 'http';
import { Stream } from 'stream';
import { RENDER_AS } from '../../common/constants';
import { isAbortError } from './util';
import { makeExecutionContext } from '../../common/execution_context';
export async function getEsGridTile({
url,
core,
logger,
context,
index,
geometryFieldName,
x,
y,
z,
requestBody = {},
renderAs = RENDER_AS.POINT,
gridPrecision,
abortController,
}: {
url: string;
core: CoreStart;
x: number;
y: number;
z: number;
geometryFieldName: string;
index: string;
context: DataRequestHandlerContext;
logger: Logger;
requestBody: any;
renderAs: RENDER_AS;
gridPrecision: number;
abortController: AbortController;
}): Promise<{ stream: Stream | null; headers: IncomingHttpHeaders; statusCode: number }> {
try {
const path = `/${encodeURIComponent(index)}/_mvt/${geometryFieldName}/${z}/${x}/${y}`;
const body = {
size: 0, // no hits
grid_precision: gridPrecision,
exact_bounds: false,
extent: 4096, // full resolution,
query: requestBody.query,
grid_agg: renderAs === RENDER_AS.HEX ? 'geohex' : 'geotile',
grid_type: renderAs === RENDER_AS.GRID || renderAs === RENDER_AS.HEX ? 'grid' : 'centroid',
aggs: requestBody.aggs,
fields: requestBody.fields,
runtime_mappings: requestBody.runtime_mappings,
};
const esClient = (await context.core).elasticsearch.client;
const tile = await core.executionContext.withContext(
makeExecutionContext({
description: 'mvt:get_grid_tile',
url,
}),
async () => {
return await esClient.asCurrentUser.transport.request(
{
method: 'GET',
path,
body,
},
{
signal: abortController.signal,
headers: {
'Accept-Encoding': 'gzip',
},
asStream: true,
meta: true,
}
);
}
);
return { stream: tile.body as Stream, headers: tile.headers, statusCode: tile.statusCode };
} catch (e) {
if (isAbortError(e)) {
return { stream: null, headers: {}, statusCode: 200 };
}
// These are often circuit breaking exceptions
// Should return a tile with some error message
logger.warn(`Cannot generate ES-grid-tile for ${z}/${x}/${y}: ${e.message}`);
return { stream: null, headers: {}, statusCode: 500 };
}
}

View file

@ -1,96 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CoreStart, Logger } from '@kbn/core/server';
import type { DataRequestHandlerContext } from '@kbn/data-plugin/server';
import { IncomingHttpHeaders } from 'http';
import { Stream } from 'stream';
import { isAbortError } from './util';
import { makeExecutionContext } from '../../common/execution_context';
import { Field, mergeFields } from './merge_fields';
export async function getEsTile({
url,
core,
logger,
context,
index,
geometryFieldName,
x,
y,
z,
requestBody = {},
abortController,
}: {
url: string;
core: CoreStart;
x: number;
y: number;
z: number;
geometryFieldName: string;
index: string;
context: DataRequestHandlerContext;
logger: Logger;
requestBody: any;
abortController: AbortController;
}): Promise<{ stream: Stream | null; headers: IncomingHttpHeaders; statusCode: number }> {
try {
const path = `/${encodeURIComponent(index)}/_mvt/${geometryFieldName}/${z}/${x}/${y}`;
const body = {
grid_precision: 0, // no aggs
exact_bounds: true,
extent: 4096, // full resolution,
query: requestBody.query,
fields: mergeFields(
[
requestBody.docvalue_fields as Field[] | undefined,
requestBody.stored_fields as Field[] | undefined,
],
[geometryFieldName]
),
runtime_mappings: requestBody.runtime_mappings,
track_total_hits: requestBody.size + 1,
};
const esClient = (await context.core).elasticsearch.client;
const tile = await core.executionContext.withContext(
makeExecutionContext({
description: 'mvt:get_tile',
url,
}),
async () => {
return await esClient.asCurrentUser.transport.request(
{
method: 'GET',
path,
body,
},
{
signal: abortController.signal,
headers: {
'Accept-Encoding': 'gzip',
},
asStream: true,
meta: true,
}
);
}
);
return { stream: tile.body as Stream, headers: tile.headers, statusCode: tile.statusCode };
} catch (e) {
if (isAbortError(e)) {
return { stream: null, headers: {}, statusCode: 200 };
}
// These are often circuit breaking exceptions
// Should return a tile with some error message
logger.warn(`Cannot generate ES-grid-tile for ${z}/${x}/${y}: ${e.message}`);
return { stream: null, headers: {}, statusCode: 500 };
}
}

View file

@ -1,40 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// can not use "import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey"
// SearchRequest is incorrectly typed and does not support Field as object
// https://github.com/elastic/elasticsearch-js/issues/1615
export type Field =
| string
| {
field: string;
format: string;
};
export function mergeFields(
fieldsList: Array<Field[] | undefined>,
excludeNames: string[]
): Field[] {
const fieldNames: string[] = [];
const mergedFields: Field[] = [];
fieldsList.forEach((fields) => {
if (!fields) {
return;
}
fields.forEach((field) => {
const fieldName = typeof field === 'string' ? field : field.field;
if (!excludeNames.includes(fieldName) && !fieldNames.includes(fieldName)) {
fieldNames.push(fieldName);
mergedFields.push(field);
}
});
});
return mergedFields;
}

View file

@ -8,18 +8,19 @@
import { Stream } from 'stream';
import { IncomingHttpHeaders } from 'http';
import { schema } from '@kbn/config-schema';
import type { KibanaExecutionContext } from '@kbn/core/public';
import { CoreStart, KibanaRequest, KibanaResponseFactory, Logger } from '@kbn/core/server';
import { IRouter } from '@kbn/core/server';
import type { DataRequestHandlerContext } from '@kbn/data-plugin/server';
import { errors } from '@elastic/elasticsearch';
import {
MVT_GETTILE_API_PATH,
API_ROOT_PATH,
MVT_GETGRIDTILE_API_PATH,
RENDER_AS,
} from '../../common/constants';
import { decodeMvtResponseBody } from '../../common/mvt_request_body';
import { getEsTile } from './get_tile';
import { getEsGridTile } from './get_grid_tile';
import { makeExecutionContext } from '../../common/execution_context';
import { getAggsTileRequest, getHitsTileRequest } from '../../common/mvt_request_body';
const CACHE_TIMEOUT_SECONDS = 60 * 60;
@ -55,21 +56,35 @@ export function initMVTRoutes({
response: KibanaResponseFactory
) => {
const { query, params } = request;
const x = parseInt((params as any).x, 10) as number;
const y = parseInt((params as any).y, 10) as number;
const z = parseInt((params as any).z, 10) as number;
const abortController = makeAbortController(request);
let tileRequest: { path: string; body: object } | undefined;
try {
tileRequest = getHitsTileRequest({
encodedRequestBody: query.requestBody as string,
geometryFieldName: query.geometryFieldName as string,
index: query.index as string,
x,
y,
z,
});
} catch (e) {
return response.badRequest();
}
const { stream, headers, statusCode } = await getEsTile({
url: `${API_ROOT_PATH}/${MVT_GETTILE_API_PATH}/{z}/{x}/{y}.pbf`,
core,
logger,
const { stream, headers, statusCode } = await getTile({
abortController: makeAbortController(request),
body: tileRequest.body,
context,
geometryFieldName: query.geometryFieldName as string,
x: parseInt((params as any).x, 10) as number,
y: parseInt((params as any).y, 10) as number,
z: parseInt((params as any).z, 10) as number,
index: query.index as string,
requestBody: decodeMvtResponseBody(query.requestBody as string) as any,
abortController,
core,
executionContext: makeExecutionContext({
description: 'mvt:get_hits_tile',
url: `${API_ROOT_PATH}/${MVT_GETTILE_API_PATH}/${z}/${x}/${y}.pbf`,
}),
logger,
path: tileRequest.path,
});
return sendResponse(response, stream, headers, statusCode);
@ -101,23 +116,37 @@ export function initMVTRoutes({
response: KibanaResponseFactory
) => {
const { query, params } = request;
const x = parseInt((params as any).x, 10) as number;
const y = parseInt((params as any).y, 10) as number;
const z = parseInt((params as any).z, 10) as number;
const abortController = makeAbortController(request);
let tileRequest: { path: string; body: object } | undefined;
try {
tileRequest = getAggsTileRequest({
encodedRequestBody: query.requestBody as string,
geometryFieldName: query.geometryFieldName as string,
gridPrecision: parseInt(query.gridPrecision, 10),
index: query.index as string,
renderAs: query.renderAs as RENDER_AS,
x,
y,
z,
});
} catch (e) {
return response.badRequest();
}
const { stream, headers, statusCode } = await getEsGridTile({
url: `${API_ROOT_PATH}/${MVT_GETGRIDTILE_API_PATH}/{z}/{x}/{y}.pbf`,
core,
logger,
const { stream, headers, statusCode } = await getTile({
abortController: makeAbortController(request),
body: tileRequest.body,
context,
geometryFieldName: query.geometryFieldName as string,
x: parseInt((params as any).x, 10) as number,
y: parseInt((params as any).y, 10) as number,
z: parseInt((params as any).z, 10) as number,
index: query.index as string,
requestBody: decodeMvtResponseBody(query.requestBody as string) as any,
renderAs: query.renderAs as RENDER_AS,
gridPrecision: parseInt(query.gridPrecision, 10),
abortController,
core,
executionContext: makeExecutionContext({
description: 'mvt:get_aggs_tile',
url: `${API_ROOT_PATH}/${MVT_GETGRIDTILE_API_PATH}/${z}/${x}/${y}.pbf`,
}),
logger,
path: tileRequest.path,
});
return sendResponse(response, stream, headers, statusCode);
@ -125,6 +154,56 @@ export function initMVTRoutes({
);
}
async function getTile({
abortController,
body,
context,
core,
executionContext,
logger,
path,
}: {
abortController: AbortController;
body: object;
context: DataRequestHandlerContext;
core: CoreStart;
executionContext: KibanaExecutionContext;
logger: Logger;
path: string;
}) {
try {
const esClient = (await context.core).elasticsearch.client;
const tile = await core.executionContext.withContext(executionContext, async () => {
return await esClient.asCurrentUser.transport.request(
{
method: 'POST',
path,
body,
},
{
signal: abortController.signal,
headers: {
'Accept-Encoding': 'gzip',
},
asStream: true,
meta: true,
}
);
});
return { stream: tile.body as Stream, headers: tile.headers, statusCode: tile.statusCode };
} catch (e) {
if (e instanceof errors.RequestAbortedError) {
return { stream: null, headers: {}, statusCode: 200 };
}
// These are often circuit breaking exceptions
// Should return a tile with some error message
logger.warn(`Cannot generate tile for ${executionContext.url}: ${e.message}`);
return { stream: null, headers: {}, statusCode: 500 };
}
}
export function sendResponse(
response: KibanaResponseFactory,
tileStream: Stream | null,

View file

@ -600,6 +600,7 @@ export class GisPageObject extends FtrService {
}
async _getResponse(requestName: string) {
await this.inspector.openInspectorView('inspectorViewChooserRequests');
if (requestName) {
await this.testSubjects.click('inspectorRequestChooser');
await this.testSubjects.click(`inspectorRequestChooser${requestName}`);

View file

@ -98,6 +98,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await find.existsByCssSelector('.vgaVis__view')).to.be(true);
log.debug('Checking map rendered');
await dashboardPanelActions.openInspectorByTitle('[Flights] Origin Time Delayed');
await inspector.openInspectorView('inspectorViewChooserRequests');
const requestStats = await inspector.getTableData();
const totalHits = PageObjects.maps.getInspectorStatRowHit(requestStats, 'Hits');
expect(totalHits).to.equal('0');