[maps] fix layer shows no data instead of error (#170084)

Closes https://github.com/elastic/kibana/issues/169545
Closes https://github.com/elastic/kibana/issues/170657

While investigating https://github.com/elastic/kibana/issues/169545, it
was determined that Maps current error handling leaves a lot to be
desired. This PR cleans up several problems at once since they are all
intertwined.

#### Problem 1 - layer error removed when another data request finished
Redux store contains error state in a single location, `__errorMessage`
key in `LayerDescriptor`. This resulted in other operations, like
"fitting to bounds", "fetching supports feature state", or "fetching
style meta" clearing layer error state.

#### Solution to problem 1
Redux store updated to contain isolated error state
1) `error` key added `DataRequestDescriptor`, allowing each data request
to store independent error state. This will capture data fetching errors
when fetching features and join metrics.
2) `error` key added to `JoinDescriptor`, allowing each join to store
independent error state. This will capture join errors like mismatched
join keys
3) `__tileErrors` added to `LayerDescriptor`, allowing each tile error
to be stored independently. This will capture tile fetch errors.

#### Problem 2 - tile status tracker clears error cache when map center
tile changes
This resulted in removing tile errors that may still be relevant if
tiles have not been refetched.

#### Solution to problem 2
Updated tile status tracker to only clear a tile error when the tile is
reloaded.

#### Problem 3 - Tile Errors do not surface elasticsearch ErrorCause
This results in useless error messages like in the screen shot below
<img width="300" alt="Screenshot 2023-11-01 at 2 39 01 PM"
src="75546228-24c6-4855-bea7-39ed421ee3f4">

#### Solution to problem 3
Updated tile status tracker to read and persist elasticsearch ErrorCause
from tile error. Now tile error messages contain more relevant
information about the problem.
<img width="200" alt="Screenshot 2023-11-03 at 9 56 41 AM"
src="b9ddff98-049e-4f22-8249-3f5988fa93a5">

#### Problem 4 - error UI is not interactive when layer editor is not
available, in dashboards or read only user

#### Solution to problem 4
* Updated layer tooltip to only display error title
<img width="200" alt="Screenshot 2023-11-03 at 11 22 50 AM"
src="6943aead-a7d6-4da3-8ecc-bb6065e0406a">
* Moved error callout from editor to legend so its visible when map is
in dashboard and by readonly users.
<img width="200" alt="Screenshot 2023-11-03 at 11 23 45 AM"
src="358fe133-4c5a-4f06-a03e-e96a16b7afb6">

Moving error details from tooltip to legend allowed error details to
contain interactive elements. For example, display a tile picker so that
users can see each tile's error. This will be useful in the future where
search source requests can display "view details" button that opens
request in inspector.

#### Problem 5 - error UI displayed as warning
This results in inconsistent view between kibana applications

#### Solution to problem 5
Updated error UI to use danger callout and error icon

### test instructions
1) install sample web logs
2) create map
3) add documents layer with vector tiles scaling
4) add documents layer with geojson scaling
5) add join layer
6) add filter
    ```
    {
      "error_query": {
        "indices": [
          {
            "error_type": "exception",
            "message": "local shard failure message 123",
            "name": "kibana_sample_data_logs"
          }
        ]
      }
    }
    ```

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2023-11-06 17:00:15 -07:00 committed by GitHub
parent 002e685f0f
commit 30c17e0222
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 963 additions and 410 deletions

View file

@ -7,6 +7,7 @@
*/
import type {
AJAXError,
Map,
LayerSpecification,
Source,
@ -44,6 +45,7 @@ maplibregl.setRTLTextPlugin(mbRtlPlugin);
export { maplibregl };
export type {
AJAXError,
Map,
LayerSpecification,
SourceSpecification,

View file

@ -123,4 +123,5 @@ export type DataRequestDescriptor = {
dataRequestToken?: symbol;
data?: object;
dataRequestMeta?: DataRequestMeta;
error?: string;
};

View file

@ -7,6 +7,7 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
import type { ErrorCause } from '@elastic/elasticsearch/lib/api/types';
import type { Query } from '@kbn/es-query';
import { Feature } from 'geojson';
import {
@ -27,6 +28,7 @@ export type Attribution = {
export type JoinDescriptor = {
leftField?: string;
right: Partial<JoinSourceDescriptor>;
error?: string;
};
export type TileMetaFeature = Feature & {
@ -49,14 +51,19 @@ export type TileMetaFeature = Feature & {
};
};
export type TileError = {
message: string;
tileKey: string; // format zoom/x/y
error?: ErrorCause;
};
export type LayerDescriptor = {
__dataRequests?: DataRequestDescriptor[];
__isInErrorState?: boolean;
__isPreviewLayer?: boolean;
__errorMessage?: string;
__trackedLayerDescriptor?: LayerDescriptor;
__areTilesLoaded?: boolean;
__metaFromTiles?: TileMetaFeature[];
__tileMetaFeatures?: TileMetaFeature[];
__tileErrors?: TileError[];
alpha?: number;
attribution?: Attribution;
id: string;

View file

@ -38,11 +38,12 @@ import {
LAYER_DATA_LOAD_ERROR,
LAYER_DATA_LOAD_STARTED,
SET_GOTO,
SET_LAYER_ERROR_STATUS,
SET_JOINS,
SET_LAYER_STYLE_META,
UPDATE_LAYER_PROP,
UPDATE_SOURCE_DATA_REQUEST,
} from './map_action_constants';
import { InnerJoin } from '../classes/joins/inner_join';
import { ILayer } from '../classes/layers/layer';
import { IVectorLayer } from '../classes/layers/vector_layer';
import { DataRequestMeta, MapExtent, DataFilters } from '../../common/descriptor_types';
@ -62,7 +63,7 @@ export type DataRequestContext = {
resultsMeta?: DataRequestMeta
): void;
onLoadError(dataId: string, requestToken: symbol, errorMessage: string): void;
onJoinError(errorMessage: string): void;
setJoinError(joinIndex: number, errorMessage?: string): void;
updateSourceData(newData: object): void;
isRequestStillActive(dataId: string, requestToken: symbol): boolean;
registerCancelCallback(requestToken: symbol, callback: () => void): void;
@ -133,8 +134,9 @@ function getDataRequestContext(
dispatch(endDataLoad(layerId, dataId, requestToken, data, meta)),
onLoadError: (dataId: string, requestToken: symbol, errorMessage: string) =>
dispatch(onDataLoadError(layerId, dataId, requestToken, errorMessage)),
onJoinError: (errorMessage: string) =>
dispatch(setLayerDataLoadErrorStatus(layerId, errorMessage)),
setJoinError: (joinIndex: number, errorMessage?: string) => {
dispatch(setJoinError(layerId, joinIndex, errorMessage));
},
updateSourceData: (newData: object) => {
dispatch(updateSourceDataRequest(layerId, newData));
},
@ -226,15 +228,6 @@ export function syncDataForLayerId(layerId: string | null, isForceRefresh: boole
};
}
export function setLayerDataLoadErrorStatus(layerId: string, errorMessage: string | null) {
return {
type: SET_LAYER_ERROR_STATUS,
isInErrorState: errorMessage !== null,
layerId,
errorMessage,
};
}
function startDataLoad(
layerId: string,
dataId: string,
@ -315,11 +308,6 @@ function endDataLoad(
requestToken,
});
// Clear any data-load errors when there is a succesful data return.
// Co this on end-data-load iso at start-data-load to avoid blipping the error status between true/false.
// This avoids jitter in the warning icon of the TOC when the requests continues to return errors.
dispatch(setLayerDataLoadErrorStatus(layerId, null));
dispatch(updateStyleMeta(layerId));
};
}
@ -349,10 +337,9 @@ function onDataLoadError(
type: LAYER_DATA_LOAD_ERROR,
layerId,
dataId,
errorMessage,
requestToken,
});
dispatch(setLayerDataLoadErrorStatus(layerId, errorMessage));
};
}
@ -452,3 +439,30 @@ function setGotoWithBounds(bounds: MapExtent) {
bounds,
};
}
function setJoinError(layerId: string, joinIndex: number, error?: string) {
return (dispatch: Dispatch, getState: () => MapStoreState) => {
const layer = getLayerById(layerId, getState());
if (!layer || !('getJoins' in layer)) {
return;
}
const joins = (layer as IVectorLayer).getJoins().map((join: InnerJoin) => {
return join.toDescriptor();
});
if (!error && !joins[joinIndex].error) {
return;
}
dispatch({
type: SET_JOINS,
layerId,
joins: [
...joins.slice(0, joinIndex),
{ ...joins[joinIndex], error },
...joins.slice(joinIndex + 1),
],
});
};
}

View file

@ -15,7 +15,6 @@ export {
cancelAllInFlightRequests,
fitToLayerExtent,
fitToDataBounds,
setLayerDataLoadErrorStatus,
} from './data_request_actions';
export {
closeOnClickTooltip,

View file

@ -55,6 +55,7 @@ import {
JoinDescriptor,
LayerDescriptor,
StyleDescriptor,
TileError,
TileMetaFeature,
VectorLayerDescriptor,
VectorStyleDescriptor,
@ -796,17 +797,13 @@ export function setHiddenLayers(hiddenLayerIds: string[]) {
};
}
export function setAreTilesLoaded(layerId: string, areTilesLoaded: boolean) {
return {
type: UPDATE_LAYER_PROP,
id: layerId,
propName: '__areTilesLoaded',
newValue: areTilesLoaded,
};
}
export function updateMetaFromTiles(layerId: string, mbMetaFeatures: TileMetaFeature[]) {
return async (
export function setTileState(
layerId: string,
areTilesLoaded: boolean,
tileMetaFeatures?: TileMetaFeature[],
tileErrors?: TileError[]
) {
return (
dispatch: ThunkDispatch<MapStoreState, void, AnyAction>,
getState: () => MapStoreState
) => {
@ -818,10 +815,28 @@ export function updateMetaFromTiles(layerId: string, mbMetaFeatures: TileMetaFea
dispatch({
type: UPDATE_LAYER_PROP,
id: layerId,
propName: '__metaFromTiles',
newValue: mbMetaFeatures,
propName: '__areTilesLoaded',
newValue: areTilesLoaded,
});
await dispatch(updateStyleMeta(layerId));
dispatch({
type: UPDATE_LAYER_PROP,
id: layerId,
propName: '__tileErrors',
newValue: tileErrors,
});
if (!tileMetaFeatures && !layer.getDescriptor().__tileMetaFeatures) {
return;
}
dispatch({
type: UPDATE_LAYER_PROP,
id: layerId,
propName: '__tileMetaFeatures',
newValue: tileMetaFeatures,
});
dispatch(updateStyleMeta(layerId));
};
}

View file

@ -8,7 +8,6 @@
export const SET_SELECTED_LAYER = 'SET_SELECTED_LAYER';
export const UPDATE_LAYER_ORDER = 'UPDATE_LAYER_ORDER';
export const ADD_LAYER = 'ADD_LAYER';
export const SET_LAYER_ERROR_STATUS = 'SET_LAYER_ERROR_STATUS';
export const ADD_WAITING_FOR_MAP_READY_LAYER = 'ADD_WAITING_FOR_MAP_READY_LAYER';
export const CLEAR_LAYER_PROP = 'CLEAR_LAYER_PROP';
export const CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST = 'CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST';

View file

@ -17,7 +17,7 @@ export class MockSyncContext implements DataRequestContext {
registerCancelCallback: (requestToken: symbol, callback: () => void) => void;
startLoading: (dataId: string, requestToken: symbol, meta: DataRequestMeta) => void;
stopLoading: (dataId: string, requestToken: symbol, data: object, meta: DataRequestMeta) => void;
onJoinError: (errorMessage: string) => void;
setJoinError: (joinIndex: number, errorMessage: string) => void;
updateSourceData: (newData: unknown) => void;
forceRefreshDueToDrawing: boolean;
isForceRefresh: boolean;
@ -44,7 +44,7 @@ export class MockSyncContext implements DataRequestContext {
this.registerCancelCallback = sinon.spy();
this.startLoading = sinon.spy();
this.stopLoading = sinon.spy();
this.onJoinError = sinon.spy();
this.setJoinError = sinon.spy();
this.updateSourceData = sinon.spy();
this.forceRefreshDueToDrawing = false;
this.isForceRefresh = false;

View file

@ -55,7 +55,7 @@ export class HeatmapLayer extends AbstractLayer {
}
getLayerIcon(isTocIcon: boolean) {
const { docCount } = getAggsMeta(this._getMetaFromTiles());
const { docCount } = getAggsMeta(this._getTileMetaFeatures());
return docCount === 0 ? NO_RESULTS_ICON_AND_TOOLTIPCONTENT : super.getLayerIcon(isTocIcon);
}
@ -169,7 +169,9 @@ export class HeatmapLayer extends AbstractLayer {
const metricField = metricFields[0];
// do not use tile meta features from previous tile URL to avoid styling new tiles from previous tile meta features
const tileMetaFeatures = this._requiresPrevSourceCleanup(mbMap) ? [] : this._getMetaFromTiles();
const tileMetaFeatures = this._requiresPrevSourceCleanup(mbMap)
? []
: this._getTileMetaFeatures();
let max = 0;
for (let i = 0; i < tileMetaFeatures.length; i++) {
const range = metricField.pluckRangeFromTileMetaFeature(tileMetaFeatures[i]);

View file

@ -7,10 +7,11 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
import { i18n } from '@kbn/i18n';
import type { Map as MbMap } from '@kbn/mapbox-gl';
import type { Query } from '@kbn/es-query';
import _ from 'lodash';
import React, { ReactElement } from 'react';
import React, { ReactElement, ReactNode } from 'react';
import { EuiIcon } from '@elastic/eui';
import { v4 as uuidv4 } from 'uuid';
import { FeatureCollection } from 'geojson';
@ -38,6 +39,12 @@ import { DataRequestContext } from '../../actions';
import { IStyle } from '../styles/style';
import { LICENSED_FEATURES } from '../../licensed_features';
import { IESSource } from '../sources/es_source';
import { TileErrorsList } from './tile_errors_list';
export interface LayerError {
title: string;
error: ReactNode;
}
export interface ILayer {
getBounds(
@ -70,7 +77,7 @@ export interface ILayer {
isLayerLoading(zoom: number): boolean;
isFilteredByGlobalTime(): Promise<boolean>;
hasErrors(): boolean;
getErrors(): string;
getErrors(): LayerError[];
/*
* ILayer.getMbLayerIds returns a list of all mapbox layers assoicated with this layer.
@ -389,13 +396,36 @@ export class AbstractLayer implements ILayer {
}
hasErrors(): boolean {
return _.get(this._descriptor, '__isInErrorState', false);
return this.getErrors().length > 0;
}
getErrors(): string {
return this.hasErrors() && this._descriptor.__errorMessage
? this._descriptor.__errorMessage
: '';
_getSourceErrorTitle() {
return i18n.translate('xpack.maps.layer.sourceErrorTitle', {
defaultMessage: `An error occurred when loading layer data`,
});
}
getErrors(): LayerError[] {
const errors: LayerError[] = [];
const sourceError = this.getSourceDataRequest()?.getError();
if (sourceError) {
errors.push({
title: this._getSourceErrorTitle(),
error: sourceError,
});
}
if (this._descriptor.__tileErrors?.length) {
errors.push({
title: i18n.translate('xpack.maps.layer.tileErrorTitle', {
defaultMessage: `An error occurred when loading layer tiles`,
}),
error: <TileErrorsList tileErrors={this._descriptor.__tileErrors} />,
});
}
return errors;
}
async syncData(syncContext: DataRequestContext) {
@ -489,8 +519,8 @@ export class AbstractLayer implements ILayer {
return this._descriptor.parent;
}
_getMetaFromTiles(): TileMetaFeature[] {
return this._descriptor.__metaFromTiles || [];
_getTileMetaFeatures(): TileMetaFeature[] {
return this._descriptor.__tileMetaFeatures ?? [];
}
_isTiled(): boolean {

View file

@ -28,7 +28,7 @@ import {
import { ISource, SourceEditorArgs } from '../../sources/source';
import { type DataRequestContext } from '../../../actions';
import { getLayersExtent } from '../../../actions/get_layers_extent';
import { ILayer, LayerIcon } from '../layer';
import { ILayer, LayerIcon, LayerError } from '../layer';
import { IStyle } from '../../styles/style';
import { LICENSED_FEATURES } from '../../../licensed_features';
@ -295,11 +295,17 @@ export class LayerGroup implements ILayer {
});
}
getErrors(): string {
const firstChildWithError = this._children.find((child) => {
return child.hasErrors();
});
return firstChildWithError ? firstChildWithError.getErrors() : '';
getErrors(): LayerError[] {
return this.hasErrors()
? [
{
title: i18n.translate('xpack.maps.layerGroup.childrenErrorMessage', {
defaultMessage: `An error occurred when loading nested layers`,
}),
error: '',
},
]
: [];
}
async syncData(syncContext: DataRequestContext) {

View file

@ -0,0 +1,100 @@
/*
* 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, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonEmpty, EuiContextMenu, EuiPopover } from '@elastic/eui';
import type { TileError } from '../../../common/descriptor_types';
interface Props {
tileErrors: TileError[];
}
export function TileErrorsList(props: Props) {
const [selectedTileError, setSelectedTileError] = useState<TileError | undefined>(undefined);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
useEffect(() => {
const hasSelectedTileError =
selectedTileError &&
props.tileErrors.some(({ tileKey }) => {
return tileKey === selectedTileError.tileKey;
});
if (!hasSelectedTileError) {
setSelectedTileError(props.tileErrors?.[0]);
}
}, [props.tileErrors, selectedTileError]);
if (props.tileErrors.length === 0 || !selectedTileError) {
return null;
}
const panels = [
{
id: 0,
items: props.tileErrors.map((tileError) => {
return {
name: getTitle(tileError.tileKey),
onClick: () => {
const nextTileError = props.tileErrors.find(({ tileKey }) => {
return tileKey === tileError.tileKey;
});
setSelectedTileError(nextTileError);
setIsPopoverOpen(false);
},
};
}),
},
];
return (
<>
<EuiPopover
id="tileErrorsPopover"
button={
<EuiButtonEmpty
flush="left"
iconType="arrowDown"
iconSide="right"
onClick={() => {
setIsPopoverOpen(!isPopoverOpen);
}}
size="s"
>
{getTitle(selectedTileError.tileKey)}
</EuiButtonEmpty>
}
isOpen={isPopoverOpen}
closePopover={() => {
setIsPopoverOpen(false);
}}
>
<EuiContextMenu initialPanelId={0} panels={panels} size="s" />
</EuiPopover>
<p>{getDescription(selectedTileError)}</p>
</>
);
}
function getTitle(tileKey: string) {
return i18n.translate('xpack.maps.tileError.title', {
defaultMessage: `tile {tileKey}`,
values: { tileKey },
});
}
function getDescription(tileError: TileError) {
if (tileError.error?.root_cause?.[0]?.reason) {
return tileError.error.root_cause[0].reason;
}
if (tileError.error?.reason) {
return tileError.error.reason;
}
return tileError.message;
}

View file

@ -28,7 +28,7 @@ import { DataRequestContext } from '../../../../actions';
import { IVectorStyle, VectorStyle } from '../../../styles/vector/vector_style';
import { ISource } from '../../../sources/source';
import { IVectorSource } from '../../../sources/vector_source';
import { AbstractLayer, LayerIcon } from '../../layer';
import { AbstractLayer, LayerError, LayerIcon } from '../../layer';
import {
AbstractVectorLayer,
noResultsIcon,
@ -154,6 +154,24 @@ export class GeoJsonVectorLayer extends AbstractVectorLayer {
);
}
getErrors(): LayerError[] {
const errors = super.getErrors();
this.getValidJoins().forEach((join) => {
const joinDescriptor = join.toDescriptor();
if (joinDescriptor.error) {
errors.push({
title: i18n.translate('xpack.maps.geojsonVectorLayer.joinErrorTitle', {
defaultMessage: `An error occurred when adding join metrics to layer features`,
}),
error: joinDescriptor.error,
});
}
});
return errors;
}
_requiresPrevSourceCleanup(mbMap: MbMap) {
const mbSource = mbMap.getSource(this.getMbSourceId());
if (!mbSource) {
@ -288,7 +306,7 @@ export class GeoJsonVectorLayer extends AbstractVectorLayer {
sourceResult,
joinStates,
syncContext.updateSourceData,
syncContext.onJoinError
syncContext.setJoinError
);
} catch (error) {
if (!(error instanceof DataRequestAbortError)) {

View file

@ -86,7 +86,7 @@ propertiesMap.set('alpha', { [COUNT_PROPERTY_NAME]: 1 });
test('should skip join when no state has changed', async () => {
const updateSourceData = sinon.spy();
const onJoinError = sinon.spy();
const setJoinError = sinon.spy();
await performInnerJoins(
{
@ -97,19 +97,20 @@ test('should skip join when no state has changed', async () => {
{
dataHasChanged: false,
join: innerJoin,
joinIndex: 0,
},
],
updateSourceData,
onJoinError
setJoinError
);
expect(updateSourceData.notCalled);
expect(onJoinError.notCalled);
expect(setJoinError.notCalled);
});
test('should perform join when features change', async () => {
const updateSourceData = sinon.spy();
const onJoinError = sinon.spy();
const setJoinError = sinon.spy();
await performInnerJoins(
{
@ -120,19 +121,20 @@ test('should perform join when features change', async () => {
{
dataHasChanged: false,
join: innerJoin,
joinIndex: 0,
},
],
updateSourceData,
onJoinError
setJoinError
);
expect(updateSourceData.calledOnce);
expect(onJoinError.notCalled);
expect(setJoinError.notCalled);
});
test('should perform join when join state changes', async () => {
const updateSourceData = sinon.spy();
const onJoinError = sinon.spy();
const setJoinError = sinon.spy();
await performInnerJoins(
{
@ -143,19 +145,20 @@ test('should perform join when join state changes', async () => {
{
dataHasChanged: true,
join: innerJoin,
joinIndex: 0,
},
],
updateSourceData,
onJoinError
setJoinError
);
expect(updateSourceData.calledOnce);
expect(onJoinError.notCalled);
expect(setJoinError.notCalled);
});
test('should call updateSourceData with feature collection with updated feature visibility and join properties', async () => {
const updateSourceData = sinon.spy();
const onJoinError = sinon.spy();
const setJoinError = sinon.spy();
await performInnerJoins(
{
@ -166,11 +169,12 @@ test('should call updateSourceData with feature collection with updated feature
{
dataHasChanged: false,
join: innerJoin,
joinIndex: 0,
propertiesMap,
},
],
updateSourceData,
onJoinError
setJoinError
);
const firstCallArgs = updateSourceData.args[0];
@ -203,12 +207,12 @@ test('should call updateSourceData with feature collection with updated feature
},
],
});
expect(onJoinError.notCalled);
expect(setJoinError.notCalled);
});
test('should call updateSourceData when no results returned from terms aggregation (properties map is undefined)', async () => {
const updateSourceData = sinon.spy();
const onJoinError = sinon.spy();
const setJoinError = sinon.spy();
await performInnerJoins(
{
@ -219,10 +223,11 @@ test('should call updateSourceData when no results returned from terms aggregati
{
dataHasChanged: true,
join: innerJoin,
joinIndex: 0,
},
],
updateSourceData,
onJoinError
setJoinError
);
const firstCallArgs = updateSourceData.args[0];
@ -255,12 +260,12 @@ test('should call updateSourceData when no results returned from terms aggregati
},
],
});
expect(onJoinError.notCalled);
expect(setJoinError.notCalled);
});
test('should call updateSourceData when no results returned from terms aggregation (properties map is empty)', async () => {
const updateSourceData = sinon.spy();
const onJoinError = sinon.spy();
const setJoinError = sinon.spy();
await performInnerJoins(
{
@ -271,11 +276,12 @@ test('should call updateSourceData when no results returned from terms aggregati
{
dataHasChanged: true,
join: innerJoin,
joinIndex: 0,
propertiesMap: new Map<string, Record<string | number, unknown>>(),
},
],
updateSourceData,
onJoinError
setJoinError
);
const firstCallArgs = updateSourceData.args[0];
@ -307,12 +313,12 @@ test('should call updateSourceData when no results returned from terms aggregati
},
],
});
expect(onJoinError.notCalled);
expect(setJoinError.notCalled);
});
test('should call onJoinError when there are no matching features', async () => {
const updateSourceData = sinon.spy();
const onJoinError = sinon.spy();
const setJoinError = sinon.spy();
// instead of returning military alphabet like "alpha" or "bravo", mismatched key returns numbers, like '1'
const propertiesMapFromMismatchedKey = new Map<string, Record<string | number, unknown>>();
@ -327,13 +333,14 @@ test('should call onJoinError when there are no matching features', async () =>
{
dataHasChanged: true,
join: innerJoin,
joinIndex: 0,
propertiesMap: propertiesMapFromMismatchedKey,
},
],
updateSourceData,
onJoinError
setJoinError
);
expect(updateSourceData.notCalled);
expect(onJoinError.calledOnce);
expect(setJoinError.calledOnce);
});

View file

@ -6,6 +6,7 @@
*/
import { FeatureCollection } from 'geojson';
import { asyncForEach } from '@kbn/std';
import { i18n } from '@kbn/i18n';
import { FEATURE_VISIBLE_PROPERTY_NAME } from '../../../../../common/constants';
import { DataRequestContext } from '../../../../actions';
@ -21,7 +22,7 @@ export async function performInnerJoins(
sourceResult: SourceResult,
joinStates: JoinState[],
updateSourceData: DataRequestContext['updateSourceData'],
onJoinError: DataRequestContext['onJoinError']
setJoinError: DataRequestContext['setJoinError']
) {
// should update the store if
// -- source result was refreshed
@ -81,56 +82,56 @@ export async function performInnerJoins(
//
// term joins are easy to misconfigure.
// Users often are unaware of left values and right values and whether they allign for joining
// Provide messaging that helps users debug term joins with no matches
// Provide messaging that helps users debug joins with no matches
//
const termJoinStatusesWithoutAnyMatches = joinStatuses.filter((joinStatus) => {
if (!isTermJoinSource(joinStatus.joinState.join.getRightJoinSource())) {
return false;
}
const hasTerms =
joinStatus.joinState.propertiesMap && joinStatus.joinState.propertiesMap.size > 0;
return !joinStatus.joinedWithAtLeastOneFeature && hasTerms;
await asyncForEach(joinStatuses, async (joinStatus) => {
setJoinError(joinStatus.joinState.joinIndex, await getJoinError(joinStatus));
});
if (termJoinStatusesWithoutAnyMatches.length) {
function prettyPrintArray(array: unknown[]) {
return array.length <= 5
? array.join(',')
: array.slice(0, 5).join(',') +
i18n.translate('xpack.maps.vectorLayer.joinError.firstTenMsg', {
defaultMessage: ` (5 of {total})`,
values: { total: array.length },
});
}
const joinStatus = termJoinStatusesWithoutAnyMatches[0];
const leftFieldName = await joinStatus.joinState.join.getLeftField().getLabel();
const termJoinSource = joinStatus.joinState.join.getRightJoinSource() as ITermJoinSource;
const rightFieldName = await termJoinSource.getTermField().getLabel();
const reason =
joinStatus.keys.length === 0
? i18n.translate('xpack.maps.vectorLayer.joinError.noLeftFieldValuesMsg', {
defaultMessage: `Left field: '{leftFieldName}', does not provide any values.`,
values: { leftFieldName },
})
: i18n.translate('xpack.maps.vectorLayer.joinError.noMatchesMsg', {
defaultMessage: `Left field values do not match right field values. Left field: '{leftFieldName}' has values { leftFieldValues }. Right field: '{rightFieldName}' has values: { rightFieldValues }.`,
values: {
leftFieldName,
leftFieldValues: prettyPrintArray(joinStatus.keys),
rightFieldName,
rightFieldValues: prettyPrintArray(
Array.from(joinStatus.joinState.propertiesMap!.keys())
),
},
});
onJoinError(
i18n.translate('xpack.maps.vectorLayer.joinErrorMsg', {
defaultMessage: `Unable to perform term join. {reason}`,
values: { reason },
})
);
}
}
function prettyPrintArray(array: unknown[]) {
return array.length <= 5
? array.join(',')
: array.slice(0, 5).join(',') +
i18n.translate('xpack.maps.vectorLayer.joinError.firstTenMsg', {
defaultMessage: ` (5 of {total})`,
values: { total: array.length },
});
}
async function getJoinError(joinStatus: {
joinedWithAtLeastOneFeature: boolean;
keys: string[];
joinState: JoinState;
}): Promise<string | undefined> {
if (!isTermJoinSource(joinStatus.joinState.join.getRightJoinSource())) {
return;
}
const hasTerms =
joinStatus.joinState.propertiesMap && joinStatus.joinState.propertiesMap.size > 0;
if (!hasTerms || joinStatus.joinedWithAtLeastOneFeature) {
return;
}
const leftFieldName = await joinStatus.joinState.join.getLeftField().getLabel();
const termJoinSource = joinStatus.joinState.join.getRightJoinSource() as ITermJoinSource;
const rightFieldName = await termJoinSource.getTermField().getLabel();
return joinStatus.keys.length === 0
? i18n.translate('xpack.maps.vectorLayer.joinError.noLeftFieldValuesMsg', {
defaultMessage: `Left field: '{leftFieldName}', did not provide any values.`,
values: { leftFieldName },
})
: i18n.translate('xpack.maps.vectorLayer.joinError.noMatchesMsg', {
defaultMessage: `Left field values do not match right field values. Left field: '{leftFieldName}' has values: { leftFieldValues }. Right field: '{rightFieldName}' has values: { rightFieldValues }.`,
values: {
leftFieldName,
leftFieldValues: prettyPrintArray(joinStatus.keys),
rightFieldName,
rightFieldValues: prettyPrintArray(
Array.from(joinStatus.joinState.propertiesMap!.keys())
),
},
});
}

View file

@ -139,7 +139,7 @@ export class MvtVectorLayer extends AbstractVectorLayer {
// TODO ES MVT specific - move to es_tiled_vector_layer implementation
//
if (this.getSource().getType() === SOURCE_TYPES.ES_GEO_GRID) {
const { docCount } = getAggsMeta(this._getMetaFromTiles());
const { docCount } = getAggsMeta(this._getTileMetaFeatures());
return docCount === 0
? NO_RESULTS_ICON_AND_TOOLTIPCONTENT
: {
@ -159,7 +159,7 @@ export class MvtVectorLayer extends AbstractVectorLayer {
}
const { totalFeaturesCount, tilesWithFeatures, tilesWithTrimmedResults } = getHitsMeta(
this._getMetaFromTiles(),
this._getTileMetaFeatures(),
maxResultWindow
);
@ -540,7 +540,7 @@ export class MvtVectorLayer extends AbstractVectorLayer {
async getStyleMetaDescriptorFromLocalFeatures(): Promise<StyleMetaDescriptor | null> {
const { joinPropertiesMap } = this._getJoinResults();
return await pluckStyleMeta(
this._getMetaFromTiles(),
this._getTileMetaFeatures(),
joinPropertiesMap,
await this.getSource().getSupportedShapeTypes(),
this.getCurrentStyle().getDynamicPropertiesArray()

View file

@ -11,5 +11,6 @@ import { PropertiesMap } from '../../../../common/elasticsearch_util';
export interface JoinState {
dataHasChanged: boolean;
join: InnerJoin;
joinIndex: number;
propertiesMap?: PropertiesMap;
}

View file

@ -7,6 +7,7 @@
import React from 'react';
import { v4 as uuidv4 } from 'uuid';
import { asyncForEach } from '@kbn/std';
import type { FilterSpecification, Map as MbMap, LayerSpecification } from '@kbn/mapbox-gl';
import type { KibanaExecutionContext } from '@kbn/core/public';
import type { Query } from '@kbn/data-plugin/common';
@ -49,7 +50,7 @@ import {
VectorStyleRequestMeta,
} from '../../../../common/descriptor_types';
import { IVectorSource } from '../../sources/vector_source';
import { LayerIcon, ILayer } from '../layer';
import { LayerIcon, ILayer, LayerError } from '../layer';
import { InnerJoin } from '../../joins/inner_join';
import { isSpatialJoin } from '../../joins/is_spatial_join';
import { IField } from '../../fields/field';
@ -267,6 +268,31 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer {
});
}
_getSourceErrorTitle() {
return i18n.translate('xpack.maps.vectorLayer.sourceErrorTitle', {
defaultMessage: `An error occurred when loading layer features`,
});
}
getErrors(): LayerError[] {
const errors = super.getErrors();
this.getValidJoins().forEach((join) => {
const joinDataRequest = this.getDataRequest(join.getSourceDataRequestId());
const error = joinDataRequest?.getError();
if (error) {
errors.push({
title: i18n.translate('xpack.maps.vectorLayer.joinFetchErrorTitle', {
defaultMessage: `An error occurred when loading join metrics`,
}),
error,
});
}
});
return errors;
}
getLayerIcon(isTocIcon: boolean): LayerIcon {
throw new Error('Should implement AbstractVectorLayer#getLayerIcon');
}
@ -535,6 +561,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer {
async _syncJoin({
join,
joinIndex,
featureCollection,
startLoading,
stopLoading,
@ -546,6 +573,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer {
inspectorAdapters,
}: {
join: InnerJoin;
joinIndex: number;
featureCollection?: FeatureCollection;
} & DataRequestContext): Promise<JoinState> {
const joinSource = join.getRightJoinSource();
@ -576,6 +604,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer {
return {
dataHasChanged: false,
join,
joinIndex,
propertiesMap: prevDataRequest?.getData() as PropertiesMap,
};
}
@ -595,6 +624,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer {
return {
dataHasChanged: true,
join,
joinIndex,
propertiesMap,
};
} catch (error) {
@ -609,14 +639,31 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer {
syncContext: DataRequestContext,
style: IVectorStyle,
featureCollection?: FeatureCollection
) {
const joinSyncs = this.getValidJoins().map(async (join) => {
): Promise<JoinState[]> {
const joinsWithIndex = this.getJoins()
.map((join, index) => {
return {
join,
joinIndex: index,
};
})
.filter(({ join }) => {
return join.hasCompleteConfig();
});
const joinStates: JoinState[] = [];
await asyncForEach(joinsWithIndex, async ({ join, joinIndex }) => {
await this._syncJoinStyleMeta(syncContext, join, style);
await this._syncJoinFormatters(syncContext, join, style);
return this._syncJoin({ join, featureCollection, ...syncContext });
const joinState = await this._syncJoin({
join,
joinIndex,
featureCollection,
...syncContext,
});
joinStates.push(joinState);
});
return await Promise.all(joinSyncs);
return joinStates;
}
async _syncJoinStyleMeta(syncContext: DataRequestContext, join: InnerJoin, style: IVectorStyle) {

View file

@ -51,6 +51,10 @@ export class DataRequest {
getRequestToken(): symbol | undefined {
return this._descriptor.dataRequestToken;
}
getError(): string | undefined {
return this._descriptor.error;
}
}
export class DataRequestAbortError extends Error {

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { round } from 'lodash';
import type { LngLatBounds } from '@kbn/mapbox-gl';
import { DECIMAL_DEGREES_PRECISION } from '../../../common/constants';
import type { MapExtent } from '../../../common/descriptor_types';
export function boundsToExtent(bounds: LngLatBounds): MapExtent {
return {
minLon: round(bounds.getWest(), DECIMAL_DEGREES_PRECISION),
minLat: round(bounds.getSouth(), DECIMAL_DEGREES_PRECISION),
maxLon: round(bounds.getEast(), DECIMAL_DEGREES_PRECISION),
maxLat: round(bounds.getNorth(), DECIMAL_DEGREES_PRECISION),
};
}

View file

@ -8,7 +8,6 @@
import React, { Component, Fragment } from 'react';
import {
EuiCallOut,
EuiIcon,
EuiFlexItem,
EuiTitle,
@ -18,7 +17,6 @@ import {
EuiFlyoutFooter,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { FilterEditor } from './filter_editor';
@ -125,26 +123,6 @@ export class EditLayerPanel extends Component<Props, State> {
return this.props.updateSourceProps(this.props.selectedLayer!.getId(), args);
};
_renderLayerErrors() {
if (!this.props.selectedLayer || !this.props.selectedLayer.hasErrors()) {
return null;
}
return (
<Fragment>
<EuiCallOut
color="warning"
title={i18n.translate('xpack.maps.layerPanel.settingsPanel.unableToLoadTitle', {
defaultMessage: 'Unable to load layer',
})}
>
<p data-test-subj="layerErrorMessage">{this.props.selectedLayer.getErrors()}</p>
</EuiCallOut>
<EuiSpacer size="m" />
</Fragment>
);
}
_renderFilterSection() {
if (
!this.props.selectedLayer ||
@ -249,8 +227,6 @@ export class EditLayerPanel extends Component<Props, State> {
<div className="mapLayerPanel__body">
<div className="mapLayerPanel__bodyOverflow">
{this._renderLayerErrors()}
<LayerSettings
layer={this.props.selectedLayer}
supportsFitToBounds={this.state.supportsFitToBounds}

View file

@ -52,6 +52,7 @@ import { CUSTOM_ICON_PIXEL_RATIO, createSdfIcon } from '../../classes/styles/vec
import { MAKI_ICONS } from '../../classes/styles/vector/maki_icons';
import { KeydownScrollZoom } from './keydown_scroll_zoom/keydown_scroll_zoom';
import { transformRequest } from './transform_request';
import { boundsToExtent } from '../../classes/util/maplibre_utils';
export interface Props {
isMapReady: boolean;
@ -141,12 +142,7 @@ export class MbMap extends Component<Props, State> {
lon: _.round(mbCenter.lng, DECIMAL_DEGREES_PRECISION),
lat: _.round(mbCenter.lat, DECIMAL_DEGREES_PRECISION),
},
extent: {
minLon: _.round(mbBounds.getWest(), DECIMAL_DEGREES_PRECISION),
minLat: _.round(mbBounds.getSouth(), DECIMAL_DEGREES_PRECISION),
maxLon: _.round(mbBounds.getEast(), DECIMAL_DEGREES_PRECISION),
maxLat: _.round(mbBounds.getNorth(), DECIMAL_DEGREES_PRECISION),
},
extent: boundsToExtent(mbBounds),
};
}

View file

@ -8,12 +8,8 @@
import { AnyAction } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { connect } from 'react-redux';
import { TileMetaFeature } from '../../../../common/descriptor_types';
import {
setAreTilesLoaded,
setLayerDataLoadErrorStatus,
updateMetaFromTiles,
} from '../../../actions';
import { TileMetaFeature, TileError } from '../../../../common/descriptor_types';
import { setTileState } from '../../../actions';
import { getLayerList } from '../../../selectors/map_selectors';
import { MapStoreState } from '../../../reducers/store';
import { TileStatusTracker } from './tile_status_tracker';
@ -26,17 +22,13 @@ function mapStateToProps(state: MapStoreState) {
function mapDispatchToProps(dispatch: ThunkDispatch<MapStoreState, void, AnyAction>) {
return {
setAreTilesLoaded(layerId: string, areTilesLoaded: boolean) {
dispatch(setAreTilesLoaded(layerId, areTilesLoaded));
},
updateMetaFromTiles(layerId: string, features: TileMetaFeature[]) {
dispatch(updateMetaFromTiles(layerId, features));
},
clearTileLoadError(layerId: string) {
dispatch(setLayerDataLoadErrorStatus(layerId, null));
},
setTileLoadError(layerId: string, errorMessage: string) {
dispatch(setLayerDataLoadErrorStatus(layerId, errorMessage));
onTileStateChange(
layerId: string,
areTilesLoaded: boolean,
tileMetaFeatures?: TileMetaFeature[],
tileErrors?: TileError[]
) {
dispatch(setTileState(layerId, areTilesLoaded, tileMetaFeatures, tileErrors));
},
};
}

View file

@ -0,0 +1,68 @@
/*
* 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 type { MapExtent, TileError } from '../../../../common/descriptor_types';
import { getTilesForExtent } from '../../../classes/util/geo_tile_utils';
type LayerId = string;
type TileKey = string;
export function getErrorCacheTileKey(canonical: { x: number; y: number; z: number }) {
return `${canonical.z}/${canonical.x}/${canonical.y}`;
}
export class TileErrorCache {
private _cache: Record<LayerId, Record<TileKey, TileError>> = {};
public clearTileError(layerId: string | undefined, tileKey: string, onClear: () => void) {
if (!layerId || !(layerId in this._cache)) {
return;
}
const tileErrors = this._cache[layerId];
if (!(tileKey in tileErrors)) {
return;
}
delete tileErrors[tileKey];
this._cache[layerId] = tileErrors;
onClear();
}
public hasAny() {
return Object.keys(this._cache).some((layerId) => {
return Object.keys(this._cache[layerId]).length;
});
}
public hasTileError(layerId: string, tileKey: string) {
return layerId in this._cache ? tileKey in this._cache[layerId] : false;
}
public setTileError(layerId: string, tileError: TileError) {
const tileErrors = this._cache[layerId] ? this._cache[layerId] : {};
tileErrors[tileError.tileKey] = tileError;
this._cache[layerId] = tileErrors;
}
public getInViewTileErrors(layerId: string, zoom: number, extent: MapExtent) {
const tileErrors = this._cache[layerId];
if (!tileErrors) {
return;
}
const tileErrorsArray = Object.values(tileErrors);
if (!tileErrorsArray.length) {
return;
}
const inViewTileKeys = getTilesForExtent(zoom, extent).map(getErrorCacheTileKey);
const inViewTileErrors = tileErrorsArray.filter((tileError) => {
return inViewTileKeys.includes(tileError.tileKey);
});
return inViewTileErrors.length ? inViewTileErrors : undefined;
}
}

View file

@ -8,7 +8,8 @@
// eslint-disable-next-line max-classes-per-file
import React from 'react';
import { mount } from 'enzyme';
import type { Map as MbMap } from '@kbn/mapbox-gl';
import type { Map as MbMap, MapSourceDataEvent } from '@kbn/mapbox-gl';
import type { TileError, TileMetaFeature } from '../../../../common/descriptor_types';
import { TileStatusTracker } from './tile_status_tracker';
import { ILayer } from '../../../classes/layers/layer';
@ -35,6 +36,27 @@ class MockMbMap {
return !(listener.type === type && listener.callback === callback);
});
}
getZoom() {
return 5;
}
getBounds() {
return {
getWest: () => {
return -115.5;
},
getSouth: () => {
return 34.5;
},
getEast: () => {
return -98;
},
getNorth: () => {
return 44;
},
};
}
}
class MockLayer {
@ -59,7 +81,7 @@ class MockLayer {
getSource() {
return {
isESSource() {
return false;
return true;
},
};
}
@ -69,18 +91,16 @@ function createMockLayer(id: string, mbSourceId: string): ILayer {
return new MockLayer(id, mbSourceId) as unknown as ILayer;
}
function createMockMbDataEvent(mbSourceId: string, tileKey: string): unknown {
function createSourceDataEvent(mbSourceId: string, canonical: { x: number; y: number; z: number }) {
return {
sourceId: mbSourceId,
dataType: 'source',
tile: {
tileID: {
canonical: {
x: 80,
y: 10,
z: 5,
...canonical,
},
key: tileKey,
key: `uniqueTileKey${Object.values(canonical).join(',')}`, // not shape of actual key returned from maplibre
},
},
source: {
@ -97,42 +117,38 @@ async function sleep(timeout: number) {
});
}
const mockMbMap = new MockMbMap();
const defaultProps = {
mbMap: mockMbMap as unknown as MbMap,
layerList: [],
setAreTilesLoaded: () => {},
updateMetaFromTiles: () => {},
clearTileLoadError: () => {},
setTileLoadError: () => {},
};
describe('TileStatusTracker', () => {
const AU55_CANONICAL_TILE = { x: 6, y: 12, z: 5 };
const AV55_CANONICAL_TILE = { x: 7, y: 12, z: 5 };
test('should set tile load status', async () => {
const layerList = [
createMockLayer('foo', 'foosource'),
createMockLayer('bar', 'barsource'),
createMockLayer('foobar', 'foobarsource'),
];
const loadedMap: Map<string, boolean> = new Map<string, boolean>();
const setAreTilesLoaded = (layerId: string, areTilesLoaded: boolean) => {
loadedMap.set(layerId, areTilesLoaded);
};
const mockMbMap = new MockMbMap();
const component = mount(
<TileStatusTracker
{...defaultProps}
layerList={layerList}
setAreTilesLoaded={setAreTilesLoaded}
mbMap={mockMbMap as unknown as MbMap}
layerList={[
createMockLayer('foo', 'foosource'),
createMockLayer('bar', 'barsource'),
createMockLayer('foobar', 'foobarsource'),
]}
onTileStateChange={(
layerId: string,
areTilesLoaded: boolean,
tileMetaFeatures?: TileMetaFeature[],
tileErrors?: TileError[]
) => {
loadedMap.set(layerId, areTilesLoaded);
}}
/>
);
mockMbMap.emit('sourcedataloading', createMockMbDataEvent('foosource', 'aa11'));
mockMbMap.emit('sourcedataloading', createSourceDataEvent('foosource', AU55_CANONICAL_TILE));
const aa11BarTile = createMockMbDataEvent('barsource', 'aa11');
mockMbMap.emit('sourcedataloading', aa11BarTile);
const au55BarTile = createSourceDataEvent('barsource', AU55_CANONICAL_TILE);
mockMbMap.emit('sourcedataloading', au55BarTile);
mockMbMap.emit('sourcedata', createMockMbDataEvent('foosource', 'aa11'));
mockMbMap.emit('sourcedata', createSourceDataEvent('foosource', AU55_CANONICAL_TILE));
// simulate delay. Cache-checking is debounced.
await sleep(300);
@ -141,10 +157,15 @@ describe('TileStatusTracker', () => {
expect(loadedMap.get('bar')).toBe(false); // still outstanding tile requests
expect(loadedMap.has('foobar')).toBe(false); // never received tile requests, status should not have been reported for layer
(aa11BarTile as { tile: { aborted: boolean } })!.tile.aborted = true; // abort tile
mockMbMap.emit('sourcedataloading', createMockMbDataEvent('barsource', 'af1d'));
mockMbMap.emit('sourcedataloading', createMockMbDataEvent('foosource', 'af1d'));
mockMbMap.emit('error', createMockMbDataEvent('barsource', 'af1d'));
(au55BarTile.tile as MapSourceDataEvent['tile'])!.aborted = true; // abort tile
mockMbMap.emit('sourcedataloading', createSourceDataEvent('barsource', AV55_CANONICAL_TILE));
mockMbMap.emit('sourcedataloading', createSourceDataEvent('foosource', AV55_CANONICAL_TILE));
mockMbMap.emit('error', {
...createSourceDataEvent('barsource', AV55_CANONICAL_TILE),
error: {
message: 'simulated error',
},
});
// simulate delay. Cache-checking is debounced.
await sleep(300);
@ -157,4 +178,212 @@ describe('TileStatusTracker', () => {
expect(mockMbMap.listeners.length).toBe(0);
});
describe('onError', () => {
const tileErrorsMap: Map<string, TileError[] | undefined> = new Map<
string,
TileError[] | undefined
>();
const onTileStateChange = (
layerId: string,
areTilesLoaded: boolean,
tileMetaFeatures?: TileMetaFeature[],
tileErrors?: TileError[]
) => {
tileErrorsMap.set(layerId, tileErrors);
};
const IN_VIEW_CANONICAL_TILE = { x: 6, y: 12, z: 5 }; // canonical key 'au55'
beforeEach(() => {
tileErrorsMap.clear();
});
test('should clear previous tile error when tile starts loading', async () => {
const mockMbMap = new MockMbMap();
mount(
<TileStatusTracker
mbMap={mockMbMap as unknown as MbMap}
layerList={[
createMockLayer('layer1', 'layer1Source'),
createMockLayer('layer2', 'layer2Source'),
]}
onTileStateChange={onTileStateChange}
/>
);
mockMbMap.emit(
'sourcedataloading',
createSourceDataEvent('layer1Source', IN_VIEW_CANONICAL_TILE)
);
mockMbMap.emit(
'sourcedataloading',
createSourceDataEvent('layer2Source', IN_VIEW_CANONICAL_TILE)
);
mockMbMap.emit('error', {
...createSourceDataEvent('layer1Source', IN_VIEW_CANONICAL_TILE),
error: {
message: 'simulated error',
},
});
// simulate delay. Cache-checking is debounced.
await sleep(300);
expect(tileErrorsMap.get('layer1')?.length).toBe(1);
expect(tileErrorsMap.get('layer1')?.[0]).toEqual({
message: 'simulated error',
tileKey: '5/6/12',
});
expect(tileErrorsMap.get('layer2')).toBeUndefined();
mockMbMap.emit(
'sourcedataloading',
createSourceDataEvent('layer1Source', IN_VIEW_CANONICAL_TILE)
);
// simulate delay. Cache-checking is debounced.
await sleep(300);
expect(tileErrorsMap.get('layer1')).toBeUndefined();
expect(tileErrorsMap.get('layer2')).toBeUndefined();
});
test('should only return tile errors within map zoom', async () => {
const mockMbMap = new MockMbMap();
mount(
<TileStatusTracker
mbMap={mockMbMap as unknown as MbMap}
layerList={[createMockLayer('layer1', 'layer1Source')]}
onTileStateChange={onTileStateChange}
/>
);
const OUT_OF_ZOOM_CANONICAL_TILE = {
...IN_VIEW_CANONICAL_TILE,
z: 4, // out of view because zoom is not 5
};
mockMbMap.emit(
'sourcedataloading',
createSourceDataEvent('layer1Source', OUT_OF_ZOOM_CANONICAL_TILE)
);
mockMbMap.emit('error', {
...createSourceDataEvent('layer1Source', OUT_OF_ZOOM_CANONICAL_TILE),
error: {
message: 'simulated error',
},
});
// simulate delay. Cache-checking is debounced.
await sleep(300);
expect(tileErrorsMap.get('layer1')).toBeUndefined();
});
test('should only return tile errors within map bounds', async () => {
const mockMbMap = new MockMbMap();
mount(
<TileStatusTracker
mbMap={mockMbMap as unknown as MbMap}
layerList={[createMockLayer('layer1', 'layer1Source')]}
onTileStateChange={onTileStateChange}
/>
);
const OUT_OF_VIEW_CANONICAL_TILE = {
...IN_VIEW_CANONICAL_TILE,
y: 13, // out of view because tile is out side of view bounds to the south
};
mockMbMap.emit(
'sourcedataloading',
createSourceDataEvent('layer1Source', OUT_OF_VIEW_CANONICAL_TILE)
);
mockMbMap.emit('error', {
...createSourceDataEvent('layer1Source', OUT_OF_VIEW_CANONICAL_TILE),
error: {
message: 'simulated error',
},
});
// simulate delay. Cache-checking is debounced.
await sleep(300);
expect(tileErrorsMap.get('layer1')).toBeUndefined();
});
test('should extract elasticsearch ErrorCause from response body', async () => {
const mockMbMap = new MockMbMap();
const mockESErrorCause = {
type: 'failure',
reason: 'simulated es error',
};
mount(
<TileStatusTracker
mbMap={mockMbMap as unknown as MbMap}
layerList={[createMockLayer('layer1', 'layer1Source')]}
onTileStateChange={onTileStateChange}
/>
);
mockMbMap.emit(
'sourcedataloading',
createSourceDataEvent('layer1Source', IN_VIEW_CANONICAL_TILE)
);
mockMbMap.emit('error', {
...createSourceDataEvent('layer1Source', IN_VIEW_CANONICAL_TILE),
error: {
message: 'simulated error',
status: 400,
statusText: 'simulated ajax error',
body: new Blob([JSON.stringify({ error: mockESErrorCause })]),
},
});
// simulate delay. Cache-checking is debounced.
await sleep(300);
expect(tileErrorsMap.get('layer1')?.[0]).toEqual({
message: 'simulated error',
tileKey: '5/6/12',
error: mockESErrorCause,
});
});
test('should safely handle non-json response body', async () => {
const mockMbMap = new MockMbMap();
mount(
<TileStatusTracker
mbMap={mockMbMap as unknown as MbMap}
layerList={[createMockLayer('layer1', 'layer1Source')]}
onTileStateChange={onTileStateChange}
/>
);
mockMbMap.emit(
'sourcedataloading',
createSourceDataEvent('layer1Source', IN_VIEW_CANONICAL_TILE)
);
mockMbMap.emit('error', {
...createSourceDataEvent('layer1Source', IN_VIEW_CANONICAL_TILE),
error: {
message: 'simulated error',
status: 400,
statusText: 'simulated ajax error',
body: new Blob(['I am not json']),
},
});
// simulate delay. Cache-checking is debounced.
await sleep(300);
expect(tileErrorsMap.get('layer1')?.[0]).toEqual({
message: 'simulated error',
tileKey: '5/6/12',
});
});
});
});

View file

@ -7,25 +7,22 @@
import _ from 'lodash';
import { Component } from 'react';
import type { Map as MbMap, MapSourceDataEvent } from '@kbn/mapbox-gl';
import { i18n } from '@kbn/i18n';
import { TileMetaFeature } from '../../../../common/descriptor_types';
import type { ErrorCause } from '@elastic/elasticsearch/lib/api/types';
import type { AJAXError, Map as MbMap, MapSourceDataEvent } from '@kbn/mapbox-gl';
import type { TileError, TileMetaFeature } from '../../../../common/descriptor_types';
import { SPATIAL_FILTERS_LAYER_ID } from '../../../../common/constants';
import { ILayer } from '../../../classes/layers/layer';
import { IVectorSource } from '../../../classes/sources/vector_source';
import { getTileKey } from '../../../classes/util/geo_tile_utils';
import { getTileKey as getCenterTileKey } from '../../../classes/util/geo_tile_utils';
import { boundsToExtent } from '../../../classes/util/maplibre_utils';
import { ES_MVT_META_LAYER_NAME } from '../../../classes/util/tile_meta_feature_utils';
import { getErrorCacheTileKey, TileErrorCache } from './tile_error_cache';
interface MbTile {
// references internal object from mapbox
aborted?: boolean;
}
type TileError = Error & {
status: number;
tileZXYKey: string; // format zoom/x/y
};
interface Tile {
mbKey: string;
mbSourceId: string;
@ -35,10 +32,12 @@ interface Tile {
export interface Props {
mbMap: MbMap;
layerList: ILayer[];
setAreTilesLoaded: (layerId: string, areTilesLoaded: boolean) => void;
updateMetaFromTiles: (layerId: string, features: TileMetaFeature[]) => void;
clearTileLoadError: (layerId: string) => void;
setTileLoadError: (layerId: string, errorMessage: string) => void;
onTileStateChange: (
layerId: string,
areTilesLoaded: boolean,
tileMetaFeatures?: TileMetaFeature[],
tileErrors?: TileError[]
) => void;
}
export class TileStatusTracker extends Component<Props> {
@ -48,9 +47,7 @@ export class TileStatusTracker extends Component<Props> {
// 'sourcedata' and 'error' events remove tile request from cache
// Tile requests with 'aborted' status are removed from cache when reporting 'areTilesLoaded' status
private _tileCache: Tile[] = [];
// Tile error cache tracks tile request errors per layer
// Error cache is cleared when map center tile changes
private _tileErrorCache: Record<string, TileError[]> = {};
private _tileErrorCache = new TileErrorCache();
// Layer cache tracks layers that have requested one or more tiles
// Layer cache is used so that only a layer that has requested one or more tiles reports 'areTilesLoaded' status
// layer cache is never cleared
@ -62,7 +59,7 @@ export class TileStatusTracker extends Component<Props> {
this.props.mbMap.on('sourcedataloading', this._onSourceDataLoading);
this.props.mbMap.on('error', this._onError);
this.props.mbMap.on('sourcedata', this._onSourceData);
this.props.mbMap.on('move', this._onMove);
this.props.mbMap.on('moveend', this._onMoveEnd);
}
componentWillUnmount() {
@ -70,7 +67,7 @@ export class TileStatusTracker extends Component<Props> {
this.props.mbMap.off('error', this._onError);
this.props.mbMap.off('sourcedata', this._onSourceData);
this.props.mbMap.off('sourcedataloading', this._onSourceDataLoading);
this.props.mbMap.off('move', this._onMove);
this.props.mbMap.off('moveend', this._onMoveEnd);
this._tileCache.length = 0;
}
@ -90,6 +87,12 @@ export class TileStatusTracker extends Component<Props> {
this._layerCache.set(layerId, true);
}
this._tileErrorCache.clearTileError(
layerId,
getErrorCacheTileKey(e.tile.tileID.canonical),
this._updateTileStatusForAllLayers
);
const tracked = this._tileCache.find((tile) => {
return (
tile.mbKey === (e.tile.tileID.key as unknown as string) && tile.mbSourceId === e.sourceId
@ -107,26 +110,56 @@ export class TileStatusTracker extends Component<Props> {
}
};
_onError = (e: MapSourceDataEvent & { error: Error & { status: number } }) => {
_onError = (e: MapSourceDataEvent & { error: { message: string } | AJAXError }) => {
if (
e.sourceId &&
e.sourceId !== SPATIAL_FILTERS_LAYER_ID &&
e.tile &&
(e.source.type === 'vector' || e.source.type === 'raster')
) {
this._removeTileFromCache(e.sourceId, e.tile.tileID.key as unknown as string);
const targetLayer = this.props.layerList.find((layer) => {
return layer.ownsMbSourceId(e.sourceId);
});
const layerId = targetLayer ? targetLayer.getId() : undefined;
if (layerId) {
const layerErrors = this._tileErrorCache[layerId] ? this._tileErrorCache[layerId] : [];
layerErrors.push({
...e.error,
tileZXYKey: `${e.tile.tileID.canonical.z}/${e.tile.tileID.canonical.x}/${e.tile.tileID.canonical.y}`,
} as TileError);
this._tileErrorCache[layerId] = layerErrors;
if (!targetLayer) {
return;
}
this._removeTileFromCache(e.sourceId, e.tile.tileID.key as unknown as string);
const layerId = targetLayer.getId();
const tileKey = getErrorCacheTileKey(e.tile.tileID.canonical);
const tileError = {
message: e.error.message,
tileKey,
};
this._tileErrorCache.setTileError(layerId, tileError);
const ajaxError =
'body' in e.error && 'statusText' in e.error ? (e.error as AJAXError) : undefined;
if (!ajaxError || !targetLayer.getSource().isESSource()) {
this._updateTileStatusForAllLayers();
return;
}
ajaxError.body
.text()
.then((body) => {
if (this._tileErrorCache.hasTileError(layerId, tileKey)) {
const parsedJson = JSON.parse(body) as { error?: ErrorCause };
if (parsedJson.error && 'type' in parsedJson.error) {
this._tileErrorCache.setTileError(layerId, {
...tileError,
error: parsedJson.error,
});
this._updateTileStatusForAllLayers();
}
}
})
.catch((processAjaxBodyError) => {
// ignore errors reading and parsing ajax request body
// Contents are used to provide better UI messaging and are not required
});
}
};
@ -142,25 +175,23 @@ export class TileStatusTracker extends Component<Props> {
}
};
/*
* Clear errors when center tile changes.
* Tracking center tile provides the cleanest way to know when a new data fetching cycle is beginning
*/
_onMove = () => {
_onMoveEnd = () => {
const center = this.props.mbMap.getCenter();
// Maplibre rounds zoom when 'source.roundZoom' is true and floors zoom when 'source.roundZoom' is false
// 'source.roundZoom' is true for raster and video layers
// 'source.roundZoom' is false for vector layers
// Always floor zoom to keep logic as simple as possible and not have to track center tile by source.
// We are mainly concerned with showing errors from Elasticsearch vector tile requests (which are vector sources)
const centerTileKey = getTileKey(
const centerTileKey = getCenterTileKey(
center.lat,
center.lng,
Math.floor(this.props.mbMap.getZoom())
);
if (this._prevCenterTileKey !== centerTileKey) {
this._prevCenterTileKey = centerTileKey;
this._tileErrorCache = {};
if (this._tileErrorCache.hasAny()) {
this._updateTileStatusForAllLayers();
}
}
};
@ -189,72 +220,46 @@ export class TileStatusTracker extends Component<Props> {
break;
}
}
const tileErrorMessages = this._tileErrorCache[layer.getId()]
? this._tileErrorCache[layer.getId()].map((tileError) => {
return i18n.translate('xpack.maps.tileStatusTracker.tileErrorMsg', {
defaultMessage: `tile '{tileZXYKey}' failed to load: '{status} {message}'`,
values: {
tileZXYKey: tileError.tileZXYKey,
status: tileError.status,
message: tileError.message,
},
});
})
: [];
this._updateTileStatusForLayer(
layer,
this.props.onTileStateChange(
layer.getId(),
!atLeastOnePendingTile,
tileErrorMessages.length
? i18n.translate('xpack.maps.tileStatusTracker.layerErrorMsg', {
defaultMessage: `Unable to load {count} tiles: {tileErrors}`,
values: {
count: tileErrorMessages.length,
tileErrors: tileErrorMessages.join(', '),
},
})
: undefined
this._getTileMetaFeatures(layer),
this._tileErrorCache.getInViewTileErrors(
layer.getId(),
this.props.mbMap.getZoom(),
boundsToExtent(this.props.mbMap.getBounds())
)
);
}
}, 100);
_updateTileStatusForLayer = (layer: ILayer, areTilesLoaded: boolean, errorMessage?: string) => {
this.props.setAreTilesLoaded(layer.getId(), areTilesLoaded);
if (errorMessage) {
this.props.setTileLoadError(layer.getId(), errorMessage);
} else {
this.props.clearTileLoadError(layer.getId());
}
_getTileMetaFeatures = (layer: ILayer) => {
const source = layer.getSource();
if (
layer.isVisible() &&
return layer.isVisible() &&
source.isESSource() &&
typeof (source as IVectorSource).isMvt === 'function' &&
(source as IVectorSource).isMvt()
) {
// querySourceFeatures can return duplicated features when features cross tile boundaries.
// Tile meta will never have duplicated features since by their nature, tile meta is a feature contained within a single tile
const mbFeatures = this.props.mbMap.querySourceFeatures(layer.getMbSourceId(), {
sourceLayer: ES_MVT_META_LAYER_NAME,
});
const features = mbFeatures
.map((mbFeature) => {
try {
return {
type: 'Feature',
id: mbFeature?.id,
geometry: mbFeature?.geometry, // this getter might throw with non-conforming geometries
properties: mbFeature?.properties,
} as TileMetaFeature;
} catch (e) {
return null;
}
})
.filter((mbFeature: TileMetaFeature | null) => mbFeature !== null) as TileMetaFeature[];
this.props.updateMetaFromTiles(layer.getId(), features);
}
? // querySourceFeatures can return duplicated features when features cross tile boundaries.
// Tile meta will never have duplicated features since by their nature, tile meta is a feature contained within a single tile
(this.props.mbMap
.querySourceFeatures(layer.getMbSourceId(), {
sourceLayer: ES_MVT_META_LAYER_NAME,
})
.map((mbFeature) => {
try {
return {
type: 'Feature',
id: mbFeature?.id,
geometry: mbFeature?.geometry, // this getter might throw with non-conforming geometries
properties: mbFeature?.properties,
} as TileMetaFeature;
} catch (e) {
return null;
}
})
.filter((feature: TileMetaFeature | null) => feature !== null) as TileMetaFeature[])
: undefined;
};
_removeTileFromCache = (mbSourceId: string, mbKey: string) => {

View file

@ -17,6 +17,7 @@ exports[`TOCEntry is rendered 1`] = `
layer={
Object {
"getDisplayName": [Function],
"getErrors": [Function],
"getId": [Function],
"hasErrors": [Function],
"hasLegendDetails": [Function],
@ -104,6 +105,7 @@ exports[`TOCEntry props Should indent child layer 1`] = `
layer={
Object {
"getDisplayName": [Function],
"getErrors": [Function],
"getId": [Function],
"hasErrors": [Function],
"hasLegendDetails": [Function],
@ -187,6 +189,7 @@ exports[`TOCEntry props Should shade background when not selected layer 1`] = `
layer={
Object {
"getDisplayName": [Function],
"getErrors": [Function],
"getId": [Function],
"hasErrors": [Function],
"hasLegendDetails": [Function],
@ -270,6 +273,7 @@ exports[`TOCEntry props Should shade background when selected layer 1`] = `
layer={
Object {
"getDisplayName": [Function],
"getErrors": [Function],
"getId": [Function],
"hasErrors": [Function],
"hasLegendDetails": [Function],
@ -353,6 +357,7 @@ exports[`TOCEntry props isReadOnly 1`] = `
layer={
Object {
"getDisplayName": [Function],
"getErrors": [Function],
"getId": [Function],
"hasErrors": [Function],
"hasLegendDetails": [Function],
@ -421,6 +426,7 @@ exports[`TOCEntry props should display layer details when isLegendDetailsOpen is
layer={
Object {
"getDisplayName": [Function],
"getErrors": [Function],
"getId": [Function],
"hasErrors": [Function],
"hasLegendDetails": [Function],

View file

@ -50,6 +50,9 @@ const mockLayer = {
hasErrors: () => {
return false;
},
getErrors: () => {
return [];
},
hasLegendDetails: () => {
return true;
},

View file

@ -9,7 +9,14 @@ import React, { Component } from 'react';
import classNames from 'classnames';
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiIcon, EuiButtonIcon, EuiConfirmModal, EuiButtonEmpty } from '@elastic/eui';
import {
EuiIcon,
EuiButtonIcon,
EuiCallOut,
EuiConfirmModal,
EuiButtonEmpty,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { TOCEntryActionsPopover } from './toc_entry_actions_popover';
import {
@ -19,6 +26,7 @@ import {
FIT_TO_DATA_LABEL,
} from './action_labels';
import { ILayer } from '../../../../../classes/layers/layer';
import { isLayerGroup } from '../../../../../classes/layers/layer_group';
function escapeLayerName(name: string) {
return name.split(' ').join('_');
@ -142,6 +150,10 @@ export class TOCEntry extends Component<Props, State> {
this.props.toggleVisible(this.props.layer.getId());
};
_getLayerErrors = () => {
return isLayerGroup(this.props.layer) ? [] : this.props.layer.getErrors();
};
_renderCancelModal() {
if (!this.state.shouldShowModal) {
return null;
@ -228,7 +240,8 @@ export class TOCEntry extends Component<Props, State> {
}
_renderDetailsToggle() {
if (this.props.isDragging || !this.state.hasLegendDetails) {
const errors = this._getLayerErrors();
if (this.props.isDragging || (!this.state.hasLegendDetails && errors.length === 0)) {
return null;
}
@ -272,7 +285,7 @@ export class TOCEntry extends Component<Props, State> {
return (
<div
className={
layer.isVisible() && layer.showAtZoomLevel(zoom) && !layer.hasErrors()
layer.isVisible() && layer.showAtZoomLevel(zoom)
? 'mapTocEntry-visible'
: 'mapTocEntry-notVisible'
}
@ -292,23 +305,29 @@ export class TOCEntry extends Component<Props, State> {
}
_renderLegendDetails = () => {
if (!this.props.isLegendDetailsOpen || !this.state.hasLegendDetails) {
if (!this.props.isLegendDetailsOpen) {
return null;
}
const tocDetails = this.props.layer.renderLegendDetails();
if (!tocDetails) {
return null;
}
const errors = this._getLayerErrors();
return (
return this.state.hasLegendDetails || errors.length ? (
<div
className="mapTocEntry__layerDetails"
data-test-subj={`mapLayerTOCDetails${escapeLayerName(this.state.displayName)}`}
>
{tocDetails}
{errors.length
? errors.map(({ title, error }, index) => (
<div key={index}>
<EuiCallOut color="danger" size="s" title={title}>
{error}
</EuiCallOut>
<EuiSpacer size="m" />
</div>
))
: this.props.layer.renderLegendDetails()}
</div>
);
) : null;
};
_hightlightAsSelectedLayer() {

View file

@ -20,7 +20,7 @@ interface Footnote {
interface IconAndTooltipContent {
icon?: ReactNode;
tooltipContent?: string | null;
tooltipContent?: ReactNode;
footnotes: Footnote[];
}
@ -74,15 +74,15 @@ export class TOCEntryButton extends Component<Props, State> {
return {
icon: (
<EuiIcon
aria-label={i18n.translate('xpack.maps.layer.loadWarningAriaLabel', {
defaultMessage: 'Load warning',
})}
size="m"
type="warning"
color="warning"
type="error"
color="danger"
data-test-subj={`layerTocErrorIcon${this.props.escapedDisplayName}`}
/>
),
tooltipContent: this.props.layer.getErrors(),
tooltipContent: this.props.layer
.getErrors()
.map(({ title }) => <div key={title}>{title}</div>),
footnotes: [],
};
}

View file

@ -50,7 +50,8 @@ export function stopDataRequest(
dataRequestId: string,
requestToken: symbol,
responseMeta?: DataRequestMeta,
data?: object
data?: object,
errorMessage?: string
): MapState {
const dataRequest = getDataRequest(state, layerId, dataRequestId, requestToken);
return dataRequest
@ -63,7 +64,8 @@ export function stopDataRequest(
requestStopTime: Date.now(),
},
dataRequestMetaAtStart: undefined,
dataRequestToken: undefined, // active data request
dataRequestToken: undefined, // active data request,
error: errorMessage,
})
: state;
}

View file

@ -13,7 +13,6 @@ import {
LAYER_DATA_LOAD_ENDED,
LAYER_DATA_LOAD_ERROR,
ADD_LAYER,
SET_LAYER_ERROR_STATUS,
ADD_WAITING_FOR_MAP_READY_LAYER,
CLEAR_LAYER_PROP,
CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST,
@ -176,25 +175,6 @@ export function map(state: MapState = DEFAULT_MAP_STATE, action: Record<string,
[action.settingKey]: action.settingValue,
},
};
case SET_LAYER_ERROR_STATUS:
const { layerList } = state;
const layerIdx = getLayerIndex(layerList, action.layerId);
if (layerIdx === -1) {
return state;
}
return {
...state,
layerList: [
...layerList.slice(0, layerIdx),
{
...layerList[layerIdx],
__isInErrorState: action.isInErrorState,
__errorMessage: action.errorMessage,
},
...layerList.slice(layerIdx + 1),
],
};
case UPDATE_SOURCE_DATA_REQUEST:
return updateSourceDataRequest(state, action.layerId, action.newData);
case LAYER_DATA_LOAD_STARTED:
@ -206,7 +186,15 @@ export function map(state: MapState = DEFAULT_MAP_STATE, action: Record<string,
action.meta
);
case LAYER_DATA_LOAD_ERROR:
return stopDataRequest(state, action.layerId, action.dataId, action.requestToken);
return stopDataRequest(
state,
action.layerId,
action.dataId,
action.requestToken,
undefined, // responseMeta meta
undefined, // response data
action.errorMessage
);
case LAYER_DATA_LOAD_ENDED:
return stopDataRequest(
state,

View file

@ -21538,7 +21538,6 @@
"xpack.maps.termSource.requestName": "Requête de liaison de terme de {leftSourceName}",
"xpack.maps.tiles.resultsCompleteMsg": "{countPrefix}{count} documents trouvés.",
"xpack.maps.tiles.resultsTrimmedMsg": "Les résultats sont limités à {countPrefix}{count} documents.",
"xpack.maps.tileStatusTracker.layerErrorMsg": "Impossible de charger {count} tuiles : {tileErrors}",
"xpack.maps.tooltip.pageNumerText": "{pageNumber} de {total}",
"xpack.maps.tooltipSelector.addLabelWithCount": "Ajouter {count}",
"xpack.maps.topNav.updatePanel": "Mettre à jour le panneau sur {originatingAppName}",
@ -21548,7 +21547,6 @@
"xpack.maps.validatedNumberInput.invalidClampErrorMessage": "Doit être compris entre {min} et {max}",
"xpack.maps.validatedRange.rangeErrorMessage": "Doit être compris entre {min} et {max}",
"xpack.maps.vectorLayer.joinError.firstTenMsg": " (5 sur {total})",
"xpack.maps.vectorLayer.joinErrorMsg": "Impossible d'effectuer la jointure de termes. {reason}",
"xpack.maps.actionSelect.label": "Action",
"xpack.maps.addBtnTitle": "Ajouter",
"xpack.maps.addLayerPanel.addLayer": "Ajouter un calque",
@ -21701,7 +21699,6 @@
"xpack.maps.layer.isUsingSearchMsg": "Résultats affinés par recherche globale",
"xpack.maps.layer.isUsingTimeFilter": "Résultats affinés par heure globale",
"xpack.maps.layer.layerHiddenTooltip": "Le calque est masqué.",
"xpack.maps.layer.loadWarningAriaLabel": "Avertissement de charge",
"xpack.maps.layerControl.addLayerButtonLabel": "Ajouter un calque",
"xpack.maps.layerControl.closeLayerTOCButtonAriaLabel": "Réduire le panneau des calques",
"xpack.maps.layerControl.hideAllLayersButton": "Masquer tous les calques",
@ -21756,7 +21753,6 @@
"xpack.maps.layerPanel.settingsPanel.layerNameLabel": "Nom",
"xpack.maps.layerPanel.settingsPanel.layerTransparencyLabel": "Opacité",
"xpack.maps.layerPanel.settingsPanel.percentageLabel": "%",
"xpack.maps.layerPanel.settingsPanel.unableToLoadTitle": "Chargement du calque impossible",
"xpack.maps.layerPanel.settingsPanel.visibleZoom": "Niveaux de zoom",
"xpack.maps.layerPanel.settingsPanel.visibleZoomLabel": "Visibilité",
"xpack.maps.layerPanel.sourceDetailsLabel": "Détails de la source",
@ -22157,7 +22153,6 @@
"xpack.maps.tileMap.vis.description": "Tracer les coordonnées de latitude et de longitude sur une carte",
"xpack.maps.tileMap.vis.title": "Carte de coordonnées",
"xpack.maps.tiles.shapeCountMsg": " Ce nombre est approximatif.",
"xpack.maps.tileStatusTracker.tileErrorMsg": "impossible de charger la tuile \"{tileZXYKey}\" : \"{status} {message}\"",
"xpack.maps.timesliderToggleButton.closeLabel": "Fermer le curseur temporel",
"xpack.maps.timesliderToggleButton.openLabel": "Ouvrir le curseur temporel",
"xpack.maps.toolbarOverlay.drawBounds.initialGeometryLabel": "limites",

View file

@ -21553,7 +21553,6 @@
"xpack.maps.termSource.requestName": "{leftSourceName}用語結合リクエスト",
"xpack.maps.tiles.resultsCompleteMsg": "{countPrefix}{count}件のドキュメントが見つかりました。",
"xpack.maps.tiles.resultsTrimmedMsg": "結果は{countPrefix}{count}ドキュメントに制限されています。",
"xpack.maps.tileStatusTracker.layerErrorMsg": "{count}ルールを読み込めません:{tileErrors}",
"xpack.maps.tooltip.pageNumerText": "{pageNumber} / {total}",
"xpack.maps.tooltipSelector.addLabelWithCount": "{count}を追加",
"xpack.maps.topNav.updatePanel": "{originatingAppName}でパネルを更新",
@ -21563,7 +21562,6 @@
"xpack.maps.validatedNumberInput.invalidClampErrorMessage": "{min}と{max}の間でなければなりません",
"xpack.maps.validatedRange.rangeErrorMessage": "{min}と{max}の間でなければなりません",
"xpack.maps.vectorLayer.joinError.firstTenMsg": " {total}中5",
"xpack.maps.vectorLayer.joinErrorMsg": "用語結合を実行できません。{reason}",
"xpack.maps.actionSelect.label": "アクション",
"xpack.maps.addBtnTitle": "追加",
"xpack.maps.addLayerPanel.addLayer": "レイヤーを追加",
@ -21716,7 +21714,6 @@
"xpack.maps.layer.isUsingSearchMsg": "グローバル検索により絞られた結果",
"xpack.maps.layer.isUsingTimeFilter": "グローバル時刻により絞られた結果",
"xpack.maps.layer.layerHiddenTooltip": "レイヤーが非表示になっています。",
"xpack.maps.layer.loadWarningAriaLabel": "警告を読み込む",
"xpack.maps.layerControl.addLayerButtonLabel": "レイヤーを追加",
"xpack.maps.layerControl.closeLayerTOCButtonAriaLabel": "レイヤーパネルを畳む",
"xpack.maps.layerControl.hideAllLayersButton": "すべてのレイヤーを非表示",
@ -21771,7 +21768,6 @@
"xpack.maps.layerPanel.settingsPanel.layerNameLabel": "名前",
"xpack.maps.layerPanel.settingsPanel.layerTransparencyLabel": "レイヤーの透明度",
"xpack.maps.layerPanel.settingsPanel.percentageLabel": "%",
"xpack.maps.layerPanel.settingsPanel.unableToLoadTitle": "レイヤーを読み込めません",
"xpack.maps.layerPanel.settingsPanel.visibleZoom": "ズームレベル",
"xpack.maps.layerPanel.settingsPanel.visibleZoomLabel": "レイヤー表示のズーム範囲",
"xpack.maps.layerPanel.sourceDetailsLabel": "ソースの詳細",
@ -22172,7 +22168,6 @@
"xpack.maps.tileMap.vis.description": "マップ上に緯度と経度の座標を表示します。",
"xpack.maps.tileMap.vis.title": "座標マップ",
"xpack.maps.tiles.shapeCountMsg": " この数は近似値です。",
"xpack.maps.tileStatusTracker.tileErrorMsg": "タイル'{tileZXYKey}'を読み込めませんでした:'{status} {message}'",
"xpack.maps.timesliderToggleButton.closeLabel": "時間スライダーを閉じる",
"xpack.maps.timesliderToggleButton.openLabel": "時間スライダーを開く",
"xpack.maps.toolbarOverlay.drawBounds.initialGeometryLabel": "境界",

View file

@ -21553,7 +21553,6 @@
"xpack.maps.termSource.requestName": "{leftSourceName} 词联接请求",
"xpack.maps.tiles.resultsCompleteMsg": "找到 {countPrefix}{count} 个文档。",
"xpack.maps.tiles.resultsTrimmedMsg": "结果仅限为 {countPrefix}{count} 个文档。",
"xpack.maps.tileStatusTracker.layerErrorMsg": "无法加载 {count} 个磁贴:{tileErrors}",
"xpack.maps.tooltip.pageNumerText": "{total} 的 {pageNumber}",
"xpack.maps.tooltipSelector.addLabelWithCount": "添加 {count} 个",
"xpack.maps.topNav.updatePanel": "更新 {originatingAppName} 中的面板",
@ -21563,7 +21562,6 @@
"xpack.maps.validatedNumberInput.invalidClampErrorMessage": "必须介于 {min} 和 {max} 之间",
"xpack.maps.validatedRange.rangeErrorMessage": "必须介于 {min} 和 {max} 之间",
"xpack.maps.vectorLayer.joinError.firstTenMsg": " (5/{total})",
"xpack.maps.vectorLayer.joinErrorMsg": "无法执行词联接。{reason}",
"xpack.maps.actionSelect.label": "操作",
"xpack.maps.addBtnTitle": "添加",
"xpack.maps.addLayerPanel.addLayer": "添加图层",
@ -21716,7 +21714,6 @@
"xpack.maps.layer.isUsingSearchMsg": "全局搜索缩减的结果",
"xpack.maps.layer.isUsingTimeFilter": "全局时间缩减的结果",
"xpack.maps.layer.layerHiddenTooltip": "图层已隐藏。",
"xpack.maps.layer.loadWarningAriaLabel": "加载警告",
"xpack.maps.layerControl.addLayerButtonLabel": "添加图层",
"xpack.maps.layerControl.closeLayerTOCButtonAriaLabel": "折叠图层面板",
"xpack.maps.layerControl.hideAllLayersButton": "隐藏所有图层",
@ -21771,7 +21768,6 @@
"xpack.maps.layerPanel.settingsPanel.layerNameLabel": "名称",
"xpack.maps.layerPanel.settingsPanel.layerTransparencyLabel": "图层透明度",
"xpack.maps.layerPanel.settingsPanel.percentageLabel": "%",
"xpack.maps.layerPanel.settingsPanel.unableToLoadTitle": "无法加载图层",
"xpack.maps.layerPanel.settingsPanel.visibleZoom": "缩放级别",
"xpack.maps.layerPanel.settingsPanel.visibleZoomLabel": "图层可见性的缩放范围",
"xpack.maps.layerPanel.sourceDetailsLabel": "源详情",
@ -22172,7 +22168,6 @@
"xpack.maps.tileMap.vis.description": "在地图上绘制纬度和经度坐标",
"xpack.maps.tileMap.vis.title": "坐标地图",
"xpack.maps.tiles.shapeCountMsg": " 此计数为近似值。",
"xpack.maps.tileStatusTracker.tileErrorMsg": "无法加载磁贴“{tileZXYKey}”:“{status} {message}”",
"xpack.maps.timesliderToggleButton.closeLabel": "关闭时间滑块",
"xpack.maps.timesliderToggleButton.openLabel": "打开时间滑块",
"xpack.maps.toolbarOverlay.drawBounds.initialGeometryLabel": "边界",

View file

@ -191,5 +191,33 @@ export default function ({ getService }) {
.responseType('blob')
.expect(404);
});
it('should return elasticsearch error', async () => {
const tileUrlParams = getTileUrlParams({
...defaultParams,
requestBody: {
...defaultParams.requestBody,
query: {
error_query: {
indices: [
{
error_type: 'exception',
message: 'local shard failure message 123',
name: 'logstash-*',
},
],
},
},
},
});
const resp = await supertest
.get(`/internal/maps/mvt/getTile/2/1/1.pbf?${tileUrlParams}`)
.set('kbn-xsrf', 'kibana')
.set(ELASTIC_HTTP_VERSION_HEADER, '1')
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
.expect(400);
expect(resp.body.error.reason).to.be('all shards failed');
});
});
}

View file

@ -16,12 +16,10 @@ export default function ({ getPageObjects }) {
});
describe('ESSearchSource with missing index pattern id', () => {
const MISSING_INDEX_ID = 'idThatDoesNotExitForESSearchSource';
const LAYER_NAME = MISSING_INDEX_ID;
const LAYER_NAME = 'idThatDoesNotExitForESSearchSource';
it('should diplay error message in layer panel', async () => {
const errorMsg = await PageObjects.maps.getLayerErrorText(LAYER_NAME);
expect(errorMsg).to.equal(`Unable to find data view \'${MISSING_INDEX_ID}\'`);
it('should diplay error icon in legend', async () => {
await PageObjects.maps.hasErrorIconExistsOrFail(LAYER_NAME);
});
it('should allow deletion of layer', async () => {
@ -31,15 +29,11 @@ export default function ({ getPageObjects }) {
});
});
//TODO, skipped because `ESGeoGridSource` show no results icon instead of error icon.
describe('ESGeoGridSource with missing index pattern id', () => {
const LAYER_NAME = 'idThatDoesNotExitForESGeoGridSource';
describe.skip('ESGeoGridSource with missing index pattern id', () => {
const MISSING_INDEX_ID = 'idThatDoesNotExitForESGeoGridSource';
const LAYER_NAME = MISSING_INDEX_ID;
it('should diplay error message in layer panel', async () => {
const errorMsg = await PageObjects.maps.getLayerErrorText(LAYER_NAME);
expect(errorMsg).to.equal(`Unable to find Index pattern for id: ${MISSING_INDEX_ID}`);
it('should diplay error icon in legend', async () => {
await PageObjects.maps.hasErrorIconExistsOrFail(LAYER_NAME);
});
it('should allow deletion of layer', async () => {
@ -50,12 +44,10 @@ export default function ({ getPageObjects }) {
});
describe('ESJoinSource with missing index pattern id', () => {
const MISSING_INDEX_ID = 'idThatDoesNotExitForESJoinSource';
const LAYER_NAME = 'geo_shapes*';
it('should diplay error message in layer panel', async () => {
const errorMsg = await PageObjects.maps.getLayerErrorText(LAYER_NAME);
expect(errorMsg).to.equal(`Join error: Unable to find data view \'${MISSING_INDEX_ID}\'`);
it('should diplay error icon in legend', async () => {
await PageObjects.maps.hasErrorIconExistsOrFail(LAYER_NAME);
});
it('should allow deletion of layer', async () => {
@ -66,14 +58,10 @@ export default function ({ getPageObjects }) {
});
describe('EMSFileSource with missing EMS id', () => {
const MISSING_EMS_ID = 'idThatDoesNotExitForEMSFileSource';
const LAYER_NAME = 'EMS_vector_shapes';
it('should diplay error message in layer panel', async () => {
const errorMsg = await PageObjects.maps.getLayerErrorText(LAYER_NAME);
expect(errorMsg).to.equal(
`Unable to find EMS vector shapes for id: ${MISSING_EMS_ID}. Kibana is unable to access Elastic Maps Service. Contact your system administrator.`
);
it('should diplay error icon in legend', async () => {
await PageObjects.maps.hasErrorIconExistsOrFail(LAYER_NAME);
});
it('should allow deletion of layer', async () => {
@ -84,14 +72,10 @@ export default function ({ getPageObjects }) {
});
describe('EMSTMSSource with missing EMS id', () => {
const MISSING_EMS_ID = 'idThatDoesNotExitForEMSTile';
const LAYER_NAME = 'EMS_tiles';
it('should diplay error message in layer panel', async () => {
const errorMsg = await PageObjects.maps.getLayerErrorText(LAYER_NAME);
expect(errorMsg).to.equal(
`Unable to find EMS tile configuration for id: ${MISSING_EMS_ID}. Kibana is unable to access Elastic Maps Service. Contact your system administrator.`
);
it('should diplay error icon in legend', async () => {
await PageObjects.maps.hasErrorIconExistsOrFail(LAYER_NAME);
});
it('should allow deletion of layer', async () => {
@ -104,9 +88,8 @@ export default function ({ getPageObjects }) {
describe('KibanaTilemapSource with missing map.tilemap.url configuration', () => {
const LAYER_NAME = 'Custom_TMS';
it('should diplay error message in layer panel', async () => {
const errorMsg = await PageObjects.maps.getLayerErrorText(LAYER_NAME);
expect(errorMsg).to.equal(`Unable to find map.tilemap.url configuration in the kibana.yml`);
it('should diplay error icon in legend', async () => {
await PageObjects.maps.hasErrorIconExistsOrFail(LAYER_NAME);
});
it('should allow deletion of layer', async () => {

View file

@ -444,6 +444,12 @@ export class GisPageObject extends FtrService {
);
}
async hasErrorIconExistsOrFail(layerName: string) {
await this.retry.try(async () => {
await this.testSubjects.existOrFail(`layerTocErrorIcon${escapeLayerName(layerName)}`);
});
}
/*
* Layer panel utility functions
*/
@ -571,12 +577,6 @@ export class GisPageObject extends FtrService {
await this.waitForLayerDeleted(layerName);
}
async getLayerErrorText(layerName: string) {
this.log.debug(`Remove layer ${layerName}`);
await this.openLayerPanel(layerName);
return await this.testSubjects.getVisibleText(`layerErrorMessage`);
}
async fullScreenModeMenuItemExists() {
return await this.testSubjects.exists('mapsFullScreenMode');
}