[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:
Abdul Wahab Zahid 2022-11-07 17:04:40 +01:00 committed by GitHub
parent ad8e48f87c
commit 20b8741263
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1287 additions and 37 deletions

View file

@ -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`,
}

View file

@ -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:

View file

@ -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,

View file

@ -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;
};

View file

@ -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" />
</>
);
};

View file

@ -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',
}
);

View file

@ -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>
</>
);
};

View file

@ -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,
},
};
}

View file

@ -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();
}

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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),
};
};

View file

@ -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>

View file

@ -18,3 +18,4 @@ export * from './monitor_list';
export * from './monitor_details';
export * from './overview';
export * from './browser_journey';
export * from './ping_status';

View file

@ -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');

View file

@ -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
);
};

View file

@ -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
)
);
}

View file

@ -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';

View file

@ -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;
}

View file

@ -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))
);
});

View file

@ -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),
]);
};

View file

@ -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,
});

View file

@ -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'];
}

View file

@ -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;
}

View file

@ -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> = (

View file

@ -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,
];

View file

@ -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;
}

View file

@ -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 {

View file

@ -6,3 +6,4 @@
*/
export { syntheticsGetPingsRoute } from './get_pings';
export { syntheticsGetPingStatusesRoute } from './get_ping_statuses';