mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[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:
parent
002e685f0f
commit
30c17e0222
38 changed files with 963 additions and 410 deletions
|
@ -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,
|
||||
|
|
|
@ -123,4 +123,5 @@ export type DataRequestDescriptor = {
|
|||
dataRequestToken?: symbol;
|
||||
data?: object;
|
||||
dataRequestMeta?: DataRequestMeta;
|
||||
error?: string;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
],
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -15,7 +15,6 @@ export {
|
|||
cancelAllInFlightRequests,
|
||||
fitToLayerExtent,
|
||||
fitToDataBounds,
|
||||
setLayerDataLoadErrorStatus,
|
||||
} from './data_request_actions';
|
||||
export {
|
||||
closeOnClickTooltip,
|
||||
|
|
|
@ -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));
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
100
x-pack/plugins/maps/public/classes/layers/tile_errors_list.tsx
Normal file
100
x-pack/plugins/maps/public/classes/layers/tile_errors_list.tsx
Normal 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;
|
||||
}
|
|
@ -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)) {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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())
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -11,5 +11,6 @@ import { PropertiesMap } from '../../../../common/elasticsearch_util';
|
|||
export interface JoinState {
|
||||
dataHasChanged: boolean;
|
||||
join: InnerJoin;
|
||||
joinIndex: number;
|
||||
propertiesMap?: PropertiesMap;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
20
x-pack/plugins/maps/public/classes/util/maplibre_utils.ts
Normal file
20
x-pack/plugins/maps/public/classes/util/maplibre_utils.ts
Normal 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),
|
||||
};
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -50,6 +50,9 @@ const mockLayer = {
|
|||
hasErrors: () => {
|
||||
return false;
|
||||
},
|
||||
getErrors: () => {
|
||||
return [];
|
||||
},
|
||||
hasLegendDetails: () => {
|
||||
return true;
|
||||
},
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "境界",
|
||||
|
|
|
@ -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": "边界",
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue