[Synthetics] ensure metric color and current status are synced (#145031)

## Summary

Relates to https://github.com/elastic/kibana/issues/146001

This PR ensures that the status of monitors is in sync with the current
status metric by deriving the status of each individual monitor card
based on the current status api.

Updates the current status api to [return objects representing all the
down monitors and all the up
monitors](https://github.com/elastic/kibana/pull/145031/files#diff-db953498cb683d4f81a105a8c53f80ef08aea4bb687e6a29f651dd4e148bcc55R122),
with the keys being the combination of the monitor config id and
location name.

These two objects are [combined into one object in
redux](https://github.com/elastic/kibana/pull/145031/files#diff-2fb9b43772c61e2706a828651abd651a2a03b33b9f7b59c91d91c3a245edf655R77).

Creates a new `useStatusByLocationOverview`
[hook](https://github.com/elastic/kibana/pull/145031/files#diff-b80b952c8c3e15be0472d8b63a23f6ca0798dfbfdbce6a1ed056d7f954ab200eR11)
that consumes this object.

[Uses that
hook](https://github.com/elastic/kibana/pull/145031/files#diff-d65b7f3fa061b4f65d63f72296d29f603e10757e595dd688a34fa99deeac3250R56)
in Overview Metric item to derive the status.

### Testing

1. Create a handful of monitors, ensuring you have at least 1 ui monitor
and 1 project monitor
2. Ensure your cluster is connected to the synthetics service, either by
running the service locally or connecting to dev environment
3. Navigate to overview. Ensure the number of down monitor cards and
number of up monitor cards matches the current status metric
4. Use the sort by status feature to test for regression. That logic was
lightly touched in this PR
This commit is contained in:
Dominique Clarke 2022-12-15 16:04:42 -05:00 committed by GitHub
parent d0039eadf6
commit 578d643032
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 153 additions and 81 deletions

View file

@ -8,19 +8,28 @@
import * as t from 'io-ts';
export const OverviewStatusMetaDataCodec = t.interface({
heartbeatId: t.string,
monitorQueryId: t.string,
configId: t.string,
location: t.string,
status: t.string,
});
export const OverviewStatusType = t.type({
export const OverviewStatusCodec = t.interface({
up: t.number,
down: t.number,
disabledCount: t.number,
upConfigs: t.array(OverviewStatusMetaDataCodec),
downConfigs: t.array(OverviewStatusMetaDataCodec),
upConfigs: t.record(t.string, OverviewStatusMetaDataCodec),
downConfigs: t.record(t.string, OverviewStatusMetaDataCodec),
enabledIds: t.array(t.string),
});
export type OverviewStatus = t.TypeOf<typeof OverviewStatusType>;
export const OverviewStatusStateCodec = t.intersection([
OverviewStatusCodec,
t.interface({
allConfigs: t.record(t.string, OverviewStatusMetaDataCodec),
}),
]);
export type OverviewStatus = t.TypeOf<typeof OverviewStatusCodec>;
export type OverviewStatusState = t.TypeOf<typeof OverviewStatusStateCodec>;
export type OverviewStatusMetaData = t.TypeOf<typeof OverviewStatusMetaDataCodec>;

View file

@ -10,19 +10,30 @@ import { Chart, Settings, Metric, MetricTrendShape } from '@elastic/charts';
import { EuiPanel } from '@elastic/eui';
import { DARK_THEME } from '@elastic/charts';
import { useTheme } from '@kbn/observability-plugin/public';
import { useLocationName, useStatusByLocation } from '../../../../hooks';
import { useLocationName, useStatusByLocationOverview } from '../../../../hooks';
import { formatDuration } from '../../../../utils/formatting';
import { MonitorOverviewItem, Ping } from '../../../../../../../common/runtime_types';
import { MonitorOverviewItem } from '../../../../../../../common/runtime_types';
import { ActionsPopover } from './actions_popover';
import { OverviewGridItemLoader } from './overview_grid_item_loader';
export const getColor = (theme: ReturnType<typeof useTheme>, isEnabled: boolean, ping?: Ping) => {
export const getColor = (
theme: ReturnType<typeof useTheme>,
isEnabled: boolean,
status?: string
) => {
if (!isEnabled) {
return theme.eui.euiColorLightestShade;
}
return (ping?.summary?.down || 0) > 0
? theme.eui.euiColorVis9_behindText
: theme.eui.euiColorVis0_behindText;
switch (status) {
case 'down':
return theme.eui.euiColorVis9_behindText;
case 'up':
return theme.eui.euiColorVis0_behindText;
case 'unknown':
return theme.eui.euiColorGhost;
default:
return theme.eui.euiColorVis0_behindText;
}
};
export const MetricItem = ({
@ -41,8 +52,7 @@ export const MetricItem = ({
const [isMouseOver, setIsMouseOver] = useState(false);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const locationName = useLocationName({ locationId: monitor.location?.id });
const { locations } = useStatusByLocation(monitor.configId);
const ping = locations.find((loc) => loc.observer?.geo?.name === locationName);
const status = useStatusByLocationOverview(monitor.configId, locationName);
const theme = useTheme();
return (
@ -92,7 +102,7 @@ export const MetricItem = ({
</span>
),
valueFormatter: (d: number) => formatDuration(d),
color: getColor(theme, monitor.isEnabled, ping),
color: getColor(theme, monitor.isEnabled, status),
},
],
]}

View file

@ -13,6 +13,10 @@ import { OverviewGrid } from './overview_grid';
import * as hooks from '../../../../hooks/use_last_50_duration_chart';
describe('Overview Grid', () => {
const locationIdToName: Record<string, string> = {
us_central: 'Us Central',
us_east: 'US East',
};
const getMockData = (): MonitorOverviewItem[] => {
const data: MonitorOverviewItem[] = [];
for (let i = 0; i < 20; i++) {
@ -72,8 +76,17 @@ describe('Overview Grid', () => {
loaded: true,
loading: false,
status: {
downConfigs: [],
upConfigs: [],
downConfigs: {},
upConfigs: {},
allConfigs: getMockData().reduce((acc, cur) => {
acc[`${cur.id}-${locationIdToName[cur.location.id]}`] = {
configId: cur.configId,
monitorQueryId: cur.id,
location: locationIdToName[cur.location.id],
status: 'down',
};
return acc;
}, {} as Record<string, any>),
},
},
serviceLocations: {
@ -121,8 +134,17 @@ describe('Overview Grid', () => {
loaded: true,
loading: false,
status: {
downConfigs: [],
upConfigs: [],
downConfigs: {},
upConfigs: {},
allConfigs: getMockData().reduce((acc, cur) => {
acc[`${cur.id}-${locationIdToName[cur.location.id]}`] = {
configId: cur.configId,
monitorQueryId: cur.id,
location: locationIdToName[cur.location.id],
status: 'down',
};
return acc;
}, {} as Record<string, any>),
},
},
serviceLocations: {

View file

@ -15,5 +15,6 @@ export * from './use_last_x_checks';
export * from './use_last_50_duration_chart';
export * from './use_location_name';
export * from './use_status_by_location';
export * from './use_status_by_location_overview';
export * from './use_composite_image';
export * from './use_dimensions';

View file

@ -56,40 +56,40 @@ describe('useMonitorsSortedByStatus', () => {
sortField: 'name.keyword',
},
status: {
upConfigs: [
{
upConfigs: {
[`test-monitor-1-${location2.label}`]: {
configId: 'test-monitor-1',
heartbeatId: 'test-monitor-1',
monitorQueryId: 'test-monitor-1',
location: location2.label,
},
{
[`test-monitor-2-${location2.label}`]: {
configId: 'test-monitor-2',
heartbeatId: 'test-monitor-2',
monitorQueryId: 'test-monitor-2',
location: location2.label,
},
{
[`test-monitor-3-${location2.label}`]: {
configId: 'test-monitor-3',
heartbeatId: 'test-monitor-3',
monitorQueryId: 'test-monitor-3',
location: location2.label,
},
],
downConfigs: [
{
},
downConfigs: {
[`test-monitor-1-${location1.label}`]: {
configId: 'test-monitor-1',
heartbeatId: 'test-monitor-1',
monitorQueryId: 'test-monitor-1',
location: location1.label,
},
{
[`test-monitor-2-${location1.label}`]: {
configId: 'test-monitor-2',
heartbeatId: 'test-monitor-2',
monitorQueryId: 'test-monitor-2',
location: location1.label,
},
{
[`test-monitor-3${location1.label}`]: {
configId: 'test-monitor-3',
heartbeatId: 'test-monitor-3',
monitorQueryId: 'test-monitor-3',
location: location1.label,
},
],
},
},
data: {
total: 0,

View file

@ -34,7 +34,7 @@ export function useMonitorsSortedByStatus() {
const { downConfigs } = status;
const downMonitorMap: Record<string, string[]> = {};
downConfigs.forEach(({ location, configId }) => {
Object.values(downConfigs).forEach(({ location, configId }) => {
if (downMonitorMap[configId]) {
downMonitorMap[configId].push(location);
} else {

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useSelector } from 'react-redux';
import { selectOverviewStatus } from '../state/overview';
export function useStatusByLocationOverview(configId: string, locationName?: string) {
const { status } = useSelector(selectOverviewStatus);
if (!locationName || !status) {
return 'unknown';
}
const allConfigs = status.allConfigs;
return allConfigs[`${configId}-${locationName}`]?.status || 'unknown';
}

View file

@ -12,7 +12,7 @@ import {
MonitorOverviewResultCodec,
FetchMonitorOverviewQueryArgs,
OverviewStatus,
OverviewStatusType,
OverviewStatusCodec,
} from '../../../../../common/runtime_types';
import { apiService } from '../../../../utils/api_service';
import { MonitorOverviewPageState } from './models';
@ -54,5 +54,5 @@ export const fetchOverviewStatus = async (
pageState: MonitorOverviewPageState
): Promise<OverviewStatus> => {
const params = toMonitorOverviewQueryArgs(pageState);
return await apiService.get(SYNTHETICS_API_URLS.OVERVIEW_STATUS, params, OverviewStatusType);
return await apiService.get(SYNTHETICS_API_URLS.OVERVIEW_STATUS, params, OverviewStatusCodec);
};

View file

@ -72,7 +72,10 @@ export const monitorOverviewReducer = createReducer(initialState, (builder) => {
state.flyoutConfig = action.payload;
})
.addCase(fetchOverviewStatusAction.success, (state, action) => {
state.status = action.payload;
state.status = {
...action.payload,
allConfigs: { ...action.payload.upConfigs, ...action.payload.downConfigs },
};
})
.addCase(fetchOverviewStatusAction.fail, (state, action) => {
state.statusError = action.payload;

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { MonitorOverviewResult, OverviewStatus } from '../../../../../common/runtime_types';
import { MonitorOverviewResult, OverviewStatusState } from '../../../../../common/runtime_types';
import { IHttpSerializedFetchError } from '../utils/http_error';
@ -31,6 +31,6 @@ export interface MonitorOverviewState {
loading: boolean;
loaded: boolean;
error: IHttpSerializedFetchError | null;
status: OverviewStatus | null;
status: OverviewStatusState | null;
statusError: IHttpSerializedFetchError | null;
}

View file

@ -87,7 +87,7 @@ describe('current status route', () => {
config_id: 'id1',
observer: {
geo: {
name: 'test-location',
name: 'Asia/Pacific - Japan',
},
},
},
@ -122,7 +122,7 @@ describe('current status route', () => {
config_id: 'id2',
observer: {
geo: {
name: 'test-location',
name: 'Asia/Pacific - Japan',
},
},
},
@ -150,7 +150,7 @@ describe('current status route', () => {
config_id: 'id2',
observer: {
geo: {
name: 'test-location',
name: 'Europe - Germany',
},
},
},
@ -168,25 +168,28 @@ describe('current status route', () => {
down: 1,
enabledIds: ['id1', 'id2'],
up: 2,
upConfigs: [
{
upConfigs: {
'id1-Asia/Pacific - Japan': {
configId: 'id1',
heartbeatId: 'id1',
location: 'test-location',
monitorQueryId: 'id1',
location: 'Asia/Pacific - Japan',
status: 'up',
},
{
'id2-Asia/Pacific - Japan': {
configId: 'id2',
heartbeatId: 'id2',
location: 'test-location',
monitorQueryId: 'id2',
location: 'Asia/Pacific - Japan',
status: 'up',
},
],
downConfigs: [
{
},
downConfigs: {
'id2-Europe - Germany': {
configId: 'id2',
heartbeatId: 'id2',
location: 'test-location',
monitorQueryId: 'id2',
location: 'Europe - Germany',
status: 'down',
},
],
},
});
});
@ -217,7 +220,7 @@ describe('current status route', () => {
config_id: 'id1',
observer: {
geo: {
name: 'test-location',
name: 'Asia/Pacific - Japan',
},
},
},
@ -252,7 +255,7 @@ describe('current status route', () => {
config_id: 'id2',
observer: {
geo: {
name: 'test-location',
name: 'Asia/Pacific - Japan',
},
},
},
@ -280,7 +283,7 @@ describe('current status route', () => {
config_id: 'id2',
observer: {
geo: {
name: 'test-location',
name: 'Europe - Germany',
},
},
},
@ -305,25 +308,28 @@ describe('current status route', () => {
down: 1,
enabledIds: ['id1', 'id2'],
up: 2,
upConfigs: [
{
upConfigs: {
'id1-Asia/Pacific - Japan': {
configId: 'id1',
heartbeatId: 'id1',
location: 'test-location',
monitorQueryId: 'id1',
location: 'Asia/Pacific - Japan',
status: 'up',
},
{
'id2-Asia/Pacific - Japan': {
configId: 'id2',
heartbeatId: 'id2',
location: 'test-location',
monitorQueryId: 'id2',
location: 'Asia/Pacific - Japan',
status: 'up',
},
],
downConfigs: [
{
},
downConfigs: {
'id2-Europe - Germany': {
configId: 'id2',
heartbeatId: 'id2',
location: 'test-location',
monitorQueryId: 'id2',
location: 'Europe - Germany',
status: 'down',
},
],
},
});
expect(esClient.search).toHaveBeenCalledTimes(2);
// These assertions are to ensure that we are paginating through the IDs we use for filtering

View file

@ -107,30 +107,32 @@ export async function queryMonitorStatus(
}
let up = 0;
let down = 0;
const upConfigs: OverviewStatusMetaData[] = [];
const downConfigs: OverviewStatusMetaData[] = [];
const upConfigs: Record<string, OverviewStatusMetaData> = {};
const downConfigs: Record<string, OverviewStatusMetaData> = {};
for await (const response of promises) {
response.aggregations?.id.buckets.forEach(({ location }: { key: string; location: any }) => {
location.buckets.forEach(({ status }: { key: string; status: any }) => {
const downCount = status.hits.hits[0]._source.summary.down;
const upCount = status.hits.hits[0]._source.summary.up;
const configId = status.hits.hits[0]._source.config_id;
const heartbeatId = status.hits.hits[0]._source.monitor.id;
const monitorQueryId = status.hits.hits[0]._source.monitor.id;
const locationName = status.hits.hits[0]._source.observer?.geo?.name;
if (upCount > 0) {
up += 1;
upConfigs.push({
upConfigs[`${configId}-${locationName}`] = {
configId,
heartbeatId,
monitorQueryId,
location: locationName,
});
status: 'up',
};
} else if (downCount > 0) {
down += 1;
downConfigs.push({
downConfigs[`${configId}-${locationName}`] = {
configId,
heartbeatId,
monitorQueryId,
location: locationName,
});
status: 'down',
};
}
});
});