[Uptime] Remove Scripted Metric Query (#69229)

Resolves #68096 , removing unnecessary scripted metric query from overview page and unifying the Check and Ping types.

This simplifies the types in a number of ways, and reduces the total quantity of code to execute the queries for the overview page. It also fixes the Tls and related types which were inconsistent and presented a problem here since they are used by this JS. There are now three stages where before there were four:

    Find potential matches: where we determine which monitor IDs are eligible for the overview page
    Refine potential matches: where we determine which ones actually match and return the summary documents for each location to build the MonitorSummary object
    Get monitor histograms: where we calculate the histograms for each monitor. In the future we might make this a separate API call.

This improves the overall code structure, and leaves the test coverage about the same depending on how you look at it. I think we can do more to improve the quality of code / tests here, but this seemed like a good initial place to draw the line for now.

In perfunctory testing on our internal observability clusters I saw perf improve from 2.5s to 1.1s on the Uptime homepage with no filters. So, it looks like there are potentially perf improvements (no real benchmarking was done).

Previously, this returned all pings from the latest check group. This was not actually used anywhere, only the summary pings are required for the current UI, so we now only return those from the list API as this saves a query.
This commit is contained in:
Andrew Cholakian 2020-07-09 08:51:55 -05:00 committed by GitHub
parent d37b053f28
commit 5085b62c9c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 1727 additions and 1991 deletions

View file

@ -5,68 +5,9 @@
*/
import * as t from 'io-ts';
export const CheckMonitorType = t.intersection([
t.partial({
name: t.string,
ip: t.union([t.array(t.union([t.string, t.null])), t.string, t.null]),
}),
t.type({
status: t.string,
}),
]);
export const CheckType = t.intersection([
t.partial({
agent: t.partial({
id: t.string,
}),
container: t.type({
id: t.string,
}),
kubernetes: t.type({
pod: t.type({
uid: t.string,
}),
}),
observer: t.type({
geo: t.partial({
name: t.string,
location: t.partial({
lat: t.number,
lon: t.number,
}),
}),
}),
}),
t.type({
monitor: CheckMonitorType,
timestamp: t.number,
}),
]);
export type Check = t.TypeOf<typeof CheckType>;
import { PingType } from '../ping/ping';
export const StateType = t.intersection([
t.partial({
checks: t.array(CheckType),
observer: t.partial({
geo: t.partial({
name: t.array(t.string),
}),
}),
summary: t.partial({
up: t.number,
down: t.number,
geo: t.partial({
name: t.string,
location: t.partial({
lat: t.number,
lon: t.number,
}),
}),
}),
}),
t.type({
timestamp: t.string,
url: t.partial({
@ -76,9 +17,31 @@ export const StateType = t.intersection([
port: t.number,
scheme: t.string,
}),
summaryPings: t.array(PingType),
summary: t.partial({
status: t.string,
up: t.number,
down: t.number,
}),
monitor: t.partial({
name: t.string,
}),
}),
t.partial({
tls: t.partial({
not_after: t.union([t.string, t.null]),
not_before: t.union([t.string, t.null]),
}),
observer: t.type({
geo: t.type({
name: t.array(t.string),
}),
}),
}),
]);
export type MonitorSummaryState = t.TypeOf<typeof StateType>;
export const HistogramPointType = t.type({
timestamp: t.number,
up: t.union([t.number, t.undefined]),
@ -105,18 +68,18 @@ export const MonitorSummaryType = t.intersection([
export type MonitorSummary = t.TypeOf<typeof MonitorSummaryType>;
export const MonitorSummaryResultType = t.intersection([
export const MonitorSummariesResultType = t.intersection([
t.partial({
summaries: t.array(MonitorSummaryType),
totalSummaryCount: t.number,
}),
t.type({
summaries: t.array(MonitorSummaryType),
prevPagePagination: t.union([t.string, t.null]),
nextPagePagination: t.union([t.string, t.null]),
totalSummaryCount: t.number,
}),
]);
export type MonitorSummaryResult = t.TypeOf<typeof MonitorSummaryResultType>;
export type MonitorSummariesResult = t.TypeOf<typeof MonitorSummariesResultType>;
export const FetchMonitorStatesQueryArgsType = t.intersection([
t.partial({

View file

@ -16,11 +16,51 @@ export const HttpResponseBodyType = t.partial({
export type HttpResponseBody = t.TypeOf<typeof HttpResponseBodyType>;
export const TlsType = t.partial({
const ECSDistinguishedName = t.type({
common_name: t.string,
distinguished_name: t.string,
});
export const X509ExpiryType = t.type({
not_after: t.string,
not_before: t.string,
});
export type X509Expiry = t.TypeOf<typeof X509ExpiryType>;
export const X509Type = t.intersection([
t.type({
issuer: ECSDistinguishedName,
subject: ECSDistinguishedName,
serial_number: t.string,
public_key_algorithm: t.string,
signature_algorithm: t.string,
}),
X509ExpiryType,
t.partial({
public_key_curve: t.string,
public_key_exponent: t.number,
public_key_size: t.number,
}),
]);
export type X509 = t.TypeOf<typeof X509Type>;
export const TlsType = t.partial({
// deprecated in favor of server.x509.not_after/not_before
certificate_not_valid_after: t.string,
certificate_not_valid_before: t.string,
cipher: t.string,
established: t.boolean,
server: t.partial({
hash: t.type({
sha256: t.string,
sha1: t.string,
}),
x509: X509Type,
}),
});
export type Tls = t.TypeOf<typeof TlsType>;
export const MonitorType = t.intersection([
@ -123,6 +163,11 @@ export const PingType = t.intersection([
observer: t.partial({
geo: t.partial({
name: t.string,
location: t.union([
t.string,
t.partial({ lat: t.number, lon: t.number }),
t.partial({ lat: t.string, lon: t.string }),
]),
}),
}),
resolve: t.partial({
@ -156,6 +201,30 @@ export const PingType = t.intersection([
export type Ping = t.TypeOf<typeof PingType>;
// Convenience function for tests etc that makes an empty ping
// object with the minimum of fields.
export const makePing = (f: {
docId?: string;
type?: string;
id?: string;
timestamp?: string;
ip?: string;
status?: string;
duration?: number;
}): Ping => {
return {
docId: f.docId || 'myDocId',
timestamp: f.timestamp || '2020-07-07T01:14:08Z',
monitor: {
id: f.id || 'myId',
type: f.type || 'myType',
ip: f.ip || '127.0.0.1',
status: f.status || 'up',
duration: { us: f.duration || 100000 },
},
};
};
export const PingsResponseType = t.type({
total: t.number,
locations: t.array(t.string),

View file

@ -176,7 +176,8 @@ exports[`SSL Certificate component shallow renders 1`] = `
<MonitorSSLCertificate
tls={
Object {
"not_after": "2020-04-24T11:41:38.200Z",
"certificate_not_valid_after": "2020-04-24T11:41:38.200Z",
"certificate_not_valid_before": "2019-04-24T11:41:38.200Z",
}
}
/>

View file

@ -15,12 +15,13 @@ import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../../common/constants';
describe('SSL Certificate component', () => {
let monitorTls: Tls;
const dateInTwoMonths = moment().add(2, 'month').toString();
const yesterday = moment().subtract(1, 'day').toString();
beforeEach(() => {
const dateInTwoMonths = moment().add(2, 'month').toString();
monitorTls = {
not_after: dateInTwoMonths,
certificate_not_valid_after: dateInTwoMonths,
certificate_not_valid_before: yesterday,
};
const useDispatchSpy = jest.spyOn(redux, 'useDispatch');
@ -32,7 +33,8 @@ describe('SSL Certificate component', () => {
it('shallow renders', () => {
const monitorTls1 = {
not_after: '2020-04-24T11:41:38.200Z',
certificate_not_valid_after: '2020-04-24T11:41:38.200Z',
certificate_not_valid_before: '2019-04-24T11:41:38.200Z',
};
const component = shallowWithRouter(<MonitorSSLCertificate tls={monitorTls1} />);
expect(component).toMatchSnapshot();
@ -45,7 +47,8 @@ describe('SSL Certificate component', () => {
it('renders null if invalid date', () => {
monitorTls = {
not_after: 'i am so invalid date',
certificate_not_valid_after: 'i am so invalid date',
certificate_not_valid_before: 'i am so invalid date',
};
const component = renderWithRouter(<MonitorSSLCertificate tls={monitorTls} />);
expect(component).toMatchSnapshot();
@ -54,7 +57,8 @@ describe('SSL Certificate component', () => {
it('renders expiration date with a warning state if ssl expiry date is less than 5 days', () => {
const dateIn5Days = moment().add(5, 'day').toString();
monitorTls = {
not_after: dateIn5Days,
certificate_not_valid_after: dateIn5Days,
certificate_not_valid_before: yesterday,
};
const component = mountWithRouter(<MonitorSSLCertificate tls={monitorTls} />);
@ -69,7 +73,8 @@ describe('SSL Certificate component', () => {
it('does not render the expiration date with a warning state if expiry date is greater than a month', () => {
const dateIn40Days = moment().add(40, 'day').toString();
monitorTls = {
not_after: dateIn40Days,
certificate_not_valid_after: dateIn40Days,
certificate_not_valid_before: yesterday,
};
const component = mountWithRouter(<MonitorSSLCertificate tls={monitorTls} />);

View file

@ -8,7 +8,7 @@ import React from 'react';
import { Link } from 'react-router-dom';
import { EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { Tls } from '../../../../../common/runtime_types';
import { Tls, X509Expiry } from '../../../../../common/runtime_types';
import { CERTIFICATES_ROUTE } from '../../../../../common/constants';
import { MonListDescription, MonListTitle } from './status_bar';
import { CertStatusColumn } from '../../../overview/monitor_list/cert_status_column';
@ -21,7 +21,21 @@ interface Props {
}
export const MonitorSSLCertificate = ({ tls }: Props) => {
return tls?.not_after ? (
let expiry: X509Expiry | null = null;
if (tls?.server?.x509) {
expiry = tls.server.x509;
} else if (tls?.certificate_not_valid_after && tls?.certificate_not_valid_before) {
expiry = {
not_after: tls.certificate_not_valid_after,
not_before: tls.certificate_not_valid_before,
};
}
if (!expiry) {
return null;
}
return (
<>
<MonListTitle>
<FormattedMessage
@ -33,9 +47,9 @@ export const MonitorSSLCertificate = ({ tls }: Props) => {
<EuiSpacer size="s" />
<MonListDescription>
<Link to={CERTIFICATES_ROUTE} className="eui-displayInline">
<CertStatusColumn cert={tls} boldStyle={true} />
<CertStatusColumn expiry={expiry} boldStyle={true} />
</Link>
</MonListDescription>
</>
) : null;
);
};

View file

@ -6,17 +6,99 @@
import React from 'react';
import {
MonitorSummaryResult,
MonitorSummariesResult,
CursorDirection,
SortOrder,
makePing,
Ping,
MonitorSummary,
} from '../../../../../common/runtime_types';
import { MonitorListComponent, noItemsMessage } from '../monitor_list';
import { renderWithRouter, shallowWithRouter } from '../../../../lib';
import * as redux from 'react-redux';
const testFooPings: Ping[] = [
makePing({
docId: 'foo1',
id: 'foo',
type: 'icmp',
status: 'up',
duration: 123,
timestamp: '124',
ip: '127.0.0.1',
}),
makePing({
docId: 'foo2',
id: 'foo',
type: 'icmp',
status: 'up',
duration: 123,
timestamp: '125',
ip: '127.0.0.2',
}),
makePing({
docId: 'foo3',
id: 'foo',
type: 'icmp',
status: 'down',
duration: 123,
timestamp: '126',
ip: '127.0.0.3',
}),
];
const testFooSummary: MonitorSummary = {
monitor_id: 'foo',
state: {
monitor: {},
summaryPings: testFooPings,
summary: {
up: 1,
down: 2,
},
timestamp: '123',
url: {},
},
};
const testBarPings: Ping[] = [
makePing({
docId: 'bar1',
id: 'bar',
type: 'icmp',
status: 'down',
duration: 123,
timestamp: '125',
ip: '127.0.0.1',
}),
makePing({
docId: 'bar2',
id: 'bar',
type: 'icmp',
status: 'down',
duration: 123,
timestamp: '126',
ip: '127.0.0.1',
}),
];
const testBarSummary: MonitorSummary = {
monitor_id: 'bar',
state: {
monitor: {},
summaryPings: testBarPings,
summary: {
up: 2,
down: 0,
},
timestamp: '125',
url: {},
},
};
// Failing: See https://github.com/elastic/kibana/issues/70386
describe.skip('MonitorList component', () => {
let result: MonitorSummaryResult;
let result: MonitorSummariesResult;
let localStorageMock: any;
beforeEach(() => {
@ -36,69 +118,7 @@ describe.skip('MonitorList component', () => {
result = {
nextPagePagination: null,
prevPagePagination: null,
summaries: [
{
monitor_id: 'foo',
state: {
checks: [
{
monitor: {
ip: '127.0.0.1',
status: 'up',
},
timestamp: 124,
},
{
monitor: {
ip: '127.0.0.2',
status: 'down',
},
timestamp: 125,
},
{
monitor: {
ip: '127.0.0.3',
status: 'down',
},
timestamp: 126,
},
],
summary: {
up: 1,
down: 2,
},
timestamp: '123',
url: {},
},
},
{
monitor_id: 'bar',
state: {
checks: [
{
monitor: {
ip: '127.0.0.1',
status: 'up',
},
timestamp: 125,
},
{
monitor: {
ip: '127.0.0.2',
status: 'up',
},
timestamp: 126,
},
],
summary: {
up: 2,
down: 0,
},
timestamp: '125',
url: {},
},
},
],
summaries: [testFooSummary, testBarSummary],
totalSummaryCount: 2,
};
});
@ -171,7 +191,7 @@ describe.skip('MonitorList component', () => {
});
describe('MonitorListPagination component', () => {
let paginationResult: MonitorSummaryResult;
let paginationResult: MonitorSummariesResult;
beforeEach(() => {
paginationResult = {
@ -185,69 +205,7 @@ describe.skip('MonitorList component', () => {
cursorDirection: CursorDirection.AFTER,
sortOrder: SortOrder.ASC,
}),
summaries: [
{
monitor_id: 'foo',
state: {
checks: [
{
monitor: {
ip: '127.0.0.1',
status: 'up',
},
timestamp: 124,
},
{
monitor: {
ip: '127.0.0.2',
status: 'down',
},
timestamp: 125,
},
{
monitor: {
ip: '127.0.0.3',
status: 'down',
},
timestamp: 126,
},
],
summary: {
up: 1,
down: 2,
},
timestamp: '123',
url: {},
},
},
{
monitor_id: 'bar',
state: {
checks: [
{
monitor: {
ip: '127.0.0.1',
status: 'up',
},
timestamp: 125,
},
{
monitor: {
ip: '127.0.0.2',
status: 'up',
},
timestamp: 126,
},
],
summary: {
up: 2,
down: 0,
},
timestamp: '125',
url: {},
},
},
],
summaries: [testFooSummary, testBarSummary],
totalSummaryCount: 2,
};
});

View file

@ -8,7 +8,7 @@ import React from 'react';
import moment from 'moment';
import { renderWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { getLocationStatus, MonitorListStatusColumn } from '../monitor_list_status_column';
import { Check } from '../../../../../common/runtime_types';
import { Ping } from '../../../../../common/runtime_types';
import { STATUS } from '../../../../../common/constants';
describe('MonitorListStatusColumn', () => {
@ -20,19 +20,23 @@ describe('MonitorListStatusColumn', () => {
Date.prototype.toString = jest.fn(() => 'Tue, 01 Jan 2019 00:00:00 GMT');
});
let upChecks: Check[];
let upChecks: Ping[];
let downChecks: Check[];
let downChecks: Ping[];
let checks: Check[];
let summaryPings: Ping[];
beforeEach(() => {
upChecks = [
{
docId: '1',
monitor: {
ip: '104.86.46.103',
name: '',
status: 'up',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {
@ -43,13 +47,17 @@ describe('MonitorListStatusColumn', () => {
},
},
},
timestamp: 1579794631464,
timestamp: '1579794631464',
},
{
docId: '2',
monitor: {
ip: '104.86.46.103',
name: '',
status: 'up',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {
@ -60,13 +68,17 @@ describe('MonitorListStatusColumn', () => {
},
},
},
timestamp: 1579794634220,
timestamp: '1579794634220',
},
{
docId: '3',
monitor: {
ip: '104.86.46.103',
name: '',
status: 'up',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {
@ -77,16 +89,20 @@ describe('MonitorListStatusColumn', () => {
},
},
},
timestamp: 1579794628368,
timestamp: '1579794628368',
},
];
downChecks = [
{
docId: '4',
monitor: {
ip: '104.86.46.103',
name: '',
status: 'down',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {
@ -97,13 +113,17 @@ describe('MonitorListStatusColumn', () => {
},
},
},
timestamp: 1579794631464,
timestamp: '1579794631464',
},
{
docId: '5',
monitor: {
ip: '104.86.46.103',
name: '',
status: 'down',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {
@ -114,13 +134,17 @@ describe('MonitorListStatusColumn', () => {
},
},
},
timestamp: 1579794634220,
timestamp: '1579794634220',
},
{
docId: '6',
monitor: {
ip: '104.86.46.103',
name: '',
status: 'down',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {
@ -131,16 +155,20 @@ describe('MonitorListStatusColumn', () => {
},
},
},
timestamp: 1579794628368,
timestamp: '1579794628368',
},
];
checks = [
summaryPings = [
{
docId: '7',
monitor: {
ip: '104.86.46.103',
name: '',
status: 'up',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {
@ -151,13 +179,17 @@ describe('MonitorListStatusColumn', () => {
},
},
},
timestamp: 1579794631464,
timestamp: '1579794631464',
},
{
docId: '8',
monitor: {
ip: '104.86.46.103',
name: '',
status: 'down',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {
@ -168,13 +200,17 @@ describe('MonitorListStatusColumn', () => {
},
},
},
timestamp: 1579794634220,
timestamp: '1579794634220',
},
{
docId: '9',
monitor: {
ip: '104.86.46.103',
name: '',
status: 'down',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {
@ -185,45 +221,53 @@ describe('MonitorListStatusColumn', () => {
},
},
},
timestamp: 1579794628368,
timestamp: '1579794628368',
},
];
});
it('provides expected tooltip and display times', () => {
const component = shallowWithIntl(
<MonitorListStatusColumn status="up" timestamp="2314123" checks={[]} />
<MonitorListStatusColumn status="up" timestamp="2314123" summaryPings={[]} />
);
expect(component).toMatchSnapshot();
});
it('can handle a non-numeric timestamp value', () => {
const component = shallowWithIntl(
<MonitorListStatusColumn status="up" timestamp={new Date().toString()} checks={[]} />
<MonitorListStatusColumn status="up" timestamp={new Date().toString()} summaryPings={[]} />
);
expect(component).toMatchSnapshot();
});
it('will display location status', () => {
const component = shallowWithIntl(
<MonitorListStatusColumn status="up" timestamp={new Date().toString()} checks={checks} />
<MonitorListStatusColumn
status="up"
timestamp={new Date().toString()}
summaryPings={summaryPings}
/>
);
expect(component).toMatchSnapshot();
});
it('will render display location status', () => {
const component = renderWithIntl(
<MonitorListStatusColumn status="up" timestamp={new Date().toString()} checks={checks} />
<MonitorListStatusColumn
status="up"
timestamp={new Date().toString()}
summaryPings={summaryPings}
/>
);
expect(component).toMatchSnapshot();
});
it(' will test getLocationStatus location', () => {
let statusMessage = getLocationStatus(checks, STATUS.UP);
let statusMessage = getLocationStatus(summaryPings, STATUS.UP);
expect(statusMessage).toBe('in 1/3 Locations');
statusMessage = getLocationStatus(checks, STATUS.DOWN);
statusMessage = getLocationStatus(summaryPings, STATUS.DOWN);
expect(statusMessage).toBe('in 2/3 Locations');

View file

@ -8,13 +8,13 @@ import React from 'react';
import moment from 'moment';
import styled from 'styled-components';
import { EuiIcon, EuiText, EuiToolTip } from '@elastic/eui';
import { Cert, Tls } from '../../../../common/runtime_types';
import { X509Expiry } from '../../../../common/runtime_types';
import { useCertStatus } from '../../../hooks';
import { EXPIRED, EXPIRES, EXPIRES_SOON } from '../../certificates/translations';
import { CERT_STATUS } from '../../../../common/constants';
interface Props {
cert: Cert | Tls;
expiry: X509Expiry;
boldStyle?: boolean;
}
@ -31,14 +31,15 @@ const H4Text = styled.h4`
}
`;
export const CertStatusColumn: React.FC<Props> = ({ cert, boldStyle = false }) => {
const certStatus = useCertStatus(cert?.not_after);
export const CertStatusColumn: React.FC<Props> = ({ expiry, boldStyle = false }) => {
const notAfter = expiry?.not_after;
const certStatus = useCertStatus(notAfter);
const relativeDate = moment(cert?.not_after).fromNow();
const relativeDate = moment(notAfter).fromNow();
const CertStatus = ({ color, text }: { color: string; text: string }) => {
return (
<EuiToolTip content={moment(cert?.not_after).format('L LT')}>
<EuiToolTip content={moment(notAfter).format('L LT')}>
<EuiText size="s">
<EuiIcon color={color} type="lock" size="s" />
{boldStyle ? (

View file

@ -16,7 +16,7 @@ import {
} from '@elastic/eui';
import React, { useState } from 'react';
import styled from 'styled-components';
import { HistogramPoint } from '../../../../common/runtime_types';
import { HistogramPoint, X509Expiry } from '../../../../common/runtime_types';
import { MonitorSummary } from '../../../../common/runtime_types';
import { MonitorListStatusColumn } from './monitor_list_status_column';
import { ExpandedRowMap } from './types';
@ -79,14 +79,18 @@ export const MonitorListComponent: React.FC<Props> = ({
const columns = [
{
align: 'left' as const,
field: 'state.monitor.status',
field: 'state.summary.status',
name: labels.STATUS_COLUMN_LABEL,
mobileOptions: {
fullWidth: true,
},
render: (status: string, { state: { timestamp, checks } }: MonitorSummary) => {
render: (status: string, { state: { timestamp, summaryPings } }: MonitorSummary) => {
return (
<MonitorListStatusColumn status={status} timestamp={timestamp} checks={checks ?? []} />
<MonitorListStatusColumn
status={status}
timestamp={timestamp}
summaryPings={summaryPings ?? []}
/>
);
},
},
@ -116,9 +120,9 @@ export const MonitorListComponent: React.FC<Props> = ({
},
{
align: 'left' as const,
field: 'state.tls',
field: 'state.tls.server.x509',
name: labels.TLS_COLUMN_LABEL,
render: (tls: any) => <CertStatusColumn cert={tls?.[0]} />,
render: (x509: X509Expiry) => <CertStatusColumn expiry={x509} />,
},
{
align: 'center' as const,

View file

@ -99,33 +99,65 @@ exports[`MonitorListDrawer component renders a MonitorListDrawer when there are
Object {
"monitor_id": "foo",
"state": Object {
"checks": Array [
Object {
"monitor": Object {
"ip": "127.0.0.1",
"status": "up",
},
"timestamp": 121,
},
Object {
"monitor": Object {
"ip": "127.0.0.2",
"status": "down",
},
"timestamp": 123,
},
Object {
"monitor": Object {
"ip": "127.0.0.3",
"status": "up",
},
"timestamp": 125,
},
],
"monitor": Object {},
"summary": Object {
"down": 0,
"up": 1,
},
"summaryPings": Array [
Object {
"docId": "foo",
"monitor": Object {
"duration": Object {
"us": 121,
},
"id": "foo",
"ip": "127.0.0.1",
"status": "up",
"type": "icmp",
},
"timestamp": "121",
},
Object {
"docId": "foo-0",
"monitor": Object {
"duration": Object {
"us": 100000,
},
"id": "foo",
"ip": "127.0.0.1",
"status": "down",
"type": "icmp",
},
"timestamp": "0",
},
Object {
"docId": "foo-1",
"monitor": Object {
"duration": Object {
"us": 1,
},
"id": "foo",
"ip": "127.0.0.2",
"status": "up",
"type": "icmp",
},
"timestamp": "1",
},
Object {
"docId": "foo-2",
"monitor": Object {
"duration": Object {
"us": 2,
},
"id": "foo",
"ip": "127.0.0.3",
"status": "down",
"type": "icmp",
},
"timestamp": "2",
},
],
"timestamp": "123",
"url": Object {
"domain": "expired.badssl.com",
@ -238,19 +270,26 @@ exports[`MonitorListDrawer component renders a MonitorListDrawer when there is o
Object {
"monitor_id": "foo",
"state": Object {
"checks": Array [
Object {
"monitor": Object {
"ip": "127.0.0.1",
"status": "up",
},
"timestamp": 121,
},
],
"monitor": Object {},
"summary": Object {
"down": 0,
"up": 1,
},
"summaryPings": Array [
Object {
"docId": "foo",
"monitor": Object {
"duration": Object {
"us": 121,
},
"id": "foo",
"ip": "127.0.0.1",
"status": "up",
"type": "icmp",
},
"timestamp": "121",
},
],
"timestamp": "123",
"url": Object {
"domain": "expired.badssl.com",

View file

@ -615,7 +615,7 @@
"__typename": "MonitorSummary"
}
],
"__typename": "MonitorSummaryResult"
"__typename": "MonitorSummariesResult"
}
}
}

View file

@ -5,7 +5,7 @@
*/
import React from 'react';
import { MonitorSummary } from '../../../../../../common/runtime_types';
import { MonitorSummary, makePing } from '../../../../../../common/runtime_types';
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { IntegrationGroup, extractSummaryValues } from '../actions_popover/integration_group';
@ -17,7 +17,8 @@ describe('IntegrationGroup', () => {
monitor_id: '12345',
state: {
summary: {},
checks: [],
monitor: {},
summaryPings: [],
timestamp: '123',
url: {},
},
@ -46,6 +47,12 @@ describe('IntegrationGroup', () => {
mockSummary = {
state: {
timestamp: 'foo',
summaryPings: [],
monitor: {},
summary: {
up: 0,
down: 0,
},
url: {},
},
};
@ -76,22 +83,25 @@ describe('IntegrationGroup', () => {
});
it('finds pod uid', () => {
mockSummary.state.checks = [
{ kubernetes: { pod: { uid: 'myuid' } }, monitor: { status: 'up' }, timestamp: 123 },
mockSummary.state.summaryPings = [
{
...makePing({}),
kubernetes: { pod: { uid: 'myuid' } },
},
];
expect(extractSummaryValues(mockSummary)).toMatchInlineSnapshot(`
Object {
"containerId": undefined,
"domain": "",
"ip": undefined,
"ip": "127.0.0.1",
"podUid": "myuid",
}
`);
});
it('does not throw for missing kubernetes fields', () => {
mockSummary.state.checks = [];
mockSummary.state.summaryPings = [];
expect(extractSummaryValues(mockSummary)).toMatchInlineSnapshot(`
Object {
@ -104,22 +114,25 @@ describe('IntegrationGroup', () => {
});
it('finds container id', () => {
mockSummary.state.checks = [
{ container: { id: 'mycontainer' }, monitor: { status: 'up' }, timestamp: 123 },
mockSummary.state.summaryPings = [
{
...makePing({}),
container: { id: 'mycontainer' },
},
];
expect(extractSummaryValues(mockSummary)).toMatchInlineSnapshot(`
Object {
"containerId": "mycontainer",
"domain": "",
"ip": undefined,
"ip": "127.0.0.1",
"podUid": undefined,
}
`);
});
it('finds ip field', () => {
mockSummary.state.checks = [{ monitor: { ip: '127.0.0.1', status: 'up' }, timestamp: 123 }];
mockSummary.state.summaryPings = [makePing({ ip: '127.0.0.1', status: 'up' })];
expect(extractSummaryValues(mockSummary)).toMatchInlineSnapshot(`
Object {

View file

@ -6,7 +6,7 @@
import 'jest';
import React from 'react';
import { MonitorListDrawerComponent } from '../monitor_list_drawer';
import { Check, MonitorDetails, MonitorSummary } from '../../../../../../common/runtime_types';
import { MonitorDetails, MonitorSummary, makePing } from '../../../../../../common/runtime_types';
import { shallowWithRouter } from '../../../../../lib';
describe('MonitorListDrawer component', () => {
@ -17,14 +17,17 @@ describe('MonitorListDrawer component', () => {
summary = {
monitor_id: 'foo',
state: {
checks: [
{
monitor: {
ip: '127.0.0.1',
status: 'up',
},
timestamp: 121,
},
monitor: {},
summaryPings: [
makePing({
docId: 'foo',
id: 'foo',
ip: '127.0.0.1',
type: 'icmp',
status: 'up',
timestamp: '121',
duration: 121,
}),
],
summary: {
up: 1,
@ -55,7 +58,7 @@ describe('MonitorListDrawer component', () => {
});
it('renders nothing when no check data is present', () => {
delete summary.state.checks;
delete summary.state.summaryPings;
const component = shallowWithRouter(
<MonitorListDrawerComponent summary={summary} monitorDetails={monitorDetails} />
);
@ -70,30 +73,20 @@ describe('MonitorListDrawer component', () => {
});
it('renders a MonitorListDrawer when there are many checks', () => {
const checks: Check[] = [
{
monitor: {
ip: '127.0.0.1',
status: 'up',
},
timestamp: 121,
},
{
monitor: {
ip: '127.0.0.2',
status: 'down',
},
timestamp: 123,
},
{
monitor: {
ip: '127.0.0.3',
status: 'up',
},
timestamp: 125,
},
];
summary.state.checks = checks;
for (let i = 0; i < 3; i++) {
summary.state.summaryPings.push(
makePing({
docId: `foo-${i}`,
id: 'foo',
ip: `127.0.0.${1 + i}`,
type: 'icmp',
timestamp: `${i}`,
duration: i,
status: i % 2 !== 0 ? 'up' : 'down',
})
);
}
const component = shallowWithRouter(
<MonitorListDrawerComponent summary={summary} monitorDetails={monitorDetails} />
);

View file

@ -8,10 +8,10 @@ import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import React from 'react';
import moment from 'moment';
import { MonitorStatusList } from '../monitor_status_list';
import { Check } from '../../../../../../common/runtime_types';
import { Ping } from '../../../../../../common/runtime_types';
describe('MonitorStatusList component', () => {
let checks: Check[];
let pings: Ping[];
beforeAll(() => {
moment.prototype.toLocaleString = jest.fn(() => '2019-06-21 15:29:26');
@ -19,105 +19,137 @@ describe('MonitorStatusList component', () => {
});
beforeEach(() => {
checks = [
pings = [
{
docId: '1',
monitor: {
ip: '151.101.130.217',
name: 'elastic',
status: 'up',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {},
},
timestamp: 1570538236414,
timestamp: '1570538236414',
},
{
docId: '2',
monitor: {
ip: '151.101.194.217',
name: 'elastic',
status: 'up',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {},
},
timestamp: 1570538236414,
timestamp: '1570538236414',
},
{
docId: '3',
monitor: {
ip: '151.101.2.217',
name: 'elastic',
status: 'up',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {},
},
timestamp: 1570538236414,
timestamp: '1570538236414',
},
{
docId: '4',
monitor: {
ip: '151.101.66.217',
name: 'elastic',
status: 'up',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {},
},
timestamp: 1570538236414,
timestamp: '1570538236414',
},
{
docId: '4',
monitor: {
ip: '2a04:4e42:200::729',
name: 'elastic',
status: 'down',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {},
},
timestamp: 1570538236414,
timestamp: '1570538236414',
},
{
docId: '5',
monitor: {
ip: '2a04:4e42:400::729',
name: 'elastic',
status: 'down',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {},
},
timestamp: 1570538236414,
timestamp: '1570538236414',
},
{
docId: '6',
monitor: {
ip: '2a04:4e42:600::729',
name: 'elastic',
status: 'down',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {},
},
timestamp: 1570538236414,
timestamp: '1570538236414',
},
{
docId: '5',
monitor: {
ip: '2a04:4e42::729',
name: 'elastic',
status: 'down',
id: 'myMonitor',
type: 'icmp',
duration: { us: 123 },
},
observer: {
geo: {},
},
timestamp: 1570538236414,
timestamp: '1570538236414',
},
];
});
it('renders checks', () => {
const component = shallowWithIntl(<MonitorStatusList checks={checks} />);
const component = shallowWithIntl(<MonitorStatusList summaryPings={pings} />);
expect(component).toMatchSnapshot();
});
it('renders null in place of child status with missing ip', () => {
const component = shallowWithIntl(<MonitorStatusList checks={checks} />);
const component = shallowWithIntl(<MonitorStatusList summaryPings={pings} />);
expect(component).toMatchSnapshot();
});
});

View file

@ -27,9 +27,9 @@ interface IntegrationGroupProps {
export const extractSummaryValues = (summary: Pick<MonitorSummary, 'state'>) => {
const domain = summary.state.url?.domain ?? '';
const podUid = summary.state.checks?.[0]?.kubernetes?.pod.uid ?? undefined;
const containerId = summary.state.checks?.[0]?.container?.id ?? undefined;
const ip = summary.state.checks?.[0]?.monitor.ip ?? undefined;
const podUid = summary.state.summaryPings?.[0]?.kubernetes?.pod?.uid ?? undefined;
const containerId = summary.state.summaryPings?.[0]?.container?.id ?? undefined;
const ip = summary.state.summaryPings?.[0]?.monitor.ip ?? undefined;
return {
domain,

View file

@ -36,7 +36,7 @@ interface MonitorListDrawerProps {
export function MonitorListDrawerComponent({ summary, monitorDetails }: MonitorListDrawerProps) {
const monitorUrl = summary?.state?.url?.full || '';
return summary && summary.state.checks ? (
return summary && summary.state.summaryPings ? (
<ContainerDiv>
<EuiFlexGroup>
<EuiFlexItem grow={true}>
@ -52,7 +52,7 @@ export function MonitorListDrawerComponent({ summary, monitorDetails }: MonitorL
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<MonitorStatusList checks={summary.state.checks} />
<MonitorStatusList summaryPings={summary.state.summaryPings} />
{monitorDetails && monitorDetails.error && (
<MostRecentError
error={monitorDetails.error}

View file

@ -5,32 +5,32 @@
*/
import React from 'react';
import { get, upperFirst } from 'lodash';
import { upperFirst } from 'lodash';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { LocationLink } from '../../../common/location_link';
import { MonitorStatusRow } from './monitor_status_row';
import { Check } from '../../../../../common/runtime_types';
import { Ping } from '../../../../../common/runtime_types';
import { STATUS, UNNAMED_LOCATION } from '../../../../../common/constants';
interface MonitorStatusListProps {
/**
* Recent List of checks performed on monitor
* Recent List of pings performed on monitor
*/
checks: Check[];
summaryPings: Ping[];
}
export const MonitorStatusList = ({ checks }: MonitorStatusListProps) => {
export const MonitorStatusList = ({ summaryPings }: MonitorStatusListProps) => {
const upChecks: Set<string> = new Set();
const downChecks: Set<string> = new Set();
checks.forEach((check: Check) => {
summaryPings.forEach((ping: Ping) => {
// Doing this way because name is either string or null, get() default value only works on undefined value
const location = get(check, 'observer.geo.name', null) || UNNAMED_LOCATION;
const location = ping.observer?.geo?.name ?? UNNAMED_LOCATION;
if (check.monitor.status === STATUS.UP) {
if (ping.monitor.status === STATUS.UP) {
upChecks.add(upperFirst(location));
} else if (check.monitor.status === STATUS.DOWN) {
} else if (ping.monitor.status === STATUS.DOWN) {
downChecks.add(upperFirst(location));
}
});

View file

@ -11,7 +11,7 @@ import { upperFirst } from 'lodash';
import styled from 'styled-components';
import { EuiHealth, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui';
import { parseTimestamp } from './parse_timestamp';
import { Check } from '../../../../common/runtime_types';
import { Ping } from '../../../../common/runtime_types';
import {
STATUS,
SHORT_TIMESPAN_LOCALE,
@ -24,7 +24,7 @@ import * as labels from './translations';
interface MonitorListStatusColumnProps {
status: string;
timestamp: string;
checks: Check[];
summaryPings: Ping[];
}
const PaddedSpan = styled.span`
@ -75,27 +75,27 @@ const getRelativeShortTimeStamp = (timeStamp: any) => {
return shortTimestamp;
};
export const getLocationStatus = (checks: Check[], status: string) => {
const upChecks: Set<string> = new Set();
const downChecks: Set<string> = new Set();
export const getLocationStatus = (summaryPings: Ping[], status: string) => {
const upPings: Set<string> = new Set();
const downPings: Set<string> = new Set();
checks.forEach((check: Check) => {
const location = check?.observer?.geo?.name ?? UNNAMED_LOCATION;
summaryPings.forEach((summaryPing: Ping) => {
const location = summaryPing?.observer?.geo?.name ?? UNNAMED_LOCATION;
if (check.monitor.status === STATUS.UP) {
upChecks.add(upperFirst(location));
} else if (check.monitor.status === STATUS.DOWN) {
downChecks.add(upperFirst(location));
if (summaryPing.monitor.status === STATUS.UP) {
upPings.add(upperFirst(location));
} else if (summaryPing.monitor.status === STATUS.DOWN) {
downPings.add(upperFirst(location));
}
});
// if monitor is down in one dns, it will be considered down so removing it from up list
const absUpChecks: Set<string> = new Set([...upChecks].filter((item) => !downChecks.has(item)));
const absUpChecks: Set<string> = new Set([...upPings].filter((item) => !downPings.has(item)));
const totalLocations = absUpChecks.size + downChecks.size;
const totalLocations = absUpChecks.size + downPings.size;
let statusMessage = '';
if (status === STATUS.DOWN) {
statusMessage = `${downChecks.size}/${totalLocations}`;
statusMessage = `${downPings.size}/${totalLocations}`;
} else {
statusMessage = `${absUpChecks.size}/${totalLocations}`;
}
@ -115,7 +115,7 @@ export const getLocationStatus = (checks: Check[], status: string) => {
export const MonitorListStatusColumn = ({
status,
checks = [],
summaryPings = [],
timestamp: tsString,
}: MonitorListStatusColumnProps) => {
const timestamp = parseTimestamp(tsString);
@ -140,7 +140,7 @@ export const MonitorListStatusColumn = ({
</PaddedSpan>
</EuiFlexItem>
<EuiFlexItem grow={2}>
<EuiText size="s">{getLocationStatus(checks, status)}</EuiText>
<EuiText size="s">{getLocationStatus(summaryPings, status)}</EuiText>
</EuiFlexItem>
</StatusColumnFlexG>
);

View file

@ -5,7 +5,7 @@
*/
import { getApmHref } from '../get_apm_href';
import { MonitorSummary } from '../../../../../common/runtime_types';
import { MonitorSummary, makePing } from '../../../../../common/runtime_types';
describe('getApmHref', () => {
let summary: MonitorSummary;
@ -15,22 +15,17 @@ describe('getApmHref', () => {
monitor_id: 'foo',
state: {
summary: {},
checks: [
{
monitor: {
ip: '151.101.202.217',
status: 'up',
},
container: {
id: 'test-container-id',
},
kubernetes: {
pod: {
uid: 'test-pod-id',
},
},
timestamp: 123,
},
monitor: {},
summaryPings: [
makePing({
docId: 'foo',
id: 'foo',
type: 'test',
ip: '151.101.202.217',
status: 'up',
timestamp: '123',
duration: 123,
}),
],
timestamp: '123',
url: {

View file

@ -5,33 +5,32 @@
*/
import { getInfraContainerHref, getInfraKubernetesHref, getInfraIpHref } from '../get_infra_href';
import { MonitorSummary } from '../../../../../common/runtime_types';
import { MonitorSummary, makePing, Ping } from '../../../../../common/runtime_types';
describe('getInfraHref', () => {
let summary: MonitorSummary;
beforeEach(() => {
const ping: Ping = {
...makePing({
docId: 'myDocId',
type: 'test',
id: 'myId',
ip: '151.101.202.217',
status: 'up',
duration: 123,
timestamp: '123',
}),
container: { id: 'test-container-id' },
kubernetes: { pod: { uid: 'test-pod-uid' } },
};
summary = {
monitor_id: 'foo',
state: {
checks: [
{
monitor: {
ip: '151.101.202.217',
status: 'up',
},
container: {
id: 'test-container-id',
},
kubernetes: {
pod: {
uid: 'test-pod-uid',
},
},
timestamp: 123,
},
],
summaryPings: [ping],
summary: {},
url: {},
monitor: {},
timestamp: '123',
},
};
@ -51,50 +50,36 @@ describe('getInfraHref', () => {
});
it('getInfraContainerHref returns undefined when no container id is present', () => {
summary.state.checks = [];
summary.state.summaryPings = [];
expect(getInfraContainerHref(summary, 'foo')).toBeUndefined();
});
it('getInfraContainerHref returns the first item when multiple container ids are supplied', () => {
summary.state.checks = [
{
monitor: {
ip: '151.101.202.217',
status: 'up',
},
container: {
id: 'test-container-id',
},
kubernetes: {
pod: {
uid: 'test-pod-uid',
},
},
timestamp: 123,
},
{
monitor: {
ip: '151.101.202.27',
status: 'up',
},
container: {
id: 'test-container-id-foo',
},
kubernetes: {
pod: {
uid: 'test-pod-uid-bar',
},
},
timestamp: 123,
},
];
const pingBase = makePing({
docId: 'myDocId',
type: 'test',
id: 'myId',
ip: '151.101.202.217',
status: 'up',
duration: 123,
timestamp: '123',
});
const pingTestContainerId: Ping = {
...pingBase,
container: { id: 'test-container-id' },
};
const pingTestFooContainerId: Ping = {
...pingBase,
container: { id: 'test-container-id-foo' },
};
summary.state.summaryPings = [pingTestContainerId, pingTestFooContainerId];
expect(getInfraContainerHref(summary, 'bar')).toMatchInlineSnapshot(
`"bar/app/metrics/link-to/container-detail/test-container-id"`
);
});
it('getInfraContainerHref returns undefined when checks are undefined', () => {
delete summary.state.checks;
it('getInfraContainerHref returns undefined when summaryPings are undefined', () => {
delete summary.state.summaryPings;
expect(getInfraContainerHref(summary, '')).toBeUndefined();
});
@ -111,55 +96,41 @@ describe('getInfraHref', () => {
});
it('getInfraKubernetesHref returns undefined when no pod data is present', () => {
summary.state.checks = [];
summary.state.summaryPings = [];
expect(getInfraKubernetesHref(summary, 'foo')).toBeUndefined();
});
it('getInfraKubernetesHref selects the first pod uid when there are multiple', () => {
summary.state.checks = [
{
monitor: {
ip: '151.101.202.217',
status: 'up',
},
container: {
id: 'test-container-id',
},
kubernetes: {
pod: {
uid: 'test-pod-uid',
},
},
timestamp: 123,
},
{
monitor: {
ip: '151.101.202.27',
status: 'up',
},
container: {
id: 'test-container-id-foo',
},
kubernetes: {
pod: {
uid: 'test-pod-uid-bar',
},
},
timestamp: 123,
},
];
const pingBase = makePing({
docId: 'myDocId',
type: 'test',
id: 'myId',
ip: '151.101.202.217',
status: 'up',
duration: 123,
timestamp: '123',
});
const pingTestPodId: Ping = {
...pingBase,
kubernetes: { pod: { uid: 'test-pod-uid' } },
};
const pingTestBarPodId: Ping = {
...pingBase,
kubernetes: { pod: { uid: 'test-pod-uid-bar' } },
};
summary.state.summaryPings = [pingTestPodId, pingTestBarPodId];
expect(getInfraKubernetesHref(summary, '')).toMatchInlineSnapshot(
`"/app/metrics/link-to/pod-detail/test-pod-uid"`
);
});
it('getInfraKubernetesHref returns undefined when checks are undefined', () => {
delete summary.state.checks;
it('getInfraKubernetesHref returns undefined when summaryPings are undefined', () => {
delete summary.state.summaryPings;
expect(getInfraKubernetesHref(summary, '')).toBeUndefined();
});
it('getInfraKubernetesHref returns undefined when checks are null', () => {
delete summary.state.checks![0]!.kubernetes!.pod!.uid;
it('getInfraKubernetesHref returns undefined when summaryPings are null', () => {
delete summary.state.summaryPings![0]!.kubernetes!.pod!.uid;
expect(getInfraKubernetesHref(summary, '')).toBeUndefined();
});
@ -177,47 +148,42 @@ describe('getInfraHref', () => {
});
it('getInfraIpHref returns undefined when ip is undefined', () => {
summary.state.checks = [];
summary.state.summaryPings = [];
expect(getInfraIpHref(summary, 'foo')).toBeUndefined();
});
it('getInfraIpHref returns undefined when ip is null', () => {
delete summary.state.checks![0].monitor.ip;
delete summary.state.summaryPings![0].monitor.ip;
expect(getInfraIpHref(summary, 'foo')).toBeUndefined();
});
it('getInfraIpHref returns a url for ors between multiple ips', () => {
summary.state.checks = [
{
timestamp: 123,
monitor: {
ip: '152.151.23.192',
status: 'up',
},
},
{
monitor: {
ip: '151.101.202.217',
status: 'up',
},
container: {
id: 'test-container-id',
},
kubernetes: {
pod: {
uid: 'test-pod-uid',
},
},
timestamp: 123,
},
];
const pingOne = makePing({
docId: 'myDocId',
type: 'test',
id: 'myId',
ip: '152.151.23.192',
status: 'up',
duration: 123,
timestamp: '123',
});
const pingTwo = makePing({
docId: 'myDocId2',
type: 'test',
id: 'myId',
ip: '151.101.202.217',
status: 'up',
duration: 123,
timestamp: '123',
});
summary.state.summaryPings = [pingOne, pingTwo];
expect(getInfraIpHref(summary, 'foo')).toMatchInlineSnapshot(
`"foo/app/metrics/inventory?waffleFilter=(expression:'host.ip%20%3A%20152.151.23.192%20or%20host.ip%20%3A%20151.101.202.217',kind:kuery)"`
);
});
it('getInfraIpHref returns undefined if checks are undefined', () => {
delete summary.state.checks;
it('getInfraIpHref returns undefined if summaryPings are undefined', () => {
delete summary.state.summaryPings;
expect(getInfraIpHref(summary, 'foo')).toBeUndefined();
});
});

View file

@ -9,34 +9,36 @@ import {
getLoggingKubernetesHref,
getLoggingIpHref,
} from '../get_logging_href';
import { MonitorSummary } from '../../../../../common/runtime_types';
import { MonitorSummary, makePing } from '../../../../../common/runtime_types';
describe('getLoggingHref', () => {
let summary: MonitorSummary;
beforeEach(() => {
const ping = makePing({
docId: 'myDocId',
type: 'test',
id: 'myId',
ip: '151.101.202.217',
status: 'up',
duration: 123,
timestamp: '123',
});
ping.container = {
id: 'test-container-id',
};
ping.kubernetes = {
pod: {
uid: 'test-pod-id',
},
};
summary = {
monitor_id: 'foo',
state: {
summary: {},
checks: [
{
monitor: {
ip: '151.101.202.217',
status: 'up',
},
container: {
id: 'test-container-id',
},
kubernetes: {
pod: {
uid: 'test-pod-id',
},
},
timestamp: 123,
},
],
summaryPings: [ping],
timestamp: '123',
monitor: {},
url: {},
},
};
@ -91,32 +93,32 @@ describe('getLoggingHref', () => {
});
it('returns undefined if necessary container is not present', () => {
delete summary.state.checks;
delete summary.state.summaryPings;
expect(getLoggingContainerHref(summary, '')).toBeUndefined();
});
it('returns undefined if necessary container is null', () => {
delete summary.state.checks![0].container!.id;
delete summary.state.summaryPings![0].container!.id;
expect(getLoggingContainerHref(summary, '')).toBeUndefined();
});
it('returns undefined if necessary pod is not present', () => {
delete summary.state.checks;
delete summary.state.summaryPings;
expect(getLoggingKubernetesHref(summary, '')).toBeUndefined();
});
it('returns undefined if necessary pod is null', () => {
delete summary.state.checks![0].kubernetes!.pod!.uid;
delete summary.state.summaryPings![0].kubernetes!.pod!.uid;
expect(getLoggingKubernetesHref(summary, '')).toBeUndefined();
});
it('returns undefined ip href if ip is not present', () => {
delete summary.state.checks;
delete summary.state.summaryPings;
expect(getLoggingIpHref(summary, '')).toBeUndefined();
});
it('returns undefined ip href if ip is null', () => {
delete summary.state.checks![0].monitor.ip;
delete summary.state.summaryPings![0].monitor.ip;
expect(getLoggingIpHref(summary, '')).toBeUndefined();
});
});

View file

@ -5,7 +5,7 @@
*/
import { get } from 'lodash';
import { Check } from '../../../../common/runtime_types';
import { Ping } from '../../../../common/runtime_types';
/**
* Builds URLs to the designated features by extracting values from the provided
@ -16,12 +16,12 @@ import { Check } from '../../../../common/runtime_types';
* @param getHref a function that returns the full URL
*/
export const buildHref = (
checks: Check[],
summaryPings: Ping[],
path: string,
getHref: (value: string | string[] | undefined) => string | undefined
): string | undefined => {
const queryValue = checks
.map((check) => get(check, path, undefined))
const queryValue = summaryPings
.map((ping) => get(ping, path, undefined))
.filter((value: string | undefined) => value !== undefined);
if (queryValue.length === 0) {
return getHref(undefined);

View file

@ -22,7 +22,7 @@ export const getInfraContainerHref = (
`/app/metrics/link-to/container-detail/${encodeURIComponent(ret)}`
);
};
return buildHref(summary.state.checks || [], 'container.id', getHref);
return buildHref(summary.state.summaryPings || [], 'container.id', getHref);
};
export const getInfraKubernetesHref = (
@ -37,7 +37,7 @@ export const getInfraKubernetesHref = (
return addBasePath(basePath, `/app/metrics/link-to/pod-detail/${encodeURIComponent(ret)}`);
};
return buildHref(summary.state.checks || [], 'kubernetes.pod.uid', getHref);
return buildHref(summary.state.summaryPings || [], 'kubernetes.pod.uid', getHref);
};
export const getInfraIpHref = (summary: MonitorSummary, basePath: string) => {
@ -63,5 +63,5 @@ export const getInfraIpHref = (summary: MonitorSummary, basePath: string) => {
`/app/metrics/inventory?waffleFilter=(expression:'${encodeURIComponent(ips)}',kind:kuery)`
);
};
return buildHref(summary.state.checks || [], 'monitor.ip', getHref);
return buildHref(summary.state.summaryPings || [], 'monitor.ip', getHref);
};

View file

@ -22,7 +22,7 @@ export const getLoggingContainerHref = (
`/app/logs?logFilter=${encodeURI(`(expression:'container.id : ${ret}',kind:kuery)`)}`
);
};
return buildHref(summary.state.checks || [], 'container.id', getHref);
return buildHref(summary.state.summaryPings || [], 'container.id', getHref);
};
export const getLoggingKubernetesHref = (summary: MonitorSummary, basePath: string) => {
@ -36,7 +36,7 @@ export const getLoggingKubernetesHref = (summary: MonitorSummary, basePath: stri
`/app/logs?logFilter=${encodeURI(`(expression:'pod.uid : ${ret}',kind:kuery)`)}`
);
};
return buildHref(summary.state.checks || [], 'kubernetes.pod.uid', getHref);
return buildHref(summary.state.summaryPings || [], 'kubernetes.pod.uid', getHref);
};
export const getLoggingIpHref = (summary: MonitorSummary, basePath: string) => {
@ -50,5 +50,5 @@ export const getLoggingIpHref = (summary: MonitorSummary, basePath: string) => {
`/app/logs?logFilter=(expression:'${encodeURIComponent(`host.ip : ${ret}`)}',kind:kuery)`
);
};
return buildHref(summary.state.checks || [], 'monitor.ip', getHref);
return buildHref(summary.state.summaryPings || [], 'monitor.ip', getHref);
};

View file

@ -5,8 +5,10 @@
*/
import { createAction } from 'redux-actions';
import { FetchMonitorStatesQueryArgs, MonitorSummaryResult } from '../../../common/runtime_types';
import { FetchMonitorStatesQueryArgs, MonitorSummariesResult } from '../../../common/runtime_types';
export const getMonitorList = createAction<FetchMonitorStatesQueryArgs>('GET_MONITOR_LIST');
export const getMonitorListSuccess = createAction<MonitorSummaryResult>('GET_MONITOR_LIST_SUCCESS');
export const getMonitorListSuccess = createAction<MonitorSummariesResult>(
'GET_MONITOR_LIST_SUCCESS'
);
export const getMonitorListFailure = createAction<Error>('GET_MONITOR_LIST_FAIL');

View file

@ -8,12 +8,12 @@ import { API_URLS } from '../../../common/constants';
import { apiService } from './utils';
import {
FetchMonitorStatesQueryArgs,
MonitorSummaryResult,
MonitorSummaryResultType,
MonitorSummariesResult,
MonitorSummariesResultType,
} from '../../../common/runtime_types';
export const fetchMonitorList = async (
params: FetchMonitorStatesQueryArgs
): Promise<MonitorSummaryResult> => {
return await apiService.get(API_URLS.MONITOR_LIST, params, MonitorSummaryResultType);
): Promise<MonitorSummariesResult> => {
return await apiService.get(API_URLS.MONITOR_LIST, params, MonitorSummariesResultType);
};

View file

@ -7,6 +7,31 @@
import { PathReporter } from 'io-ts/lib/PathReporter';
import { isRight } from 'fp-ts/lib/Either';
import { HttpFetchQuery, HttpSetup } from 'src/core/public';
import * as t from 'io-ts';
import { isObject } from 'lodash';
// TODO: Copied from https://github.com/elastic/kibana/blob/master/x-pack/plugins/security_solution/common/format_errors.ts
// We should figure out a better way to share this
export const formatErrors = (errors: t.Errors): string[] => {
return errors.map((error) => {
if (error.message != null) {
return error.message;
} else {
const keyContext = error.context
.filter(
(entry) => entry.key != null && !Number.isInteger(+entry.key) && entry.key.trim() !== ''
)
.map((entry) => entry.key)
.join('.');
const nameContext = error.context.find((entry) => entry.type?.name?.length > 0);
const suppliedValue =
keyContext !== '' ? keyContext : nameContext != null ? nameContext.type.name : '';
const value = isObject(error.value) ? JSON.stringify(error.value) : error.value;
return `Invalid value "${value}" supplied to "${suppliedValue}"`;
}
});
};
class ApiService {
private static instance: ApiService;
@ -40,7 +65,10 @@ class ApiService {
} else {
// eslint-disable-next-line no-console
console.error(
`API ${apiUrl} is not returning expected response, ${PathReporter.report(decoded)}`
`API ${apiUrl} is not returning expected response, ${formatErrors(
decoded.left
)} for response`,
response
);
}
}

View file

@ -6,12 +6,12 @@
import { handleActions, Action } from 'redux-actions';
import { getMonitorList, getMonitorListSuccess, getMonitorListFailure } from '../actions';
import { MonitorSummaryResult } from '../../../common/runtime_types';
import { MonitorSummariesResult } from '../../../common/runtime_types';
export interface MonitorList {
error?: Error;
loading: boolean;
list: MonitorSummaryResult;
list: MonitorSummariesResult;
}
export const initialState: MonitorList = {
@ -24,7 +24,7 @@ export const initialState: MonitorList = {
loading: false,
};
type Payload = MonitorSummaryResult & Error;
type Payload = MonitorSummariesResult & Error;
export const monitorListReducer = handleActions<MonitorList, Payload>(
{
@ -34,7 +34,7 @@ export const monitorListReducer = handleActions<MonitorList, Payload>(
}),
[String(getMonitorListSuccess)]: (
state: MonitorList,
action: Action<MonitorSummaryResult>
action: Action<MonitorSummariesResult>
) => ({
...state,
loading: false,

View file

@ -32,14 +32,7 @@ describe('getLatestMonitor', () => {
},
},
size: 1,
_source: [
'url',
'monitor',
'observer',
'@timestamp',
'tls.certificate_not_valid_after',
'tls.certificate_not_valid_before',
],
_source: ['url', 'monitor', 'observer', '@timestamp', 'tls.*'],
sort: {
'@timestamp': { order: 'desc' },
},
@ -90,10 +83,7 @@ describe('getLatestMonitor', () => {
"type": "http",
},
"timestamp": "123456",
"tls": Object {
"not_after": undefined,
"not_before": undefined,
},
"tls": undefined,
}
`);
expect(result.timestamp).toBe('123456');

View file

@ -45,14 +45,7 @@ export const getLatestMonitor: UMElasticsearchQueryFn<GetLatestMonitorParams, Pi
},
},
size: 1,
_source: [
'url',
'monitor',
'observer',
'@timestamp',
'tls.certificate_not_valid_after',
'tls.certificate_not_valid_before',
],
_source: ['url', 'monitor', 'observer', '@timestamp', 'tls.*'],
sort: {
'@timestamp': { order: 'desc' },
},
@ -68,9 +61,6 @@ export const getLatestMonitor: UMElasticsearchQueryFn<GetLatestMonitorParams, Pi
...ping,
docId,
timestamp: ping['@timestamp'],
tls: {
not_after: tls?.certificate_not_valid_after,
not_before: tls?.certificate_not_valid_before,
},
tls,
};
};

View file

@ -4,11 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { CONTEXT_DEFAULTS } from '../../../common/constants';
import { fetchPage } from './search';
import { CONTEXT_DEFAULTS, QUERY } from '../../../common/constants';
import { UMElasticsearchQueryFn } from '../adapters';
import { MonitorSummary, SortOrder, CursorDirection } from '../../../common/runtime_types';
import { QueryContext } from './search';
import { SortOrder, CursorDirection, MonitorSummariesResult } from '../../../common/runtime_types';
import { QueryContext, MonitorSummaryIterator } from './search';
import { HistogramPoint, Histogram } from '../../../common/runtime_types';
import { getHistogramInterval } from '../helper/get_histogram_interval';
export interface CursorPagination {
cursorKey?: any;
@ -25,12 +26,6 @@ export interface GetMonitorStatesParams {
statusFilter?: string;
}
export interface GetMonitorStatesResult {
summaries: MonitorSummary[];
nextPagePagination: string | null;
prevPagePagination: string | null;
}
// To simplify the handling of the group of pagination vars they're passed back to the client as a string
const jsonifyPagination = (p: any): string | null => {
if (!p) {
@ -43,7 +38,7 @@ const jsonifyPagination = (p: any): string | null => {
// Gets a page of monitor states.
export const getMonitorStates: UMElasticsearchQueryFn<
GetMonitorStatesParams,
GetMonitorStatesResult
MonitorSummariesResult
> = async ({
callES,
dynamicSettings,
@ -68,11 +63,113 @@ export const getMonitorStates: UMElasticsearchQueryFn<
statusFilter
);
const page = await fetchPage(queryContext);
const size = Math.min(queryContext.size, QUERY.DEFAULT_AGGS_CAP);
const iterator = new MonitorSummaryIterator(queryContext);
const page = await iterator.nextPage(size);
const histograms = await getHistogramForMonitors(
queryContext,
page.monitorSummaries.map((s) => s.monitor_id)
);
page.monitorSummaries.forEach((s) => {
s.histogram = histograms[s.monitor_id];
});
return {
summaries: page.items,
summaries: page.monitorSummaries,
nextPagePagination: jsonifyPagination(page.nextPagePagination),
prevPagePagination: jsonifyPagination(page.prevPagePagination),
};
};
export const getHistogramForMonitors = async (
queryContext: QueryContext,
monitorIds: string[]
): Promise<{ [key: string]: Histogram }> => {
const params = {
index: queryContext.heartbeatIndices,
body: {
size: 0,
query: {
bool: {
filter: [
{
range: {
'summary.down': { gt: 0 },
},
},
{
terms: {
'monitor.id': monitorIds,
},
},
{
range: {
'@timestamp': {
gte: queryContext.dateRangeStart,
lte: queryContext.dateRangeEnd,
},
},
},
],
},
},
aggs: {
histogram: {
date_histogram: {
field: '@timestamp',
// 12 seems to be a good size for performance given
// long monitor lists of up to 100 on the overview page
fixed_interval:
getHistogramInterval(queryContext.dateRangeStart, queryContext.dateRangeEnd, 12) +
'ms',
missing: 0,
},
aggs: {
by_id: {
terms: {
field: 'monitor.id',
size: Math.max(monitorIds.length, 1),
},
aggs: {
totalDown: {
sum: { field: 'summary.down' },
},
},
},
},
},
},
},
};
const result = await queryContext.search(params);
const histoBuckets: any[] = result.aggregations.histogram.buckets;
const simplified = histoBuckets.map((histoBucket: any): { timestamp: number; byId: any } => {
const byId: { [key: string]: number } = {};
histoBucket.by_id.buckets.forEach((idBucket: any) => {
byId[idBucket.key] = idBucket.totalDown.value;
});
return {
timestamp: parseInt(histoBucket.key, 10),
byId,
};
});
const histosById: { [key: string]: Histogram } = {};
monitorIds.forEach((id: string) => {
const points: HistogramPoint[] = [];
simplified.forEach((simpleHisto) => {
points.push({
timestamp: simpleHisto.timestamp,
up: undefined,
down: simpleHisto.byId[id],
});
});
histosById[id] = { points };
});
return histosById;
};

View file

@ -1,98 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { sortChecksBy } from '../enrich_monitor_groups';
describe('enrich monitor groups', () => {
describe('sortChecksBy', () => {
it('identifies lesser geo name', () => {
expect(
sortChecksBy(
{ observer: { geo: { name: 'less' } }, monitor: { status: 'up' } },
{ observer: { geo: { name: 'more' } }, monitor: { status: 'up' } }
)
).toBe(-1);
});
it('identifies greater geo name', () => {
expect(
sortChecksBy(
{ observer: { geo: { name: 'more' } }, monitor: { status: 'up' } },
{ observer: { geo: { name: 'less' } }, monitor: { status: 'up' } }
)
).toBe(1);
});
it('identifies equivalent geo name and sorts by lesser ip', () => {
expect(
sortChecksBy(
{ observer: { geo: { name: 'same' } }, monitor: { ip: '127.0.0.1', status: 'up' } },
{ observer: { geo: { name: 'same' } }, monitor: { ip: '127.0.0.2', status: 'up' } }
)
).toBe(-1);
});
it('identifies equivalent geo name and sorts by greater ip', () => {
expect(
sortChecksBy(
{ observer: { geo: { name: 'same' } }, monitor: { ip: '127.0.0.2', status: 'up' } },
{ observer: { geo: { name: 'same' } }, monitor: { ip: '127.0.0.1', status: 'up' } }
)
).toBe(1);
});
it('identifies equivalent geo name and sorts by equivalent ip', () => {
expect(
sortChecksBy(
{ observer: { geo: { name: 'same' } }, monitor: { ip: '127.0.0.1', status: 'up' } },
{ observer: { geo: { name: 'same' } }, monitor: { ip: '127.0.0.1', status: 'up' } }
)
).toBe(0);
});
it('handles equivalent ip arrays', () => {
expect(
sortChecksBy(
{ observer: { geo: { name: 'same' } }, monitor: { ip: ['127.0.0.1'], status: 'up' } },
{ observer: { geo: { name: 'same' } }, monitor: { ip: ['127.0.0.1'], status: 'up' } }
)
).toBe(0);
});
it('handles non-equal ip arrays', () => {
expect(
sortChecksBy(
{
observer: { geo: { name: 'same' } },
monitor: { ip: ['127.0.0.2', '127.0.0.9'], status: 'up' },
},
{
observer: { geo: { name: 'same' } },
monitor: { ip: ['127.0.0.3', '127.0.0.1'], status: 'up' },
}
)
).toBe(1);
});
it('handles undefined observer fields', () => {
expect(
sortChecksBy(
{ observer: undefined, monitor: { ip: ['127.0.0.1'], status: 'up' } },
{ observer: { geo: { name: 'same' } }, monitor: { ip: ['127.0.0.1'], status: 'up' } }
)
).toBe(-1);
});
it('handles undefined ip fields', () => {
expect(
sortChecksBy(
{ observer: { geo: { name: 'same' } }, monitor: { ip: undefined, status: 'up' } },
{ observer: { geo: { name: 'same' } }, monitor: { ip: ['127.0.0.1'], status: 'up' } }
)
).toBe(-1);
});
});
});

View file

@ -1,111 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
fetchPage,
MonitorEnricher,
MonitorGroups,
MonitorGroupsFetcher,
MonitorGroupsPage,
} from '../fetch_page';
import { QueryContext } from '../query_context';
import { MonitorSummary } from '../../../../../common/runtime_types';
import { nextPagination, prevPagination, simpleQueryContext } from './test_helpers';
const simpleFixture: MonitorGroups[] = [
{
id: 'foo',
groups: [
{
monitorId: 'foo',
location: 'foo-loc',
checkGroup: 'foo-check',
status: 'up',
summaryTimestamp: new Date(),
},
],
},
{
id: 'bar',
groups: [
{
monitorId: 'bar',
location: 'bar-loc',
checkGroup: 'bar-check',
status: 'down',
summaryTimestamp: new Date(),
},
],
},
];
const simpleFetcher = (monitorGroups: MonitorGroups[]): MonitorGroupsFetcher => {
return async (queryContext: QueryContext, size: number): Promise<MonitorGroupsPage> => {
return {
monitorGroups,
prevPagePagination: prevPagination(monitorGroups[0].id),
nextPagePagination: nextPagination(monitorGroups[monitorGroups.length - 1].id),
};
};
};
const simpleEnricher = (monitorGroups: MonitorGroups[]): MonitorEnricher => {
return async (_queryContext: QueryContext, checkGroups: string[]): Promise<MonitorSummary[]> => {
return checkGroups.map((cg) => {
const monitorGroup = monitorGroups.find((mg) => mg.groups.some((g) => g.checkGroup === cg))!;
return {
monitor_id: monitorGroup.id,
state: {
summary: {},
timestamp: new Date(Date.parse('1999-12-31')).valueOf().toString(),
url: {},
},
};
});
};
};
describe('fetching a page', () => {
it('returns the enriched groups', async () => {
const res = await fetchPage(
simpleQueryContext(),
simpleFetcher(simpleFixture),
simpleEnricher(simpleFixture)
);
expect(res).toMatchInlineSnapshot(`
Object {
"items": Array [
Object {
"monitor_id": "foo",
"state": Object {
"summary": Object {},
"timestamp": "946598400000",
"url": Object {},
},
},
Object {
"monitor_id": "bar",
"state": Object {
"summary": Object {},
"timestamp": "946598400000",
"url": Object {},
},
},
],
"nextPagePagination": Object {
"cursorDirection": "AFTER",
"cursorKey": "bar",
"sortOrder": "ASC",
},
"prevPagePagination": Object {
"cursorDirection": "BEFORE",
"cursorKey": "foo",
"sortOrder": "ASC",
},
}
`);
});
});

View file

@ -1,100 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
CHUNK_SIZE,
ChunkFetcher,
ChunkResult,
MonitorGroupIterator,
} from '../monitor_group_iterator';
import { simpleQueryContext } from './test_helpers';
import { MonitorGroups } from '../fetch_page';
import { QueryContext } from '../query_context';
describe('iteration', () => {
let iterator: MonitorGroupIterator | null = null;
let fetched: MonitorGroups[];
const setup = async (numGroups: number) => {
fetched = [];
const expectedMonitorGroups = makeMonitorGroups(numGroups);
const chunkFetcher = mockChunkFetcher(expectedMonitorGroups);
iterator = new MonitorGroupIterator(simpleQueryContext(), [], -1, chunkFetcher);
while (true) {
const got = await iterator.next();
if (got) {
fetched.push(got);
} else {
break;
}
}
};
describe('matching', () => {
[
{ name: 'zero results', numGroups: 0 },
{ name: 'one result', numGroups: 1 },
{ name: 'less than chunk', numGroups: CHUNK_SIZE - 1 },
{ name: 'multiple full chunks', numGroups: CHUNK_SIZE * 3 },
{ name: 'multiple full chunks + partial', numGroups: CHUNK_SIZE * 3 + 3 },
].forEach(({ name, numGroups }) => {
describe(`scenario given ${name}`, () => {
beforeEach(async () => {
await setup(numGroups);
});
it('should receive the expected number of results', () => {
expect(fetched.length).toEqual(numGroups);
});
it('should have no remaining pages', async () => {
expect(await iterator!.paginationAfterCurrent()).toBeNull();
});
});
});
});
});
const makeMonitorGroups = (count: number): MonitorGroups[] => {
const groups: MonitorGroups[] = [];
for (let i = 0; i < count; i++) {
const id = `monitor-${i}`;
groups.push({
id,
groups: [
{
monitorId: id,
location: 'a-location',
status: 'up',
checkGroup: `check-group-${i}`,
summaryTimestamp: new Date(),
},
],
});
}
return groups;
};
const mockChunkFetcher = (groups: MonitorGroups[]): ChunkFetcher => {
const buffer = groups.slice(0); // Clone it since we'll modify it
return async (
queryContext: QueryContext,
searchAfter: any,
size: number
): Promise<ChunkResult> => {
const resultMonitorGroups = buffer.splice(0, size);
const resultSearchAfter =
buffer.length === 0
? null
: { monitor_id: resultMonitorGroups[resultMonitorGroups.length - 1].id };
return {
monitorGroups: resultMonitorGroups,
searchAfter: resultSearchAfter,
};
};
};

View file

@ -0,0 +1,131 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
CHUNK_SIZE,
ChunkFetcher,
ChunkResult,
MonitorSummaryIterator,
} from '../monitor_summary_iterator';
import { simpleQueryContext } from './test_helpers';
import { QueryContext } from '../query_context';
import { MonitorSummary } from '../../../../../common/runtime_types';
describe('iteration', () => {
let iterator: MonitorSummaryIterator | null = null;
let fetched: MonitorSummary[];
let fullSummaryDataset: MonitorSummary[];
const setup = async (numSummaries: number) => {
fetched = [];
fullSummaryDataset = makeMonitorSummaries(numSummaries);
const chunkFetcher = mockChunkFetcher(fullSummaryDataset);
iterator = new MonitorSummaryIterator(simpleQueryContext(), [], -1, chunkFetcher);
};
const fetchAllViaNext = async () => {
while (true) {
const got = await iterator!.next();
if (got) {
fetched.push(got);
} else {
break;
}
}
};
describe('matching', () => {
[
{ name: 'zero results', numSummaries: 0 },
{ name: 'one result', numSummaries: 1 },
{ name: 'less than chunk', numSummaries: CHUNK_SIZE - 1 },
{ name: 'multiple full chunks', numSummaries: CHUNK_SIZE * 3 },
{ name: 'multiple full chunks + partial', numSummaries: CHUNK_SIZE * 3 + 3 },
].forEach(({ name, numSummaries }) => {
describe(`scenario given ${name}`, () => {
beforeEach(async () => {
await setup(numSummaries);
});
describe('fetching via next', () => {
beforeEach(async () => {
await fetchAllViaNext();
});
it('should receive the expected number of results', async () => {
expect(fetched.length).toEqual(numSummaries);
});
it('should have no remaining pages', async () => {
expect(await iterator!.paginationAfterCurrent()).toBeNull();
});
});
describe('nextPage()', () => {
const pageSize = 900;
it('should fetch no more than the page size results', async () => {
const page = await iterator!.nextPage(pageSize);
const expectedLength = numSummaries < pageSize ? numSummaries : pageSize;
expect(page.monitorSummaries).toHaveLength(expectedLength);
});
it('should return all the results if called until none remain', async () => {
const receivedResults: MonitorSummary[] = [];
while (true) {
const page = await iterator!.nextPage(pageSize);
if (page.monitorSummaries.length === 0) {
break;
}
page.monitorSummaries.forEach((s) => receivedResults.push(s));
}
expect(receivedResults.length).toEqual(fullSummaryDataset.length);
});
});
});
});
});
});
const makeMonitorSummaries = (count: number): MonitorSummary[] => {
const summaries: MonitorSummary[] = [];
for (let i = 0; i < count; i++) {
const id = `monitor-${i}`;
summaries.push({
monitor_id: id,
state: {
monitor: {},
timestamp: (123 + i).toString(),
url: {},
summaryPings: [],
summary: { up: 1, down: 0 },
},
});
}
return summaries;
};
const mockChunkFetcher = (summaries: MonitorSummary[]): ChunkFetcher => {
const buffer = summaries.slice(0); // Clone it since we'll modify it
return async (
queryContext: QueryContext,
searchAfter: any,
size: number
): Promise<ChunkResult> => {
const offset = searchAfter?.monitor_id ? parseInt(searchAfter.monitor_id.split('-')[1], 10) : 0;
const resultMonitorSummaries = buffer.slice(offset, offset + size);
const resultSearchAfter =
offset > buffer.length - 1
? null
: { monitor_id: `monitor-${offset + resultMonitorSummaries.length}` };
return {
monitorSummaries: resultMonitorSummaries,
searchAfter: resultSearchAfter,
};
};
};

View file

@ -1,112 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { fullyMatchingIds } from '../refine_potential_matches';
import { MonitorLocCheckGroup } from '..';
const mockQueryResult = (opts: { latestSummary: any; latestMatching: any }) => {
return {
aggregations: {
monitor: {
buckets: [
{
key: 'my-monitor',
location: {
buckets: [
{
key: 'my-location',
summaries: {
latest: {
hits: {
hits: [
{
_source: opts.latestSummary,
},
],
},
},
},
latest_matching: {
top: {
hits: {
hits: [
{
_source: opts.latestMatching,
},
],
},
},
},
},
],
},
},
],
},
},
};
};
describe('fully matching IDs', () => {
it('should exclude items whose latest result does not match', () => {
const queryRes = mockQueryResult({
latestSummary: {
'@timestamp': '2020-06-04T12:39:54.698-0500',
monitor: {
check_group: 'latest-summary-check-group',
},
summary: {
up: 1,
down: 0,
},
},
latestMatching: {
'@timestamp': '2019-06-04T12:39:54.698-0500',
summary: {
up: 1,
down: 0,
},
},
});
const res = fullyMatchingIds(queryRes, undefined);
const expected = new Map<string, MonitorLocCheckGroup[]>();
expect(res).toEqual(expected);
});
it('should include items whose latest result does match', () => {
const queryRes = mockQueryResult({
latestSummary: {
'@timestamp': '2020-06-04T12:39:54.698-0500',
monitor: {
check_group: 'latest-summary-check-group',
},
summary: {
up: 1,
down: 0,
},
},
latestMatching: {
'@timestamp': '2020-06-04T12:39:54.698-0500',
summary: {
up: 1,
down: 0,
},
},
});
const res = fullyMatchingIds(queryRes, undefined);
const expected = new Map<string, MonitorLocCheckGroup[]>();
expected.set('my-monitor', [
{
checkGroup: 'latest-summary-check-group',
location: 'my-location',
monitorId: 'my-monitor',
status: 'up',
summaryTimestamp: new Date('2020-06-04T12:39:54.698-0500'),
},
]);
expect(res).toEqual(expected);
});
});

View file

@ -1,409 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { QueryContext } from './query_context';
import {
Check,
Histogram,
HistogramPoint,
MonitorSummary,
CursorDirection,
SortOrder,
} from '../../../../common/runtime_types';
import { MonitorEnricher } from './fetch_page';
import { getHistogramInterval } from '../../helper/get_histogram_interval';
export const enrichMonitorGroups: MonitorEnricher = async (
queryContext: QueryContext,
checkGroups: string[]
): Promise<MonitorSummary[]> => {
// TODO the scripted metric query here is totally unnecessary and largely
// redundant with the way the code works now. This could be simplified
// to a much simpler query + some JS processing.
const params = {
index: queryContext.heartbeatIndices,
body: {
query: {
bool: {
filter: [{ terms: { 'monitor.check_group': checkGroups } }],
},
},
size: 0,
aggs: {
monitors: {
composite: {
/**
* TODO: extract this to a constant; we can't be looking for _only_
* ten monitors, because it's possible our check groups selection will represent more than ten.
*
* We were previously passing the after key from the check groups query regardless of the number of monitors we had,
* it's important that the latest check group from the final monitor we use is what we return, or we will be way ahead in terms
* of check groups and end up skipping monitors on subsequent calls.
*/
size: 500,
sources: [
{
monitor_id: {
terms: {
field: 'monitor.id',
order: cursorDirectionToOrder(queryContext.pagination.cursorDirection),
},
},
},
],
},
aggregations: {
state: {
scripted_metric: {
init_script: `
// Globals are values that should be identical across all docs
// We can cheat a bit by always overwriting these and make the
// assumption that there is no variation in these across checks
state.globals = new HashMap();
// Here we store stuff broken out by agent.id and monitor.id
// This should correspond to a unique check.
state.checksByAgentIdIP = new HashMap();
`,
map_script: `
Map curCheck = new HashMap();
String agentId = doc["agent.id"][0];
String ip = null;
if (doc["monitor.ip"].length > 0) {
ip = doc["monitor.ip"][0];
}
String agentIdIP = agentId + "-" + (ip == null ? "" : ip.toString());
def ts = doc["@timestamp"][0].toInstant().toEpochMilli();
def lastCheck = state.checksByAgentIdIP[agentId];
Instant lastTs = lastCheck != null ? lastCheck["@timestamp"] : null;
if (lastTs != null && lastTs > ts) {
return;
}
curCheck.put("@timestamp", ts);
Map agent = new HashMap();
agent.id = agentId;
curCheck.put("agent", agent);
if (state.globals.url == null) {
Map url = new HashMap();
Collection fields = ["full", "original", "scheme", "username", "password", "domain", "port", "path", "query", "fragment"];
for (field in fields) {
String docPath = "url." + field;
def val = doc[docPath];
if (!val.isEmpty()) {
url[field] = val[0];
}
}
state.globals.url = url;
}
Map monitor = new HashMap();
monitor.status = doc["monitor.status"][0];
monitor.ip = ip;
if (!doc["monitor.name"].isEmpty()) {
String monitorName = doc["monitor.name"][0];
if (monitor.name != "") {
monitor.name = monitorName;
}
}
curCheck.monitor = monitor;
if (curCheck.observer == null) {
curCheck.observer = new HashMap();
}
if (curCheck.observer.geo == null) {
curCheck.observer.geo = new HashMap();
}
if (!doc["observer.geo.name"].isEmpty()) {
curCheck.observer.geo.name = doc["observer.geo.name"][0];
}
if (!doc["observer.geo.location"].isEmpty()) {
curCheck.observer.geo.location = doc["observer.geo.location"][0];
}
if (!doc["kubernetes.pod.uid"].isEmpty() && curCheck.kubernetes == null) {
curCheck.kubernetes = new HashMap();
curCheck.kubernetes.pod = new HashMap();
curCheck.kubernetes.pod.uid = doc["kubernetes.pod.uid"][0];
}
if (!doc["container.id"].isEmpty() && curCheck.container == null) {
curCheck.container = new HashMap();
curCheck.container.id = doc["container.id"][0];
}
if (curCheck.tls == null) {
curCheck.tls = new HashMap();
}
if (!doc["tls.certificate_not_valid_after"].isEmpty()) {
curCheck.tls.not_after = doc["tls.certificate_not_valid_after"][0];
}
if (!doc["tls.certificate_not_valid_before"].isEmpty()) {
curCheck.tls.not_before = doc["tls.certificate_not_valid_before"][0];
}
state.checksByAgentIdIP[agentIdIP] = curCheck;
`,
combine_script: 'return state;',
reduce_script: `
// The final document
Map result = new HashMap();
Map checks = new HashMap();
Instant maxTs = Instant.ofEpochMilli(0);
Collection ips = new HashSet();
Collection geoNames = new HashSet();
Collection podUids = new HashSet();
Collection containerIds = new HashSet();
Collection tls = new HashSet();
String name = null;
for (state in states) {
result.putAll(state.globals);
for (entry in state.checksByAgentIdIP.entrySet()) {
def agentIdIP = entry.getKey();
def check = entry.getValue();
def lastBestCheck = checks.get(agentIdIP);
def checkTs = Instant.ofEpochMilli(check.get("@timestamp"));
if (maxTs.isBefore(checkTs)) { maxTs = checkTs}
if (lastBestCheck == null || lastBestCheck.get("@timestamp") < checkTs) {
check["@timestamp"] = check["@timestamp"];
checks[agentIdIP] = check
}
if (check.monitor.name != null && check.monitor.name != "") {
name = check.monitor.name;
}
ips.add(check.monitor.ip);
if (check.observer != null && check.observer.geo != null && check.observer.geo.name != null) {
geoNames.add(check.observer.geo.name);
}
if (check.kubernetes != null && check.kubernetes.pod != null) {
podUids.add(check.kubernetes.pod.uid);
}
if (check.container != null) {
containerIds.add(check.container.id);
}
if (check.tls != null) {
tls.add(check.tls);
}
}
}
// We just use the values so we can store these as nested docs
result.checks = checks.values();
result.put("@timestamp", maxTs);
Map summary = new HashMap();
summary.up = checks.entrySet().stream().filter(c -> c.getValue().monitor.status == "up").count();
summary.down = checks.size() - summary.up;
result.summary = summary;
Map monitor = new HashMap();
monitor.ip = ips;
monitor.name = name;
monitor.status = summary.down > 0 ? "down" : "up";
result.monitor = monitor;
Map observer = new HashMap();
Map geo = new HashMap();
observer.geo = geo;
geo.name = geoNames;
result.observer = observer;
if (!podUids.isEmpty()) {
result.kubernetes = new HashMap();
result.kubernetes.pod = new HashMap();
result.kubernetes.pod.uid = podUids;
}
if (!containerIds.isEmpty()) {
result.container = new HashMap();
result.container.id = containerIds;
}
if (!tls.isEmpty()) {
result.tls = new HashMap();
result.tls = tls;
}
return result;
`,
},
},
},
},
},
},
};
const items = await queryContext.search(params);
const monitorBuckets = items?.aggregations?.monitors?.buckets ?? [];
const monitorIds: string[] = [];
const summaries: MonitorSummary[] = monitorBuckets.map((monitor: any) => {
const monitorId = monitor.key.monitor_id;
monitorIds.push(monitorId);
const state: any = monitor.state?.value;
state.timestamp = state['@timestamp'];
const { checks } = state;
if (Array.isArray(checks)) {
checks.sort(sortChecksBy);
state.checks = state.checks.map((check: any) => ({
...check,
timestamp: check['@timestamp'],
}));
} else {
state.checks = [];
}
return {
monitor_id: monitorId,
state,
};
});
const histogramMap = await getHistogramForMonitors(queryContext, monitorIds);
const resItems = summaries.map((summary) => ({
...summary,
histogram: histogramMap[summary.monitor_id],
}));
const sortedResItems: any = resItems.sort((a, b) => {
if (a.monitor_id === b.monitor_id) return 0;
return a.monitor_id > b.monitor_id ? 1 : -1;
});
if (queryContext.pagination.sortOrder === SortOrder.DESC) {
sortedResItems.reverse();
}
return sortedResItems;
};
const getHistogramForMonitors = async (
queryContext: QueryContext,
monitorIds: string[]
): Promise<{ [key: string]: Histogram }> => {
const params = {
index: queryContext.heartbeatIndices,
body: {
size: 0,
query: {
bool: {
filter: [
{
range: {
'summary.down': { gt: 0 },
},
},
{
terms: {
'monitor.id': monitorIds,
},
},
{
range: {
'@timestamp': {
gte: queryContext.dateRangeStart,
lte: queryContext.dateRangeEnd,
},
},
},
],
},
},
aggs: {
histogram: {
date_histogram: {
field: '@timestamp',
// 12 seems to be a good size for performance given
// long monitor lists of up to 100 on the overview page
fixed_interval:
getHistogramInterval(queryContext.dateRangeStart, queryContext.dateRangeEnd, 12) +
'ms',
missing: 0,
},
aggs: {
by_id: {
terms: {
field: 'monitor.id',
size: Math.max(monitorIds.length, 1),
},
aggs: {
totalDown: {
sum: { field: 'summary.down' },
},
},
},
},
},
},
},
};
const result = await queryContext.search(params);
const histoBuckets: any[] = result.aggregations.histogram.buckets;
const simplified = histoBuckets.map((histoBucket: any): { timestamp: number; byId: any } => {
const byId: { [key: string]: number } = {};
histoBucket.by_id.buckets.forEach((idBucket: any) => {
byId[idBucket.key] = idBucket.totalDown.value;
});
return {
timestamp: parseInt(histoBucket.key, 10),
byId,
};
});
const histosById: { [key: string]: Histogram } = {};
monitorIds.forEach((id: string) => {
const points: HistogramPoint[] = [];
simplified.forEach((simpleHisto) => {
points.push({
timestamp: simpleHisto.timestamp,
up: undefined,
down: simpleHisto.byId[id],
});
});
histosById[id] = { points };
});
return histosById;
};
const cursorDirectionToOrder = (cd: CursorDirection): 'asc' | 'desc' => {
return CursorDirection[cd] === CursorDirection.AFTER ? 'asc' : 'desc';
};
const getStringValue = (value: string | Array<string | null> | null | undefined): string => {
if (Array.isArray(value)) {
value.sort();
return value[0] ?? '';
}
return value ?? '';
};
export const sortChecksBy = (
a: Pick<Check, 'observer' | 'monitor'>,
b: Pick<Check, 'observer' | 'monitor'>
) => {
const nameA: string = a.observer?.geo?.name ?? '';
const nameB: string = b.observer?.geo?.name ?? '';
if (nameA === nameB) {
const ipA = getStringValue(a.monitor.ip);
const ipB = getStringValue(b.monitor.ip);
if (ipA === ipB) {
return 0;
}
return ipA > ipB ? 1 : -1;
}
return nameA > nameB ? 1 : -1;
};

View file

@ -6,7 +6,7 @@
import { refinePotentialMatches } from './refine_potential_matches';
import { findPotentialMatches } from './find_potential_matches';
import { ChunkFetcher, ChunkResult } from './monitor_group_iterator';
import { ChunkFetcher, ChunkResult } from './monitor_summary_iterator';
import { QueryContext } from './query_context';
/**
@ -18,9 +18,6 @@ import { QueryContext } from './query_context';
* @param searchAfter indicates where Elasticsearch should continue querying on subsequent requests, if at all
* @param size the minimum size of the matches to chunk
*/
// Note that all returned data may be erroneous. If `searchAfter` is returned the caller should invoke this function
// repeatedly with the new searchAfter value as there may be more matching data in a future chunk. If `searchAfter`
// is falsey there is no more data to fetch.
export const fetchChunk: ChunkFetcher = async (
queryContext: QueryContext,
searchAfter: any,
@ -34,7 +31,7 @@ export const fetchChunk: ChunkFetcher = async (
const matching = await refinePotentialMatches(queryContext, monitorIds);
return {
monitorGroups: matching,
monitorSummaries: matching,
searchAfter: foundSearchAfter,
};
};

View file

@ -1,138 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { flatten } from 'lodash';
import { CursorPagination } from './types';
import { QueryContext } from './query_context';
import { QUERY } from '../../../../common/constants';
import { CursorDirection, MonitorSummary, SortOrder } from '../../../../common/runtime_types';
import { enrichMonitorGroups } from './enrich_monitor_groups';
import { MonitorGroupIterator } from './monitor_group_iterator';
/**
*
* Gets a single page of results per the settings in the provided queryContext. These results are very minimal,
* just monitor IDs and check groups. This takes an optional `MonitorGroupEnricher` that post-processes the minimal
* data, decorating it appropriately. The function also takes a fetcher, which does all the actual fetching.
* @param queryContext defines the criteria for the data on the current page
* @param monitorGroupFetcher performs paginated monitor fetching
* @param monitorEnricher decorates check group results with additional data
*/
// just monitor IDs and check groups. This takes an optional `MonitorGroupEnricher` that post-processes the minimal
// data, decorating it appropriately. The function also takes a fetcher, which does all the actual fetching.
export const fetchPage = async (
queryContext: QueryContext,
monitorGroupFetcher: MonitorGroupsFetcher = fetchPageMonitorGroups,
monitorEnricher: MonitorEnricher = enrichMonitorGroups
): Promise<EnrichedPage> => {
const size = Math.min(queryContext.size, QUERY.DEFAULT_AGGS_CAP);
const monitorPage = await monitorGroupFetcher(queryContext, size);
const checkGroups: string[] = flatten(
monitorPage.monitorGroups.map((monitorGroups) => monitorGroups.groups.map((g) => g.checkGroup))
);
const enrichedMonitors = await monitorEnricher(queryContext, checkGroups);
return {
items: enrichedMonitors,
nextPagePagination: monitorPage.nextPagePagination,
prevPagePagination: monitorPage.prevPagePagination,
};
};
// Fetches the most recent monitor groups for the given page,
// in the manner demanded by the `queryContext` and return at most `size` results.
const fetchPageMonitorGroups: MonitorGroupsFetcher = async (
queryContext: QueryContext,
size: number
): Promise<MonitorGroupsPage> => {
const monitorGroups: MonitorGroups[] = [];
const iterator = new MonitorGroupIterator(queryContext);
let paginationBefore: CursorPagination | null = null;
while (monitorGroups.length < size) {
const monitor = await iterator.next();
if (!monitor) {
break; // No more items to fetch
}
monitorGroups.push(monitor);
// We want the before pagination to be before the first item we encounter
if (monitorGroups.length === 1) {
paginationBefore = await iterator.paginationBeforeCurrent();
}
}
// We have to create these objects before checking if we can navigate backward
const paginationAfter = await iterator.paginationAfterCurrent();
const ssAligned = searchSortAligned(queryContext.pagination);
if (!ssAligned) {
monitorGroups.reverse();
}
return {
monitorGroups,
nextPagePagination: ssAligned ? paginationAfter : paginationBefore,
prevPagePagination: ssAligned ? paginationBefore : paginationAfter,
};
};
// Returns true if the order returned by the ES query matches the requested sort order.
// This useful to determine if the results need to be reversed from their ES results order.
// I.E. when navigating backwards using prevPagePagination (CursorDirection.Before) yet using a SortOrder.ASC.
const searchSortAligned = (pagination: CursorPagination): boolean => {
if (pagination.cursorDirection === CursorDirection.AFTER) {
return pagination.sortOrder === SortOrder.ASC;
} else {
return pagination.sortOrder === SortOrder.DESC;
}
};
// Minimal interface representing the most recent set of groups accompanying a MonitorId in a given context.
export interface MonitorGroups {
id: string;
groups: MonitorLocCheckGroup[];
}
// Representation of the data returned when aggregating summary check groups.
export interface MonitorLocCheckGroup {
monitorId: string;
location: string | null;
checkGroup: string;
status: 'up' | 'down';
summaryTimestamp: Date;
}
// Represents a page that has not yet been enriched.
export interface MonitorGroupsPage {
monitorGroups: MonitorGroups[];
nextPagePagination: CursorPagination | null;
prevPagePagination: CursorPagination | null;
}
// Representation of a full page of results with pagination data for constructing next/prev links.
export interface EnrichedPage {
items: MonitorSummary[];
nextPagePagination: CursorPagination | null;
prevPagePagination: CursorPagination | null;
}
// A function that does the work of matching the minimal set of data for this query, returning just matching fields
// that are efficient to access while performing the query.
export type MonitorGroupsFetcher = (
queryContext: QueryContext,
size: number
) => Promise<MonitorGroupsPage>;
// A function that takes a set of check groups and returns richer MonitorSummary objects.
export type MonitorEnricher = (
queryContext: QueryContext,
checkGroups: string[]
) => Promise<MonitorSummary[]>;

View file

@ -5,7 +5,6 @@
*/
import { get, set } from 'lodash';
import { CursorDirection } from '../../../../common/runtime_types';
import { QueryContext } from './query_context';
/**
@ -44,8 +43,6 @@ const query = async (queryContext: QueryContext, searchAfter: any, size: number)
};
const queryBody = async (queryContext: QueryContext, searchAfter: any, size: number) => {
const compositeOrder = cursorDirectionToOrder(queryContext.pagination.cursorDirection);
const filters = await queryContext.dateAndCustomFilters();
if (queryContext.statusFilter) {
@ -66,7 +63,7 @@ const queryBody = async (queryContext: QueryContext, searchAfter: any, size: num
size,
sources: [
{
monitor_id: { terms: { field: 'monitor.id', order: compositeOrder } },
monitor_id: { terms: { field: 'monitor.id', order: queryContext.cursorOrder() } },
},
],
},
@ -80,7 +77,3 @@ const queryBody = async (queryContext: QueryContext, searchAfter: any, size: num
return body;
};
const cursorDirectionToOrder = (cd: CursorDirection): 'asc' | 'desc' => {
return CursorDirection[cd] === CursorDirection.AFTER ? 'asc' : 'desc';
};

View file

@ -4,6 +4,5 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { fetchPage, MonitorGroups, MonitorLocCheckGroup, MonitorGroupsPage } from './fetch_page';
export { MonitorGroupIterator } from './monitor_group_iterator';
export { MonitorSummaryIterator } from './monitor_summary_iterator';
export { QueryContext } from './query_context';

View file

@ -6,8 +6,7 @@
import { QueryContext } from './query_context';
import { fetchChunk } from './fetch_chunk';
import { CursorDirection } from '../../../../common/runtime_types';
import { MonitorGroups } from './fetch_page';
import { CursorDirection, MonitorSummary } from '../../../../common/runtime_types';
import { CursorPagination } from './types';
// Hardcoded chunk size for how many monitors to fetch at a time when querying
@ -22,7 +21,7 @@ export type ChunkFetcher = (
// Result of fetching more results from the search.
export interface ChunkResult {
monitorGroups: MonitorGroups[];
monitorSummaries: MonitorSummary[];
searchAfter: any;
}
@ -32,21 +31,19 @@ export interface ChunkResult {
* querying, this class provides a `next` function that is cleaner to call. `next` provides the next matching result,
* which may require many subsequent fetches, while keeping the external API clean.
*/
// matches, or may simple be empty results that tell us a to keep looking for more, this class exists to simplify things.
// The idea is that you can call next() on it and receive the next matching result, even if internally we need to fetch
// multiple chunks to find that result.
export class MonitorGroupIterator {
export class MonitorSummaryIterator {
queryContext: QueryContext;
// Cache representing pre-fetched query results.
// The first item is the CheckGroup this represents.
buffer: MonitorGroups[];
// The first item is the MonitorSummary this represents.
buffer: MonitorSummary[];
bufferPos: number;
searchAfter: any;
chunkFetcher: ChunkFetcher;
endOfResults: boolean; // true if we've hit the end of results from ES
constructor(
queryContext: QueryContext,
initialBuffer: MonitorGroups[] = [],
initialBuffer: MonitorSummary[] = [],
initialBufferPos: number = -1,
chunkFetcher: ChunkFetcher = fetchChunk
) {
@ -55,11 +52,12 @@ export class MonitorGroupIterator {
this.bufferPos = initialBufferPos;
this.searchAfter = queryContext.pagination.cursorKey;
this.chunkFetcher = chunkFetcher;
this.endOfResults = false;
}
// Fetch the next matching result.
async next(): Promise<MonitorGroups | null> {
await this.bufferNext(CHUNK_SIZE);
async next(): Promise<MonitorSummary | null> {
await this.bufferNext();
const found = this.buffer[this.bufferPos + 1];
if (found) {
@ -69,29 +67,62 @@ export class MonitorGroupIterator {
return null;
}
async nextPage(size: number): Promise<MonitorSummariesPage> {
const monitorSummaries: MonitorSummary[] = [];
let paginationBefore: CursorPagination | null = null;
while (monitorSummaries.length < size) {
const monitor = await this.next();
if (!monitor) {
break; // No more items to fetch
}
monitorSummaries.push(monitor);
// We want the before pagination to be before the first item we encounter
if (monitorSummaries.length === 1) {
paginationBefore = await this.paginationBeforeCurrent();
}
}
// We have to create these objects before checking if we can navigate backward
const paginationAfter = await this.paginationAfterCurrent();
const ssAligned = this.queryContext.searchSortAligned();
if (!ssAligned) {
monitorSummaries.reverse();
}
return {
monitorSummaries,
nextPagePagination: ssAligned ? paginationAfter : paginationBefore,
prevPagePagination: ssAligned ? paginationBefore : paginationAfter,
};
}
// Look ahead to see if there are additional results.
async peek(): Promise<MonitorGroups | null> {
await this.bufferNext(CHUNK_SIZE);
async peek(): Promise<MonitorSummary | null> {
await this.bufferNext();
return this.buffer[this.bufferPos + 1] || null;
}
// Returns the last item fetched with next(). null if no items fetched with
// next or if next has not yet been invoked.
getCurrent(): MonitorGroups | null {
getCurrent(): MonitorSummary | null {
return this.buffer[this.bufferPos] || null;
}
// Attempts to buffer at most `size` number of additional results, stopping when at least one additional
// result is buffered or there are no more matching items to be found.
async bufferNext(size: number = CHUNK_SIZE): Promise<void> {
// The next element is already buffered.
async bufferNext(): Promise<void> {
// Nothing to do if there are no more results or
// the next element is already buffered.
if (this.buffer[this.bufferPos + 1]) {
return;
}
while (true) {
const result = await this.attemptBufferMore(size);
if (result.gotHit || !result.hasMore) {
while (!this.endOfResults) {
const result = await this.attemptBufferMore();
if (result.gotHit) {
return;
}
}
@ -103,9 +134,7 @@ export class MonitorGroupIterator {
* to free up space.
* @param size the number of items to chunk
*/
async attemptBufferMore(
size: number = CHUNK_SIZE
): Promise<{ hasMore: boolean; gotHit: boolean }> {
async attemptBufferMore(): Promise<{ gotHit: boolean }> {
// Trim the buffer to just the current element since we'll be fetching more
const current = this.getCurrent();
@ -117,17 +146,21 @@ export class MonitorGroupIterator {
this.bufferPos = 0;
}
const results = await this.chunkFetcher(this.queryContext, this.searchAfter, size);
const results = await this.chunkFetcher(this.queryContext, this.searchAfter, CHUNK_SIZE);
// If we've hit the end of the stream searchAfter will be empty
results.monitorGroups.forEach((mig: MonitorGroups) => this.buffer.push(mig));
results.monitorSummaries.forEach((ms: MonitorSummary) => this.buffer.push(ms));
if (results.searchAfter) {
this.searchAfter = results.searchAfter;
}
// Remember, the chunk fetcher might return no results in one chunk, but still have more matching
// results, so we use the searchAfter field to determine whether we keep going.
if (!results.searchAfter) {
this.endOfResults = true;
}
return {
gotHit: results.monitorGroups.length > 0,
hasMore: !!results.searchAfter,
gotHit: results.monitorSummaries.length > 0,
};
}
@ -142,7 +175,7 @@ export class MonitorGroupIterator {
if (!current) {
return null;
}
const cursorKey = { monitor_id: current.id };
const cursorKey = { monitor_id: current.monitor_id };
return Object.assign({}, this.queryContext.pagination, { cursorKey });
}
@ -154,12 +187,12 @@ export class MonitorGroupIterator {
}
// Returns a copy of this fetcher that goes backwards from the current position
reverse(): MonitorGroupIterator | null {
reverse(): MonitorSummaryIterator | null {
const reverseContext = this.queryContext.clone();
const current = this.getCurrent();
reverseContext.pagination = {
cursorKey: current ? { monitor_id: current.id } : null,
cursorKey: current ? { monitor_id: current.monitor_id } : null,
sortOrder: this.queryContext.pagination.sortOrder,
cursorDirection:
this.queryContext.pagination.cursorDirection === CursorDirection.AFTER
@ -168,12 +201,18 @@ export class MonitorGroupIterator {
};
return current
? new MonitorGroupIterator(reverseContext, [current], 0, this.chunkFetcher)
? new MonitorSummaryIterator(reverseContext, [current], 0, this.chunkFetcher)
: null;
}
// Returns a copy of this with a shallow copied buffer. Note that the queryContext is still shared!
clone() {
return new MonitorGroupIterator(this.queryContext, this.buffer.slice(0), this.bufferPos);
return new MonitorSummaryIterator(this.queryContext, this.buffer.slice(0), this.bufferPos);
}
}
export interface MonitorSummariesPage {
monitorSummaries: MonitorSummary[];
nextPagePagination: CursorPagination | null;
prevPagePagination: CursorPagination | null;
}

View file

@ -8,6 +8,7 @@ import moment from 'moment';
import { LegacyAPICaller } from 'src/core/server';
import { CursorPagination } from './types';
import { parseRelativeDate } from '../../helper';
import { CursorDirection, SortOrder } from '../../../../common/runtime_types';
export class QueryContext {
callES: LegacyAPICaller;
@ -146,4 +147,21 @@ export class QueryContext {
this.statusFilter
);
}
// Returns true if the order returned by the ES query matches the requested sort order.
// This useful to determine if the results need to be reversed from their ES results order.
// I.E. when navigating backwards using prevPagePagination (CursorDirection.Before) yet using a SortOrder.ASC.
searchSortAligned(): boolean {
if (this.pagination.cursorDirection === CursorDirection.AFTER) {
return this.pagination.sortOrder === SortOrder.ASC;
} else {
return this.pagination.sortOrder === SortOrder.DESC;
}
}
cursorOrder(): 'asc' | 'desc' {
return CursorDirection[this.pagination.cursorDirection] === CursorDirection.AFTER
? 'asc'
: 'desc';
}
}

View file

@ -5,8 +5,7 @@
*/
import { QueryContext } from './query_context';
import { CursorDirection } from '../../../../common/runtime_types';
import { MonitorGroups, MonitorLocCheckGroup } from './fetch_page';
import { MonitorSummary, Ping } from '../../../../common/runtime_types';
/**
* Determines whether the provided check groups are the latest complete check groups for their associated monitor ID's.
@ -19,80 +18,83 @@ import { MonitorGroups, MonitorLocCheckGroup } from './fetch_page';
export const refinePotentialMatches = async (
queryContext: QueryContext,
potentialMatchMonitorIDs: string[]
): Promise<MonitorGroups[]> => {
): Promise<MonitorSummary[]> => {
if (potentialMatchMonitorIDs.length === 0) {
return [];
}
const queryResult = await query(queryContext, potentialMatchMonitorIDs);
const recentGroupsMatchingStatus = await fullyMatchingIds(queryResult, queryContext.statusFilter);
// Return the monitor groups filtering out potential matches that weren't current
const matches: MonitorGroups[] = potentialMatchMonitorIDs
.map((id: string) => {
return { id, groups: recentGroupsMatchingStatus.get(id) || [] };
})
.filter((mrg) => mrg.groups.length > 0);
// Sort matches by ID
matches.sort((a: MonitorGroups, b: MonitorGroups) => {
return a.id === b.id ? 0 : a.id > b.id ? 1 : -1;
});
if (queryContext.pagination.cursorDirection === CursorDirection.BEFORE) {
matches.reverse();
}
return matches;
return await fullyMatchingIds(queryResult, queryContext.statusFilter);
};
export const fullyMatchingIds = (queryResult: any, statusFilter?: string) => {
const matching = new Map<string, MonitorLocCheckGroup[]>();
MonitorLoop: for (const monBucket of queryResult.aggregations.monitor.buckets) {
const monitorId: string = monBucket.key;
const groups: MonitorLocCheckGroup[] = [];
export const fullyMatchingIds = (queryResult: any, statusFilter?: string): MonitorSummary[] => {
const summaries: MonitorSummary[] = [];
for (const monBucket of queryResult.aggregations.monitor.buckets) {
// Did at least one location match?
let matched = false;
const summaryPings: Ping[] = [];
for (const locBucket of monBucket.location.buckets) {
const location = locBucket.key;
const latestSource = locBucket.summaries.latest.hits.hits[0]._source;
const latestStillMatchingSource = locBucket.latest_matching.top.hits.hits[0]?._source;
const latest = locBucket.summaries.latest.hits.hits[0];
const latestStillMatching = locBucket.latest_matching.top.hits.hits[0];
// If the most recent document still matches the most recent document matching the current filters
// we can include this in the result
//
// We just check if the timestamp is greater. Note this may match an incomplete check group
// that has not yet sent a summary doc
if (
latestStillMatchingSource &&
latestStillMatchingSource['@timestamp'] >= latestSource['@timestamp']
latestStillMatching &&
latestStillMatching._source['@timestamp'] >= latest._source['@timestamp']
) {
matched = true;
}
const checkGroup = latestSource.monitor.check_group;
const status = latestSource.summary.down > 0 ? 'down' : 'up';
// This monitor doesn't match, so just skip ahead and don't add it to the output
// Only skip in case of up statusFilter, for a monitor to be up, all checks should be up
if (statusFilter === 'up' && statusFilter !== status) {
continue MonitorLoop;
}
groups.push({
monitorId,
location,
checkGroup,
status,
summaryTimestamp: new Date(latestSource['@timestamp']),
summaryPings.push({
docId: latest._id,
timestamp: latest._source['@timestamp'],
...latest._source,
});
}
// If one location matched, include data from all locations in the result set
if (matched) {
matching.set(monitorId, groups);
const someDown = summaryPings.some((p) => (p.summary?.down ?? 0) > 0);
const statusFilterOk = !statusFilter ? true : statusFilter === 'up' ? !someDown : someDown;
if (matched && statusFilterOk) {
summaries.push(summaryPingsToSummary(summaryPings));
}
}
return matching;
return summaries;
};
export const summaryPingsToSummary = (summaryPings: Ping[]): MonitorSummary => {
summaryPings.sort((a, b) =>
a.timestamp > b.timestamp ? 1 : a.timestamp === b.timestamp ? 0 : -1
);
const latest = summaryPings[summaryPings.length - 1];
return {
monitor_id: latest.monitor.id,
state: {
timestamp: latest.timestamp,
monitor: {
name: latest.monitor?.name,
},
url: latest.url ?? {},
summary: {
up: summaryPings.reduce((acc, p) => (p.summary?.up ?? 0) + acc, 0),
down: summaryPings.reduce((acc, p) => (p.summary?.down ?? 0) + acc, 0),
status: summaryPings.some((p) => (p.summary?.down ?? 0) > 0) ? 'down' : 'up',
},
summaryPings,
tls: latest.tls,
// easier to ensure to use '' for an empty geo name in terms of types
observer: {
geo: { name: summaryPings.map((p) => p.observer?.geo?.name ?? '').filter((n) => n !== '') },
},
},
};
};
export const query = async (
@ -113,7 +115,11 @@ export const query = async (
},
aggs: {
monitor: {
terms: { field: 'monitor.id', size: potentialMatchMonitorIDs.length },
terms: {
field: 'monitor.id',
size: potentialMatchMonitorIDs.length,
order: { _key: queryContext.cursorOrder() },
},
aggs: {
location: {
terms: { field: 'observer.geo.name', missing: 'N/A', size: 100 },
@ -125,14 +131,6 @@ export const query = async (
latest: {
top_hits: {
sort: [{ '@timestamp': 'desc' }],
_source: {
includes: [
'monitor.check_group',
'@timestamp',
'summary.up',
'summary.down',
],
},
size: 1,
},
},
@ -144,10 +142,8 @@ export const query = async (
aggs: {
top: {
top_hits: {
_source: ['@timestamp'],
sort: [{ '@timestamp': 'desc' }],
_source: {
includes: ['monitor.check_group', '@timestamp'],
},
size: 1,
},
},

View file

@ -17,6 +17,7 @@ import {
GetCertsParams,
GetPingsParams,
CertResult,
MonitorSummariesResult,
} from '../../../common/runtime_types';
import { MonitorDurationResult } from '../../../common/types';
@ -31,7 +32,6 @@ import {
GetMonitorStatusParams,
GetMonitorStatusResult,
} from '.';
import { GetMonitorStatesResult } from './get_monitor_states';
import { GetSnapshotCountParams } from './get_snapshot_counts';
import { IIndexPattern } from '../../../../../../src/plugins/data/server';
@ -45,7 +45,7 @@ export interface UptimeRequests {
getMonitorDurationChart: ESQ<GetMonitorChartsParams, MonitorDurationResult>;
getMonitorDetails: ESQ<GetMonitorDetailsParams, MonitorDetails>;
getMonitorLocations: ESQ<GetMonitorLocationsParams, MonitorLocations>;
getMonitorStates: ESQ<GetMonitorStatesParams, GetMonitorStatesResult>;
getMonitorStates: ESQ<GetMonitorStatesParams, MonitorSummariesResult>;
getMonitorStatus: ESQ<GetMonitorStatusParams, GetMonitorStatusResult[]>;
getPings: ESQ<GetPingsParams, PingsResponse>;
getPingHistogram: ESQ<GetPingHistogramParams, HistogramResult>;

View file

@ -27,6 +27,5 @@
"full": "http://localhost:5678/pattern?r=200x1"
},
"docId": "h5toHm0B0I9WX_CznN_V",
"timestamp": "2019-09-11T03:40:34.371Z",
"tls": {}
}
"timestamp": "2019-09-11T03:40:34.371Z"
}

View file

@ -53,18 +53,6 @@ export default function ({ getService }: FtrProviderContext) {
nonSummaryIp = checks[0][0].monitor.ip;
});
it('should return all IPs', async () => {
const filters = makeApiParams(testMonitorId);
const url = getBaseUrl(dateRangeStart, dateRangeEnd) + `&filters=${filters}`;
const apiResponse = await supertest.get(url);
const res = apiResponse.body;
const uniqueIps = new Set<string>();
res.summaries[0].state.checks.forEach((c: any) => uniqueIps.add(c.monitor.ip));
expect(uniqueIps.size).to.eql(4);
});
it('should match non summary documents without a status filter', async () => {
const filters = makeApiParams(testMonitorId, [{ match: { 'monitor.ip': nonSummaryIp } }]);

View file

@ -7,7 +7,7 @@
import expect from '@kbn/expect';
import { isRight } from 'fp-ts/lib/Either';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { MonitorSummaryResultType } from '../../../../../plugins/uptime/common/runtime_types';
import { MonitorSummariesResultType } from '../../../../../plugins/uptime/common/runtime_types';
import { API_URLS } from '../../../../../plugins/uptime/common/constants';
interface ExpectedMonitorStatesPage {
@ -38,7 +38,7 @@ const checkMonitorStatesResponse = ({
prevPagination,
nextPagination,
}: ExpectedMonitorStatesPage) => {
const decoded = MonitorSummaryResultType.decode(response);
const decoded = MonitorSummariesResultType.decode(response);
expect(isRight(decoded)).to.be.ok();
if (isRight(decoded)) {
const { summaries, prevPagePagination, nextPagePagination, totalSummaryCount } = decoded.right;