mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Synthetics UI] Monitor Status Panel (histogram) - Summary / History Page (#144293)
- Create ping statuses route and state. - Create and insert Monitor Status Panel component in Monitor Detail -> Summary page. - Refresh the chart from refresh context while considering the minimum monitor interval. - Added brushing on the chart on History page. - Added "View History" button on the Summary page. - Adjust color shades for darkMode. - Address PR Feedback.
This commit is contained in:
parent
ad8e48f87c
commit
20b8741263
30 changed files with 1287 additions and 37 deletions
|
@ -8,5 +8,6 @@
|
|||
export enum SYNTHETICS_API_URLS {
|
||||
SYNTHETICS_OVERVIEW = '/internal/synthetics/overview',
|
||||
PINGS = '/internal/synthetics/pings',
|
||||
PING_STATUSES = '/internal/synthetics/ping_statuses',
|
||||
OVERVIEW_STATUS = `/internal/synthetics/overview/status`,
|
||||
}
|
||||
|
|
|
@ -12,6 +12,10 @@ export function scheduleToMilli(schedule: SyntheticsMonitorSchedule): number {
|
|||
return timeValue * getMilliFactorForScheduleUnit(schedule.unit);
|
||||
}
|
||||
|
||||
export function scheduleToMinutes(schedule: SyntheticsMonitorSchedule): number {
|
||||
return Math.floor(scheduleToMilli(schedule) / (60 * 1000));
|
||||
}
|
||||
|
||||
function getMilliFactorForScheduleUnit(scheduleUnit: ScheduleUnit): number {
|
||||
switch (scheduleUnit) {
|
||||
case ScheduleUnit.SECONDS:
|
||||
|
|
|
@ -244,6 +244,24 @@ export const PingType = t.intersection([
|
|||
|
||||
export type Ping = t.TypeOf<typeof PingType>;
|
||||
|
||||
export const PingStatusType = t.intersection([
|
||||
t.type({
|
||||
timestamp: t.string,
|
||||
docId: t.string,
|
||||
config_id: t.string,
|
||||
locationId: t.string,
|
||||
summary: t.partial({
|
||||
down: t.number,
|
||||
up: t.number,
|
||||
}),
|
||||
}),
|
||||
t.partial({
|
||||
error: PingErrorType,
|
||||
}),
|
||||
]);
|
||||
|
||||
export type PingStatus = t.TypeOf<typeof PingStatusType>;
|
||||
|
||||
// Convenience function for tests etc that makes an empty ping
|
||||
// object with the minimum of fields.
|
||||
export const makePing = (f: {
|
||||
|
@ -282,6 +300,15 @@ export const PingsResponseType = t.type({
|
|||
|
||||
export type PingsResponse = t.TypeOf<typeof PingsResponseType>;
|
||||
|
||||
export const PingStatusesResponseType = t.type({
|
||||
total: t.number,
|
||||
pings: t.array(PingStatusType),
|
||||
from: t.string,
|
||||
to: t.string,
|
||||
});
|
||||
|
||||
export type PingStatusesResponse = t.TypeOf<typeof PingStatusesResponseType>;
|
||||
|
||||
export const GetPingsParamsType = t.intersection([
|
||||
t.type({
|
||||
dateRange: DateRangeType,
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* 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 { useEffect, useCallback, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import { PingStatus } from '../../../../../../common/runtime_types';
|
||||
import {
|
||||
getMonitorPingStatusesAction,
|
||||
selectIsMonitorStatusesLoading,
|
||||
selectPingStatusesForMonitorAndLocationAsc,
|
||||
} from '../../../state';
|
||||
|
||||
import { useSelectedMonitor } from './use_selected_monitor';
|
||||
import { useSelectedLocation } from './use_selected_location';
|
||||
|
||||
export const usePingStatuses = ({
|
||||
from,
|
||||
to,
|
||||
size,
|
||||
monitorInterval,
|
||||
lastRefresh,
|
||||
}: {
|
||||
from: number;
|
||||
to: number;
|
||||
size: number;
|
||||
monitorInterval: number;
|
||||
lastRefresh: number;
|
||||
}) => {
|
||||
const { monitor } = useSelectedMonitor();
|
||||
const location = useSelectedLocation();
|
||||
|
||||
const pingStatusesSelector = useCallback(() => {
|
||||
return selectPingStatusesForMonitorAndLocationAsc(monitor?.id ?? '', location?.label ?? '');
|
||||
}, [monitor?.id, location?.label]);
|
||||
const isLoading = useSelector(selectIsMonitorStatusesLoading);
|
||||
const pingStatuses = useSelector(pingStatusesSelector()) as PingStatus[];
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const lastCall = useRef({ monitorId: '', locationLabel: '', to: 0, from: 0, lastRefresh: 0 });
|
||||
const toDiff = Math.abs(lastCall.current.to - to) / (1000 * 60);
|
||||
const fromDiff = Math.abs(lastCall.current.from - from) / (1000 * 60);
|
||||
const lastRefreshDiff = Math.abs(lastCall.current.lastRefresh - lastRefresh) / (1000 * 60);
|
||||
const isDataChangedEnough =
|
||||
toDiff >= monitorInterval ||
|
||||
fromDiff >= monitorInterval ||
|
||||
lastRefreshDiff >= 3 || // Minimum monitor interval
|
||||
monitor?.id !== lastCall.current.monitorId ||
|
||||
location?.label !== lastCall.current.locationLabel;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isDataChangedEnough && monitor?.id && location?.label && from && to && size) {
|
||||
dispatch(
|
||||
getMonitorPingStatusesAction.get({
|
||||
monitorId: monitor.id,
|
||||
locationId: location.label,
|
||||
from,
|
||||
to,
|
||||
size,
|
||||
})
|
||||
);
|
||||
|
||||
lastCall.current = {
|
||||
monitorId: monitor.id,
|
||||
locationLabel: location?.label,
|
||||
to,
|
||||
from,
|
||||
lastRefresh,
|
||||
};
|
||||
}
|
||||
// `isLoading` shouldn't be included in deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch, monitor?.id, location?.label, from, to, size, isDataChangedEnough, lastRefresh]);
|
||||
|
||||
return pingStatuses.filter(({ timestamp }) => {
|
||||
const timestampN = Number(new Date(timestamp));
|
||||
return timestampN >= from && timestampN <= to;
|
||||
});
|
||||
};
|
||||
|
||||
export const usePingStatusesIsLoading = () => {
|
||||
return useSelector(selectIsMonitorStatusesLoading) as boolean;
|
||||
};
|
|
@ -4,9 +4,37 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { EuiText } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { EuiSpacer } from '@elastic/eui';
|
||||
import { useUrlParams } from '../../../hooks';
|
||||
import { SyntheticsDatePicker } from '../../common/date_picker/synthetics_date_picker';
|
||||
import { MonitorStatusPanel } from '../monitor_status/monitor_status_panel';
|
||||
|
||||
export const MonitorHistory = () => {
|
||||
return <EuiText>Monitor history tab content</EuiText>;
|
||||
const [useGetUrlParams, updateUrlParams] = useUrlParams();
|
||||
const { dateRangeStart, dateRangeEnd } = useGetUrlParams();
|
||||
|
||||
const handleStatusChartBrushed = useCallback(
|
||||
({ fromUtc, toUtc }) => {
|
||||
updateUrlParams({ dateRangeStart: fromUtc, dateRangeEnd: toUtc });
|
||||
},
|
||||
[updateUrlParams]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SyntheticsDatePicker fullWidth={true} />
|
||||
<EuiSpacer size="m" />
|
||||
<MonitorStatusPanel
|
||||
from={dateRangeStart}
|
||||
to={dateRangeEnd}
|
||||
showViewHistoryButton={false}
|
||||
periodCaption={''}
|
||||
brushable={true}
|
||||
onBrushed={handleStatusChartBrushed}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const AVAILABILITY_LABEL = i18n.translate(
|
||||
'xpack.synthetics.monitorDetails.availability.label',
|
||||
{
|
||||
defaultMessage: 'Availability',
|
||||
}
|
||||
);
|
||||
|
||||
export const COMPLETE_LABEL = i18n.translate('xpack.synthetics.monitorDetails.complete.label', {
|
||||
defaultMessage: 'Complete',
|
||||
});
|
||||
|
||||
export const FAILED_LABEL = i18n.translate('xpack.synthetics.monitorDetails.failed.label', {
|
||||
defaultMessage: 'Failed',
|
||||
});
|
||||
|
||||
export const SKIPPED_LABEL = i18n.translate('xpack.synthetics.monitorDetails.skipped.label', {
|
||||
defaultMessage: 'Skipped',
|
||||
});
|
||||
|
||||
export const ERROR_LABEL = i18n.translate('xpack.synthetics.monitorDetails.error.label', {
|
||||
defaultMessage: 'Error',
|
||||
});
|
||||
|
||||
export const STATUS_LABEL = i18n.translate('xpack.synthetics.monitorDetails.status', {
|
||||
defaultMessage: 'Status',
|
||||
});
|
||||
|
||||
export const LAST_24_HOURS_LABEL = i18n.translate('xpack.synthetics.monitorDetails.last24Hours', {
|
||||
defaultMessage: 'Last 24 hours',
|
||||
});
|
||||
|
||||
export const VIEW_HISTORY_LABEL = i18n.translate('xpack.synthetics.monitorDetails.viewHistory', {
|
||||
defaultMessage: 'View History',
|
||||
});
|
||||
|
||||
export const BRUSH_AREA_MESSAGE = i18n.translate(
|
||||
'xpack.synthetics.monitorDetails.brushArea.message',
|
||||
{
|
||||
defaultMessage: 'Brush an area for higher fidelity',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import { css } from '@emotion/react';
|
||||
import { useEuiTheme, EuiText, EuiProgress } from '@elastic/eui';
|
||||
|
||||
import {
|
||||
TooltipTable,
|
||||
TooltipTableBody,
|
||||
TooltipHeader,
|
||||
TooltipDivider,
|
||||
TooltipTableRow,
|
||||
TooltipTableCell,
|
||||
} from '@elastic/charts';
|
||||
|
||||
import { usePingStatusesIsLoading } from '../hooks/use_ping_statuses';
|
||||
import { MonitorStatusTimeBin, SUCCESS_VIZ_COLOR, DANGER_VIZ_COLOR } from './monitor_status_data';
|
||||
import * as labels from './labels';
|
||||
|
||||
export const MonitorStatusCellTooltip = ({ timeBin }: { timeBin?: MonitorStatusTimeBin }) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const isLoading = usePingStatusesIsLoading();
|
||||
|
||||
if (!timeBin) {
|
||||
return <>{''}</>;
|
||||
}
|
||||
|
||||
const startM = moment(timeBin.start);
|
||||
const endM = moment(timeBin.end);
|
||||
const startDateStr = startM.format('LL');
|
||||
const timeStartStr = startM.format('HH:mm');
|
||||
const timeEndStr = endM.format('HH:mm');
|
||||
const isDifferentDays = startM.dayOfYear() !== endM.dayOfYear();
|
||||
|
||||
// If start and end days are different, show date for both of the days
|
||||
const endDateSegment = isDifferentDays ? `${endM.format('LL')} @ ` : '';
|
||||
const tooltipTitle = `${startDateStr} @ ${timeStartStr} - ${endDateSegment}${timeEndStr}`;
|
||||
|
||||
const availabilityStr =
|
||||
timeBin.ups + timeBin.downs > 0
|
||||
? `${Math.round((timeBin.ups / (timeBin.ups + timeBin.downs)) * 100)}%`
|
||||
: '-';
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipHeader>
|
||||
<EuiText size="xs" css={css({ border: 0, fontWeight: euiTheme.font.weight.bold })}>
|
||||
{tooltipTitle}
|
||||
</EuiText>
|
||||
</TooltipHeader>
|
||||
|
||||
{isLoading ? <EuiProgress size="xs" /> : <TooltipDivider />}
|
||||
|
||||
<div css={css({ border: 0, padding: euiTheme.size.xs })}>
|
||||
<TooltipTable>
|
||||
<TooltipTableBody>
|
||||
<TooltipTableRow>
|
||||
<TooltipListRow label={labels.AVAILABILITY_LABEL} value={availabilityStr} />
|
||||
</TooltipTableRow>
|
||||
<TooltipTableRow>
|
||||
<TooltipListRow
|
||||
label={labels.COMPLETE_LABEL}
|
||||
value={`${timeBin.ups}` ?? ''}
|
||||
color={SUCCESS_VIZ_COLOR}
|
||||
/>
|
||||
</TooltipTableRow>
|
||||
<TooltipTableRow>
|
||||
<TooltipListRow
|
||||
label={labels.FAILED_LABEL}
|
||||
value={`${timeBin.downs}` ?? ''}
|
||||
color={DANGER_VIZ_COLOR}
|
||||
/>
|
||||
</TooltipTableRow>
|
||||
</TooltipTableBody>
|
||||
</TooltipTable>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TooltipListRow = ({
|
||||
color,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
color?: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipTableCell
|
||||
css={css({
|
||||
outline: 0,
|
||||
border: 0,
|
||||
borderLeft: 4,
|
||||
borderStyle: 'solid',
|
||||
borderColor: color ?? 'transparent',
|
||||
padding: euiTheme.size.s,
|
||||
})}
|
||||
>
|
||||
<EuiText size="xs">{label}</EuiText>
|
||||
</TooltipTableCell>
|
||||
<TooltipTableCell> </TooltipTableCell>
|
||||
<TooltipTableCell css={css({ border: 0, padding: euiTheme.size.xs, textAlign: 'right' })}>
|
||||
<EuiText size="xs" css={css({ border: 0, fontWeight: euiTheme.font.weight.bold })}>
|
||||
{value}
|
||||
</EuiText>
|
||||
</TooltipTableCell>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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 { HeatmapStyle, RecursivePartial } from '@elastic/charts';
|
||||
import { EuiThemeComputed } from '@elastic/eui';
|
||||
import { CHART_CELL_WIDTH } from './monitor_status_data';
|
||||
|
||||
export function getMonitorStatusChartTheme(
|
||||
euiTheme: EuiThemeComputed,
|
||||
brushable: boolean
|
||||
): RecursivePartial<HeatmapStyle> {
|
||||
return {
|
||||
grid: {
|
||||
cellHeight: {
|
||||
min: 20,
|
||||
},
|
||||
stroke: {
|
||||
width: 0,
|
||||
color: 'transparent',
|
||||
},
|
||||
},
|
||||
maxRowHeight: 30,
|
||||
maxColumnWidth: CHART_CELL_WIDTH,
|
||||
cell: {
|
||||
maxWidth: 'fill',
|
||||
maxHeight: 3,
|
||||
label: {
|
||||
visible: false,
|
||||
},
|
||||
border: {
|
||||
stroke: 'transparent',
|
||||
strokeWidth: 0.5,
|
||||
},
|
||||
},
|
||||
xAxisLabel: {
|
||||
visible: true,
|
||||
fontSize: 10,
|
||||
fontFamily: euiTheme.font.family,
|
||||
fontWeight: euiTheme.font.weight.light,
|
||||
textColor: euiTheme.colors.subduedText,
|
||||
},
|
||||
yAxisLabel: {
|
||||
visible: false,
|
||||
},
|
||||
brushTool: {
|
||||
visible: brushable,
|
||||
fill: euiTheme.colors.darkShade,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
* 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 datemath from '@elastic/datemath';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
tint,
|
||||
transparentize,
|
||||
VISUALIZATION_COLORS,
|
||||
EuiThemeComputed,
|
||||
EuiThemeColorModeStandard,
|
||||
COLOR_MODES_STANDARD,
|
||||
} from '@elastic/eui';
|
||||
import type { BrushEvent } from '@elastic/charts';
|
||||
import { PingStatus } from '../../../../../../common/runtime_types';
|
||||
|
||||
export const SUCCESS_VIZ_COLOR = VISUALIZATION_COLORS[0];
|
||||
export const DANGER_VIZ_COLOR = VISUALIZATION_COLORS[VISUALIZATION_COLORS.length - 1];
|
||||
export const CHART_CELL_WIDTH = 17;
|
||||
|
||||
export interface MonitorStatusTimeBucket {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface MonitorStatusTimeBin {
|
||||
start: number;
|
||||
end: number;
|
||||
ups: number;
|
||||
downs: number;
|
||||
|
||||
/**
|
||||
* To color code the time bin on chart
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface MonitorStatusPanelProps {
|
||||
/**
|
||||
* Either epoch in millis or Kibana date range e.g. 'now-24h'
|
||||
*/
|
||||
from: string | number;
|
||||
|
||||
/**
|
||||
* Either epoch in millis or Kibana date range e.g. 'now'
|
||||
*/
|
||||
to: string | number;
|
||||
|
||||
brushable: boolean; // Whether to allow brushing on the chart to allow zooming in on data.
|
||||
periodCaption?: string; // e.g. Last 24 Hours
|
||||
showViewHistoryButton?: boolean;
|
||||
onBrushed?: (timeBounds: { from: number; to: number; fromUtc: string; toUtc: string }) => void;
|
||||
}
|
||||
|
||||
export function getColorBands(euiTheme: EuiThemeComputed, colorMode: EuiThemeColorModeStandard) {
|
||||
const colorTransitionFn = colorMode === COLOR_MODES_STANDARD.dark ? transparentize : tint;
|
||||
|
||||
return [
|
||||
{ color: DANGER_VIZ_COLOR, start: -Infinity, end: -1 },
|
||||
{ color: DANGER_VIZ_COLOR, start: -1, end: -0.75 },
|
||||
{ color: colorTransitionFn(DANGER_VIZ_COLOR, 0.25), start: -0.75, end: -0.5 },
|
||||
{ color: colorTransitionFn(DANGER_VIZ_COLOR, 0.5), start: -0.5, end: -0.25 },
|
||||
{ color: colorTransitionFn(DANGER_VIZ_COLOR, 0.75), start: -0.25, end: -0.000000001 },
|
||||
{
|
||||
color: getSkippedVizColor(euiTheme),
|
||||
start: -0.000000001,
|
||||
end: 0.000000001,
|
||||
},
|
||||
{ color: colorTransitionFn(SUCCESS_VIZ_COLOR, 0.5), start: 0.000000001, end: 0.25 },
|
||||
{ color: colorTransitionFn(SUCCESS_VIZ_COLOR, 0.35), start: 0.25, end: 0.5 },
|
||||
{ color: colorTransitionFn(SUCCESS_VIZ_COLOR, 0.2), start: 0.5, end: 0.8 },
|
||||
{ color: SUCCESS_VIZ_COLOR, start: 0.8, end: 1 },
|
||||
{ color: SUCCESS_VIZ_COLOR, start: 1, end: Infinity },
|
||||
];
|
||||
}
|
||||
|
||||
export function getSkippedVizColor(euiTheme: EuiThemeComputed) {
|
||||
return euiTheme.colors.lightestShade;
|
||||
}
|
||||
|
||||
export function getErrorVizColor(euiTheme: EuiThemeComputed) {
|
||||
return euiTheme.colors.dangerText;
|
||||
}
|
||||
|
||||
export function getXAxisLabelFormatter(interval: number) {
|
||||
return (value: string | number) => {
|
||||
const m = moment(value);
|
||||
const [hours, minutes] = [m.hours(), m.minutes()];
|
||||
const isFirstBucketOfADay = hours === 0 && minutes <= 36;
|
||||
const isIntervalAcrossDays = interval >= 24 * 60;
|
||||
return moment(value).format(isFirstBucketOfADay || isIntervalAcrossDays ? 'l' : 'HH:mm');
|
||||
};
|
||||
}
|
||||
|
||||
export function createTimeBuckets(intervalMinutes: number, from: number, to: number) {
|
||||
const currentMark = getEndTime(intervalMinutes, to);
|
||||
const buckets: MonitorStatusTimeBucket[] = [];
|
||||
|
||||
let tick = currentMark;
|
||||
let maxIterations = 5000;
|
||||
while (tick >= from && maxIterations > 0) {
|
||||
const start = tick - Math.floor(intervalMinutes * 60 * 1000);
|
||||
buckets.unshift({ start, end: tick });
|
||||
tick = start;
|
||||
--maxIterations;
|
||||
}
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
export function createStatusTimeBins(
|
||||
timeBuckets: MonitorStatusTimeBucket[],
|
||||
pingStatuses: PingStatus[]
|
||||
): MonitorStatusTimeBin[] {
|
||||
let iPingStatus = 0;
|
||||
return (timeBuckets ?? []).map((bucket) => {
|
||||
const currentBin: MonitorStatusTimeBin = {
|
||||
start: bucket.start,
|
||||
end: bucket.end,
|
||||
ups: 0,
|
||||
downs: 0,
|
||||
value: 0,
|
||||
};
|
||||
while (
|
||||
iPingStatus < pingStatuses.length &&
|
||||
moment(pingStatuses[iPingStatus].timestamp).valueOf() < bucket.end
|
||||
) {
|
||||
currentBin.ups += pingStatuses[iPingStatus]?.summary.up ?? 0;
|
||||
currentBin.downs += pingStatuses[iPingStatus]?.summary.down ?? 0;
|
||||
currentBin.value = getStatusEffectiveValue(currentBin.ups, currentBin.downs);
|
||||
iPingStatus++;
|
||||
}
|
||||
|
||||
return currentBin;
|
||||
});
|
||||
}
|
||||
|
||||
export function indexBinsByEndTime(bins: MonitorStatusTimeBin[]) {
|
||||
return bins.reduce((acc, cur) => {
|
||||
return acc.set(cur.end, cur);
|
||||
}, new Map<number, MonitorStatusTimeBin>());
|
||||
}
|
||||
|
||||
export function dateToMilli(date: string | number | moment.Moment | undefined): number {
|
||||
if (typeof date === 'number') {
|
||||
return date;
|
||||
}
|
||||
|
||||
let d = date;
|
||||
if (typeof date === 'string') {
|
||||
d = datemath.parse(date, { momentInstance: moment });
|
||||
}
|
||||
|
||||
return moment(d).valueOf();
|
||||
}
|
||||
|
||||
export function getBrushData(e: BrushEvent) {
|
||||
const [from, to] = [Number(e.x?.[0]), Number(e.x?.[1])];
|
||||
const [fromUtc, toUtc] = [moment(from).format(), moment(to).format()];
|
||||
|
||||
return { from, to, fromUtc, toUtc };
|
||||
}
|
||||
|
||||
function getStatusEffectiveValue(ups: number, downs: number): number {
|
||||
if (ups === downs) {
|
||||
return -0.1;
|
||||
}
|
||||
|
||||
return (ups - downs) / (ups + downs);
|
||||
}
|
||||
|
||||
function getEndTime(intervalMinutes: number, to: number) {
|
||||
const intervalUnderHour = Math.floor(intervalMinutes) % 60;
|
||||
|
||||
const upperBoundMinutes =
|
||||
Math.ceil(new Date(to).getUTCMinutes() / intervalUnderHour) * intervalUnderHour;
|
||||
|
||||
return moment(to).utc().startOf('hour').add(upperBoundMinutes, 'minute').valueOf();
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
EuiLink,
|
||||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/css';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { MONITOR_HISTORY_ROUTE } from '../../../../../../common/constants';
|
||||
import { stringifyUrlParams } from '../../../utils/url_params';
|
||||
import { useGetUrlParams } from '../../../hooks';
|
||||
|
||||
import { useSelectedMonitor } from '../hooks/use_selected_monitor';
|
||||
|
||||
import * as labels from './labels';
|
||||
import { MonitorStatusPanelProps } from './monitor_status_data';
|
||||
|
||||
export const MonitorStatusHeader = ({
|
||||
from,
|
||||
to,
|
||||
periodCaption,
|
||||
showViewHistoryButton,
|
||||
}: MonitorStatusPanelProps) => {
|
||||
const history = useHistory();
|
||||
const params = useGetUrlParams();
|
||||
const { monitor } = useSelectedMonitor();
|
||||
|
||||
const isLast24Hours = from === 'now-24h' && to === 'now';
|
||||
const periodCaptionText = !!periodCaption
|
||||
? periodCaption
|
||||
: isLast24Hours
|
||||
? labels.LAST_24_HOURS_LABEL
|
||||
: '';
|
||||
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
css={css`
|
||||
margin-bottom: 0;
|
||||
`}
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="xs">
|
||||
<h4>{labels.STATUS_LABEL}</h4>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
{periodCaptionText ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="xs" color="subdued">
|
||||
{periodCaptionText}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
<EuiFlexItem grow={true} />
|
||||
|
||||
{showViewHistoryButton ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLink
|
||||
href={
|
||||
monitor?.id
|
||||
? history.createHref({
|
||||
pathname: MONITOR_HISTORY_ROUTE.replace(':monitorId', monitor?.id),
|
||||
search: stringifyUrlParams(
|
||||
{ ...params, dateRangeStart: 'now-24h', dateRangeEnd: 'now' },
|
||||
true
|
||||
),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="monitorStatusChartViewHistoryButton"
|
||||
size="xs"
|
||||
iconType="list"
|
||||
>
|
||||
{labels.VIEW_HISTORY_LABEL}
|
||||
</EuiButtonEmpty>
|
||||
</EuiLink>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* 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, { useMemo } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, useEuiTheme } from '@elastic/eui';
|
||||
import * as labels from './labels';
|
||||
import { DANGER_VIZ_COLOR, getSkippedVizColor, SUCCESS_VIZ_COLOR } from './monitor_status_data';
|
||||
|
||||
export const MonitorStatusLegend = ({ brushable }: { brushable: boolean }) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const LegendItem = useMemo(() => {
|
||||
return ({
|
||||
color,
|
||||
label,
|
||||
iconType = 'dot',
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
iconType?: string;
|
||||
}) => (
|
||||
<EuiFlexItem
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 2px;
|
||||
`}
|
||||
grow={false}
|
||||
>
|
||||
<EuiIcon type={iconType} color={color} />
|
||||
<EuiText size="xs">{label}</EuiText>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup wrap={true} responsive={false}>
|
||||
<LegendItem color={SUCCESS_VIZ_COLOR} label={labels.COMPLETE_LABEL} />
|
||||
<LegendItem color={DANGER_VIZ_COLOR} label={labels.FAILED_LABEL} />
|
||||
<LegendItem color={getSkippedVizColor(euiTheme)} label={labels.SKIPPED_LABEL} />
|
||||
{/*
|
||||
// Hiding error for now until @elastic/chart's Heatmap chart supports annotations
|
||||
// `getErrorVizColor` can be imported from './monitor_status_data'
|
||||
<LegendItem color={getErrorVizColor(euiTheme)} label={labels.ERROR_LABEL} iconType="alert" />
|
||||
*/}
|
||||
|
||||
{brushable ? (
|
||||
<>
|
||||
<EuiFlexItem aria-hidden={true} grow={true} />
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="xs" color={euiTheme.colors.subduedText}>
|
||||
{labels.BRUSH_AREA_MESSAGE}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* 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, { useMemo } from 'react';
|
||||
|
||||
import { EuiPanel, useEuiTheme, EuiResizeObserver } from '@elastic/eui';
|
||||
import { Chart, Settings, Heatmap, ScaleType } from '@elastic/charts';
|
||||
|
||||
import { MonitorStatusHeader } from './monitor_status_header';
|
||||
import { MonitorStatusCellTooltip } from './monitor_status_cell_tooltip';
|
||||
import { MonitorStatusLegend } from './monitor_status_legend';
|
||||
import { getMonitorStatusChartTheme } from './monitor_status_chart_theme';
|
||||
import {
|
||||
getXAxisLabelFormatter,
|
||||
getColorBands,
|
||||
getBrushData,
|
||||
MonitorStatusPanelProps,
|
||||
} from './monitor_status_data';
|
||||
import { useMonitorStatusData } from './use_monitor_status_data';
|
||||
|
||||
export const MonitorStatusPanel = ({
|
||||
from = 'now-24h',
|
||||
to = 'now',
|
||||
brushable = true,
|
||||
periodCaption = undefined,
|
||||
showViewHistoryButton = false,
|
||||
onBrushed,
|
||||
}: MonitorStatusPanelProps) => {
|
||||
const { euiTheme, colorMode } = useEuiTheme();
|
||||
const { timeBins, handleResize, getTimeBinByXValue, xDomain, intervalByWidth } =
|
||||
useMonitorStatusData({ from, to });
|
||||
|
||||
const heatmap = useMemo(() => {
|
||||
return getMonitorStatusChartTheme(euiTheme, brushable);
|
||||
}, [euiTheme, brushable]);
|
||||
|
||||
return (
|
||||
<EuiPanel hasShadow={false} hasBorder={true}>
|
||||
<MonitorStatusHeader
|
||||
from={from}
|
||||
to={to}
|
||||
brushable={brushable}
|
||||
periodCaption={periodCaption}
|
||||
showViewHistoryButton={showViewHistoryButton}
|
||||
onBrushed={onBrushed}
|
||||
/>
|
||||
|
||||
<EuiResizeObserver onResize={handleResize}>
|
||||
{(resizeRef) => (
|
||||
<div ref={resizeRef}>
|
||||
<Chart css={{ height: 60 }}>
|
||||
<Settings
|
||||
showLegend={false}
|
||||
xDomain={xDomain}
|
||||
tooltip={{
|
||||
customTooltip: ({ values }) => (
|
||||
<MonitorStatusCellTooltip timeBin={getTimeBinByXValue(values?.[0]?.datum?.x)} />
|
||||
),
|
||||
}}
|
||||
theme={{ heatmap }}
|
||||
onBrushEnd={(brushArea) => {
|
||||
onBrushed?.(getBrushData(brushArea));
|
||||
}}
|
||||
/>
|
||||
<Heatmap
|
||||
id="monitor-details-monitor-status-chart"
|
||||
colorScale={{
|
||||
type: 'bands',
|
||||
bands: getColorBands(euiTheme, colorMode),
|
||||
}}
|
||||
data={timeBins}
|
||||
xAccessor={(timeBin) => timeBin.end}
|
||||
yAccessor={() => 'T'}
|
||||
valueAccessor={(timeBin) => timeBin.value}
|
||||
valueFormatter={(d) => d.toFixed(2)}
|
||||
xAxisLabelFormatter={getXAxisLabelFormatter(intervalByWidth)}
|
||||
timeZone="UTC"
|
||||
xScale={{
|
||||
type: ScaleType.Time,
|
||||
interval: {
|
||||
type: 'calendar',
|
||||
unit: 'm',
|
||||
value: intervalByWidth,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Chart>
|
||||
</div>
|
||||
)}
|
||||
</EuiResizeObserver>
|
||||
|
||||
<MonitorStatusLegend brushable={brushable} />
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* 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 { useCallback, useMemo, useState } from 'react';
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
import { scheduleToMinutes } from '../../../../../../common/lib/schedule_to_time';
|
||||
import { useSyntheticsRefreshContext } from '../../../contexts/synthetics_refresh_context';
|
||||
|
||||
import { useSelectedMonitor } from '../hooks/use_selected_monitor';
|
||||
import { usePingStatuses } from '../hooks/use_ping_statuses';
|
||||
import {
|
||||
dateToMilli,
|
||||
createTimeBuckets,
|
||||
createStatusTimeBins,
|
||||
CHART_CELL_WIDTH,
|
||||
indexBinsByEndTime,
|
||||
MonitorStatusPanelProps,
|
||||
} from './monitor_status_data';
|
||||
|
||||
export const useMonitorStatusData = ({
|
||||
from,
|
||||
to,
|
||||
}: Pick<MonitorStatusPanelProps, 'from' | 'to'>) => {
|
||||
const { lastRefresh } = useSyntheticsRefreshContext();
|
||||
const { monitor } = useSelectedMonitor();
|
||||
const monitorInterval = Math.max(3, monitor?.schedule ? scheduleToMinutes(monitor?.schedule) : 3);
|
||||
|
||||
const fromMillis = dateToMilli(from);
|
||||
const toMillis = dateToMilli(to);
|
||||
const totalMinutes = Math.ceil(toMillis - fromMillis) / (1000 * 60);
|
||||
const pingStatuses = usePingStatuses({
|
||||
from: fromMillis,
|
||||
to: toMillis,
|
||||
size: Math.min(10000, Math.ceil((totalMinutes / monitorInterval) * 2)), // Acts as max size between from - to
|
||||
monitorInterval,
|
||||
lastRefresh,
|
||||
});
|
||||
|
||||
const [binsAvailableByWidth, setBinsAvailableByWidth] = useState(50);
|
||||
const intervalByWidth = Math.floor(
|
||||
Math.max(monitorInterval, totalMinutes / binsAvailableByWidth)
|
||||
);
|
||||
|
||||
// Disabling deps warning as we wanna throttle the callback
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleResize = useCallback(
|
||||
throttle((e: { width: number; height: number }) => {
|
||||
setBinsAvailableByWidth(Math.floor(e.width / CHART_CELL_WIDTH));
|
||||
}, 500),
|
||||
[]
|
||||
);
|
||||
|
||||
const { timeBins, timeBinsByEndTime, xDomain } = useMemo(() => {
|
||||
const timeBuckets = createTimeBuckets(intervalByWidth, fromMillis, toMillis);
|
||||
const bins = createStatusTimeBins(timeBuckets, pingStatuses);
|
||||
const indexedBins = indexBinsByEndTime(bins);
|
||||
|
||||
const timeDomain = {
|
||||
min: bins?.[0]?.end ?? fromMillis,
|
||||
max: bins?.[bins.length - 1]?.end ?? toMillis,
|
||||
};
|
||||
|
||||
return { timeBins: bins, timeBinsByEndTime: indexedBins, xDomain: timeDomain };
|
||||
}, [intervalByWidth, pingStatuses, fromMillis, toMillis]);
|
||||
|
||||
return {
|
||||
intervalByWidth,
|
||||
timeBins,
|
||||
xDomain,
|
||||
handleResize,
|
||||
getTimeBinByXValue: (xValue: number | undefined) =>
|
||||
xValue === undefined ? undefined : timeBinsByEndTime.get(xValue),
|
||||
};
|
||||
};
|
|
@ -20,6 +20,7 @@ import { LoadWhenInView } from '@kbn/observability-plugin/public';
|
|||
|
||||
import { useEarliestStartDate } from '../hooks/use_earliest_start_data';
|
||||
import { MonitorErrorSparklines } from './monitor_error_sparklines';
|
||||
import { MonitorStatusPanel } from '../monitor_status/monitor_status_panel';
|
||||
import { DurationSparklines } from './duration_sparklines';
|
||||
import { MonitorDurationTrend } from './duration_trend';
|
||||
import { StepDurationPanel } from './step_duration_panel';
|
||||
|
@ -110,8 +111,13 @@ export const MonitorSummary = () => {
|
|||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
{/* <EuiSpacer size="l" /> */}
|
||||
{/* <EuiPanel style={{ height: 100 }}>/!* TODO: Add status panel*!/</EuiPanel> */}
|
||||
<EuiSpacer size="m" />
|
||||
<MonitorStatusPanel
|
||||
from={'now-24h'}
|
||||
to={'now'}
|
||||
brushable={false}
|
||||
showViewHistoryButton={true}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup gutterSize="m">
|
||||
<EuiFlexItem>
|
||||
|
|
|
@ -18,3 +18,4 @@ export * from './monitor_list';
|
|||
export * from './monitor_details';
|
||||
export * from './overview';
|
||||
export * from './browser_journey';
|
||||
export * from './ping_status';
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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 { PingStatusesResponse } from '../../../../../common/runtime_types';
|
||||
import { createAsyncAction } from '../utils/actions';
|
||||
|
||||
import { PingStatusActionArgs } from './models';
|
||||
|
||||
export const getMonitorPingStatusesAction = createAsyncAction<
|
||||
PingStatusActionArgs,
|
||||
PingStatusesResponse
|
||||
>('[PING STATUSES] GET PING STATUSES');
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { SYNTHETICS_API_URLS } from '../../../../../common/constants';
|
||||
import {
|
||||
PingStatusesResponse,
|
||||
PingStatusesResponseType,
|
||||
} from '../../../../../common/runtime_types';
|
||||
import { apiService } from '../../../../utils/api_service';
|
||||
|
||||
export const fetchMonitorPingStatuses = async ({
|
||||
monitorId,
|
||||
locationId,
|
||||
from,
|
||||
to,
|
||||
size,
|
||||
}: {
|
||||
monitorId: string;
|
||||
locationId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
size: number;
|
||||
}): Promise<PingStatusesResponse> => {
|
||||
const locations = JSON.stringify([locationId]);
|
||||
const sort = 'desc';
|
||||
|
||||
return await apiService.get(
|
||||
SYNTHETICS_API_URLS.PING_STATUSES,
|
||||
{ monitorId, from, to, locations, sort, size },
|
||||
PingStatusesResponseType
|
||||
);
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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 { takeEvery } from 'redux-saga/effects';
|
||||
import { fetchEffectFactory } from '../utils/fetch_effect';
|
||||
import { fetchMonitorPingStatuses } from './api';
|
||||
|
||||
import { getMonitorPingStatusesAction } from './actions';
|
||||
|
||||
export function* fetchPingStatusesEffect() {
|
||||
yield takeEvery(
|
||||
getMonitorPingStatusesAction.get,
|
||||
fetchEffectFactory(
|
||||
fetchMonitorPingStatuses,
|
||||
getMonitorPingStatusesAction.success,
|
||||
getMonitorPingStatusesAction.fail
|
||||
)
|
||||
);
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* 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 { createReducer } from '@reduxjs/toolkit';
|
||||
|
||||
import { PingStatus } from '../../../../../common/runtime_types';
|
||||
|
||||
import { IHttpSerializedFetchError } from '../utils/http_error';
|
||||
|
||||
import { getMonitorPingStatusesAction } from './actions';
|
||||
|
||||
export interface PingStatusState {
|
||||
pingStatuses: {
|
||||
[monitorId: string]: {
|
||||
[locationId: string]: {
|
||||
[timestamp: string]: PingStatus;
|
||||
};
|
||||
};
|
||||
};
|
||||
loading: boolean;
|
||||
error: IHttpSerializedFetchError | null;
|
||||
}
|
||||
|
||||
const initialState: PingStatusState = {
|
||||
pingStatuses: {},
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export const pingStatusReducer = createReducer(initialState, (builder) => {
|
||||
builder
|
||||
.addCase(getMonitorPingStatusesAction.get, (state) => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(getMonitorPingStatusesAction.success, (state, action) => {
|
||||
(action.payload.pings ?? []).forEach((ping) => {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { config_id, locationId, timestamp } = ping;
|
||||
if (!state.pingStatuses[config_id]) {
|
||||
state.pingStatuses[config_id] = {};
|
||||
}
|
||||
|
||||
if (!state.pingStatuses[config_id][locationId]) {
|
||||
state.pingStatuses[config_id][locationId] = {};
|
||||
}
|
||||
|
||||
state.pingStatuses[config_id][locationId][timestamp] = ping;
|
||||
});
|
||||
|
||||
state.loading = false;
|
||||
})
|
||||
.addCase(getMonitorPingStatusesAction.fail, (state, action) => {
|
||||
state.error = action.payload;
|
||||
state.loading = false;
|
||||
});
|
||||
});
|
||||
|
||||
export * from './actions';
|
||||
export * from './effects';
|
||||
export * from './selectors';
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export interface PingStatusActionArgs {
|
||||
monitorId: string;
|
||||
locationId: string;
|
||||
from: string | number;
|
||||
to: string | number;
|
||||
size: number;
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 { createSelector } from 'reselect';
|
||||
|
||||
import { PingStatus } from '../../../../../common/runtime_types';
|
||||
import { SyntheticsAppState } from '../root_reducer';
|
||||
|
||||
import { PingStatusState } from '.';
|
||||
|
||||
type PingSelectorReturnType = (state: SyntheticsAppState) => PingStatus[];
|
||||
|
||||
const getState = (appState: SyntheticsAppState) => appState.pingStatus;
|
||||
|
||||
export const selectIsMonitorStatusesLoading = createSelector(getState, (state) => state.loading);
|
||||
|
||||
export const selectPingStatusesForMonitorAndLocationAsc = (
|
||||
monitorId: string,
|
||||
locationId: string
|
||||
): PingSelectorReturnType =>
|
||||
createSelector([(state: SyntheticsAppState) => state.pingStatus], (state: PingStatusState) => {
|
||||
return Object.values(state?.pingStatuses?.[monitorId]?.[locationId] ?? {}).sort(
|
||||
(a, b) => Number(new Date(a.timestamp)) - Number(new Date(b.timestamp))
|
||||
);
|
||||
});
|
|
@ -14,6 +14,7 @@ import { fetchMonitorListEffect, upsertMonitorEffect } from './monitor_list';
|
|||
import { fetchMonitorOverviewEffect, fetchOverviewStatusEffect } from './overview';
|
||||
import { fetchServiceLocationsEffect } from './service_locations';
|
||||
import { browserJourneyEffects } from './browser_journey';
|
||||
import { fetchPingStatusesEffect } from './ping_status';
|
||||
|
||||
export const rootEffect = function* root(): Generator {
|
||||
yield all([
|
||||
|
@ -27,5 +28,6 @@ export const rootEffect = function* root(): Generator {
|
|||
fork(browserJourneyEffects),
|
||||
fork(fetchOverviewStatusEffect),
|
||||
fork(fetchNetworkEventsEffect),
|
||||
fork(fetchPingStatusesEffect),
|
||||
]);
|
||||
};
|
||||
|
|
|
@ -17,6 +17,7 @@ import { serviceLocationsReducer, ServiceLocationsState } from './service_locati
|
|||
import { monitorOverviewReducer, MonitorOverviewState } from './overview';
|
||||
import { BrowserJourneyState } from './browser_journey/models';
|
||||
import { browserJourneyReducer } from './browser_journey';
|
||||
import { PingStatusState, pingStatusReducer } from './ping_status';
|
||||
|
||||
export interface SyntheticsAppState {
|
||||
ui: UiState;
|
||||
|
@ -28,6 +29,7 @@ export interface SyntheticsAppState {
|
|||
overview: MonitorOverviewState;
|
||||
browserJourney: BrowserJourneyState;
|
||||
networkEvents: NetworkEventsState;
|
||||
pingStatus: PingStatusState;
|
||||
}
|
||||
|
||||
export const rootReducer = combineReducers<SyntheticsAppState>({
|
||||
|
@ -40,4 +42,5 @@ export const rootReducer = combineReducers<SyntheticsAppState>({
|
|||
overview: monitorOverviewReducer,
|
||||
browserJourney: browserJourneyReducer,
|
||||
networkEvents: networkEventsReducer,
|
||||
pingStatus: pingStatusReducer,
|
||||
});
|
||||
|
|
|
@ -105,6 +105,7 @@ export const mockState: SyntheticsAppState = {
|
|||
monitorDetails: getMonitorDetailsMockSlice(),
|
||||
browserJourney: getBrowserJourneyMockSlice(),
|
||||
networkEvents: {},
|
||||
pingStatus: getPingStatusesMockSlice(),
|
||||
};
|
||||
|
||||
function getBrowserJourneyMockSlice() {
|
||||
|
@ -416,3 +417,32 @@ function getMonitorDetailsMockSlice() {
|
|||
selectedLocationId: 'us_central',
|
||||
};
|
||||
}
|
||||
|
||||
function getPingStatusesMockSlice() {
|
||||
const monitorDetails = getMonitorDetailsMockSlice();
|
||||
|
||||
return {
|
||||
pingStatuses: monitorDetails.pings.data.reduce((acc, cur) => {
|
||||
if (!acc[cur.monitor.id]) {
|
||||
acc[cur.monitor.id] = {};
|
||||
}
|
||||
|
||||
if (!acc[cur.monitor.id][cur.observer.geo.name]) {
|
||||
acc[cur.monitor.id][cur.observer.geo.name] = {};
|
||||
}
|
||||
|
||||
acc[cur.monitor.id][cur.observer.geo.name][cur.timestamp] = {
|
||||
timestamp: cur.timestamp,
|
||||
error: undefined,
|
||||
locationId: cur.observer.geo.name,
|
||||
config_id: cur.config_id,
|
||||
docId: cur.docId,
|
||||
summary: cur.summary,
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {} as SyntheticsAppState['pingStatus']['pingStatuses']),
|
||||
loading: false,
|
||||
error: null,
|
||||
} as SyntheticsAppState['pingStatus'];
|
||||
}
|
||||
|
|
|
@ -5,8 +5,12 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { UMElasticsearchQueryFn } from '../../legacy_uptime/lib/adapters/framework';
|
||||
import {
|
||||
Field,
|
||||
QueryDslFieldAndFormat,
|
||||
QueryDslQueryContainer,
|
||||
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { UMElasticsearchQueryFnParams } from '../../legacy_uptime/lib/adapters/framework';
|
||||
import {
|
||||
GetPingsParams,
|
||||
HttpResponseBody,
|
||||
|
@ -60,18 +64,35 @@ function isStringArray(value: unknown): value is string[] {
|
|||
throw Error('Excluded locations can only be strings');
|
||||
}
|
||||
|
||||
export const queryPings: UMElasticsearchQueryFn<GetPingsParams, PingsResponse> = async ({
|
||||
uptimeEsClient,
|
||||
dateRange: { from, to },
|
||||
index,
|
||||
monitorId,
|
||||
status,
|
||||
sort,
|
||||
size: sizeParam,
|
||||
pageIndex,
|
||||
locations,
|
||||
excludedLocations,
|
||||
}) => {
|
||||
type QueryFields = Array<QueryDslFieldAndFormat | Field>;
|
||||
type GetParamsWithFields<F> = UMElasticsearchQueryFnParams<
|
||||
GetPingsParams & { fields: QueryFields; fieldsExtractorFn: (doc: any) => F }
|
||||
>;
|
||||
type GetParamsWithoutFields = UMElasticsearchQueryFnParams<GetPingsParams>;
|
||||
|
||||
export function queryPings(
|
||||
params: UMElasticsearchQueryFnParams<GetPingsParams>
|
||||
): Promise<PingsResponse>;
|
||||
|
||||
export function queryPings<F>(
|
||||
params: UMElasticsearchQueryFnParams<GetParamsWithFields<F>>
|
||||
): Promise<{ total: number; pings: F[] }>;
|
||||
|
||||
export async function queryPings<F>(
|
||||
params: GetParamsWithFields<F> | GetParamsWithoutFields
|
||||
): Promise<PingsResponse | { total: number; pings: F[] }> {
|
||||
const {
|
||||
uptimeEsClient,
|
||||
dateRange: { from, to },
|
||||
index,
|
||||
monitorId,
|
||||
status,
|
||||
sort,
|
||||
size: sizeParam,
|
||||
pageIndex,
|
||||
locations,
|
||||
excludedLocations,
|
||||
} = params;
|
||||
const size = sizeParam ?? DEFAULT_PAGE_SIZE;
|
||||
|
||||
const searchBody = {
|
||||
|
@ -92,6 +113,8 @@ export const queryPings: UMElasticsearchQueryFn<GetPingsParams, PingsResponse> =
|
|||
...((locations ?? []).length > 0
|
||||
? { post_filter: { terms: { 'observer.geo.name': locations as unknown as string[] } } }
|
||||
: {}),
|
||||
_source: true,
|
||||
fields: [] as QueryFields,
|
||||
};
|
||||
|
||||
// if there are excluded locations, add a clause to the query's filter
|
||||
|
@ -110,6 +133,23 @@ export const queryPings: UMElasticsearchQueryFn<GetPingsParams, PingsResponse> =
|
|||
});
|
||||
}
|
||||
|
||||
// If fields are queried, only query the subset of asked fields and omit _source
|
||||
if (isGetParamsWithFields(params)) {
|
||||
searchBody._source = false;
|
||||
searchBody.fields = params.fields;
|
||||
|
||||
const {
|
||||
body: {
|
||||
hits: { hits, total },
|
||||
},
|
||||
} = await uptimeEsClient.search({ body: searchBody });
|
||||
|
||||
return {
|
||||
total: total.value,
|
||||
pings: hits.map((doc: any) => params.fieldsExtractorFn(doc)),
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
body: {
|
||||
hits: { hits, total },
|
||||
|
@ -133,4 +173,10 @@ export const queryPings: UMElasticsearchQueryFn<GetPingsParams, PingsResponse> =
|
|||
total: total.value,
|
||||
pings,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function isGetParamsWithFields<F>(
|
||||
params: GetParamsWithFields<F> | GetParamsWithoutFields
|
||||
): params is GetParamsWithFields<F> {
|
||||
return (params as GetParamsWithFields<F>).fields?.length > 0;
|
||||
}
|
||||
|
|
|
@ -35,11 +35,13 @@ import type { TelemetryEventsSender } from '../../telemetry/sender';
|
|||
import type { UptimeRouter } from '../../../../types';
|
||||
import { UptimeConfig } from '../../../../../common/config';
|
||||
|
||||
export type UMElasticsearchQueryFnParams<P> = {
|
||||
uptimeEsClient: UptimeEsClient;
|
||||
esClient?: IScopedClusterClient;
|
||||
} & P;
|
||||
|
||||
export type UMElasticsearchQueryFn<P, R = any> = (
|
||||
params: {
|
||||
uptimeEsClient: UptimeEsClient;
|
||||
esClient?: IScopedClusterClient;
|
||||
} & P
|
||||
params: UMElasticsearchQueryFnParams<P>
|
||||
) => Promise<R>;
|
||||
|
||||
export type UMSavedObjectsQueryFn<T = any, P = undefined> = (
|
||||
|
|
|
@ -28,7 +28,7 @@ import { editSyntheticsMonitorRoute } from './monitor_cruds/edit_monitor';
|
|||
import { addSyntheticsMonitorRoute } from './monitor_cruds/add_monitor';
|
||||
import { addSyntheticsProjectMonitorRoute } from './monitor_cruds/add_monitor_project';
|
||||
import { addSyntheticsProjectMonitorRouteLegacy } from './monitor_cruds/add_monitor_project_legacy';
|
||||
import { syntheticsGetPingsRoute } from './pings';
|
||||
import { syntheticsGetPingsRoute, syntheticsGetPingStatusesRoute } from './pings';
|
||||
import { createGetCurrentStatusRoute } from './status/current_status';
|
||||
import {
|
||||
SyntheticsRestApiRouteFactory,
|
||||
|
@ -56,6 +56,7 @@ export const syntheticsAppRestApiRoutes: SyntheticsRestApiRouteFactory[] = [
|
|||
getServiceAllowedRoute,
|
||||
getAPIKeySyntheticsRoute,
|
||||
syntheticsGetPingsRoute,
|
||||
syntheticsGetPingStatusesRoute,
|
||||
getHasZipUrlMonitorRoute,
|
||||
createGetCurrentStatusRoute,
|
||||
];
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* 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 { SYNTHETICS_API_URLS } from '../../../common/constants';
|
||||
import { PingError, PingStatus } from '../../../common/runtime_types';
|
||||
import { UMServerLibs } from '../../legacy_uptime/lib/lib';
|
||||
import { UMRestApiRouteFactory } from '../../legacy_uptime/routes/types';
|
||||
import { queryPings } from '../../common/pings/query_pings';
|
||||
|
||||
import { getPingsRouteQuerySchema } from './get_pings';
|
||||
|
||||
export const syntheticsGetPingStatusesRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({
|
||||
method: 'GET',
|
||||
path: SYNTHETICS_API_URLS.PING_STATUSES,
|
||||
validate: {
|
||||
query: getPingsRouteQuerySchema,
|
||||
},
|
||||
handler: async ({ uptimeEsClient, request, response }): Promise<any> => {
|
||||
const {
|
||||
from,
|
||||
to,
|
||||
index,
|
||||
monitorId,
|
||||
status,
|
||||
sort,
|
||||
size,
|
||||
pageIndex,
|
||||
locations,
|
||||
excludedLocations,
|
||||
} = request.query;
|
||||
|
||||
const result = await queryPings<PingStatus>({
|
||||
uptimeEsClient,
|
||||
dateRange: { from, to },
|
||||
index,
|
||||
monitorId,
|
||||
status,
|
||||
sort,
|
||||
size,
|
||||
pageIndex,
|
||||
locations: locations ? JSON.parse(locations) : [],
|
||||
excludedLocations,
|
||||
fields: ['@timestamp', 'config_id', 'summary.*', 'error.*', 'observer.geo.name'],
|
||||
fieldsExtractorFn: extractPingStatus,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
from,
|
||||
to,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function grabPingError(doc: any): PingError | undefined {
|
||||
const docContainsError = Object.keys(doc?.fields ?? {}).some((key) => key.startsWith('error.'));
|
||||
if (!docContainsError) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
code: doc.fields['error.code']?.[0],
|
||||
id: doc.fields['error.id']?.[0],
|
||||
stack_trace: doc.fields['error.stack_trace']?.[0],
|
||||
type: doc.fields['error.type']?.[0],
|
||||
message: doc.fields['error.message']?.[0],
|
||||
};
|
||||
}
|
||||
|
||||
function extractPingStatus(doc: any) {
|
||||
return {
|
||||
timestamp: doc.fields['@timestamp']?.[0],
|
||||
docId: doc._id,
|
||||
config_id: doc.fields.config_id?.[0],
|
||||
locationId: doc.fields['observer.geo.name']?.[0],
|
||||
summary: { up: doc.fields['summary.up']?.[0], down: doc.fields['summary.down']?.[0] },
|
||||
error: grabPingError(doc),
|
||||
} as PingStatus;
|
||||
}
|
|
@ -11,22 +11,24 @@ import { UMServerLibs } from '../../legacy_uptime/lib/lib';
|
|||
import { UMRestApiRouteFactory } from '../../legacy_uptime/routes/types';
|
||||
import { queryPings } from '../../common/pings/query_pings';
|
||||
|
||||
export const getPingsRouteQuerySchema = schema.object({
|
||||
from: schema.string(),
|
||||
to: schema.string(),
|
||||
locations: schema.maybe(schema.string()),
|
||||
excludedLocations: schema.maybe(schema.string()),
|
||||
monitorId: schema.maybe(schema.string()),
|
||||
index: schema.maybe(schema.number()),
|
||||
size: schema.maybe(schema.number()),
|
||||
pageIndex: schema.maybe(schema.number()),
|
||||
sort: schema.maybe(schema.string()),
|
||||
status: schema.maybe(schema.string()),
|
||||
});
|
||||
|
||||
export const syntheticsGetPingsRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({
|
||||
method: 'GET',
|
||||
path: SYNTHETICS_API_URLS.PINGS,
|
||||
validate: {
|
||||
query: schema.object({
|
||||
from: schema.string(),
|
||||
to: schema.string(),
|
||||
locations: schema.maybe(schema.string()),
|
||||
excludedLocations: schema.maybe(schema.string()),
|
||||
monitorId: schema.maybe(schema.string()),
|
||||
index: schema.maybe(schema.number()),
|
||||
size: schema.maybe(schema.number()),
|
||||
pageIndex: schema.maybe(schema.number()),
|
||||
sort: schema.maybe(schema.string()),
|
||||
status: schema.maybe(schema.string()),
|
||||
}),
|
||||
query: getPingsRouteQuerySchema,
|
||||
},
|
||||
handler: async ({ uptimeEsClient, request, response }): Promise<any> => {
|
||||
const {
|
||||
|
|
|
@ -6,3 +6,4 @@
|
|||
*/
|
||||
|
||||
export { syntheticsGetPingsRoute } from './get_pings';
|
||||
export { syntheticsGetPingStatusesRoute } from './get_ping_statuses';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue