[Uptime] Certificates page (#64059)

This commit is contained in:
Shahzad 2020-05-01 16:04:19 +02:00 committed by GitHub
parent c8ddb6b8ed
commit 523926f0a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
73 changed files with 4482 additions and 1002 deletions

View file

@ -10,6 +10,8 @@ export const OVERVIEW_ROUTE = '/';
export const SETTINGS_ROUTE = '/settings';
export const CERTIFICATES_ROUTE = '/certificates';
export enum STATUS {
UP = 'up',
DOWN = 'down',
@ -41,3 +43,10 @@ export const SHORT_TIMESPAN_LOCALE = {
yy: '%d Yr',
},
};
export enum CERT_STATUS {
OK = 'OK',
EXPIRING_SOON = 'EXPIRING_SOON',
EXPIRED = 'EXPIRED',
TOO_OLD = 'TOO_OLD',
}

View file

@ -8,35 +8,45 @@ import * as t from 'io-ts';
export const GetCertsParamsType = t.intersection([
t.type({
from: t.string,
to: t.string,
index: t.number,
size: t.number,
sortBy: t.string,
direction: t.string,
}),
t.partial({
search: t.string,
from: t.string,
to: t.string,
}),
]);
export type GetCertsParams = t.TypeOf<typeof GetCertsParamsType>;
export const CertMonitorType = t.partial({
name: t.string,
id: t.string,
url: t.string,
});
export const CertType = t.intersection([
t.type({
monitors: t.array(
t.partial({
name: t.string,
id: t.string,
})
),
monitors: t.array(CertMonitorType),
sha256: t.string,
}),
t.partial({
certificate_not_valid_after: t.string,
certificate_not_valid_before: t.string,
not_after: t.string,
not_before: t.string,
common_name: t.string,
issuer: t.string,
sha1: t.string,
}),
]);
export const CertResultType = t.type({
certs: t.array(CertType),
total: t.number,
});
export type Cert = t.TypeOf<typeof CertType>;
export type CertMonitor = t.TypeOf<typeof CertMonitorType>;
export type CertResult = t.TypeOf<typeof CertResultType>;

View file

@ -17,8 +17,8 @@ export const HttpResponseBodyType = t.partial({
export type HttpResponseBody = t.TypeOf<typeof HttpResponseBodyType>;
export const TlsType = t.partial({
certificate_not_valid_after: t.string,
certificate_not_valid_before: t.string,
not_after: t.string,
not_before: t.string,
});
export type Tls = t.TypeOf<typeof TlsType>;

View file

@ -0,0 +1,134 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CertMonitors renders expected elements for valid props 1`] = `
<span>
<span>
<span
class="euiToolTipAnchor"
>
<button
class="euiLink euiLink--primary"
type="button"
>
<a
data-test-subj="monitor-page-link-bad-ssl-dashboard"
href="/monitor/YmFkLXNzbC1kYXNoYm9hcmQ="
>
bad-ssl-dashboard
</a>
</button>
</span>
</span>
<span>
,
<span
class="euiToolTipAnchor"
>
<button
class="euiLink euiLink--primary"
type="button"
>
<a
data-test-subj="monitor-page-link-elastic-co"
href="/monitor/ZWxhc3RpYy1jbw=="
>
elastic
</a>
</button>
</span>
</span>
<span>
,
<span
class="euiToolTipAnchor"
>
<button
class="euiLink euiLink--primary"
type="button"
>
<a
data-test-subj="monitor-page-link-extended-validation"
href="/monitor/ZXh0ZW5kZWQtdmFsaWRhdGlvbg=="
>
extended-validation
</a>
</button>
</span>
</span>
</span>
`;
exports[`CertMonitors shallow renders expected elements for valid props 1`] = `
<ContextProvider
value={
Object {
"history": Object {
"action": "POP",
"block": [Function],
"canGo": [Function],
"createHref": [Function],
"entries": Array [
Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"index": 0,
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
},
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"match": Object {
"isExact": true,
"params": Object {},
"path": "/",
"url": "/",
},
"staticContext": undefined,
}
}
>
<CertMonitors
monitors={
Array [
Object {
"id": "bad-ssl-dashboard",
"name": "",
"url": "https://badssl.com/dashboard/",
},
Object {
"id": "elastic-co",
"name": "elastic",
"url": "https://www.elastic.co/",
},
Object {
"id": "extended-validation",
"name": "",
"url": "https://extended-validation.badssl.com/",
},
]
}
/>
</ContextProvider>
`;

View file

@ -0,0 +1,93 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CertificatesSearch renders expected elements for valid props 1`] = `
.c0 {
min-width: 700px;
}
<div
class="euiFormControlLayout"
>
<div
class="euiFormControlLayout__childrenWrapper"
>
<input
aria-label="Search certificates"
class="euiFieldSearch c0"
data-test-subj="uptimeCertSearch"
placeholder="Search certificates"
type="search"
/>
<div
class="euiFormControlLayoutIcons"
>
<span
class="euiFormControlLayoutCustomIcon"
>
<div
aria-hidden="true"
class="euiFormControlLayoutCustomIcon__icon"
data-euiicon-type="search"
/>
</span>
</div>
</div>
</div>
`;
exports[`CertificatesSearch shallow renders expected elements for valid props 1`] = `
<ContextProvider
value={
Object {
"history": Object {
"action": "POP",
"block": [Function],
"canGo": [Function],
"createHref": [Function],
"entries": Array [
Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"index": 0,
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
},
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"match": Object {
"isExact": true,
"params": Object {},
"path": "/",
"url": "/",
},
"staticContext": undefined,
}
}
>
<CertificateSearch
setSearch={[MockFunction]}
/>
</ContextProvider>
`;

View file

@ -0,0 +1,100 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CertStatus renders expected elements for valid props 1`] = `
<div
class="euiHealth"
>
<div
class="euiFlexGroup euiFlexGroup--gutterExtraSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
color="success"
data-euiicon-type="dot"
/>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<span>
OK
</span>
</div>
</div>
</div>
`;
exports[`CertStatus shallow renders expected elements for valid props 1`] = `
<ContextProvider
value={
Object {
"history": Object {
"action": "POP",
"block": [Function],
"canGo": [Function],
"createHref": [Function],
"entries": Array [
Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"index": 0,
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
},
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"match": Object {
"isExact": true,
"params": Object {},
"path": "/",
"url": "/",
},
"staticContext": undefined,
}
}
>
<CertStatus
cert={
Object {
"common_name": "github.com",
"issuer": "DigiCert SHA2 Extended Validation Server CA",
"monitors": Array [
Object {
"id": "github",
"name": "",
"url": "https://github.com/",
},
],
"not_after": "2020-05-08T00:00:00.000Z",
"not_before": "2018-05-08T00:00:00.000Z",
"sha1": "ca06f56b258b7a0d4f2b05470939478651151984",
"sha256": "3111500c4a66012cdae333ec3fca1c9dde45c954440e7ee413716bff3663c074",
}
}
/>
</ContextProvider>
`;

View file

@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CertificateList shallow renders expected elements for valid props 1`] = `
<ContextProvider
value={
Object {
"history": Object {
"action": "POP",
"block": [Function],
"canGo": [Function],
"createHref": [Function],
"entries": Array [
Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"index": 0,
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
},
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"match": Object {
"isExact": true,
"params": Object {},
"path": "/",
"url": "/",
},
"staticContext": undefined,
}
}
>
<CertificateList
onChange={[MockFunction]}
page={
Object {
"index": 0,
"size": 10,
}
}
sort={
Object {
"direction": "asc",
"field": "not_after",
}
}
/>
</ContextProvider>
`;

View file

@ -0,0 +1,171 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FingerprintCol renders expected elements for valid props 1`] = `
Array [
.c1 .euiButtonEmpty__content {
padding-right: 0px;
}
.c0 {
margin-right: 8px;
}
<span
class="c0"
data-test-subj="CA06F56B258B7A0D4F2B05470939478651151984"
>
<span
class="euiToolTipAnchor"
>
<button
class="euiButtonEmpty euiButtonEmpty--primary c1"
type="button"
>
<span
class="euiButtonEmpty__content"
>
<span
class="euiButtonEmpty__text"
>
SHA 1
</span>
</span>
</button>
</span>
<span
class="euiToolTipAnchor"
>
<button
class="euiButtonIcon euiButtonIcon--primary"
title="Click to copy fingerprint value"
type="button"
>
<div
aria-hidden="true"
class="euiButtonIcon__icon"
data-euiicon-type="copy"
/>
</button>
</span>
</span>,
.c1 .euiButtonEmpty__content {
padding-right: 0px;
}
.c0 {
margin-right: 8px;
}
<span
class="c0"
data-test-subj="3111500C4A66012CDAE333EC3FCA1C9DDE45C954440E7EE413716BFF3663C074"
>
<span
class="euiToolTipAnchor"
>
<button
class="euiButtonEmpty euiButtonEmpty--primary c1"
type="button"
>
<span
class="euiButtonEmpty__content"
>
<span
class="euiButtonEmpty__text"
>
SHA 256
</span>
</span>
</button>
</span>
<span
class="euiToolTipAnchor"
>
<button
class="euiButtonIcon euiButtonIcon--primary"
title="Click to copy fingerprint value"
type="button"
>
<div
aria-hidden="true"
class="euiButtonIcon__icon"
data-euiicon-type="copy"
/>
</button>
</span>
</span>,
]
`;
exports[`FingerprintCol shallow renders expected elements for valid props 1`] = `
<ContextProvider
value={
Object {
"history": Object {
"action": "POP",
"block": [Function],
"canGo": [Function],
"createHref": [Function],
"entries": Array [
Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"index": 0,
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
},
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"match": Object {
"isExact": true,
"params": Object {},
"path": "/",
"url": "/",
},
"staticContext": undefined,
}
}
>
<FingerprintCol
cert={
Object {
"common_name": "github.com",
"issuer": "DigiCert SHA2 Extended Validation Server CA",
"monitors": Array [
Object {
"id": "github",
"name": "",
"url": "https://github.com/",
},
],
"not_after": "2020-05-08T00:00:00.000Z",
"not_before": "2018-05-08T00:00:00.000Z",
"sha1": "ca06f56b258b7a0d4f2b05470939478651151984",
"sha256": "3111500c4a66012cdae333ec3fca1c9dde45c954440e7ee413716bff3663c074",
}
}
/>
</ContextProvider>
`;

View file

@ -0,0 +1,24 @@
/*
* 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 React from 'react';
import { CertMonitors } from '../cert_monitors';
import { renderWithRouter, shallowWithRouter } from '../../../lib';
describe('CertMonitors', () => {
const certMons = [
{ name: '', id: 'bad-ssl-dashboard', url: 'https://badssl.com/dashboard/' },
{ name: 'elastic', id: 'elastic-co', url: 'https://www.elastic.co/' },
{ name: '', id: 'extended-validation', url: 'https://extended-validation.badssl.com/' },
];
it('shallow renders expected elements for valid props', () => {
expect(shallowWithRouter(<CertMonitors monitors={certMons} />)).toMatchSnapshot();
});
it('renders expected elements for valid props', () => {
expect(renderWithRouter(<CertMonitors monitors={certMons} />)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,18 @@
/*
* 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 React from 'react';
import { renderWithRouter, shallowWithRouter } from '../../../lib';
import { CertificateSearch } from '../cert_search';
describe('CertificatesSearch', () => {
it('shallow renders expected elements for valid props', () => {
expect(shallowWithRouter(<CertificateSearch setSearch={jest.fn()} />)).toMatchSnapshot();
});
it('renders expected elements for valid props', () => {
expect(renderWithRouter(<CertificateSearch setSearch={jest.fn()} />)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 React from 'react';
import { renderWithRouter, shallowWithRouter } from '../../../lib';
import { CertStatus } from '../cert_status';
import * as redux from 'react-redux';
import moment from 'moment';
describe('CertStatus', () => {
beforeEach(() => {
const spy = jest.spyOn(redux, 'useDispatch');
spy.mockReturnValue(jest.fn());
const spy1 = jest.spyOn(redux, 'useSelector');
spy1.mockReturnValue(true);
});
const cert = {
monitors: [{ name: '', id: 'github', url: 'https://github.com/' }],
not_after: '2020-05-08T00:00:00.000Z',
not_before: '2018-05-08T00:00:00.000Z',
issuer: 'DigiCert SHA2 Extended Validation Server CA',
sha1: 'ca06f56b258b7a0d4f2b05470939478651151984',
sha256: '3111500c4a66012cdae333ec3fca1c9dde45c954440e7ee413716bff3663c074',
common_name: 'github.com',
};
it('shallow renders expected elements for valid props', () => {
expect(shallowWithRouter(<CertStatus cert={cert} />)).toMatchSnapshot();
});
it('renders expected elements for valid props', () => {
cert.not_after = moment()
.add('4', 'months')
.toISOString();
expect(renderWithRouter(<CertStatus cert={cert} />)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,26 @@
/*
* 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 React from 'react';
import { shallowWithRouter } from '../../../lib';
import { CertificateList, CertSort } from '../certificates_list';
describe('CertificateList', () => {
it('shallow renders expected elements for valid props', () => {
const page = {
index: 0,
size: 10,
};
const sort: CertSort = {
field: 'not_after',
direction: 'asc',
};
expect(
shallowWithRouter(<CertificateList page={page} sort={sort} onChange={jest.fn()} />)
).toMatchSnapshot();
});
});

View file

@ -0,0 +1,34 @@
/*
* 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 React from 'react';
import { renderWithRouter, shallowWithRouter } from '../../../lib';
import { FingerprintCol } from '../fingerprint_col';
import moment from 'moment';
describe('FingerprintCol', () => {
const cert = {
monitors: [{ name: '', id: 'github', url: 'https://github.com/' }],
not_after: '2020-05-08T00:00:00.000Z',
not_before: '2018-05-08T00:00:00.000Z',
issuer: 'DigiCert SHA2 Extended Validation Server CA',
sha1: 'ca06f56b258b7a0d4f2b05470939478651151984',
sha256: '3111500c4a66012cdae333ec3fca1c9dde45c954440e7ee413716bff3663c074',
common_name: 'github.com',
};
it('shallow renders expected elements for valid props', () => {
expect(shallowWithRouter(<FingerprintCol cert={cert} />)).toMatchSnapshot();
});
it('renders expected elements for valid props', () => {
cert.not_after = moment()
.add('4', 'months')
.toISOString();
expect(renderWithRouter(<FingerprintCol cert={cert} />)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 React from 'react';
import { EuiToolTip } from '@elastic/eui';
import { CertMonitor } from '../../../common/runtime_types';
import { MonitorPageLink } from '../common/monitor_page_link';
interface Props {
monitors: CertMonitor[];
}
export const CertMonitors: React.FC<Props> = ({ monitors }) => {
return (
<span>
{monitors.map((mon: CertMonitor, ind: number) => (
<span key={mon.id}>
{ind > 0 && ', '}
<EuiToolTip content={mon.url}>
<MonitorPageLink monitorId={mon.id!} linkParameters={''}>
{mon.name || mon.id}
</MonitorPageLink>
</EuiToolTip>
</span>
))}
</span>
);
};

View file

@ -0,0 +1,34 @@
/*
* 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 React, { ChangeEvent } from 'react';
import { EuiFieldSearch } from '@elastic/eui';
import styled from 'styled-components';
import * as labels from './translations';
const WrapFieldSearch = styled(EuiFieldSearch)`
min-width: 700px;
`;
interface Props {
setSearch: (val: string) => void;
}
export const CertificateSearch: React.FC<Props> = ({ setSearch }) => {
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
};
return (
<WrapFieldSearch
data-test-subj="uptimeCertSearch"
placeholder={labels.SEARCH_CERTS}
onChange={onChange}
isClearable={true}
aria-label={labels.SEARCH_CERTS}
/>
);
};

View file

@ -0,0 +1,49 @@
/*
* 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 React from 'react';
import { EuiHealth } from '@elastic/eui';
import { Cert } from '../../../common/runtime_types';
import { useCertStatus } from '../../hooks';
import * as labels from './translations';
import { CERT_STATUS } from '../../../common/constants';
interface Props {
cert: Cert;
}
export const CertStatus: React.FC<Props> = ({ cert }) => {
const certStatus = useCertStatus(cert?.not_after, cert?.not_before);
if (certStatus === CERT_STATUS.EXPIRING_SOON) {
return (
<EuiHealth color="warning">
<span>{labels.EXPIRES_SOON}</span>
</EuiHealth>
);
}
if (certStatus === CERT_STATUS.EXPIRED) {
return (
<EuiHealth color="danger">
<span>{labels.EXPIRED}</span>
</EuiHealth>
);
}
if (certStatus === CERT_STATUS.TOO_OLD) {
return (
<EuiHealth color="danger">
<span>{labels.TOO_OLD}</span>
</EuiHealth>
);
}
return (
<EuiHealth color="success">
<span>{labels.OK}</span>
</EuiHealth>
);
};

View file

@ -0,0 +1,113 @@
/*
* 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 React from 'react';
import moment from 'moment';
import { useSelector } from 'react-redux';
import { Direction, EuiBasicTable } from '@elastic/eui';
import { certificatesSelector } from '../../state/certificates/certificates';
import { CertStatus } from './cert_status';
import { CertMonitors } from './cert_monitors';
import * as labels from './translations';
import { Cert, CertMonitor } from '../../../common/runtime_types';
import { FingerprintCol } from './fingerprint_col';
interface Page {
index: number;
size: number;
}
export type CertFields =
| 'sha256'
| 'sha1'
| 'issuer'
| 'common_name'
| 'monitors'
| 'not_after'
| 'not_before';
export interface CertSort {
field: CertFields;
direction: Direction;
}
interface Props {
page: Page;
sort: CertSort;
onChange: (page: Page, sort: CertSort) => void;
}
export const CertificateList: React.FC<Props> = ({ page, sort, onChange }) => {
const certificates = useSelector(certificatesSelector);
const onTableChange = (newVal: Partial<Props>) => {
onChange(newVal.page as Page, newVal.sort as CertSort);
};
const pagination = {
pageIndex: page.index,
pageSize: page.size,
totalItemCount: certificates?.total ?? 0,
pageSizeOptions: [10, 25, 50, 100],
hidePerPageOptions: false,
};
const columns = [
{
field: 'not_after',
name: labels.STATUS_COL,
sortable: true,
render: (val: string, item: Cert) => <CertStatus cert={item} />,
},
{
name: labels.COMMON_NAME_COL,
field: 'common_name',
sortable: true,
},
{
name: labels.MONITORS_COL,
field: 'monitors',
render: (monitors: CertMonitor[]) => <CertMonitors monitors={monitors} />,
},
{
name: labels.ISSUED_BY_COL,
field: 'issuer',
sortable: true,
},
{
name: labels.VALID_UNTIL_COL,
field: 'not_after',
sortable: true,
render: (value: string) => moment(value).format('L LT'),
},
{
name: labels.AGE_COL,
field: 'not_before',
sortable: true,
render: (value: string) => moment().diff(moment(value), 'days') + ' ' + labels.DAYS,
},
{
name: labels.FINGERPRINTS_COL,
field: 'sha256',
render: (val: string, item: Cert) => <FingerprintCol cert={item} />,
},
];
return (
<EuiBasicTable
columns={columns}
items={certificates?.certs ?? []}
pagination={pagination}
onChange={onTableChange}
sorting={{
sort: {
field: sort.field,
direction: sort.direction,
},
}}
/>
);
};

View file

@ -0,0 +1,46 @@
/*
* 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 React from 'react';
import { EuiButtonEmpty, EuiButtonIcon, EuiCopy, EuiToolTip } from '@elastic/eui';
import styled from 'styled-components';
import { Cert } from '../../../common/runtime_types';
import { COPY_FINGERPRINT } from './translations';
const EmptyButton = styled(EuiButtonEmpty)`
.euiButtonEmpty__content {
padding-right: 0px;
}
`;
const Span = styled.span`
margin-right: 8px;
`;
interface Props {
cert: Cert;
}
export const FingerprintCol: React.FC<Props> = ({ cert }) => {
const ShaComponent = ({ text, val }: { text: string; val: string }) => {
return (
<Span data-test-subj={val}>
<EuiToolTip content={val}>
<EmptyButton>{text} </EmptyButton>
</EuiToolTip>
<EuiCopy textToCopy={val ?? ''}>
{copy => <EuiButtonIcon onClick={copy} iconType="copy" title={COPY_FINGERPRINT} />}
</EuiCopy>
</Span>
);
};
return (
<>
<ShaComponent text="SHA 1" val={cert?.sha1?.toUpperCase() ?? ''} />
<ShaComponent text="SHA 256" val={cert?.sha256?.toUpperCase() ?? ''} />
</>
);
};

View file

@ -0,0 +1,11 @@
/*
* 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.
*/
export * from './cert_monitors';
export * from './cert_search';
export * from './cert_status';
export * from './certificates_list';
export * from './fingerprint_col';

View file

@ -0,0 +1,63 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const OK = i18n.translate('xpack.uptime.certs.ok', {
defaultMessage: 'OK',
});
export const EXPIRED = i18n.translate('xpack.uptime.certs.expired', {
defaultMessage: 'Expired',
});
export const EXPIRES_SOON = i18n.translate('xpack.uptime.certs.expireSoon', {
defaultMessage: 'Expires soon',
});
export const SEARCH_CERTS = i18n.translate('xpack.uptime.certs.searchCerts', {
defaultMessage: 'Search certificates',
});
export const STATUS_COL = i18n.translate('xpack.uptime.certs.list.status', {
defaultMessage: 'Status',
});
export const TOO_OLD = i18n.translate('xpack.uptime.certs.list.status.old', {
defaultMessage: 'Too old',
});
export const COMMON_NAME_COL = i18n.translate('xpack.uptime.certs.list.commonName', {
defaultMessage: 'Common name',
});
export const MONITORS_COL = i18n.translate('xpack.uptime.certs.list.monitors', {
defaultMessage: 'Monitors',
});
export const ISSUED_BY_COL = i18n.translate('xpack.uptime.certs.list.issuedBy', {
defaultMessage: 'Issued by',
});
export const VALID_UNTIL_COL = i18n.translate('xpack.uptime.certs.list.validUntil', {
defaultMessage: 'Valid until',
});
export const AGE_COL = i18n.translate('xpack.uptime.certs.list.ageCol', {
defaultMessage: 'Age',
});
export const DAYS = i18n.translate('xpack.uptime.certs.list.days', {
defaultMessage: 'days',
});
export const FINGERPRINTS_COL = i18n.translate('xpack.uptime.certs.list.expirationDate', {
defaultMessage: 'Fingerprints',
});
export const COPY_FINGERPRINT = i18n.translate('xpack.uptime.certs.list.copyFingerprint', {
defaultMessage: 'Click to copy fingerprint value',
});

View file

@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import React from 'react';
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { MonitorPageLink } from '../monitor_page_link';
describe('MonitorPageLink component', () => {

View file

@ -4,9 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
import { EuiLink } from '@elastic/eui';
import { Link } from 'react-router-dom';
import React, { FunctionComponent } from 'react';
interface DetailPageLinkProps {
/**
@ -19,7 +19,7 @@ interface DetailPageLinkProps {
linkParameters: string | undefined;
}
export const MonitorPageLink: FunctionComponent<DetailPageLinkProps> = ({
export const MonitorPageLink: FC<DetailPageLinkProps> = ({
children,
monitorId,
linkParameters,

View file

@ -1,31 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MonitorStatusBar component renders 1`] = `
Array [
<div
class="euiSpacer euiSpacer--s"
/>,
<div
aria-label="SSL certificate expires in 2 months"
class="euiText euiText--small euiText--constrainedWidth"
>
SSL certificate expires
<span
class="euiBadge euiBadge--iconLeft"
style="background-color:#d3dae6;color:#000"
>
<span
class="euiBadge__content"
>
<span
class="euiBadge__text"
>
in 2 months
</span>
</span>
</span>
</div>,
]
`;
exports[`MonitorStatusBar component renders null if invalid date 1`] = `null`;

View file

@ -0,0 +1,119 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SSL Certificate component renders 1`] = `
Array [
<div
class="euiText euiText--medium"
>
Certificate
</div>,
<div
class="euiSpacer euiSpacer--s"
/>,
<div
class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
aria-label="Expires in 2 months"
class="euiText euiText--small eui-displayInline euiText--constrainedWidth"
>
Expires
<span
class="euiBadge euiBadge--iconLeft"
style="background-color:#d3dae6;color:#000"
>
<span
class="euiBadge__content"
>
<span
class="euiBadge__text"
>
in 2 months
</span>
</span>
</span>
</div>
</div>
<div
class="euiFlexItem"
>
<a
class="eui-displayInline"
href="/certificates"
>
<div
class="euiText euiText--medium"
>
Certificate overview
</div>
</a>
</div>
</div>,
]
`;
exports[`SSL Certificate component renders null if invalid date 1`] = `null`;
exports[`SSL Certificate component shallow renders 1`] = `
<ContextProvider
value={
Object {
"history": Object {
"action": "POP",
"block": [Function],
"canGo": [Function],
"createHref": [Function],
"entries": Array [
Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"index": 0,
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
},
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"match": Object {
"isExact": true,
"params": Object {},
"path": "/",
"url": "/",
},
"staticContext": undefined,
}
}
>
<MonitorSSLCertificate
tls={
Object {
"not_after": "2020-04-24T11:41:38.200Z",
}
}
/>
</ContextProvider>
`;

View file

@ -9,6 +9,7 @@ import React from 'react';
import { renderWithIntl } from 'test_utils/enzyme_helpers';
import { MonitorStatusBarComponent } from '../monitor_status_bar';
import { Ping } from '../../../../../common/runtime_types';
import * as redux from 'react-redux';
describe('MonitorStatusBar component', () => {
let monitorStatus: Ping;
@ -46,6 +47,12 @@ describe('MonitorStatusBar component', () => {
},
],
};
const spy = jest.spyOn(redux, 'useDispatch');
spy.mockReturnValue(jest.fn());
const spy1 = jest.spyOn(redux, 'useSelector');
spy1.mockReturnValue(true);
});
it('renders duration in ms, not us', () => {

View file

@ -6,13 +6,14 @@
import React from 'react';
import moment from 'moment';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { EuiBadge } from '@elastic/eui';
import { renderWithIntl } from 'test_utils/enzyme_helpers';
import { Tls } from '../../../../../common/runtime_types';
import { MonitorSSLCertificate } from '../monitor_status_bar';
import * as redux from 'react-redux';
import { mountWithRouter, renderWithRouter, shallowWithRouter } from '../../../../lib';
import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../../common/constants';
describe('MonitorStatusBar component', () => {
describe('SSL Certificate component', () => {
let monitorTls: Tls;
beforeEach(() => {
@ -21,37 +22,52 @@ describe('MonitorStatusBar component', () => {
.toString();
monitorTls = {
certificate_not_valid_after: dateInTwoMonths,
not_after: dateInTwoMonths,
};
const useDispatchSpy = jest.spyOn(redux, 'useDispatch');
useDispatchSpy.mockReturnValue(jest.fn());
const useSelectorSpy = jest.spyOn(redux, 'useSelector');
useSelectorSpy.mockReturnValue({ settings: DYNAMIC_SETTINGS_DEFAULTS });
});
it('shallow renders', () => {
const monitorTls1 = {
not_after: '2020-04-24T11:41:38.200Z',
};
const component = shallowWithRouter(<MonitorSSLCertificate tls={monitorTls1} />);
expect(component).toMatchSnapshot();
});
it('renders', () => {
const component = renderWithIntl(<MonitorSSLCertificate tls={monitorTls} />);
const component = renderWithRouter(<MonitorSSLCertificate tls={monitorTls} />);
expect(component).toMatchSnapshot();
});
it('renders null if invalid date', () => {
monitorTls = {
certificate_not_valid_after: 'i am so invalid date',
not_after: 'i am so invalid date',
};
const component = renderWithIntl(<MonitorSSLCertificate tls={monitorTls} />);
const component = renderWithRouter(<MonitorSSLCertificate tls={monitorTls} />);
expect(component).toMatchSnapshot();
});
it('renders expiration date with a warning state if ssl expiry date is less than 30 days', () => {
const dateIn15Days = moment()
.add(15, 'day')
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 = {
certificate_not_valid_after: dateIn15Days,
not_after: dateIn5Days,
};
const component = mountWithIntl(<MonitorSSLCertificate tls={monitorTls} />);
const component = mountWithRouter(<MonitorSSLCertificate tls={monitorTls} />);
const badgeComponent = component.find(EuiBadge);
expect(badgeComponent.props().color).toBe('warning');
const badgeComponentText = component.find('.euiBadge__text');
expect(badgeComponentText.text()).toBe(moment(dateIn15Days).fromNow());
expect(badgeComponentText.text()).toBe(moment(dateIn5Days).fromNow());
expect(badgeComponent.find('span.euiBadge--warning')).toBeTruthy();
});
@ -61,9 +77,9 @@ describe('MonitorStatusBar component', () => {
.add(40, 'day')
.toString();
monitorTls = {
certificate_not_valid_after: dateIn40Days,
not_after: dateIn40Days,
};
const component = mountWithIntl(<MonitorSSLCertificate tls={monitorTls} />);
const component = mountWithRouter(<MonitorSSLCertificate tls={monitorTls} />);
const badgeComponent = component.find(EuiBadge);
expect(badgeComponent.props().color).toBe('default');

View file

@ -6,10 +6,13 @@
import React from 'react';
import moment from 'moment';
import { EuiSpacer, EuiText, EuiBadge } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { Link } from 'react-router-dom';
import { EuiSpacer, EuiText, EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { Tls } from '../../../../../common/runtime_types';
import { useCertStatus } from '../../../../hooks';
import { CERT_STATUS, CERTIFICATES_ROUTE } from '../../../../../common/constants';
interface Props {
/**
@ -19,40 +22,79 @@ interface Props {
}
export const MonitorSSLCertificate = ({ tls }: Props) => {
const certValidityDate = new Date(tls?.certificate_not_valid_after ?? '');
const certStatus = useCertStatus(tls?.not_after);
const isValidDate = !isNaN(certValidityDate.valueOf());
const isExpiringSoon = certStatus === CERT_STATUS.EXPIRING_SOON;
const dateIn30Days = moment().add('30', 'days');
const isExpired = certStatus === CERT_STATUS.EXPIRED;
const isExpiringInMonth = isValidDate && dateIn30Days > moment(certValidityDate);
const relativeDate = moment(tls?.not_after).fromNow();
return isValidDate ? (
return certStatus ? (
<>
<EuiSpacer size="s" />
<EuiText
grow={false}
size="s"
aria-label={i18n.translate(
'xpack.uptime.monitorStatusBar.sslCertificateExpiry.label.ariaLabel',
{
defaultMessage: 'SSL certificate expires {validityDate}',
values: { validityDate: moment(certValidityDate).fromNow() },
}
)}
>
<FormattedMessage
id="xpack.uptime.monitorStatusBar.sslCertificateExpiry.badgeContent"
defaultMessage="SSL certificate expires {emphasizedText}"
values={{
emphasizedText: (
<EuiBadge color={isExpiringInMonth ? 'warning' : 'default'}>
{moment(certValidityDate).fromNow()}
</EuiBadge>
),
}}
/>
<EuiText>
{i18n.translate('xpack.uptime.monitorStatusBar.sslCertificate.title', {
defaultMessage: 'Certificate',
})}
</EuiText>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiText
className="eui-displayInline"
grow={false}
size="s"
aria-label={
isExpired
? i18n.translate(
'xpack.uptime.monitorStatusBar.sslCertificateExpired.label.ariaLabel',
{
defaultMessage: 'Expired {validityDate}',
values: { validityDate: relativeDate },
}
)
: i18n.translate(
'xpack.uptime.monitorStatusBar.sslCertificateExpiry.label.ariaLabel',
{
defaultMessage: 'Expires {validityDate}',
values: { validityDate: relativeDate },
}
)
}
>
{isExpired ? (
<FormattedMessage
id="xpack.uptime.monitorStatusBar.sslCertificateExpired.badgeContent"
defaultMessage="Expired {emphasizedText}"
values={{
emphasizedText: <EuiBadge color={'danger'}>{relativeDate}</EuiBadge>,
}}
/>
) : (
<FormattedMessage
id="xpack.uptime.monitorStatusBar.sslCertificateExpiry.badgeContent"
defaultMessage="Expires {emphasizedText}"
values={{
emphasizedText: (
<EuiBadge color={isExpiringSoon ? 'warning' : 'default'}>
{relativeDate}
</EuiBadge>
),
}}
/>
)}
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<Link to={CERTIFICATES_ROUTE} className="eui-displayInline">
<EuiText>
{i18n.translate('xpack.uptime.monitorStatusBar.sslCertificate.overview', {
defaultMessage: 'Certificate overview',
})}
</EuiText>
</Link>
</EuiFlexItem>
</EuiFlexGroup>
</>
) : null;
};

View file

@ -556,13 +556,35 @@ exports[`MonitorList component renders the monitor list 1`] = `
<div
class="euiPanel euiPanel--paddingMedium"
>
<h5
class="euiTitle euiTitle--xsmall"
>
Monitor status
</h5>
<div
class="euiSpacer euiSpacer--s"
class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem"
>
<h5
class="euiTitle euiTitle--xsmall"
>
Monitor status
</h5>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<h5
class="euiTitle euiTitle--xsmall"
>
<a
data-test-subj="uptimeCertificatesLink"
href="/certificates"
>
View certificates status
</a>
</h5>
</div>
</div>
<div
class="euiSpacer euiSpacer--m"
/>
<div
aria-label="Monitor Status table with columns for Status, Name, URL, IP, Downtime History and Integrations. The table is currently displaying 2 items."
@ -639,9 +661,25 @@ exports[`MonitorList component renders the monitor list 1`] = `
</span>
</div>
</th>
<th
class="euiTableHeaderCell"
data-test-subj="tableHeaderCell_state.tls_3"
role="columnheader"
scope="col"
>
<div
class="euiTableCellContent euiTableCellContent--alignCenter"
>
<span
class="euiTableCellContent__text"
>
TLS Certificate
</span>
</div>
</th>
<th
class="euiTableHeaderCell euiTableHeaderCell--hideForMobile"
data-test-subj="tableHeaderCell_histogram.points_3"
data-test-subj="tableHeaderCell_histogram.points_4"
role="columnheader"
scope="col"
>
@ -657,7 +695,7 @@ exports[`MonitorList component renders the monitor list 1`] = `
</th>
<td
class="euiTableHeaderCell"
data-test-subj="tableHeaderCell_monitor_id_4"
data-test-subj="tableHeaderCell_monitor_id_5"
role="columnheader"
scope="col"
style="width:24px"
@ -791,6 +829,22 @@ exports[`MonitorList component renders the monitor list 1`] = `
</button>
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
TLS Certificate
</div>
<div
class="euiTableCellContent euiTableCellContent--alignCenter euiTableCellContent--overflowingContent"
>
<span>
-
</span>
</div>
</td>
<td
class="euiTableRowCell euiTableRowCell--hideForMobile"
>
@ -951,6 +1005,22 @@ exports[`MonitorList component renders the monitor list 1`] = `
</button>
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
TLS Certificate
</div>
<div
class="euiTableCellContent euiTableCellContent--alignCenter euiTableCellContent--overflowingContent"
>
<span>
-
</span>
</div>
</td>
<td
class="euiTableRowCell euiTableRowCell--hideForMobile"
>

View file

@ -12,12 +12,19 @@ import {
} from '../../../../../common/runtime_types';
import { MonitorListComponent } from '../monitor_list';
import { renderWithRouter, shallowWithRouter } from '../../../../lib';
import * as redux from 'react-redux';
describe('MonitorList component', () => {
let result: MonitorSummaryResult;
let localStorageMock: any;
beforeEach(() => {
const useDispatchSpy = jest.spyOn(redux, 'useDispatch');
useDispatchSpy.mockReturnValue(jest.fn());
const useSelectorSpy = jest.spyOn(redux, 'useSelector');
useSelectorSpy.mockReturnValue(true);
localStorageMock = {
getItem: jest.fn().mockImplementation(() => '25'),
setItem: jest.fn(),

View file

@ -0,0 +1,51 @@
/*
* 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 React from 'react';
import moment from 'moment';
import styled from 'styled-components';
import { EuiIcon, EuiText, EuiToolTip } from '@elastic/eui';
import { Cert } from '../../../../common/runtime_types';
import { useCertStatus } from '../../../hooks';
import { EXPIRED, EXPIRES_SOON } from '../../certificates/translations';
import { CERT_STATUS } from '../../../../common/constants';
interface Props {
cert: Cert;
}
const Span = styled.span`
margin-left: 5px;
vertical-align: middle;
`;
export const CertStatusColumn: React.FC<Props> = ({ cert }) => {
const certStatus = useCertStatus(cert?.not_after);
const relativeDate = moment(cert?.not_after).fromNow();
const CertStatus = ({ color, text }: { color: string; text: string }) => {
return (
<EuiToolTip content={moment(cert?.not_after).format('L LT')}>
<EuiText size="s">
<EuiIcon color={color} type="lock" size="s" />
<Span>
{text} {relativeDate}
</Span>
</EuiText>
</EuiToolTip>
);
};
if (certStatus === CERT_STATUS.EXPIRING_SOON) {
return <CertStatus color="warning" text={EXPIRES_SOON} />;
}
if (certStatus === CERT_STATUS.EXPIRED) {
return <CertStatus color="danger" text={EXPIRED} />;
}
return certStatus ? <CertStatus color="success" text={'Expires'} /> : <span>-</span>;
};

View file

@ -18,12 +18,13 @@ import {
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { Link } from 'react-router-dom';
import { HistogramPoint, FetchMonitorStatesQueryArgs } from '../../../../common/runtime_types';
import { MonitorSummary } from '../../../../common/runtime_types';
import { MonitorListStatusColumn } from './monitor_list_status_column';
import { ExpandedRowMap } from './types';
import { MonitorBarSeries } from '../../common/charts';
import { MonitorPageLink } from './monitor_page_link';
import { MonitorPageLink } from '../../common/monitor_page_link';
import { OverviewPageLink } from './overview_page_link';
import * as labels from './translations';
import { MonitorListPageSizeSelect } from './monitor_list_page_size_select';
@ -31,6 +32,8 @@ import { MonitorListDrawer } from './monitor_list_drawer/list_drawer_container';
import { MonitorListProps } from './monitor_list_container';
import { MonitorList } from '../../../state/reducers/monitor_list';
import { useUrlParams } from '../../../hooks';
import { CERTIFICATES_ROUTE } from '../../../../common/constants';
import { CertStatusColumn } from './cert_status_column';
interface Props extends MonitorListProps {
lastRefresh: number;
@ -143,6 +146,12 @@ export const MonitorListComponent: React.FC<Props> = ({
</TruncatedEuiLink>
),
},
{
align: 'center' as const,
field: 'state.tls',
name: labels.TLS_COLUMN_LABEL,
render: (tls: any) => <CertStatusColumn cert={tls?.[0]} />,
},
{
align: 'center' as const,
field: 'histogram.points',
@ -181,15 +190,32 @@ export const MonitorListComponent: React.FC<Props> = ({
return (
<EuiPanel>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.uptime.monitorList.monitoringStatusTitle"
defaultMessage="Monitor status"
/>
</h5>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.uptime.monitorList.monitoringStatusTitle"
defaultMessage="Monitor status"
/>
</h5>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h5>
<Link to={CERTIFICATES_ROUTE} data-test-subj="uptimeCertificatesLink">
<FormattedMessage
id="xpack.uptime.monitorList.viewCertificateTitle"
defaultMessage="View certificates status"
/>
</Link>
</h5>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiBasicTable
aria-label={labels.getDescriptionLabel(items.length)}
error={error?.message}

View file

@ -7,7 +7,7 @@ import React from 'react';
import { EuiText, EuiSpacer } from '@elastic/eui';
import moment from 'moment';
import { i18n } from '@kbn/i18n';
import { MonitorPageLink } from '../monitor_page_link';
import { MonitorPageLink } from '../../../common/monitor_page_link';
import { useGetUrlParams } from '../../../../hooks';
import { stringifyUrlParams } from '../../../../lib/helper/stringify_url_params';
import { MonitorError } from '../../../../../common/runtime_types';

View file

@ -21,6 +21,10 @@ export const HISTORY_COLUMN_LABEL = i18n.translate(
}
);
export const TLS_COLUMN_LABEL = i18n.translate('xpack.uptime.monitorList.tlsColumnLabel', {
defaultMessage: 'TLS Certificate',
});
export const getExpandDrawerLabel = (id: string) => {
return i18n.translate('xpack.uptime.monitorList.expandDrawerButton.ariaLabel', {
defaultMessage: 'Expand row for monitor with ID {id}',

View file

@ -8,3 +8,4 @@ export * from './use_monitor';
export * from './use_url_params';
export * from './use_telemetry';
export * from './update_kuery_string';
export * from './use_cert_status';

View file

@ -0,0 +1,42 @@
/*
* 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 moment from 'moment';
import { useSelector } from 'react-redux';
import { selectDynamicSettings } from '../state/selectors';
import { CERT_STATUS } from '../../common/constants';
export const useCertStatus = (expiryDate?: string, issueDate?: string) => {
const dss = useSelector(selectDynamicSettings);
const expiryThreshold = dss.settings?.certThresholds?.expiration;
const ageThreshold = dss.settings?.certThresholds?.age;
const certValidityDate = new Date(expiryDate ?? '');
const isValidDate = !isNaN(certValidityDate.valueOf());
if (!isValidDate) {
return false;
}
const isExpiringSoon = moment(certValidityDate).diff(moment(), 'days') < expiryThreshold!;
const isTooOld = moment().diff(moment(issueDate), 'days') > ageThreshold!;
const isExpired = moment(certValidityDate) < moment();
if (isExpired) {
return CERT_STATUS.EXPIRED;
}
return isExpiringSoon
? CERT_STATUS.EXPIRING_SOON
: isTooOld
? CERT_STATUS.TOO_OLD
: CERT_STATUS.OK;
};

View file

@ -13,6 +13,7 @@ export enum UptimePage {
Overview = 'Overview',
Monitor = 'Monitor',
Settings = 'Settings',
Certificates = 'Certificates',
NotFound = '__not-found__',
}

View file

@ -0,0 +1,56 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CertificatesPage shallow renders expected elements for valid props 1`] = `
<ContextProvider
value={
Object {
"history": Object {
"action": "POP",
"block": [Function],
"canGo": [Function],
"createHref": [Function],
"entries": Array [
Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"index": 0,
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
},
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"match": Object {
"isExact": true,
"params": Object {},
"path": "/",
"url": "/",
},
"staticContext": undefined,
}
}
>
<CertificatesPage />
</ContextProvider>
`;

View file

@ -0,0 +1,15 @@
/*
* 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 React from 'react';
import { shallowWithRouter } from '../../lib';
import { CertificatesPage } from '../certificates';
describe('CertificatesPage', () => {
it('shallow renders expected elements for valid props', () => {
expect(shallowWithRouter(<CertificatesPage />)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,136 @@
/*
* 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 { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiSpacer,
} from '@elastic/eui';
import React, { useContext, useEffect, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { useTrackPageview } from '../../../observability/public';
import { PageHeader } from './page_header';
import { useBreadcrumbs } from '../hooks/use_breadcrumbs';
import { OVERVIEW_ROUTE, SETTINGS_ROUTE } from '../../common/constants';
import { getDynamicSettings } from '../state/actions/dynamic_settings';
import { UptimeRefreshContext } from '../contexts';
import * as labels from './translations';
import { UptimePage, useUptimeTelemetry } from '../hooks';
import { certificatesSelector, getCertificatesAction } from '../state/certificates/certificates';
import { CertificateList, CertificateSearch, CertSort } from '../components/certificates';
const DEFAULT_PAGE_SIZE = 10;
const LOCAL_STORAGE_KEY = 'xpack.uptime.certList.pageSize';
const getPageSizeValue = () => {
const value = parseInt(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '', 10);
if (isNaN(value)) {
return DEFAULT_PAGE_SIZE;
}
return value;
};
export const CertificatesPage: React.FC = () => {
useUptimeTelemetry(UptimePage.Certificates);
useTrackPageview({ app: 'uptime', path: 'certificates' });
useTrackPageview({ app: 'uptime', path: 'certificates', delay: 15000 });
useBreadcrumbs([{ text: 'Certificates' }]);
const [page, setPage] = useState({ index: 0, size: getPageSizeValue() });
const [sort, setSort] = useState<CertSort>({
field: 'not_after',
direction: 'asc',
});
const [search, setSearch] = useState('');
const dispatch = useDispatch();
const { lastRefresh, refreshApp } = useContext(UptimeRefreshContext);
useEffect(() => {
dispatch(getDynamicSettings());
}, [dispatch]);
useEffect(() => {
dispatch(
getCertificatesAction.get({
search,
...page,
sortBy: sort.field,
direction: sort.direction,
})
);
}, [dispatch, page, search, sort.direction, sort.field, lastRefresh]);
const certificates = useSelector(certificatesSelector);
return (
<>
<EuiFlexGroup>
<EuiFlexItem grow={false} style={{ marginRight: 'auto', alignSelf: 'center' }}>
<Link to={OVERVIEW_ROUTE} data-test-subj="uptimeCertificatesToOverviewLink">
<EuiButtonEmpty size="s" color="primary" iconType="arrowLeft">
{labels.RETURN_TO_OVERVIEW}
</EuiButtonEmpty>
</Link>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<Link to={SETTINGS_ROUTE} data-test-subj="uptimeCertificatesToOverviewLink">
<EuiButtonEmpty size="s" color="primary" iconType="gear">
{labels.SETTINGS_ON_CERT}
</EuiButtonEmpty>
</Link>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
iconType="refresh"
onClick={() => {
refreshApp();
}}
data-test-subj="superDatePickerApplyTimeButton"
>
{labels.REFRESH_CERT}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiPanel>
<PageHeader
headingText={
<FormattedMessage
id="xpack.uptime.certificates.heading"
defaultMessage="TLS Certificates ({total})"
values={{
total: <span data-test-subj="uptimeCertTotal">{certificates?.total ?? 0}</span>,
}}
/>
}
datePicker={false}
/>
<EuiSpacer size="m" />
<CertificateSearch setSearch={setSearch} />
<EuiSpacer size="m" />
<CertificateList
page={page}
onChange={(pageVal, sortVal) => {
setPage(pageVal);
setSort(sortVal);
localStorage.setItem(LOCAL_STORAGE_KEY, pageVal.size.toString());
}}
sort={sort}
/>
</EuiPanel>
</>
);
};

View file

@ -5,8 +5,8 @@
*/
import { EuiSpacer } from '@elastic/eui';
import React from 'react';
import { useSelector } from 'react-redux';
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { monitorStatusSelector } from '../state/selectors';
import { PageHeader } from './page_header';
import { useBreadcrumbs } from '../hooks/use_breadcrumbs';
@ -14,8 +14,15 @@ import { useTrackPageview } from '../../../observability/public';
import { useMonitorId, useUptimeTelemetry, UptimePage } from '../hooks';
import { MonitorCharts } from '../components/monitor';
import { MonitorStatusDetails, PingList } from '../components/monitor';
import { getDynamicSettings } from '../state/actions/dynamic_settings';
export const MonitorPage: React.FC = () => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(getDynamicSettings());
}, [dispatch]);
const monitorId = useMonitorId();
const selectedMonitor = useSelector(monitorStatusSelector);

View file

@ -13,7 +13,7 @@ import { SETTINGS_ROUTE } from '../../common/constants';
import { ToggleAlertFlyoutButton } from '../components/overview/alerts/alerts_containers';
interface PageHeaderProps {
headingText: string;
headingText: string | JSX.Element;
extraLinks?: boolean;
datePicker?: boolean;
}

View file

@ -6,6 +6,21 @@
import { i18n } from '@kbn/i18n';
export const SETTINGS_ON_CERT = i18n.translate('xpack.uptime.certificates.settingsLinkLabel', {
defaultMessage: 'Settings',
});
export const RETURN_TO_OVERVIEW = i18n.translate(
'xpack.uptime.certificates.returnToOverviewLinkLabel',
{
defaultMessage: 'Return to overview',
}
);
export const REFRESH_CERT = i18n.translate('xpack.uptime.certificates.refresh', {
defaultMessage: 'Refresh',
});
export const settings = {
breadcrumbText: i18n.translate('xpack.uptime.settingsBreadcrumbText', {
defaultMessage: 'Settings',

View file

@ -8,8 +8,14 @@ import React, { FC } from 'react';
import { Route, Switch } from 'react-router-dom';
import { DataPublicPluginSetup } from '../../../../src/plugins/data/public';
import { OverviewPage } from './components/overview/overview_container';
import { MONITOR_ROUTE, OVERVIEW_ROUTE, SETTINGS_ROUTE } from '../common/constants';
import {
CERTIFICATES_ROUTE,
MONITOR_ROUTE,
OVERVIEW_ROUTE,
SETTINGS_ROUTE,
} from '../common/constants';
import { MonitorPage, NotFoundPage, SettingsPage } from './pages';
import { CertificatesPage } from './pages/certificates';
interface RouterProps {
autocomplete: DataPublicPluginSetup['autocomplete'];
@ -27,6 +33,11 @@ export const PageRouter: FC<RouterProps> = ({ autocomplete }) => (
<SettingsPage />
</div>
</Route>
<Route path={CERTIFICATES_ROUTE}>
<div data-test-subj="uptimeCertificatesPage">
<CertificatesPage />
</div>
</Route>
<Route path={OVERVIEW_ROUTE}>
<div data-test-subj="uptimeOverviewPage">
<OverviewPage autocomplete={autocomplete} />

View file

@ -0,0 +1,13 @@
/*
* 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 { API_URLS } from '../../../common/constants';
import { apiService } from './utils';
import { CertResultType, GetCertsParams } from '../../../common/runtime_types';
export const fetchCertificates = async (params: GetCertsParams) => {
return await apiService.get(API_URLS.CERTS, params, CertResultType);
};

View file

@ -0,0 +1,43 @@
/*
* 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 { handleActions } from 'redux-actions';
import { takeLatest } from 'redux-saga/effects';
import { createAsyncAction } from '../actions/utils';
import { getAsyncInitialState, handleAsyncAction } from '../reducers/utils';
import { CertResult, GetCertsParams } from '../../../common/runtime_types';
import { AppState } from '../index';
import { AsyncInitialState } from '../reducers/types';
import { fetchEffectFactory } from '../effects/fetch_effect';
import { fetchCertificates } from '../api/certificates';
export const getCertificatesAction = createAsyncAction<GetCertsParams, CertResult>(
'GET_CERTIFICATES'
);
interface CertificatesState {
certs: AsyncInitialState<CertResult>;
}
const initialState = {
certs: getAsyncInitialState(),
};
export const certificatesReducer = handleActions<CertificatesState>(
{
...handleAsyncAction<CertificatesState>('certs', getCertificatesAction),
},
initialState
);
export function* fetchCertificatesEffect() {
yield takeLatest(
getCertificatesAction.get,
fetchEffectFactory(fetchCertificates, getCertificatesAction.success, getCertificatesAction.fail)
);
}
export const certificatesSelector = ({ certificates }: AppState) => certificates.certs.data;

View file

@ -16,6 +16,7 @@ import { fetchPingsEffect, fetchPingHistogramEffect } from './ping';
import { fetchMonitorDurationEffect } from './monitor_duration';
import { fetchMLJobEffect } from './ml_anomaly';
import { fetchIndexStatusEffect } from './index_status';
import { fetchCertificatesEffect } from '../certificates/certificates';
export function* rootEffect() {
yield fork(fetchMonitorDetailsEffect);
@ -31,4 +32,5 @@ export function* rootEffect() {
yield fork(fetchMLJobEffect);
yield fork(fetchMonitorDurationEffect);
yield fork(fetchIndexStatusEffect);
yield fork(fetchCertificatesEffect);
}

View file

@ -18,6 +18,7 @@ import { pingListReducer } from './ping_list';
import { monitorDurationReducer } from './monitor_duration';
import { indexStatusReducer } from './index_status';
import { mlJobsReducer } from './ml_anomaly';
import { certificatesReducer } from '../certificates/certificates';
export const rootReducer = combineReducers({
monitor: monitorReducer,
@ -33,4 +34,5 @@ export const rootReducer = combineReducers({
ml: mlJobsReducer,
monitorDuration: monitorDurationReducer,
indexStatus: indexStatusReducer,
certificates: certificatesReducer,
});

View file

@ -15,7 +15,6 @@ import {
getMLCapabilitiesAction,
} from '../actions';
import { getAsyncInitialState, handleAsyncAction } from './utils';
import { IHttpFetchError } from '../../../../../../target/types/core/public/http';
import { AsyncInitialState } from './types';
import { MlCapabilitiesResponse } from '../../../../../plugins/ml/common/types/capabilities';
import { CreateMLJobSuccess, DeleteJobResults } from '../actions/types';
@ -37,15 +36,13 @@ const initialState: MLJobState = {
mlCapabilities: getAsyncInitialState(),
};
type Payload = IHttpFetchError;
export const mlJobsReducer = handleActions<MLJobState>(
{
...handleAsyncAction<MLJobState, Payload>('mlJob', getExistingMLJobAction),
...handleAsyncAction<MLJobState, Payload>('mlCapabilities', getMLCapabilitiesAction),
...handleAsyncAction<MLJobState, Payload>('createJob', createMLJobAction),
...handleAsyncAction<MLJobState, Payload>('deleteJob', deleteMLJobAction),
...handleAsyncAction<MLJobState, Payload>('anomalies', getAnomalyRecordsAction),
...handleAsyncAction<MLJobState>('mlJob', getExistingMLJobAction),
...handleAsyncAction<MLJobState>('mlCapabilities', getMLCapabilitiesAction),
...handleAsyncAction<MLJobState>('createJob', createMLJobAction),
...handleAsyncAction<MLJobState>('deleteJob', deleteMLJobAction),
...handleAsyncAction<MLJobState>('anomalies', getAnomalyRecordsAction),
...{
[String(resetMLState)]: state => ({
...state,

View file

@ -9,9 +9,9 @@ import { getMonitorList, getMonitorListSuccess, getMonitorListFailure } from '..
import { MonitorSummaryResult } from '../../../common/runtime_types';
export interface MonitorList {
list: MonitorSummaryResult;
error?: Error;
loading: boolean;
list: MonitorSummaryResult;
}
export const initialState: MonitorList = {

View file

@ -7,7 +7,7 @@
import { Action } from 'redux-actions';
import { AsyncAction } from '../actions/types';
export function handleAsyncAction<ReducerState, Payload>(
export function handleAsyncAction<ReducerState>(
storeKey: string,
asyncAction: AsyncAction<any, any>
) {
@ -24,7 +24,7 @@ export function handleAsyncAction<ReducerState, Payload>(
...state,
[storeKey]: {
...(state as any)[storeKey],
data: action.payload === null ? action.payload : { ...action.payload },
data: action.payload,
loading: false,
},
}),

View file

@ -101,6 +101,12 @@ describe('state selectors', () => {
loading: false,
},
},
certificates: {
certs: {
data: null,
loading: false,
},
},
};
it('selects base path from state', () => {

View file

@ -19,9 +19,10 @@ describe('getCerts', () => {
_score: 0,
_source: {
tls: {
certificate_not_valid_before: '2019-08-16T01:40:25.000Z',
server: {
x509: {
not_before: '2019-08-16T01:40:25.000Z',
not_after: '2020-07-16T03:15:39.000Z',
subject: {
common_name: 'r2.shared.global.fastly.net',
},
@ -34,12 +35,14 @@ describe('getCerts', () => {
sha256: '12b00d04db0db8caa302bfde043e88f95baceb91e86ac143e93830b4bbec726d',
},
},
certificate_not_valid_after: '2020-07-16T03:15:39.000Z',
},
monitor: {
name: 'Real World Test',
id: 'real-world-test',
},
url: {
full: 'https://fullurl.com',
},
},
fields: {
'tls.server.hash.sha256': [
@ -96,24 +99,30 @@ describe('getCerts', () => {
to: 'now+1h',
search: 'my_common_name',
size: 30,
sortBy: 'not_after',
direction: 'desc',
});
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"certificate_not_valid_after": "2020-07-16T03:15:39.000Z",
"certificate_not_valid_before": "2019-08-16T01:40:25.000Z",
"common_name": "r2.shared.global.fastly.net",
"issuer": "GlobalSign CloudSSL CA - SHA256 - G3",
"monitors": Array [
Object {
"id": "real-world-test",
"name": "Real World Test",
},
],
"sha1": "b7b4b89ef0d0caf39d223736f0fdbb03c7b426f1",
"sha256": "12b00d04db0db8caa302bfde043e88f95baceb91e86ac143e93830b4bbec726d",
},
]
Object {
"certs": Array [
Object {
"common_name": "r2.shared.global.fastly.net",
"issuer": "GlobalSign CloudSSL CA - SHA256 - G3",
"monitors": Array [
Object {
"id": "real-world-test",
"name": "Real World Test",
"url": undefined,
},
],
"not_after": "2020-07-16T03:15:39.000Z",
"not_before": "2019-08-16T01:40:25.000Z",
"sha1": "b7b4b89ef0d0caf39d223736f0fdbb03c7b426f1",
"sha256": "12b00d04db0db8caa302bfde043e88f95baceb91e86ac143e93830b4bbec726d",
},
],
"total": 0,
}
`);
expect(mockCallES.mock.calls).toMatchInlineSnapshot(`
Array [
@ -128,9 +137,16 @@ describe('getCerts', () => {
"tls.server.x509.subject.common_name",
"tls.server.hash.sha1",
"tls.server.hash.sha256",
"tls.certificate_not_valid_before",
"tls.certificate_not_valid_after",
"tls.server.x509.not_after",
"tls.server.x509.not_before",
],
"aggs": Object {
"total": Object {
"cardinality": Object {
"field": "tls.server.hash.sha256",
},
},
},
"collapse": Object {
"field": "tls.server.hash.sha256",
"inner_hits": Object {
@ -138,6 +154,7 @@ describe('getCerts', () => {
"includes": Array [
"monitor.id",
"monitor.name",
"url.full",
],
},
"collapse": Object {
@ -151,13 +168,13 @@ describe('getCerts', () => {
],
},
},
"from": 1,
"from": 30,
"query": Object {
"bool": Object {
"filter": Array [
Object {
"exists": Object {
"field": "tls",
"field": "tls.server",
},
},
Object {
@ -169,39 +186,32 @@ describe('getCerts', () => {
},
},
],
"minimum_should_match": 1,
"should": Array [
Object {
"wildcard": Object {
"tls.server.issuer": Object {
"value": "*my_common_name*",
},
},
},
Object {
"wildcard": Object {
"tls.common_name": Object {
"value": "*my_common_name*",
},
},
},
Object {
"wildcard": Object {
"monitor.id": Object {
"value": "*my_common_name*",
},
},
},
Object {
"wildcard": Object {
"monitor.name": Object {
"value": "*my_common_name*",
},
"multi_match": Object {
"fields": Array [
"monitor.id.text",
"monitor.name.text",
"url.full.text",
"tls.server.x509.subject.common_name.text",
"tls.server.x509.issuer.common_name.text",
],
"query": "my_common_name",
"type": "phrase_prefix",
},
},
],
},
},
"size": 30,
"sort": Array [
Object {
"tls.server.x509.not_after": Object {
"order": "desc",
},
},
],
},
"index": "heartbeat*",
},

View file

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

View file

@ -5,9 +5,16 @@
*/
import { UMElasticsearchQueryFn } from '../adapters';
import { Cert, GetCertsParams } from '../../../common/runtime_types';
import { CertResult, GetCertsParams } from '../../../common/runtime_types';
export const getCerts: UMElasticsearchQueryFn<GetCertsParams, Cert[]> = async ({
enum SortFields {
'issuer' = 'tls.server.x509.issuer.common_name',
'not_after' = 'tls.server.x509.not_after',
'not_before' = 'tls.server.x509.not_before',
'common_name' = 'tls.server.x509.subject.common_name',
}
export const getCerts: UMElasticsearchQueryFn<GetCertsParams, CertResult> = async ({
callES,
dynamicSettings,
index,
@ -15,19 +22,29 @@ export const getCerts: UMElasticsearchQueryFn<GetCertsParams, Cert[]> = async ({
to,
search,
size,
sortBy,
direction,
}) => {
const searchWrapper = `*${search}*`;
const sort = SortFields[sortBy as keyof typeof SortFields];
const params: any = {
index: dynamicSettings.heartbeatIndices,
body: {
from: index,
from: index * size,
size,
sort: [
{
[sort]: {
order: direction,
},
},
],
query: {
bool: {
filter: [
{
exists: {
field: 'tls',
field: 'tls.server',
},
},
{
@ -48,14 +65,14 @@ export const getCerts: UMElasticsearchQueryFn<GetCertsParams, Cert[]> = async ({
'tls.server.x509.subject.common_name',
'tls.server.hash.sha1',
'tls.server.hash.sha256',
'tls.certificate_not_valid_before',
'tls.certificate_not_valid_after',
'tls.server.x509.not_after',
'tls.server.x509.not_before',
],
collapse: {
field: 'tls.server.hash.sha256',
inner_hits: {
_source: {
includes: ['monitor.id', 'monitor.name'],
includes: ['monitor.id', 'monitor.name', 'url.full'],
},
collapse: {
field: 'monitor.id',
@ -64,72 +81,67 @@ export const getCerts: UMElasticsearchQueryFn<GetCertsParams, Cert[]> = async ({
sort: [{ 'monitor.id': 'asc' }],
},
},
aggs: {
total: {
cardinality: {
field: 'tls.server.hash.sha256',
},
},
},
},
};
if (search) {
params.body.query.bool.minimum_should_match = 1;
params.body.query.bool.should = [
{
wildcard: {
'tls.server.issuer': {
value: searchWrapper,
},
},
},
{
wildcard: {
'tls.common_name': {
value: searchWrapper,
},
},
},
{
wildcard: {
'monitor.id': {
value: searchWrapper,
},
},
},
{
wildcard: {
'monitor.name': {
value: searchWrapper,
},
multi_match: {
query: escape(search),
type: 'phrase_prefix',
fields: [
'monitor.id.text',
'monitor.name.text',
'url.full.text',
'tls.server.x509.subject.common_name.text',
'tls.server.x509.issuer.common_name.text',
],
},
},
];
}
const result = await callES('search', params);
const formatted = (result?.hits?.hits ?? []).map((hit: any) => {
const certs = (result?.hits?.hits ?? []).map((hit: any) => {
const {
_source: {
tls: {
server: {
x509: {
issuer: { common_name: issuer },
subject: { common_name },
},
hash: { sha1, sha256 },
},
certificate_not_valid_after,
certificate_not_valid_before,
},
tls: { server },
},
} = hit;
const notAfter = server?.x509?.not_after;
const notBefore = server?.x509?.not_before;
const issuer = server?.x509?.issuer?.common_name;
const commonName = server?.x509?.subject?.common_name;
const sha1 = server?.hash?.sha1;
const sha256 = server?.hash?.sha256;
const monitors = hit.inner_hits.monitors.hits.hits.map((monitor: any) => ({
name: monitor._source?.monitor.name,
id: monitor._source?.monitor.id,
url: monitor._source?.url?.full,
}));
return {
monitors,
certificate_not_valid_after,
certificate_not_valid_before,
issuer,
sha1,
sha256,
common_name,
not_after: notAfter,
not_before: notBefore,
common_name: commonName,
};
});
return formatted;
const total = result?.aggregations?.total?.value ?? 0;
return { certs, total };
};

View file

@ -45,7 +45,14 @@ export const getLatestMonitor: UMElasticsearchQueryFn<GetLatestMonitorParams, Pi
},
},
size: 1,
_source: ['url', 'monitor', 'observer', 'tls', '@timestamp'],
_source: [
'url',
'monitor',
'observer',
'@timestamp',
'tls.server.x509.not_after',
'tls.server.x509.not_before',
],
sort: {
'@timestamp': { order: 'desc' },
},
@ -54,8 +61,13 @@ export const getLatestMonitor: UMElasticsearchQueryFn<GetLatestMonitorParams, Pi
const result = await callES('search', params);
const doc = result.hits?.hits?.[0];
const source = doc?._source ?? {};
const docId = doc?._id ?? '';
const { tls, ...ping } = doc?._source ?? {};
return { ...source, docId, timestamp: source['@timestamp'] };
return {
...ping,
docId,
timestamp: ping['@timestamp'],
tls: { not_after: tls?.server?.x509?.not_after, not_before: tls?.server?.x509?.not_before },
};
};

View file

@ -137,11 +137,11 @@ export const enrichMonitorGroups: MonitorEnricher = async (
if (curCheck.tls == null) {
curCheck.tls = new HashMap();
}
if (!doc["tls.certificate_not_valid_after"].isEmpty()) {
curCheck.tls.certificate_not_valid_after = doc["tls.certificate_not_valid_after"][0];
if (!doc["tls.server.x509.not_after"].isEmpty()) {
curCheck.tls.not_after = doc["tls.server.x509.not_after"][0];
}
if (!doc["tls.certificate_not_valid_before"].isEmpty()) {
curCheck.tls.certificate_not_valid_before = doc["tls.certificate_not_valid_before"][0];
if (!doc["tls.server.x509.not_before"].isEmpty()) {
curCheck.tls.not_before = doc["tls.server.x509.not_before"][0];
}
state.checksByAgentIdIP[agentIdIP] = curCheck;

View file

@ -6,19 +6,20 @@
import { UMElasticsearchQueryFn } from '../adapters';
import {
HistogramResult,
Ping,
PingsResponse,
GetCertsParams,
GetPingsParams,
Cert,
OverviewFilters,
MonitorDetails,
MonitorLocations,
Snapshot,
StatesIndexStatus,
HistogramResult,
Ping,
PingsResponse,
GetCertsParams,
GetPingsParams,
CertResult,
} from '../../../common/runtime_types';
import { MonitorDurationResult } from '../../../common/types';
import {
GetFilterBarParams,
GetLatestMonitorParams,
@ -36,7 +37,7 @@ import { GetSnapshotCountParams } from './get_snapshot_counts';
type ESQ<P, R> = UMElasticsearchQueryFn<P, R>;
export interface UptimeRequests {
getCerts: ESQ<GetCertsParams, Cert[]>;
getCerts: ESQ<GetCertsParams, CertResult>;
getFilterBar: ESQ<GetFilterBarParams, OverviewFilters>;
getIndexPattern: ESQ<{}, {}>;
getLatestMonitor: ESQ<GetLatestMonitorParams, Ping>;

View file

@ -5,14 +5,16 @@
*/
import { schema } from '@kbn/config-schema';
import { UMServerLibs } from '../lib/lib';
import { UMRestApiRouteFactory } from '.';
import { API_URLS } from '../../common/constants';
import { API_URLS } from '../../../common/constants';
import { UMServerLibs } from '../../lib/lib';
import { UMRestApiRouteFactory } from '../types';
const DEFAULT_INDEX = 0;
const DEFAULT_SIZE = 25;
const DEFAULT_FROM = 'now-1d';
const DEFAULT_TO = 'now';
const DEFAULT_SORT = 'not_after';
const DEFAULT_DIRECTION = 'asc';
export const createGetCertsRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({
method: 'GET',
@ -24,30 +26,33 @@ export const createGetCertsRoute: UMRestApiRouteFactory = (libs: UMServerLibs) =
search: schema.maybe(schema.string()),
index: schema.maybe(schema.number()),
size: schema.maybe(schema.number()),
sortBy: schema.maybe(schema.string()),
direction: schema.maybe(schema.string()),
}),
},
writeAccess: false,
options: {
tags: ['access:uptime-read'],
},
handler: async ({ callES, dynamicSettings }, _context, request, response): Promise<any> => {
const index = request.query?.index ?? DEFAULT_INDEX;
const size = request.query?.size ?? DEFAULT_SIZE;
const from = request.query?.from ?? DEFAULT_FROM;
const to = request.query?.to ?? DEFAULT_TO;
const sortBy = request.query?.sortBy ?? DEFAULT_SORT;
const direction = request.query?.direction ?? DEFAULT_DIRECTION;
const { search } = request.query;
const result = await libs.requests.getCerts({
callES,
dynamicSettings,
index,
search,
size,
from,
to,
sortBy,
direction,
});
return response.ok({
body: {
certs: await libs.requests.getCerts({
callES,
dynamicSettings,
index,
search,
size,
from,
to,
}),
certs: result.certs,
total: result.total,
},
});
},

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { createGetCertsRoute } from './certs';
import { createGetCertsRoute } from './certs/certs';
import { createGetOverviewFilters } from './overview_filters';
import { createGetPingHistogramRoute, createGetPingsRoute } from './pings';
import { createGetDynamicSettingsRoute, createPostDynamicSettingsRoute } from './dynamic_settings';
@ -19,6 +19,7 @@ import {
} from './monitors';
import { createGetMonitorDurationRoute } from './monitors/monitors_durations';
import { createGetIndexPatternRoute, createGetIndexStatusRoute } from './index_state';
export * from './types';
export { createRouteWithAuth } from './create_route_with_auth';
export { uptimeRouteWrapper } from './uptime_route_wrapper';

View file

@ -21,7 +21,7 @@ export default function({ getService }: FtrProviderContext) {
describe('empty index', async () => {
it('returns empty array for no data', async () => {
const apiResponse = await supertest.get(API_URLS.CERTS);
expect(JSON.stringify(apiResponse.body)).to.eql('{"certs":[]}');
expect(JSON.stringify(apiResponse.body)).to.eql('{"certs":[],"total":0}');
});
});
@ -39,10 +39,10 @@ export default function({ getService }: FtrProviderContext) {
10000,
{
tls: {
certificate_not_valid_after: cnva,
certificate_not_valid_before: cnvb,
server: {
x509: {
not_after: cnva,
not_before: cnvb,
issuer: {
common_name: 'issuer-common-name',
},
@ -78,9 +78,12 @@ export default function({ getService }: FtrProviderContext) {
const cert = body.certs[0];
expect(Array.isArray(cert.monitors)).to.be(true);
expect(cert.monitors[0]).to.eql({ id: monitorId });
expect(cert.certificate_not_valid_after).to.eql(cnva);
expect(cert.certificate_not_valid_before).to.eql(cnvb);
expect(cert.monitors[0]).to.eql({
id: monitorId,
url: 'http://localhost:5678/pattern?r=200x5,500x1',
});
expect(cert.not_after).to.eql(cnva);
expect(cert.not_before).to.eql(cnvb);
expect(cert.common_name).to.eql('subject-common-name');
expect(cert.issuer).to.eql('issuer-common-name');
});

View file

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

View file

@ -6,119 +6,36 @@
import uuid from 'uuid';
import { merge, flattenDeep } from 'lodash';
import { makePing } from './make_ping';
import { TlsProps } from './make_tls';
const INDEX_NAME = 'heartbeat-8-generated-test';
interface CheckProps {
es: any;
monitorId?: string;
numIps?: number;
fields?: { [key: string]: any };
mogrify?: (doc: any) => any;
refresh?: boolean;
tls?: boolean | TlsProps;
}
export const makePing = async (
es: any,
monitorId: string,
fields: { [key: string]: any },
mogrify: (doc: any) => any,
refresh: boolean = true
) => {
const baseDoc = {
tcp: {
rtt: {
connect: {
us: 14687,
},
},
},
observer: {
geo: {
name: 'mpls',
location: '37.926868, -78.024902',
},
hostname: 'avc-x1e',
},
agent: {
hostname: 'avc-x1e',
id: '10730a1a-4cb7-45ce-8524-80c4820476ab',
type: 'heartbeat',
ephemeral_id: '0d9a8dc6-f604-49e3-86a0-d8f9d6f2cbad',
version: '8.0.0',
},
'@timestamp': new Date().toISOString(),
resolve: {
rtt: {
us: 350,
},
ip: '127.0.0.1',
},
ecs: {
version: '1.1.0',
},
host: {
name: 'avc-x1e',
},
http: {
rtt: {
response_header: {
us: 19349,
},
total: {
us: 48954,
},
write_request: {
us: 33,
},
content: {
us: 51,
},
validate: {
us: 19400,
},
},
response: {
status_code: 200,
body: {
bytes: 3,
hash: '27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf',
},
},
},
monitor: {
duration: {
us: 49347,
},
ip: '127.0.0.1',
id: monitorId,
check_group: uuid.v4(),
type: 'http',
status: 'up',
},
event: {
dataset: 'uptime',
},
url: {
path: '/pattern',
scheme: 'http',
port: 5678,
domain: 'localhost',
query: 'r=200x5,500x1',
full: 'http://localhost:5678/pattern?r=200x5,500x1',
},
};
const doc = mogrify(merge(baseDoc, fields));
await es.index({
index: INDEX_NAME,
refresh,
body: doc,
});
return doc;
const getRandomMonitorId = () => {
return (
'monitor-' +
Math.random()
.toString(36)
.substring(7)
);
};
export const makeCheck = async (
es: any,
monitorId: string,
numIps: number,
fields: { [key: string]: any },
mogrify: (doc: any) => any,
refresh: boolean = true
) => {
export const makeCheck = async ({
es,
monitorId = getRandomMonitorId(),
numIps = 1,
fields = {},
mogrify = d => d,
refresh = true,
tls = false,
}: CheckProps): Promise<{ monitorId: string; docs: any }> => {
const cgFields = {
monitor: {
check_group: uuid.v4(),
@ -139,7 +56,7 @@ export const makeCheck = async (
if (i === numIps - 1) {
pingFields.summary = summary;
}
const doc = await makePing(es, monitorId, pingFields, mogrify, false);
const doc = await makePing(es, monitorId, pingFields, mogrify, false, tls as any);
docs.push(doc);
// @ts-ignore
summary[doc.monitor.status]++;
@ -149,15 +66,15 @@ export const makeCheck = async (
await es.indices.refresh();
}
return docs;
return { monitorId, docs };
};
export const makeChecks = async (
es: any,
monitorId: string,
numChecks: number,
numIps: number,
every: number, // number of millis between checks
numChecks: number = 1,
numIps: number = 1,
every: number = 10000, // number of millis between checks
fields: { [key: string]: any } = {},
mogrify: (doc: any) => any = d => d,
refresh: boolean = true
@ -177,7 +94,8 @@ export const makeChecks = async (
},
},
});
checks.push(await makeCheck(es, monitorId, numIps, fields, mogrify, false));
const { docs } = await makeCheck({ es, monitorId, numIps, fields, mogrify, refresh: false });
checks.push(docs);
}
if (refresh) {

View file

@ -0,0 +1,118 @@
/*
* 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 uuid from 'uuid';
import { merge } from 'lodash';
import { makeTls, TlsProps } from './make_tls';
const INDEX_NAME = 'heartbeat-8-generated-test';
export const makePing = async (
es: any,
monitorId: string,
fields: { [key: string]: any },
mogrify: (doc: any) => any,
refresh: boolean = true,
tls: boolean | TlsProps = false
) => {
const baseDoc: any = {
tcp: {
rtt: {
connect: {
us: 14687,
},
},
},
observer: {
geo: {
name: 'mpls',
location: '37.926868, -78.024902',
},
hostname: 'avc-x1e',
},
agent: {
hostname: 'avc-x1e',
id: '10730a1a-4cb7-45ce-8524-80c4820476ab',
type: 'heartbeat',
ephemeral_id: '0d9a8dc6-f604-49e3-86a0-d8f9d6f2cbad',
version: '8.0.0',
},
'@timestamp': new Date().toISOString(),
resolve: {
rtt: {
us: 350,
},
ip: '127.0.0.1',
},
ecs: {
version: '1.1.0',
},
host: {
name: 'avc-x1e',
},
http: {
rtt: {
response_header: {
us: 19349,
},
total: {
us: 48954,
},
write_request: {
us: 33,
},
content: {
us: 51,
},
validate: {
us: 19400,
},
},
response: {
status_code: 200,
body: {
bytes: 3,
hash: '27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf',
},
},
},
monitor: {
duration: {
us: 49347,
},
ip: '127.0.0.1',
id: monitorId,
check_group: uuid.v4(),
type: 'http',
status: 'up',
},
event: {
dataset: 'uptime',
},
url: {
path: '/pattern',
scheme: 'http',
port: 5678,
domain: 'localhost',
query: 'r=200x5,500x1',
full: 'http://localhost:5678/pattern?r=200x5,500x1',
},
};
if (tls) {
baseDoc.tls = makeTls(tls as any);
}
const doc = mogrify(merge(baseDoc, fields));
await es.index({
index: INDEX_NAME,
refresh,
body: doc,
});
return doc;
};

View file

@ -0,0 +1,70 @@
/*
* 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 moment from 'moment';
import crypto from 'crypto';
export interface TlsProps {
valid?: boolean;
commonName?: string;
expiry?: string;
sha256?: string;
}
type Props = TlsProps & boolean;
// Note This is just a mock sha256 value, this doesn't actually generate actually sha 256 val
export const getSha256 = () => {
return crypto
.randomBytes(64)
.toString('hex')
.toUpperCase();
};
export const makeTls = ({ valid = true, commonName = '*.elastic.co', expiry, sha256 }: Props) => {
const expiryDate =
expiry ??
moment()
.add(valid ? 2 : -2, 'months')
.toISOString();
return {
version: '1.3',
cipher: 'TLS-AES-128-GCM-SHA256',
certificate_not_valid_before: '2020-03-01T00:00:00.000Z',
certificate_not_valid_after: expiryDate,
server: {
x509: {
not_before: '2020-03-01T00:00:00.000Z',
not_after: '2020-05-30T12:00:00.000Z',
issuer: {
distinguished_name:
'CN=DigiCert SHA2 High Assurance Server CA,OU=www.digicert.com,O=DigiCert Inc,C=US',
common_name: 'DigiCert SHA2 High Assurance Server CA',
},
subject: {
common_name: commonName,
distinguished_name: 'CN=*.facebook.com,O=Facebook Inc.,L=Menlo Park,ST=California,C=US',
},
serial_number: '10043199409725537507026285099403602396',
signature_algorithm: 'SHA256-RSA',
public_key_algorithm: 'ECDSA',
public_key_curve: 'P-256',
},
hash: {
sha256: sha256 ?? '1a48f1db13c3bd1482ba1073441e74a1bb1308dc445c88749e0dc4f1889a88a4',
sha1: '23291c758d925b9f4bb3584de3763317e94c6ce9',
},
},
established: true,
rtt: {
handshake: {
us: 33103,
},
},
version_protocol: 'tls',
};
};

View file

@ -0,0 +1,62 @@
/*
* 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 { FtrProviderContext } from '../../ftr_provider_context';
import { makeCheck } from '../../../api_integration/apis/uptime/rest/helper/make_checks';
import { getSha256 } from '../../../api_integration/apis/uptime/rest/helper/make_tls';
export default ({ getPageObjects, getService }: FtrProviderContext) => {
const { uptime } = getPageObjects(['uptime']);
const uptimeService = getService('uptime');
const es = getService('es');
describe('certificate page', function() {
before(async () => {
await uptime.goToRoot(true);
});
beforeEach(async () => {
await makeCheck({ es, tls: true });
await uptimeService.navigation.refreshApp();
});
it('can navigate to cert page', async () => {
await uptimeService.navigation.refreshApp();
await uptimeService.cert.hasViewCertButton();
await uptimeService.navigation.goToCertificates();
});
it('displays certificates', async () => {
await uptimeService.cert.hasCertificates();
});
it('displays specific certificates', async () => {
const certId = getSha256();
const { monitorId } = await makeCheck({
es,
tls: {
sha256: certId,
},
});
await uptimeService.navigation.refreshApp();
await uptimeService.cert.certificateExists({ certId, monitorId });
});
it('performs search against monitor id', async () => {
const certId = getSha256();
const { monitorId } = await makeCheck({
es,
tls: {
sha256: certId,
},
});
await uptimeService.navigation.refreshApp();
await uptimeService.cert.searchIsWorking(monitorId);
});
});
};

View file

@ -53,6 +53,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => {
loadTestFile(require.resolve('./locations'));
loadTestFile(require.resolve('./settings'));
loadTestFile(require.resolve('./certificates'));
});
describe('with real-world data', () => {
before(async () => {

View file

@ -1665,6 +1665,13 @@
},
"id": {
"type": "keyword",
"fields": {
"text": {
"type": "text",
"norms": false,
"analyzer": "simple"
}
},
"ignore_above": 1024
},
"ip": {
@ -1672,6 +1679,13 @@
},
"name": {
"type": "keyword",
"fields": {
"text": {
"type": "text",
"norms": false,
"analyzer": "simple"
}
},
"ignore_above": 1024
},
"status": {
@ -3079,10 +3093,21 @@
},
"x509": {
"properties": {
"alternative_names": {
"type": "keyword",
"ignore_above": 1024
},
"issuer": {
"properties": {
"common_name": {
"type": "keyword",
"fields": {
"text": {
"type": "text",
"norms": false,
"analyzer": "simple"
}
},
"ignore_above": 1024
},
"distinguished_name": {
@ -3092,14 +3117,16 @@
}
},
"not_after": {
"type": "keyword",
"ignore_above": 1024
"type": "date"
},
"not_before": {
"type": "date"
},
"public_key_algorithm": {
"type": "keyword",
"ignore_above": 1024
},
"public_key_algorithm": {
"public_key_curve": {
"type": "keyword",
"ignore_above": 1024
},
@ -3121,6 +3148,13 @@
"properties": {
"common_name": {
"type": "keyword",
"fields": {
"text": {
"type": "text",
"norms": false,
"analyzer": "simple"
}
},
"ignore_above": 1024
},
"distinguished_name": {
@ -3128,6 +3162,10 @@
"ignore_above": 1024
}
}
},
"version_number": {
"type": "keyword",
"ignore_above": 1024
}
}
}

View file

@ -13,8 +13,11 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo
const retry = getService('retry');
return new (class UptimePage {
public async goToRoot() {
public async goToRoot(refresh?: boolean) {
await navigation.goToUptime();
if (refresh) {
await navigation.refreshApp();
}
}
public async setDateRange(start: string, end: string) {

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export function UptimeCertProvider({ getService }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const retry = getService('retry');
const changeSearchField = async (text: string) => {
const input = await testSubjects.find('uptimeCertSearch');
await input.clearValueWithKeyboard();
await input.type(text);
};
return {
async hasViewCertButton() {
return retry.tryForTime(15000, async () => {
await testSubjects.existOrFail('uptimeCertificatesLink');
});
},
async certificateExists(cert: { certId: string; monitorId: string }) {
return retry.tryForTime(15000, async () => {
await testSubjects.existOrFail(cert.certId);
await testSubjects.existOrFail('monitor-page-link-' + cert.monitorId);
});
},
async hasCertificates(expectedTotal?: number) {
return retry.tryForTime(15000, async () => {
const totalCerts = await testSubjects.getVisibleText('uptimeCertTotal');
if (expectedTotal) {
expect(Number(totalCerts) === expectedTotal).to.eql(true);
} else {
expect(Number(totalCerts) > 0).to.eql(true);
}
});
},
async searchIsWorking(monId: string) {
const self = this;
return retry.tryForTime(15000, async () => {
await changeSearchField(monId);
await self.hasCertificates(1);
});
},
};
}

View file

@ -25,9 +25,13 @@ export function UptimeNavigationProvider({ getService, getPageObjects }: FtrProv
});
};
const refreshApp = async () => {
await testSubjects.click('superDatePickerApplyTimeButton');
};
return {
async refreshApp() {
await testSubjects.click('superDatePickerApplyTimeButton');
await refreshApp();
},
async goToUptime() {
@ -60,6 +64,13 @@ export function UptimeNavigationProvider({ getService, getPageObjects }: FtrProv
}
},
goToCertificates: async () => {
return retry.tryForTime(30 * 1000, async () => {
await testSubjects.click('uptimeCertificatesLink');
await testSubjects.existOrFail('uptimeCertificatesPage');
});
},
async loadDataAndGoToMonitorPage(dateStart: string, dateEnd: string, monitorId: string) {
await PageObjects.timePicker.setAbsoluteRange(dateStart, dateEnd);
await this.goToMonitor(monitorId);

View file

@ -12,6 +12,7 @@ import { UptimeMonitorProvider } from './monitor';
import { UptimeNavigationProvider } from './navigation';
import { UptimeAlertsProvider } from './alerts';
import { UptimeMLAnomalyProvider } from './ml_anomaly';
import { UptimeCertProvider } from './certificates';
export function UptimeProvider(context: FtrProviderContext) {
const common = UptimeCommonProvider(context);
@ -20,6 +21,7 @@ export function UptimeProvider(context: FtrProviderContext) {
const navigation = UptimeNavigationProvider(context);
const alerts = UptimeAlertsProvider(context);
const ml = UptimeMLAnomalyProvider(context);
const cert = UptimeCertProvider(context);
return {
common,
@ -28,5 +30,6 @@ export function UptimeProvider(context: FtrProviderContext) {
navigation,
alerts,
ml,
cert,
};
}