mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[RAC] Populate Observability alerts table with data from alerts indices (#96692)
* Set up Observability rule APIs * Populate alerts table with data from API * Move field map types/utils to common * Format reason/link in alert type * Format reason/link in alert type * Fix issues with tsconfigs * Storybook cleanup for example alerts * Use `MemoryRouter` in the stories and `useHistory` in the component to get the history * Replace examples with ones from "real" data * Use `() => {}` instead of `jest.fn()` in mock registry data * Store/display evaluations, add active/recovered badge * Some more story fixes * Decode rule data with type from owning registry * Use transaction type/environment in link to app * Fix type issues * Fix API tests * Undo changes in task_runner.ts * Remove Mutable<> wrappers for field map * Remove logger.debug calls in alerting es client * Add API test for recovery of alerts * Revert changes to src/core/server/http/router * Use type imports where possible * Update limits * Set limit to 100kb Co-authored-by: Nathan L Smith <smith@nlsmith.com>
This commit is contained in:
parent
5ecf09843e
commit
5bb9eecd26
110 changed files with 3379 additions and 4430 deletions
|
@ -9,3 +9,6 @@
|
|||
export { jsonRt } from './json_rt';
|
||||
export { mergeRt } from './merge_rt';
|
||||
export { strictKeysRt } from './strict_keys_rt';
|
||||
export { isoToEpochRt } from './iso_to_epoch_rt';
|
||||
export { toNumberRt } from './to_number_rt';
|
||||
export { toBooleanRt } from './to_boolean_rt';
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { isoToEpochRt } from './index';
|
|
@ -1,8 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import * as t from 'io-ts';
|
||||
|
@ -17,9 +18,7 @@ export const isoToEpochRt = new t.Type<number, string, unknown>(
|
|||
(input, context) =>
|
||||
either.chain(t.string.validate(input, context), (str) => {
|
||||
const epochDate = new Date(str).getTime();
|
||||
return isNaN(epochDate)
|
||||
? t.failure(input, context)
|
||||
: t.success(epochDate);
|
||||
return isNaN(epochDate) ? t.failure(input, context) : t.success(epochDate);
|
||||
}),
|
||||
(output) => new Date(output).toISOString()
|
||||
);
|
|
@ -1,8 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import * as t from 'io-ts';
|
|
@ -1,8 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import * as t from 'io-ts';
|
|
@ -61,6 +61,7 @@ pageLoadAssetSize:
|
|||
remoteClusters: 51327
|
||||
reporting: 183418
|
||||
rollup: 97204
|
||||
ruleRegistry: 100000
|
||||
savedObjects: 108518
|
||||
savedObjectsManagement: 101836
|
||||
savedObjectsTagging: 59482
|
||||
|
|
25
x-pack/plugins/apm/common/rules.ts
Normal file
25
x-pack/plugins/apm/common/rules.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
const plainApmRuleRegistrySettings = {
|
||||
name: 'apm',
|
||||
fieldMap: {
|
||||
'service.environment': {
|
||||
type: 'keyword',
|
||||
},
|
||||
'transaction.type': {
|
||||
type: 'keyword',
|
||||
},
|
||||
'processor.event': {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
type APMRuleRegistrySettings = typeof plainApmRuleRegistrySettings;
|
||||
|
||||
export const apmRuleRegistrySettings: APMRuleRegistrySettings = plainApmRuleRegistrySettings;
|
|
@ -7,18 +7,39 @@
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { lazy } from 'react';
|
||||
import { format } from 'url';
|
||||
import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values';
|
||||
import { asDuration, asPercent } from '../../../common/utils/formatters';
|
||||
import { AlertType } from '../../../common/alert_types';
|
||||
import { ApmPluginStartDeps } from '../../plugin';
|
||||
import { ApmRuleRegistry } from '../../plugin';
|
||||
|
||||
export function registerApmAlerts(
|
||||
alertTypeRegistry: ApmPluginStartDeps['triggersActionsUi']['alertTypeRegistry']
|
||||
) {
|
||||
alertTypeRegistry.register({
|
||||
export function registerApmAlerts(apmRuleRegistry: ApmRuleRegistry) {
|
||||
apmRuleRegistry.registerType({
|
||||
id: AlertType.ErrorCount,
|
||||
description: i18n.translate('xpack.apm.alertTypes.errorCount.description', {
|
||||
defaultMessage:
|
||||
'Alert when the number of errors in a service exceeds a defined threshold.',
|
||||
}),
|
||||
format: ({ alert }) => {
|
||||
return {
|
||||
reason: i18n.translate('xpack.apm.alertTypes.errorCount.reason', {
|
||||
defaultMessage: `Error count is greater than {threshold} (current value is {measured}) for {serviceName}`,
|
||||
values: {
|
||||
threshold: alert['kibana.observability.evaluation.threshold'],
|
||||
measured: alert['kibana.observability.evaluation.value'],
|
||||
serviceName: alert['service.name']!,
|
||||
},
|
||||
}),
|
||||
link: format({
|
||||
pathname: `/app/apm/services/${alert['service.name']!}`,
|
||||
query: {
|
||||
...(alert['service.environment']
|
||||
? { environment: alert['service.environment'] }
|
||||
: { environment: ENVIRONMENT_ALL.value }),
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
iconClass: 'bell',
|
||||
documentationUrl(docLinks) {
|
||||
return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`;
|
||||
|
@ -41,7 +62,7 @@ export function registerApmAlerts(
|
|||
),
|
||||
});
|
||||
|
||||
alertTypeRegistry.register({
|
||||
apmRuleRegistry.registerType({
|
||||
id: AlertType.TransactionDuration,
|
||||
description: i18n.translate(
|
||||
'xpack.apm.alertTypes.transactionDuration.description',
|
||||
|
@ -50,6 +71,32 @@ export function registerApmAlerts(
|
|||
'Alert when the latency of a specific transaction type in a service exceeds a defined threshold.',
|
||||
}
|
||||
),
|
||||
format: ({ alert }) => ({
|
||||
reason: i18n.translate(
|
||||
'xpack.apm.alertTypes.transactionDuration.reason',
|
||||
{
|
||||
defaultMessage: `Latency is above {threshold} (current value is {measured}) for {serviceName}`,
|
||||
values: {
|
||||
threshold: asDuration(
|
||||
alert['kibana.observability.evaluation.threshold']
|
||||
),
|
||||
measured: asDuration(
|
||||
alert['kibana.observability.evaluation.value']
|
||||
),
|
||||
serviceName: alert['service.name']!,
|
||||
},
|
||||
}
|
||||
),
|
||||
link: format({
|
||||
pathname: `/app/apm/services/${alert['service.name']!}`,
|
||||
query: {
|
||||
transactionType: alert['transaction.type']!,
|
||||
...(alert['service.environment']
|
||||
? { environment: alert['service.environment'] }
|
||||
: { environment: ENVIRONMENT_ALL.value }),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
iconClass: 'bell',
|
||||
documentationUrl(docLinks) {
|
||||
return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`;
|
||||
|
@ -75,7 +122,7 @@ export function registerApmAlerts(
|
|||
),
|
||||
});
|
||||
|
||||
alertTypeRegistry.register({
|
||||
apmRuleRegistry.registerType({
|
||||
id: AlertType.TransactionErrorRate,
|
||||
description: i18n.translate(
|
||||
'xpack.apm.alertTypes.transactionErrorRate.description',
|
||||
|
@ -84,6 +131,34 @@ export function registerApmAlerts(
|
|||
'Alert when the rate of transaction errors in a service exceeds a defined threshold.',
|
||||
}
|
||||
),
|
||||
format: ({ alert }) => ({
|
||||
reason: i18n.translate(
|
||||
'xpack.apm.alertTypes.transactionErrorRate.reason',
|
||||
{
|
||||
defaultMessage: `Transaction error rate is greater than {threshold} (current value is {measured}) for {serviceName}`,
|
||||
values: {
|
||||
threshold: asPercent(
|
||||
alert['kibana.observability.evaluation.threshold'],
|
||||
100
|
||||
),
|
||||
measured: asPercent(
|
||||
alert['kibana.observability.evaluation.value'],
|
||||
100
|
||||
),
|
||||
serviceName: alert['service.name']!,
|
||||
},
|
||||
}
|
||||
),
|
||||
link: format({
|
||||
pathname: `/app/apm/services/${alert['service.name']!}`,
|
||||
query: {
|
||||
transactionType: alert['transaction.type']!,
|
||||
...(alert['service.environment']
|
||||
? { environment: alert['service.environment'] }
|
||||
: { environment: ENVIRONMENT_ALL.value }),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
iconClass: 'bell',
|
||||
documentationUrl(docLinks) {
|
||||
return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`;
|
||||
|
@ -109,7 +184,7 @@ export function registerApmAlerts(
|
|||
),
|
||||
});
|
||||
|
||||
alertTypeRegistry.register({
|
||||
apmRuleRegistry.registerType({
|
||||
id: AlertType.TransactionDurationAnomaly,
|
||||
description: i18n.translate(
|
||||
'xpack.apm.alertTypes.transactionDurationAnomaly.description',
|
||||
|
@ -117,6 +192,28 @@ export function registerApmAlerts(
|
|||
defaultMessage: 'Alert when the latency of a service is abnormal.',
|
||||
}
|
||||
),
|
||||
format: ({ alert }) => ({
|
||||
reason: i18n.translate(
|
||||
'xpack.apm.alertTypes.transactionDurationAnomaly.reason',
|
||||
{
|
||||
defaultMessage: `{severityLevel} anomaly detected for {serviceName} (score was {measured})`,
|
||||
values: {
|
||||
serviceName: alert['service.name'],
|
||||
severityLevel: alert['kibana.rac.alert.severity.level'],
|
||||
measured: alert['kibana.observability.evaluation.value'],
|
||||
},
|
||||
}
|
||||
),
|
||||
link: format({
|
||||
pathname: `/app/apm/services/${alert['service.name']!}`,
|
||||
query: {
|
||||
transactionType: alert['transaction.type']!,
|
||||
...(alert['service.environment']
|
||||
? { environment: alert['service.environment'] }
|
||||
: { environment: ENVIRONMENT_ALL.value }),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
iconClass: 'bell',
|
||||
documentationUrl(docLinks) {
|
||||
return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`;
|
||||
|
@ -137,7 +234,7 @@ export function registerApmAlerts(
|
|||
- Type: \\{\\{context.transactionType\\}\\}
|
||||
- Environment: \\{\\{context.environment\\}\\}
|
||||
- Severity threshold: \\{\\{context.threshold\\}\\}
|
||||
- Severity value: \\{\\{context.thresholdValue\\}\\}
|
||||
- Severity value: \\{\\{context.triggerValue\\}\\}
|
||||
`,
|
||||
}
|
||||
),
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import { ConfigSchema } from '.';
|
||||
import {
|
||||
FetchDataParams,
|
||||
FormatterRuleRegistry,
|
||||
HasDataParams,
|
||||
ObservabilityPublicSetup,
|
||||
} from '../../observability/public';
|
||||
|
@ -40,8 +41,11 @@ import { EmbeddableStart } from '../../../../src/plugins/embeddable/public';
|
|||
import { registerApmAlerts } from './components/alerting/register_apm_alerts';
|
||||
import { MlPluginSetup, MlPluginStart } from '../../ml/public';
|
||||
import { MapsStartApi } from '../../maps/public';
|
||||
import { apmRuleRegistrySettings } from '../common/rules';
|
||||
|
||||
export type ApmPluginSetup = ReturnType<ApmPlugin['setup']>;
|
||||
export type ApmRuleRegistry = ApmPluginSetup['ruleRegistry'];
|
||||
|
||||
export type ApmPluginSetup = void;
|
||||
export type ApmPluginStart = void;
|
||||
|
||||
export interface ApmPluginSetupDeps {
|
||||
|
@ -52,7 +56,7 @@ export interface ApmPluginSetupDeps {
|
|||
home?: HomePublicPluginSetup;
|
||||
licensing: LicensingPluginSetup;
|
||||
triggersActionsUi: TriggersAndActionsUIPublicPluginSetup;
|
||||
observability?: ObservabilityPublicSetup;
|
||||
observability: ObservabilityPublicSetup;
|
||||
}
|
||||
|
||||
export interface ApmPluginStartDeps {
|
||||
|
@ -156,6 +160,13 @@ export class ApmPlugin implements Plugin<ApmPluginSetup, ApmPluginStart> {
|
|||
},
|
||||
});
|
||||
|
||||
const apmRuleRegistry = plugins.observability.ruleRegistry.create({
|
||||
...apmRuleRegistrySettings,
|
||||
ctor: FormatterRuleRegistry,
|
||||
});
|
||||
|
||||
registerApmAlerts(apmRuleRegistry);
|
||||
|
||||
core.application.register({
|
||||
id: 'ux',
|
||||
title: 'User Experience',
|
||||
|
@ -196,9 +207,12 @@ export class ApmPlugin implements Plugin<ApmPluginSetup, ApmPluginStart> {
|
|||
);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ruleRegistry: apmRuleRegistry,
|
||||
};
|
||||
}
|
||||
public start(core: CoreStart, plugins: ApmPluginStartDeps) {
|
||||
toggleAppLinkInNav(core, this.initializerContext.config.get());
|
||||
registerApmAlerts(plugins.triggersActionsUi.alertTypeRegistry);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -85,8 +85,14 @@ async function setIgnoreChanges() {
|
|||
}
|
||||
}
|
||||
|
||||
async function deleteApmTsConfig() {
|
||||
await unlink(path.resolve(kibanaRoot, 'x-pack/plugins/apm', 'tsconfig.json'));
|
||||
async function deleteTsConfigs() {
|
||||
const toDelete = ['apm', 'observability', 'rule_registry'];
|
||||
|
||||
for (const app of toDelete) {
|
||||
await unlink(
|
||||
path.resolve(kibanaRoot, 'x-pack/plugins', app, 'tsconfig.json')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function optimizeTsConfig() {
|
||||
|
@ -98,7 +104,7 @@ async function optimizeTsConfig() {
|
|||
|
||||
await addApmFilesToTestTsConfig();
|
||||
|
||||
await deleteApmTsConfig();
|
||||
await deleteTsConfigs();
|
||||
|
||||
await setIgnoreChanges();
|
||||
// eslint-disable-next-line no-console
|
||||
|
|
|
@ -15,6 +15,8 @@ const filesToIgnore = [
|
|||
path.resolve(kibanaRoot, 'tsconfig.json'),
|
||||
path.resolve(kibanaRoot, 'tsconfig.base.json'),
|
||||
path.resolve(kibanaRoot, 'x-pack/plugins/apm', 'tsconfig.json'),
|
||||
path.resolve(kibanaRoot, 'x-pack/plugins/observability', 'tsconfig.json'),
|
||||
path.resolve(kibanaRoot, 'x-pack/plugins/rule_registry', 'tsconfig.json'),
|
||||
path.resolve(kibanaRoot, 'x-pack/test', 'tsconfig.json'),
|
||||
];
|
||||
|
||||
|
|
|
@ -11,14 +11,17 @@ import {
|
|||
} from '../../../../../../typings/elasticsearch';
|
||||
import { AlertServices } from '../../../../alerting/server';
|
||||
|
||||
export async function alertingEsClient<TParams extends ESSearchRequest>(
|
||||
export async function alertingEsClient<TParams extends ESSearchRequest>({
|
||||
scopedClusterClient,
|
||||
params,
|
||||
}: {
|
||||
scopedClusterClient: AlertServices<
|
||||
never,
|
||||
never,
|
||||
never
|
||||
>['scopedClusterClient'],
|
||||
params: TParams
|
||||
): Promise<ESSearchResponse<unknown, TParams>> {
|
||||
>['scopedClusterClient'];
|
||||
params: TParams;
|
||||
}): Promise<ESSearchResponse<unknown, TParams>> {
|
||||
const response = await scopedClusterClient.asCurrentUser.search({
|
||||
...params,
|
||||
ignore_unavailable: true,
|
||||
|
|
|
@ -114,10 +114,10 @@ export function registerErrorCountAlertType({
|
|||
},
|
||||
};
|
||||
|
||||
const response = await alertingEsClient(
|
||||
services.scopedClusterClient,
|
||||
searchParams
|
||||
);
|
||||
const response = await alertingEsClient({
|
||||
scopedClusterClient: services.scopedClusterClient,
|
||||
params: searchParams,
|
||||
});
|
||||
|
||||
const errorCountResults =
|
||||
response.aggregations?.error_counts.buckets.map((bucket) => {
|
||||
|
@ -145,7 +145,10 @@ export function registerErrorCountAlertType({
|
|||
...(environment
|
||||
? { [SERVICE_ENVIRONMENT]: environment }
|
||||
: {}),
|
||||
[PROCESSOR_EVENT]: 'error',
|
||||
[PROCESSOR_EVENT]: ProcessorEvent.error,
|
||||
'kibana.observability.evaluation.value': errorCount,
|
||||
'kibana.observability.evaluation.threshold':
|
||||
alertParams.threshold,
|
||||
},
|
||||
})
|
||||
.scheduleActions(alertTypeConfig.defaultActionGroupId, {
|
||||
|
|
|
@ -77,8 +77,8 @@ export function registerTransactionDurationAlertType({
|
|||
|
||||
const searchParams = {
|
||||
index: indices['apm_oss.transactionIndices'],
|
||||
size: 0,
|
||||
body: {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
|
@ -112,10 +112,10 @@ export function registerTransactionDurationAlertType({
|
|||
},
|
||||
};
|
||||
|
||||
const response = await alertingEsClient(
|
||||
services.scopedClusterClient,
|
||||
searchParams
|
||||
);
|
||||
const response = await alertingEsClient({
|
||||
scopedClusterClient: services.scopedClusterClient,
|
||||
params: searchParams,
|
||||
});
|
||||
|
||||
if (!response.aggregations) {
|
||||
return {};
|
||||
|
@ -149,6 +149,10 @@ export function registerTransactionDurationAlertType({
|
|||
? { [SERVICE_ENVIRONMENT]: environmentParsed.esFieldValue }
|
||||
: {}),
|
||||
[TRANSACTION_TYPE]: alertParams.transactionType,
|
||||
[PROCESSOR_EVENT]: ProcessorEvent.transaction,
|
||||
'kibana.observability.evaluation.value': transactionDuration,
|
||||
'kibana.observability.evaluation.threshold':
|
||||
alertParams.threshold * 1000,
|
||||
},
|
||||
})
|
||||
.scheduleActions(alertTypeConfig.defaultActionGroupId, {
|
||||
|
|
|
@ -9,8 +9,10 @@ import { schema } from '@kbn/config-schema';
|
|||
import { compact } from 'lodash';
|
||||
import { ESSearchResponse } from 'typings/elasticsearch';
|
||||
import { QueryContainer } from '@elastic/elasticsearch/api/types';
|
||||
import { ProcessorEvent } from '../../../common/processor_event';
|
||||
import { getSeverity } from '../../../common/anomaly_detection';
|
||||
import {
|
||||
PROCESSOR_EVENT,
|
||||
SERVICE_ENVIRONMENT,
|
||||
SERVICE_NAME,
|
||||
TRANSACTION_TYPE,
|
||||
|
@ -49,7 +51,6 @@ const alertTypeConfig =
|
|||
export function registerTransactionDurationAnomalyAlertType({
|
||||
registry,
|
||||
ml,
|
||||
logger,
|
||||
}: RegisterRuleDependencies) {
|
||||
registry.registerType(
|
||||
createAPMLifecycleRuleType({
|
||||
|
@ -166,7 +167,7 @@ export function registerTransactionDurationAnomalyAlertType({
|
|||
{ field: 'job_id' },
|
||||
] as const),
|
||||
sort: {
|
||||
'@timestamp': 'desc' as const,
|
||||
timestamp: 'desc' as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -189,7 +190,7 @@ export function registerTransactionDurationAnomalyAlertType({
|
|||
const job = mlJobs.find((j) => j.job_id === latest.job_id);
|
||||
|
||||
if (!job) {
|
||||
logger.warn(
|
||||
services.logger.warn(
|
||||
`Could not find matching job for job id ${latest.job_id}`
|
||||
);
|
||||
return undefined;
|
||||
|
@ -211,6 +212,8 @@ export function registerTransactionDurationAnomalyAlertType({
|
|||
|
||||
const parsedEnvironment = parseEnvironmentUrlParam(environment);
|
||||
|
||||
const severityLevel = getSeverity(score);
|
||||
|
||||
services
|
||||
.alertWithLifecycle({
|
||||
id: [
|
||||
|
@ -227,6 +230,11 @@ export function registerTransactionDurationAnomalyAlertType({
|
|||
? { [SERVICE_ENVIRONMENT]: environment }
|
||||
: {}),
|
||||
[TRANSACTION_TYPE]: transactionType,
|
||||
[PROCESSOR_EVENT]: ProcessorEvent.transaction,
|
||||
'kibana.rac.alert.severity.level': severityLevel,
|
||||
'kibana.rac.alert.severity.value': score,
|
||||
'kibana.observability.evaluation.value': score,
|
||||
'kibana.observability.evaluation.threshold': threshold,
|
||||
},
|
||||
})
|
||||
.scheduleActions(alertTypeConfig.defaultActionGroupId, {
|
||||
|
@ -234,7 +242,7 @@ export function registerTransactionDurationAnomalyAlertType({
|
|||
transactionType,
|
||||
environment,
|
||||
threshold: selectedOption?.label,
|
||||
triggerValue: getSeverity(score),
|
||||
triggerValue: severityLevel,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -129,10 +129,10 @@ export function registerTransactionErrorRateAlertType({
|
|||
},
|
||||
};
|
||||
|
||||
const response = await alertingEsClient(
|
||||
services.scopedClusterClient,
|
||||
searchParams
|
||||
);
|
||||
const response = await alertingEsClient({
|
||||
scopedClusterClient: services.scopedClusterClient,
|
||||
params: searchParams,
|
||||
});
|
||||
|
||||
if (!response.aggregations) {
|
||||
return {};
|
||||
|
@ -182,6 +182,10 @@ export function registerTransactionErrorRateAlertType({
|
|||
[SERVICE_NAME]: serviceName,
|
||||
...(environment ? { [SERVICE_ENVIRONMENT]: environment } : {}),
|
||||
[TRANSACTION_TYPE]: transactionType,
|
||||
[PROCESSOR_EVENT]: ProcessorEvent.transaction,
|
||||
'kibana.observability.evaluation.value': errorRate,
|
||||
'kibana.observability.evaluation.threshold':
|
||||
alertParams.threshold,
|
||||
},
|
||||
})
|
||||
.scheduleActions(alertTypeConfig.defaultActionGroupId, {
|
||||
|
|
|
@ -42,6 +42,7 @@ import {
|
|||
} from './types';
|
||||
import { registerRoutes } from './routes/register_routes';
|
||||
import { getGlobalApmServerRouteRepository } from './routes/get_global_apm_server_route_repository';
|
||||
import { apmRuleRegistrySettings } from '../common/rules';
|
||||
|
||||
export type APMRuleRegistry = ReturnType<APMPlugin['setup']>['ruleRegistry'];
|
||||
|
||||
|
@ -150,20 +151,9 @@ export class APMPlugin
|
|||
config: await mergedConfig$.pipe(take(1)).toPromise(),
|
||||
});
|
||||
|
||||
const apmRuleRegistry = plugins.observability.ruleRegistry.create({
|
||||
name: 'apm',
|
||||
fieldMap: {
|
||||
'service.environment': {
|
||||
type: 'keyword',
|
||||
},
|
||||
'transaction.type': {
|
||||
type: 'keyword',
|
||||
},
|
||||
'processor.event': {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
});
|
||||
const apmRuleRegistry = plugins.observability.ruleRegistry.create(
|
||||
apmRuleRegistrySettings
|
||||
);
|
||||
|
||||
registerApmAlerts({
|
||||
registry: apmRuleRegistry,
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import * as t from 'io-ts';
|
||||
import { isoToEpochRt } from '../../common/runtime_types/iso_to_epoch_rt';
|
||||
import { isoToEpochRt } from '@kbn/io-ts-utils';
|
||||
|
||||
export const rangeRt = t.type({
|
||||
start: isoToEpochRt,
|
||||
|
|
|
@ -6,13 +6,11 @@
|
|||
*/
|
||||
|
||||
import Boom from '@hapi/boom';
|
||||
import { jsonRt } from '@kbn/io-ts-utils';
|
||||
import { jsonRt, isoToEpochRt, toNumberRt } from '@kbn/io-ts-utils';
|
||||
import * as t from 'io-ts';
|
||||
import { uniq } from 'lodash';
|
||||
import { latencyAggregationTypeRt } from '../../common/latency_aggregation_types';
|
||||
import { ProfilingValueType } from '../../common/profiling';
|
||||
import { isoToEpochRt } from '../../common/runtime_types/iso_to_epoch_rt';
|
||||
import { toNumberRt } from '../../common/runtime_types/to_number_rt';
|
||||
import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions';
|
||||
import { setupRequest } from '../lib/helpers/setup_request';
|
||||
import { getServiceAnnotations } from '../lib/services/annotations';
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import * as t from 'io-ts';
|
||||
import Boom from '@hapi/boom';
|
||||
import { toBooleanRt } from '../../../common/runtime_types/to_boolean_rt';
|
||||
import { toBooleanRt } from '@kbn/io-ts-utils';
|
||||
import { setupRequest } from '../../lib/helpers/setup_request';
|
||||
import { getServiceNames } from '../../lib/settings/agent_configuration/get_service_names';
|
||||
import { createOrUpdateConfiguration } from '../../lib/settings/agent_configuration/create_or_update_configuration';
|
||||
|
|
|
@ -7,11 +7,11 @@
|
|||
|
||||
import { jsonRt } from '@kbn/io-ts-utils';
|
||||
import * as t from 'io-ts';
|
||||
import { toNumberRt } from '@kbn/io-ts-utils';
|
||||
import {
|
||||
LatencyAggregationType,
|
||||
latencyAggregationTypeRt,
|
||||
} from '../../common/latency_aggregation_types';
|
||||
import { toNumberRt } from '../../common/runtime_types/to_number_rt';
|
||||
import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions';
|
||||
import { setupRequest } from '../lib/helpers/setup_request';
|
||||
import { getServiceTransactionGroups } from '../lib/services/get_service_transaction_groups';
|
||||
|
|
|
@ -5,13 +5,13 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { esKuery } from '../../../../../src/plugins/data/server';
|
||||
import { ESFilter } from '../../../../../typings/elasticsearch';
|
||||
import { SERVICE_ENVIRONMENT } from '../../common/elasticsearch_fieldnames';
|
||||
import {
|
||||
ENVIRONMENT_ALL,
|
||||
ENVIRONMENT_NOT_DEFINED,
|
||||
} from '../../common/environment_filter_values';
|
||||
export { kqlQuery, rangeQuery } from '../../../observability/server';
|
||||
|
||||
type QueryContainer = ESFilter;
|
||||
|
||||
|
@ -26,30 +26,3 @@ export function environmentQuery(environment?: string): QueryContainer[] {
|
|||
|
||||
return [{ term: { [SERVICE_ENVIRONMENT]: environment } }];
|
||||
}
|
||||
|
||||
export function rangeQuery(
|
||||
start: number,
|
||||
end: number,
|
||||
field = '@timestamp'
|
||||
): QueryContainer[] {
|
||||
return [
|
||||
{
|
||||
range: {
|
||||
[field]: {
|
||||
gte: start,
|
||||
lte: end,
|
||||
format: 'epoch_millis',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function kqlQuery(kql?: string) {
|
||||
if (!kql) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ast = esKuery.fromKueryExpression(kql);
|
||||
return [esKuery.toElasticsearchQuery(ast) as ESFilter];
|
||||
}
|
||||
|
|
12
x-pack/plugins/observability/common/i18n.ts
Normal file
12
x-pack/plugins/observability/common/i18n.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const NOT_AVAILABLE_LABEL = i18n.translate('xpack.observability.notAvailable', {
|
||||
defaultMessage: 'N/A',
|
||||
});
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { ecsFieldMap, pickWithPatterns } from '../../rule_registry/common';
|
||||
|
||||
export const observabilityRuleRegistrySettings = {
|
||||
name: 'observability',
|
||||
fieldMap: {
|
||||
...pickWithPatterns(ecsFieldMap, 'host.name', 'service.name'),
|
||||
'kibana.observability.evaluation.value': {
|
||||
type: 'scaled_float' as const,
|
||||
scaling_factor: 1000,
|
||||
},
|
||||
'kibana.observability.evaluation.threshold': {
|
||||
type: 'scaled_float' as const,
|
||||
scaling_factor: 1000,
|
||||
},
|
||||
},
|
||||
};
|
8
x-pack/plugins/observability/common/typings.ts
Normal file
8
x-pack/plugins/observability/common/typings.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export type Maybe<T> = T | null | undefined;
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ValuesType } from 'utility-types';
|
||||
|
||||
// work around a TypeScript limitation described in https://stackoverflow.com/posts/49511416
|
||||
|
||||
export const arrayUnionToCallable = <T extends any[]>(array: T): Array<ValuesType<T>> => {
|
||||
return array;
|
||||
};
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
// Sometimes we use `as const` to have a more specific type,
|
||||
// because TypeScript by default will widen the value type of an
|
||||
// array literal. Consider the following example:
|
||||
//
|
||||
// const filter = [
|
||||
// { term: { 'agent.name': 'nodejs' } },
|
||||
// { range: { '@timestamp': { gte: 'now-15m ' }}
|
||||
// ];
|
||||
|
||||
// The result value type will be:
|
||||
|
||||
// const filter: ({
|
||||
// term: {
|
||||
// 'agent.name'?: string
|
||||
// };
|
||||
// range?: undefined
|
||||
// } | {
|
||||
// term?: undefined;
|
||||
// range: {
|
||||
// '@timestamp': {
|
||||
// gte: string
|
||||
// }
|
||||
// }
|
||||
// })[];
|
||||
|
||||
// This can sometimes leads to issues. In those cases, we can
|
||||
// use `as const`. However, the Readonly<any> type is not compatible
|
||||
// with Array<any>. This function returns a mutable version of a type.
|
||||
|
||||
export function asMutableArray<T extends Readonly<any>>(
|
||||
arr: T
|
||||
): T extends Readonly<[...infer U]> ? U : unknown[] {
|
||||
return arr as any;
|
||||
}
|
|
@ -0,0 +1,194 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import { asRelativeDateTimeRange, asAbsoluteDateTime, getDateDifference } from './datetime';
|
||||
|
||||
describe('date time formatters', () => {
|
||||
beforeAll(() => {
|
||||
moment.tz.setDefault('Europe/Amsterdam');
|
||||
});
|
||||
afterAll(() => moment.tz.setDefault(''));
|
||||
describe('asRelativeDateTimeRange', () => {
|
||||
const formatDateToTimezone = (dateTimeString: string) => moment(dateTimeString).valueOf();
|
||||
describe('YYYY - YYYY', () => {
|
||||
it('range: 10 years', () => {
|
||||
const start = formatDateToTimezone('2000-01-01 10:01:01');
|
||||
const end = formatDateToTimezone('2010-01-01 10:01:01');
|
||||
const dateRange = asRelativeDateTimeRange(start, end);
|
||||
expect(dateRange).toEqual('2000 - 2010');
|
||||
});
|
||||
it('range: 5 years', () => {
|
||||
const start = formatDateToTimezone('2010-01-01 10:01:01');
|
||||
const end = formatDateToTimezone('2015-01-01 10:01:01');
|
||||
const dateRange = asRelativeDateTimeRange(start, end);
|
||||
expect(dateRange).toEqual('2010 - 2015');
|
||||
});
|
||||
});
|
||||
describe('MMM YYYY - MMM YYYY', () => {
|
||||
it('range: 4 years ', () => {
|
||||
const start = formatDateToTimezone('2010-01-01 10:01:01');
|
||||
const end = formatDateToTimezone('2014-04-01 10:01:01');
|
||||
const dateRange = asRelativeDateTimeRange(start, end);
|
||||
expect(dateRange).toEqual('Jan 2010 - Apr 2014');
|
||||
});
|
||||
it('range: 6 months ', () => {
|
||||
const start = formatDateToTimezone('2019-01-01 10:01:01');
|
||||
const end = formatDateToTimezone('2019-07-01 10:01:01');
|
||||
const dateRange = asRelativeDateTimeRange(start, end);
|
||||
expect(dateRange).toEqual('Jan 2019 - Jul 2019');
|
||||
});
|
||||
});
|
||||
describe('MMM D, YYYY - MMM D, YYYY', () => {
|
||||
it('range: 2 days', () => {
|
||||
const start = formatDateToTimezone('2019-10-01 10:01:01');
|
||||
const end = formatDateToTimezone('2019-10-05 10:01:01');
|
||||
const dateRange = asRelativeDateTimeRange(start, end);
|
||||
expect(dateRange).toEqual('Oct 1, 2019 - Oct 5, 2019');
|
||||
});
|
||||
it('range: 1 day', () => {
|
||||
const start = formatDateToTimezone('2019-10-01 10:01:01');
|
||||
const end = formatDateToTimezone('2019-10-03 10:01:01');
|
||||
const dateRange = asRelativeDateTimeRange(start, end);
|
||||
expect(dateRange).toEqual('Oct 1, 2019 - Oct 3, 2019');
|
||||
});
|
||||
});
|
||||
describe('MMM D, YYYY, HH:mm - HH:mm (UTC)', () => {
|
||||
it('range: 9 hours', () => {
|
||||
const start = formatDateToTimezone('2019-10-29 10:01:01');
|
||||
const end = formatDateToTimezone('2019-10-29 19:01:01');
|
||||
const dateRange = asRelativeDateTimeRange(start, end);
|
||||
expect(dateRange).toEqual('Oct 29, 2019, 10:01 - 19:01 (UTC+1)');
|
||||
});
|
||||
it('range: 5 hours', () => {
|
||||
const start = formatDateToTimezone('2019-10-29 10:01:01');
|
||||
const end = formatDateToTimezone('2019-10-29 15:01:01');
|
||||
const dateRange = asRelativeDateTimeRange(start, end);
|
||||
expect(dateRange).toEqual('Oct 29, 2019, 10:01 - 15:01 (UTC+1)');
|
||||
});
|
||||
it('range: 14 minutes', () => {
|
||||
const start = formatDateToTimezone('2019-10-29 10:01:01');
|
||||
const end = formatDateToTimezone('2019-10-29 10:15:01');
|
||||
const dateRange = asRelativeDateTimeRange(start, end);
|
||||
expect(dateRange).toEqual('Oct 29, 2019, 10:01 - 10:15 (UTC+1)');
|
||||
});
|
||||
it('range: 5 minutes', () => {
|
||||
const start = formatDateToTimezone('2019-10-29 10:01:01');
|
||||
const end = formatDateToTimezone('2019-10-29 10:06:01');
|
||||
const dateRange = asRelativeDateTimeRange(start, end);
|
||||
expect(dateRange).toEqual('Oct 29, 2019, 10:01 - 10:06 (UTC+1)');
|
||||
});
|
||||
it('range: 1 minute', () => {
|
||||
const start = formatDateToTimezone('2019-10-29 10:01:01');
|
||||
const end = formatDateToTimezone('2019-10-29 10:02:01');
|
||||
const dateRange = asRelativeDateTimeRange(start, end);
|
||||
expect(dateRange).toEqual('Oct 29, 2019, 10:01 - 10:02 (UTC+1)');
|
||||
});
|
||||
});
|
||||
describe('MMM D, YYYY, HH:mm:ss - HH:mm:ss (UTC)', () => {
|
||||
it('range: 50 seconds', () => {
|
||||
const start = formatDateToTimezone('2019-10-29 10:01:01');
|
||||
const end = formatDateToTimezone('2019-10-29 10:01:50');
|
||||
const dateRange = asRelativeDateTimeRange(start, end);
|
||||
expect(dateRange).toEqual('Oct 29, 2019, 10:01:01 - 10:01:50 (UTC+1)');
|
||||
});
|
||||
it('range: 10 seconds', () => {
|
||||
const start = formatDateToTimezone('2019-10-29 10:01:01');
|
||||
const end = formatDateToTimezone('2019-10-29 10:01:11');
|
||||
const dateRange = asRelativeDateTimeRange(start, end);
|
||||
expect(dateRange).toEqual('Oct 29, 2019, 10:01:01 - 10:01:11 (UTC+1)');
|
||||
});
|
||||
});
|
||||
describe('MMM D, YYYY, HH:mm:ss.SSS - HH:mm:ss.SSS (UTC)', () => {
|
||||
it('range: 9 seconds', () => {
|
||||
const start = formatDateToTimezone('2019-10-29 10:01:01.001');
|
||||
const end = formatDateToTimezone('2019-10-29 10:01:10.002');
|
||||
const dateRange = asRelativeDateTimeRange(start, end);
|
||||
expect(dateRange).toEqual('Oct 29, 2019, 10:01:01.001 - 10:01:10.002 (UTC+1)');
|
||||
});
|
||||
it('range: 1 second', () => {
|
||||
const start = formatDateToTimezone('2019-10-29 10:01:01.001');
|
||||
const end = formatDateToTimezone('2019-10-29 10:01:02.002');
|
||||
const dateRange = asRelativeDateTimeRange(start, end);
|
||||
expect(dateRange).toEqual('Oct 29, 2019, 10:01:01.001 - 10:01:02.002 (UTC+1)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('asAbsoluteDateTime', () => {
|
||||
afterAll(() => moment.tz.setDefault(''));
|
||||
|
||||
it('should add a leading plus for timezones with positive UTC offset', () => {
|
||||
moment.tz.setDefault('Europe/Copenhagen');
|
||||
expect(asAbsoluteDateTime(1559390400000, 'minutes')).toBe('Jun 1, 2019, 14:00 (UTC+2)');
|
||||
});
|
||||
|
||||
it('should add a leading minus for timezones with negative UTC offset', () => {
|
||||
moment.tz.setDefault('America/Los_Angeles');
|
||||
expect(asAbsoluteDateTime(1559390400000, 'minutes')).toBe('Jun 1, 2019, 05:00 (UTC-7)');
|
||||
});
|
||||
|
||||
it('should use default UTC offset formatting when offset contains minutes', () => {
|
||||
moment.tz.setDefault('Canada/Newfoundland');
|
||||
expect(asAbsoluteDateTime(1559390400000, 'minutes')).toBe('Jun 1, 2019, 09:30 (UTC-02:30)');
|
||||
});
|
||||
|
||||
it('should respect DST', () => {
|
||||
moment.tz.setDefault('Europe/Copenhagen');
|
||||
const timeWithDST = 1559390400000; // Jun 1, 2019
|
||||
const timeWithoutDST = 1575201600000; // Dec 1, 2019
|
||||
|
||||
expect(asAbsoluteDateTime(timeWithDST)).toBe('Jun 1, 2019, 14:00:00.000 (UTC+2)');
|
||||
|
||||
expect(asAbsoluteDateTime(timeWithoutDST)).toBe('Dec 1, 2019, 13:00:00.000 (UTC+1)');
|
||||
});
|
||||
});
|
||||
describe('getDateDifference', () => {
|
||||
it('milliseconds', () => {
|
||||
const start = moment('2019-10-29 08:00:00.001');
|
||||
const end = moment('2019-10-29 08:00:00.005');
|
||||
expect(getDateDifference({ start, end, unitOfTime: 'milliseconds' })).toEqual(4);
|
||||
});
|
||||
it('seconds', () => {
|
||||
const start = moment('2019-10-29 08:00:00');
|
||||
const end = moment('2019-10-29 08:00:10');
|
||||
expect(getDateDifference({ start, end, unitOfTime: 'seconds' })).toEqual(10);
|
||||
});
|
||||
it('minutes', () => {
|
||||
const start = moment('2019-10-29 08:00:00');
|
||||
const end = moment('2019-10-29 08:15:00');
|
||||
expect(getDateDifference({ start, end, unitOfTime: 'minutes' })).toEqual(15);
|
||||
});
|
||||
it('hours', () => {
|
||||
const start = moment('2019-10-29 08:00:00');
|
||||
const end = moment('2019-10-29 10:00:00');
|
||||
expect(getDateDifference({ start, end, unitOfTime: 'hours' })).toEqual(2);
|
||||
});
|
||||
it('days', () => {
|
||||
const start = moment('2019-10-29 08:00:00');
|
||||
const end = moment('2019-10-30 10:00:00');
|
||||
expect(getDateDifference({ start, end, unitOfTime: 'days' })).toEqual(1);
|
||||
});
|
||||
it('months', () => {
|
||||
const start = moment('2019-10-29 08:00:00');
|
||||
const end = moment('2019-12-29 08:00:00');
|
||||
expect(getDateDifference({ start, end, unitOfTime: 'months' })).toEqual(2);
|
||||
});
|
||||
it('years', () => {
|
||||
const start = moment('2019-10-29 08:00:00');
|
||||
const end = moment('2020-10-29 08:00:00');
|
||||
expect(getDateDifference({ start, end, unitOfTime: 'years' })).toEqual(1);
|
||||
});
|
||||
it('precise days', () => {
|
||||
const start = moment('2019-10-29 08:00:00');
|
||||
const end = moment('2019-10-30 10:00:00');
|
||||
expect(getDateDifference({ start, end, unitOfTime: 'days', precise: true })).toEqual(
|
||||
1.0833333333333333
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
148
x-pack/plugins/observability/common/utils/formatters/datetime.ts
Normal file
148
x-pack/plugins/observability/common/utils/formatters/datetime.ts
Normal file
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
/**
|
||||
* Returns the timezone set on momentTime.
|
||||
* (UTC+offset) when offset if bigger than 0.
|
||||
* (UTC-offset) when offset if lower than 0.
|
||||
* @param momentTime Moment
|
||||
*/
|
||||
function formatTimezone(momentTime: moment.Moment) {
|
||||
const DEFAULT_TIMEZONE_FORMAT = 'Z';
|
||||
|
||||
const utcOffsetHours = momentTime.utcOffset() / 60;
|
||||
|
||||
const customTimezoneFormat = utcOffsetHours > 0 ? `+${utcOffsetHours}` : utcOffsetHours;
|
||||
|
||||
const utcOffsetFormatted = Number.isInteger(utcOffsetHours)
|
||||
? customTimezoneFormat
|
||||
: DEFAULT_TIMEZONE_FORMAT;
|
||||
|
||||
return momentTime.format(`(UTC${utcOffsetFormatted})`);
|
||||
}
|
||||
|
||||
export type TimeUnit = 'hours' | 'minutes' | 'seconds' | 'milliseconds';
|
||||
function getTimeFormat(timeUnit: TimeUnit) {
|
||||
switch (timeUnit) {
|
||||
case 'hours':
|
||||
return 'HH';
|
||||
case 'minutes':
|
||||
return 'HH:mm';
|
||||
case 'seconds':
|
||||
return 'HH:mm:ss';
|
||||
case 'milliseconds':
|
||||
return 'HH:mm:ss.SSS';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
type DateUnit = 'days' | 'months' | 'years';
|
||||
function getDateFormat(dateUnit: DateUnit) {
|
||||
switch (dateUnit) {
|
||||
case 'years':
|
||||
return 'YYYY';
|
||||
case 'months':
|
||||
return 'MMM YYYY';
|
||||
case 'days':
|
||||
return 'MMM D, YYYY';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export const getDateDifference = ({
|
||||
start,
|
||||
end,
|
||||
unitOfTime,
|
||||
precise,
|
||||
}: {
|
||||
start: moment.Moment;
|
||||
end: moment.Moment;
|
||||
unitOfTime: DateUnit | TimeUnit;
|
||||
precise?: boolean;
|
||||
}) => end.diff(start, unitOfTime, precise);
|
||||
|
||||
function getFormatsAccordingToDateDifference(start: moment.Moment, end: moment.Moment) {
|
||||
if (getDateDifference({ start, end, unitOfTime: 'years' }) >= 5) {
|
||||
return { dateFormat: getDateFormat('years') };
|
||||
}
|
||||
|
||||
if (getDateDifference({ start, end, unitOfTime: 'months' }) >= 5) {
|
||||
return { dateFormat: getDateFormat('months') };
|
||||
}
|
||||
|
||||
const dateFormatWithDays = getDateFormat('days');
|
||||
if (getDateDifference({ start, end, unitOfTime: 'days' }) > 1) {
|
||||
return { dateFormat: dateFormatWithDays };
|
||||
}
|
||||
|
||||
if (getDateDifference({ start, end, unitOfTime: 'minutes' }) >= 1) {
|
||||
return {
|
||||
dateFormat: dateFormatWithDays,
|
||||
timeFormat: getTimeFormat('minutes'),
|
||||
};
|
||||
}
|
||||
|
||||
if (getDateDifference({ start, end, unitOfTime: 'seconds' }) >= 10) {
|
||||
return {
|
||||
dateFormat: dateFormatWithDays,
|
||||
timeFormat: getTimeFormat('seconds'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
dateFormat: dateFormatWithDays,
|
||||
timeFormat: getTimeFormat('milliseconds'),
|
||||
};
|
||||
}
|
||||
|
||||
export function asAbsoluteDateTime(time: number, timeUnit: TimeUnit = 'milliseconds') {
|
||||
const momentTime = moment(time);
|
||||
const formattedTz = formatTimezone(momentTime);
|
||||
|
||||
return momentTime.format(`${getDateFormat('days')}, ${getTimeFormat(timeUnit)} ${formattedTz}`);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Returns the dates formatted according to the difference between the two dates:
|
||||
*
|
||||
* | Difference | Format |
|
||||
* | -------------- |:----------------------------------------------:|
|
||||
* | >= 5 years | YYYY - YYYY |
|
||||
* | >= 5 months | MMM YYYY - MMM YYYY |
|
||||
* | > 1 day | MMM D, YYYY - MMM D, YYYY |
|
||||
* | >= 1 minute | MMM D, YYYY, HH:mm - HH:mm (UTC) |
|
||||
* | >= 10 seconds | MMM D, YYYY, HH:mm:ss - HH:mm:ss (UTC) |
|
||||
* | default | MMM D, YYYY, HH:mm:ss.SSS - HH:mm:ss.SSS (UTC) |
|
||||
*
|
||||
* @param start timestamp
|
||||
* @param end timestamp
|
||||
*/
|
||||
export function asRelativeDateTimeRange(start: number, end: number) {
|
||||
const momentStartTime = moment(start);
|
||||
const momentEndTime = moment(end);
|
||||
|
||||
const { dateFormat, timeFormat } = getFormatsAccordingToDateDifference(
|
||||
momentStartTime,
|
||||
momentEndTime
|
||||
);
|
||||
|
||||
if (timeFormat) {
|
||||
const startFormatted = momentStartTime.format(`${dateFormat}, ${timeFormat}`);
|
||||
const endFormatted = momentEndTime.format(timeFormat);
|
||||
const formattedTz = formatTimezone(momentStartTime);
|
||||
return `${startFormatted} - ${endFormatted} ${formattedTz}`;
|
||||
}
|
||||
|
||||
const startFormatted = momentStartTime.format(dateFormat);
|
||||
const endFormatted = momentEndTime.format(dateFormat);
|
||||
return `${startFormatted} - ${endFormatted}`;
|
||||
}
|
|
@ -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
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { asDuration, toMicroseconds, asMillisecondDuration } from './duration';
|
||||
|
||||
describe('duration formatters', () => {
|
||||
describe('asDuration', () => {
|
||||
it('formats correctly with defaults', () => {
|
||||
expect(asDuration(null)).toEqual('N/A');
|
||||
expect(asDuration(undefined)).toEqual('N/A');
|
||||
expect(asDuration(0)).toEqual('0 μs');
|
||||
expect(asDuration(1)).toEqual('1 μs');
|
||||
expect(asDuration(toMicroseconds(1, 'milliseconds'))).toEqual('1,000 μs');
|
||||
expect(asDuration(toMicroseconds(1000, 'milliseconds'))).toEqual('1,000 ms');
|
||||
expect(asDuration(toMicroseconds(10000, 'milliseconds'))).toEqual('10,000 ms');
|
||||
expect(asDuration(toMicroseconds(20, 'seconds'))).toEqual('20 s');
|
||||
expect(asDuration(toMicroseconds(10, 'minutes'))).toEqual('600 s');
|
||||
expect(asDuration(toMicroseconds(11, 'minutes'))).toEqual('11 min');
|
||||
expect(asDuration(toMicroseconds(1, 'hours'))).toEqual('60 min');
|
||||
expect(asDuration(toMicroseconds(1.5, 'hours'))).toEqual('90 min');
|
||||
expect(asDuration(toMicroseconds(10, 'hours'))).toEqual('600 min');
|
||||
expect(asDuration(toMicroseconds(11, 'hours'))).toEqual('11 h');
|
||||
});
|
||||
|
||||
it('falls back to default value', () => {
|
||||
expect(asDuration(undefined, { defaultValue: 'nope' })).toEqual('nope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toMicroseconds', () => {
|
||||
it('transformes to microseconds', () => {
|
||||
expect(toMicroseconds(1, 'hours')).toEqual(3600000000);
|
||||
expect(toMicroseconds(10, 'minutes')).toEqual(600000000);
|
||||
expect(toMicroseconds(10, 'seconds')).toEqual(10000000);
|
||||
expect(toMicroseconds(10, 'milliseconds')).toEqual(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('asMilliseconds', () => {
|
||||
it('converts to formatted decimal milliseconds', () => {
|
||||
expect(asMillisecondDuration(0)).toEqual('0 ms');
|
||||
});
|
||||
it('formats correctly with undefined values', () => {
|
||||
expect(asMillisecondDuration(undefined)).toEqual('N/A');
|
||||
});
|
||||
});
|
||||
});
|
214
x-pack/plugins/observability/common/utils/formatters/duration.ts
Normal file
214
x-pack/plugins/observability/common/utils/formatters/duration.ts
Normal file
|
@ -0,0 +1,214 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import moment from 'moment';
|
||||
import { memoize } from 'lodash';
|
||||
import { NOT_AVAILABLE_LABEL } from '../../i18n';
|
||||
import { asDecimalOrInteger, asInteger, asDecimal } from './formatters';
|
||||
import { TimeUnit } from './datetime';
|
||||
import { Maybe } from '../../typings';
|
||||
import { isFiniteNumber } from '../is_finite_number';
|
||||
|
||||
interface FormatterOptions {
|
||||
defaultValue?: string;
|
||||
extended?: boolean;
|
||||
}
|
||||
|
||||
type DurationTimeUnit = TimeUnit | 'microseconds';
|
||||
|
||||
interface ConvertedDuration {
|
||||
value: string;
|
||||
unit?: string;
|
||||
formatted: string;
|
||||
}
|
||||
|
||||
export type TimeFormatter = (value: Maybe<number>, options?: FormatterOptions) => ConvertedDuration;
|
||||
|
||||
type TimeFormatterBuilder = (max: number) => TimeFormatter;
|
||||
|
||||
function getUnitLabelAndConvertedValue(unitKey: DurationTimeUnit, value: number) {
|
||||
switch (unitKey) {
|
||||
case 'hours': {
|
||||
return {
|
||||
unitLabel: i18n.translate('xpack.observability.formatters.hoursTimeUnitLabel', {
|
||||
defaultMessage: 'h',
|
||||
}),
|
||||
unitLabelExtended: i18n.translate(
|
||||
'xpack.observability.formatters.hoursTimeUnitLabelExtended',
|
||||
{
|
||||
defaultMessage: 'hours',
|
||||
}
|
||||
),
|
||||
convertedValue: asDecimalOrInteger(moment.duration(value / 1000).asHours()),
|
||||
};
|
||||
}
|
||||
case 'minutes': {
|
||||
return {
|
||||
unitLabel: i18n.translate('xpack.observability.formatters.minutesTimeUnitLabel', {
|
||||
defaultMessage: 'min',
|
||||
}),
|
||||
unitLabelExtended: i18n.translate(
|
||||
'xpack.observability.formatters.minutesTimeUnitLabelExtended',
|
||||
{
|
||||
defaultMessage: 'minutes',
|
||||
}
|
||||
),
|
||||
convertedValue: asDecimalOrInteger(moment.duration(value / 1000).asMinutes()),
|
||||
};
|
||||
}
|
||||
case 'seconds': {
|
||||
return {
|
||||
unitLabel: i18n.translate('xpack.observability.formatters.secondsTimeUnitLabel', {
|
||||
defaultMessage: 's',
|
||||
}),
|
||||
unitLabelExtended: i18n.translate(
|
||||
'xpack.observability.formatters.secondsTimeUnitLabelExtended',
|
||||
{
|
||||
defaultMessage: 'seconds',
|
||||
}
|
||||
),
|
||||
convertedValue: asDecimalOrInteger(moment.duration(value / 1000).asSeconds()),
|
||||
};
|
||||
}
|
||||
case 'milliseconds': {
|
||||
return {
|
||||
unitLabel: i18n.translate('xpack.observability.formatters.millisTimeUnitLabel', {
|
||||
defaultMessage: 'ms',
|
||||
}),
|
||||
unitLabelExtended: i18n.translate(
|
||||
'xpack.observability.formatters.millisTimeUnitLabelExtended',
|
||||
{
|
||||
defaultMessage: 'milliseconds',
|
||||
}
|
||||
),
|
||||
convertedValue: asDecimalOrInteger(moment.duration(value / 1000).asMilliseconds()),
|
||||
};
|
||||
}
|
||||
case 'microseconds': {
|
||||
return {
|
||||
unitLabel: i18n.translate('xpack.observability.formatters.microsTimeUnitLabel', {
|
||||
defaultMessage: 'μs',
|
||||
}),
|
||||
unitLabelExtended: i18n.translate(
|
||||
'xpack.observability.formatters.microsTimeUnitLabelExtended',
|
||||
{
|
||||
defaultMessage: 'microseconds',
|
||||
}
|
||||
),
|
||||
convertedValue: asInteger(value),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a microseconds value into the unit defined.
|
||||
*/
|
||||
function convertTo({
|
||||
unit,
|
||||
microseconds,
|
||||
defaultValue = NOT_AVAILABLE_LABEL,
|
||||
extended,
|
||||
}: {
|
||||
unit: DurationTimeUnit;
|
||||
microseconds: Maybe<number>;
|
||||
defaultValue?: string;
|
||||
extended?: boolean;
|
||||
}): ConvertedDuration {
|
||||
if (!isFiniteNumber(microseconds)) {
|
||||
return { value: defaultValue, formatted: defaultValue };
|
||||
}
|
||||
|
||||
const { convertedValue, unitLabel, unitLabelExtended } = getUnitLabelAndConvertedValue(
|
||||
unit,
|
||||
microseconds
|
||||
);
|
||||
|
||||
const label = extended ? unitLabelExtended : unitLabel;
|
||||
|
||||
return {
|
||||
value: convertedValue,
|
||||
unit: unitLabel,
|
||||
formatted: `${convertedValue} ${label}`,
|
||||
};
|
||||
}
|
||||
|
||||
export const toMicroseconds = (value: number, timeUnit: TimeUnit) =>
|
||||
moment.duration(value, timeUnit).asMilliseconds() * 1000;
|
||||
|
||||
function getDurationUnitKey(max: number): DurationTimeUnit {
|
||||
if (max > toMicroseconds(10, 'hours')) {
|
||||
return 'hours';
|
||||
}
|
||||
if (max > toMicroseconds(10, 'minutes')) {
|
||||
return 'minutes';
|
||||
}
|
||||
if (max > toMicroseconds(10, 'seconds')) {
|
||||
return 'seconds';
|
||||
}
|
||||
if (max > toMicroseconds(1, 'milliseconds')) {
|
||||
return 'milliseconds';
|
||||
}
|
||||
return 'microseconds';
|
||||
}
|
||||
|
||||
export const getDurationFormatter: TimeFormatterBuilder = memoize((max: number) => {
|
||||
const unit = getDurationUnitKey(max);
|
||||
return (value, { defaultValue, extended }: FormatterOptions = {}) => {
|
||||
return convertTo({ unit, microseconds: value, defaultValue, extended });
|
||||
};
|
||||
});
|
||||
|
||||
export function asTransactionRate(value: Maybe<number>) {
|
||||
if (!isFiniteNumber(value)) {
|
||||
return NOT_AVAILABLE_LABEL;
|
||||
}
|
||||
|
||||
let displayedValue: string;
|
||||
|
||||
if (value === 0) {
|
||||
displayedValue = '0';
|
||||
} else if (value <= 0.1) {
|
||||
displayedValue = '< 0.1';
|
||||
} else {
|
||||
displayedValue = asDecimal(value);
|
||||
}
|
||||
|
||||
return i18n.translate('xpack.observability.transactionRateLabel', {
|
||||
defaultMessage: `{value} tpm`,
|
||||
values: {
|
||||
value: displayedValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts value and returns it formatted - 00 unit
|
||||
*/
|
||||
export function asDuration(
|
||||
value: Maybe<number>,
|
||||
{ defaultValue = NOT_AVAILABLE_LABEL, extended }: FormatterOptions = {}
|
||||
) {
|
||||
if (!isFiniteNumber(value)) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const formatter = getDurationFormatter(value);
|
||||
return formatter(value, { defaultValue, extended }).formatted;
|
||||
}
|
||||
/**
|
||||
* Convert a microsecond value to decimal milliseconds. Normally we use
|
||||
* `asDuration`, but this is used in places like tables where we always want
|
||||
* the same units.
|
||||
*/
|
||||
export function asMillisecondDuration(value: Maybe<number>) {
|
||||
return convertTo({
|
||||
unit: 'milliseconds',
|
||||
microseconds: value,
|
||||
}).formatted;
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { asPercent, asDecimalOrInteger } from './formatters';
|
||||
|
||||
describe('formatters', () => {
|
||||
describe('asPercent', () => {
|
||||
it('formats as integer when number is above 10', () => {
|
||||
expect(asPercent(3725, 10000, 'n/a')).toEqual('37%');
|
||||
});
|
||||
|
||||
it('adds a decimal when value is below 10', () => {
|
||||
expect(asPercent(0.092, 1)).toEqual('9.2%');
|
||||
});
|
||||
|
||||
it('formats when numerator is 0', () => {
|
||||
expect(asPercent(0, 1, 'n/a')).toEqual('0%');
|
||||
});
|
||||
|
||||
it('returns fallback when denominator is undefined', () => {
|
||||
expect(asPercent(3725, undefined, 'n/a')).toEqual('n/a');
|
||||
});
|
||||
|
||||
it('returns fallback when denominator is 0 ', () => {
|
||||
expect(asPercent(3725, 0, 'n/a')).toEqual('n/a');
|
||||
});
|
||||
|
||||
it('returns fallback when numerator or denominator is NaN', () => {
|
||||
expect(asPercent(3725, NaN, 'n/a')).toEqual('n/a');
|
||||
expect(asPercent(NaN, 10000, 'n/a')).toEqual('n/a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('asDecimalOrInteger', () => {
|
||||
it('formats as integer when number equals to 0 ', () => {
|
||||
expect(asDecimalOrInteger(0)).toEqual('0');
|
||||
});
|
||||
it('formats as integer when number is above or equals 10 ', () => {
|
||||
expect(asDecimalOrInteger(10.123)).toEqual('10');
|
||||
expect(asDecimalOrInteger(15.123)).toEqual('15');
|
||||
});
|
||||
it('formats as decimal when number is below 10 ', () => {
|
||||
expect(asDecimalOrInteger(0.25435632645)).toEqual('0.3');
|
||||
expect(asDecimalOrInteger(1)).toEqual('1.0');
|
||||
expect(asDecimalOrInteger(3.374329704990765)).toEqual('3.4');
|
||||
expect(asDecimalOrInteger(5)).toEqual('5.0');
|
||||
expect(asDecimalOrInteger(9)).toEqual('9.0');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import numeral from '@elastic/numeral';
|
||||
import { Maybe } from '../../typings';
|
||||
import { NOT_AVAILABLE_LABEL } from '../../i18n';
|
||||
import { isFiniteNumber } from '../is_finite_number';
|
||||
|
||||
export function asDecimal(value?: number | null) {
|
||||
if (!isFiniteNumber(value)) {
|
||||
return NOT_AVAILABLE_LABEL;
|
||||
}
|
||||
|
||||
return numeral(value).format('0,0.0');
|
||||
}
|
||||
|
||||
export function asInteger(value?: number | null) {
|
||||
if (!isFiniteNumber(value)) {
|
||||
return NOT_AVAILABLE_LABEL;
|
||||
}
|
||||
|
||||
return numeral(value).format('0,0');
|
||||
}
|
||||
|
||||
export function asPercent(
|
||||
numerator: Maybe<number>,
|
||||
denominator: number | undefined,
|
||||
fallbackResult = NOT_AVAILABLE_LABEL
|
||||
) {
|
||||
if (!denominator || !isFiniteNumber(numerator)) {
|
||||
return fallbackResult;
|
||||
}
|
||||
|
||||
const decimal = numerator / denominator;
|
||||
|
||||
// 33.2 => 33%
|
||||
// 3.32 => 3.3%
|
||||
// 0 => 0%
|
||||
if (Math.abs(decimal) >= 0.1 || decimal === 0) {
|
||||
return numeral(decimal).format('0%');
|
||||
}
|
||||
|
||||
return numeral(decimal).format('0.0%');
|
||||
}
|
||||
|
||||
export function asDecimalOrInteger(value: number) {
|
||||
// exact 0 or above 10 should not have decimal
|
||||
if (value === 0 || value >= 10) {
|
||||
return asInteger(value);
|
||||
}
|
||||
return asDecimal(value);
|
||||
}
|
|
@ -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
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export * from './formatters';
|
||||
export * from './datetime';
|
||||
export * from './duration';
|
||||
export * from './size';
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { getFixedByteFormatter, asDynamicBytes } from './size';
|
||||
|
||||
describe('size formatters', () => {
|
||||
describe('byte formatting', () => {
|
||||
const bytes = 10;
|
||||
const kb = 1000 + 1;
|
||||
const mb = 1e6 + 1;
|
||||
const gb = 1e9 + 1;
|
||||
const tb = 1e12 + 1;
|
||||
|
||||
test('dynamic', () => {
|
||||
expect(asDynamicBytes(bytes)).toEqual('10.0 B');
|
||||
expect(asDynamicBytes(kb)).toEqual('1.0 KB');
|
||||
expect(asDynamicBytes(mb)).toEqual('1.0 MB');
|
||||
expect(asDynamicBytes(gb)).toEqual('1.0 GB');
|
||||
expect(asDynamicBytes(tb)).toEqual('1.0 TB');
|
||||
expect(asDynamicBytes(null)).toEqual('');
|
||||
expect(asDynamicBytes(NaN)).toEqual('');
|
||||
});
|
||||
|
||||
describe('fixed', () => {
|
||||
test('in bytes', () => {
|
||||
const formatInBytes = getFixedByteFormatter(bytes);
|
||||
expect(formatInBytes(bytes)).toEqual('10.0 B');
|
||||
expect(formatInBytes(kb)).toEqual('1,001.0 B');
|
||||
expect(formatInBytes(mb)).toEqual('1,000,001.0 B');
|
||||
expect(formatInBytes(gb)).toEqual('1,000,000,001.0 B');
|
||||
expect(formatInBytes(tb)).toEqual('1,000,000,000,001.0 B');
|
||||
expect(formatInBytes(null)).toEqual('');
|
||||
expect(formatInBytes(NaN)).toEqual('');
|
||||
});
|
||||
|
||||
test('in kb', () => {
|
||||
const formatInKB = getFixedByteFormatter(kb);
|
||||
expect(formatInKB(bytes)).toEqual('0.0 KB');
|
||||
expect(formatInKB(kb)).toEqual('1.0 KB');
|
||||
expect(formatInKB(mb)).toEqual('1,000.0 KB');
|
||||
expect(formatInKB(gb)).toEqual('1,000,000.0 KB');
|
||||
expect(formatInKB(tb)).toEqual('1,000,000,000.0 KB');
|
||||
});
|
||||
|
||||
test('in mb', () => {
|
||||
const formatInMB = getFixedByteFormatter(mb);
|
||||
expect(formatInMB(bytes)).toEqual('0.0 MB');
|
||||
expect(formatInMB(kb)).toEqual('0.0 MB');
|
||||
expect(formatInMB(mb)).toEqual('1.0 MB');
|
||||
expect(formatInMB(gb)).toEqual('1,000.0 MB');
|
||||
expect(formatInMB(tb)).toEqual('1,000,000.0 MB');
|
||||
expect(formatInMB(null)).toEqual('');
|
||||
expect(formatInMB(NaN)).toEqual('');
|
||||
});
|
||||
|
||||
test('in gb', () => {
|
||||
const formatInGB = getFixedByteFormatter(gb);
|
||||
expect(formatInGB(bytes)).toEqual('1e-8 GB');
|
||||
expect(formatInGB(kb)).toEqual('0.0 GB');
|
||||
expect(formatInGB(mb)).toEqual('0.0 GB');
|
||||
expect(formatInGB(gb)).toEqual('1.0 GB');
|
||||
expect(formatInGB(tb)).toEqual('1,000.0 GB');
|
||||
expect(formatInGB(null)).toEqual('');
|
||||
expect(formatInGB(NaN)).toEqual('');
|
||||
});
|
||||
|
||||
test('in tb', () => {
|
||||
const formatInTB = getFixedByteFormatter(tb);
|
||||
expect(formatInTB(bytes)).toEqual('1e-11 TB');
|
||||
expect(formatInTB(kb)).toEqual('1.001e-9 TB');
|
||||
expect(formatInTB(mb)).toEqual('0.0 TB');
|
||||
expect(formatInTB(gb)).toEqual('0.0 TB');
|
||||
expect(formatInTB(tb)).toEqual('1.0 TB');
|
||||
expect(formatInTB(null)).toEqual('');
|
||||
expect(formatInTB(NaN)).toEqual('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
69
x-pack/plugins/observability/common/utils/formatters/size.ts
Normal file
69
x-pack/plugins/observability/common/utils/formatters/size.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { memoize } from 'lodash';
|
||||
import { asDecimal } from './formatters';
|
||||
import { Maybe } from '../../typings';
|
||||
|
||||
function asKilobytes(value: number) {
|
||||
return `${asDecimal(value / 1000)} KB`;
|
||||
}
|
||||
|
||||
function asMegabytes(value: number) {
|
||||
return `${asDecimal(value / 1e6)} MB`;
|
||||
}
|
||||
|
||||
function asGigabytes(value: number) {
|
||||
return `${asDecimal(value / 1e9)} GB`;
|
||||
}
|
||||
|
||||
function asTerabytes(value: number) {
|
||||
return `${asDecimal(value / 1e12)} TB`;
|
||||
}
|
||||
|
||||
function asBytes(value: number) {
|
||||
return `${asDecimal(value)} B`;
|
||||
}
|
||||
|
||||
const bailIfNumberInvalid = (cb: (val: number) => string) => {
|
||||
return (val: Maybe<number>) => {
|
||||
if (val === null || val === undefined || isNaN(val)) {
|
||||
return '';
|
||||
}
|
||||
return cb(val);
|
||||
};
|
||||
};
|
||||
|
||||
export const getFixedByteFormatter = memoize((max: number) => {
|
||||
const formatter = unmemoizedFixedByteFormatter(max);
|
||||
|
||||
return bailIfNumberInvalid(formatter);
|
||||
});
|
||||
|
||||
export const asDynamicBytes = bailIfNumberInvalid((value: number) => {
|
||||
return unmemoizedFixedByteFormatter(value)(value);
|
||||
});
|
||||
|
||||
const unmemoizedFixedByteFormatter = (max: number) => {
|
||||
if (max > 1e12) {
|
||||
return asTerabytes;
|
||||
}
|
||||
|
||||
if (max > 1e9) {
|
||||
return asGigabytes;
|
||||
}
|
||||
|
||||
if (max > 1e6) {
|
||||
return asMegabytes;
|
||||
}
|
||||
|
||||
if (max > 1000) {
|
||||
return asKilobytes;
|
||||
}
|
||||
|
||||
return asBytes;
|
||||
};
|
|
@ -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
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { isFinite } from 'lodash';
|
||||
|
||||
// _.isNumber() returns true for NaN, _.isFinite() does not refine
|
||||
export function isFiniteNumber(value: any): value is number {
|
||||
return isFinite(value);
|
||||
}
|
|
@ -0,0 +1,169 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { joinByKey } from './';
|
||||
|
||||
describe('joinByKey', () => {
|
||||
it('joins by a string key', () => {
|
||||
const joined = joinByKey(
|
||||
[
|
||||
{
|
||||
serviceName: 'opbeans-node',
|
||||
avg: 10,
|
||||
},
|
||||
{
|
||||
serviceName: 'opbeans-node',
|
||||
count: 12,
|
||||
},
|
||||
{
|
||||
serviceName: 'opbeans-java',
|
||||
avg: 11,
|
||||
},
|
||||
{
|
||||
serviceName: 'opbeans-java',
|
||||
p95: 18,
|
||||
},
|
||||
],
|
||||
'serviceName'
|
||||
);
|
||||
|
||||
expect(joined.length).toBe(2);
|
||||
|
||||
expect(joined).toEqual([
|
||||
{
|
||||
serviceName: 'opbeans-node',
|
||||
avg: 10,
|
||||
count: 12,
|
||||
},
|
||||
{
|
||||
serviceName: 'opbeans-java',
|
||||
avg: 11,
|
||||
p95: 18,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('joins by a record key', () => {
|
||||
const joined = joinByKey(
|
||||
[
|
||||
{
|
||||
key: {
|
||||
serviceName: 'opbeans-node',
|
||||
transactionName: '/api/opbeans-node',
|
||||
},
|
||||
avg: 10,
|
||||
},
|
||||
{
|
||||
key: {
|
||||
serviceName: 'opbeans-node',
|
||||
transactionName: '/api/opbeans-node',
|
||||
},
|
||||
count: 12,
|
||||
},
|
||||
{
|
||||
key: {
|
||||
serviceName: 'opbeans-java',
|
||||
transactionName: '/api/opbeans-java',
|
||||
},
|
||||
avg: 11,
|
||||
},
|
||||
{
|
||||
key: {
|
||||
serviceName: 'opbeans-java',
|
||||
transactionName: '/api/opbeans-java',
|
||||
},
|
||||
p95: 18,
|
||||
},
|
||||
],
|
||||
'key'
|
||||
);
|
||||
|
||||
expect(joined.length).toBe(2);
|
||||
|
||||
expect(joined).toEqual([
|
||||
{
|
||||
key: {
|
||||
serviceName: 'opbeans-node',
|
||||
transactionName: '/api/opbeans-node',
|
||||
},
|
||||
avg: 10,
|
||||
count: 12,
|
||||
},
|
||||
{
|
||||
key: {
|
||||
serviceName: 'opbeans-java',
|
||||
transactionName: '/api/opbeans-java',
|
||||
},
|
||||
avg: 11,
|
||||
p95: 18,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the custom merge fn to replace items', () => {
|
||||
const joined = joinByKey(
|
||||
[
|
||||
{
|
||||
serviceName: 'opbeans-java',
|
||||
values: ['a'],
|
||||
},
|
||||
{
|
||||
serviceName: 'opbeans-node',
|
||||
values: ['a'],
|
||||
},
|
||||
{
|
||||
serviceName: 'opbeans-node',
|
||||
values: ['b'],
|
||||
},
|
||||
{
|
||||
serviceName: 'opbeans-node',
|
||||
values: ['c'],
|
||||
},
|
||||
],
|
||||
'serviceName',
|
||||
(a, b) => ({
|
||||
...a,
|
||||
...b,
|
||||
values: a.values.concat(b.values),
|
||||
})
|
||||
);
|
||||
|
||||
expect(joined.find((item) => item.serviceName === 'opbeans-node')?.values).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('deeply merges objects', () => {
|
||||
const joined = joinByKey(
|
||||
[
|
||||
{
|
||||
serviceName: 'opbeans-node',
|
||||
properties: {
|
||||
foo: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
serviceName: 'opbeans-node',
|
||||
properties: {
|
||||
bar: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
'serviceName'
|
||||
);
|
||||
|
||||
expect(joined[0]).toEqual({
|
||||
serviceName: 'opbeans-node',
|
||||
properties: {
|
||||
foo: '',
|
||||
bar: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { UnionToIntersection, ValuesType } from 'utility-types';
|
||||
import { isEqual, pull, merge, castArray } from 'lodash';
|
||||
|
||||
/**
|
||||
* Joins a list of records by a given key. Key can be any type of value, from
|
||||
* strings to plain objects, as long as it is present in all records. `isEqual`
|
||||
* is used for comparing keys.
|
||||
*
|
||||
* UnionToIntersection is needed to get all keys of union types, see below for
|
||||
* example.
|
||||
*
|
||||
const agentNames = [{ serviceName: '', agentName: '' }];
|
||||
const transactionRates = [{ serviceName: '', transactionsPerMinute: 1 }];
|
||||
const flattened = joinByKey(
|
||||
[...agentNames, ...transactionRates],
|
||||
'serviceName'
|
||||
);
|
||||
*/
|
||||
|
||||
type JoinedReturnType<T extends Record<string, any>, U extends UnionToIntersection<T>> = Array<
|
||||
Partial<U> &
|
||||
{
|
||||
[k in keyof T]: T[k];
|
||||
}
|
||||
>;
|
||||
|
||||
type ArrayOrSingle<T> = T | T[];
|
||||
|
||||
export function joinByKey<
|
||||
T extends Record<string, any>,
|
||||
U extends UnionToIntersection<T>,
|
||||
V extends ArrayOrSingle<keyof T & keyof U>
|
||||
>(items: T[], key: V): JoinedReturnType<T, U>;
|
||||
|
||||
export function joinByKey<
|
||||
T extends Record<string, any>,
|
||||
U extends UnionToIntersection<T>,
|
||||
V extends ArrayOrSingle<keyof T & keyof U>,
|
||||
W extends JoinedReturnType<T, U>,
|
||||
X extends (a: T, b: T) => ValuesType<W>
|
||||
>(items: T[], key: V, mergeFn: X): W;
|
||||
|
||||
export function joinByKey(
|
||||
items: Array<Record<string, any>>,
|
||||
key: string | string[],
|
||||
mergeFn: Function = (a: Record<string, any>, b: Record<string, any>) => merge({}, a, b)
|
||||
) {
|
||||
const keys = castArray(key);
|
||||
return items.reduce<Array<Record<string, any>>>((prev, current) => {
|
||||
let item = prev.find((prevItem) => keys.every((k) => isEqual(prevItem[k], current[k])));
|
||||
|
||||
if (!item) {
|
||||
item = { ...current };
|
||||
prev.push(item);
|
||||
} else {
|
||||
pull(prev, item).push(mergeFn(item, current));
|
||||
}
|
||||
|
||||
return prev;
|
||||
}, []);
|
||||
}
|
10
x-pack/plugins/observability/common/utils/maybe.ts
Normal file
10
x-pack/plugins/observability/common/utils/maybe.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export function maybe<T>(value: T): T | null | undefined {
|
||||
return value;
|
||||
}
|
12
x-pack/plugins/observability/common/utils/pick_keys.ts
Normal file
12
x-pack/plugins/observability/common/utils/pick_keys.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { pick } from 'lodash';
|
||||
|
||||
export function pickKeys<T, K extends keyof T>(obj: T, ...keys: K[]) {
|
||||
return pick(obj, keys) as Pick<T, K>;
|
||||
}
|
|
@ -2,10 +2,26 @@
|
|||
"id": "observability",
|
||||
"version": "8.0.0",
|
||||
"kibanaVersion": "kibana",
|
||||
"configPath": ["xpack", "observability"],
|
||||
"optionalPlugins": ["licensing", "home", "usageCollection","lens", "ruleRegistry"],
|
||||
"requiredPlugins": ["data"],
|
||||
"configPath": [
|
||||
"xpack",
|
||||
"observability"
|
||||
],
|
||||
"optionalPlugins": [
|
||||
"licensing",
|
||||
"home",
|
||||
"usageCollection",
|
||||
"lens"
|
||||
],
|
||||
"requiredPlugins": [
|
||||
"data",
|
||||
"alerting",
|
||||
"ruleRegistry"
|
||||
],
|
||||
"ui": true,
|
||||
"server": true,
|
||||
"requiredBundles": ["data", "kibanaReact", "kibanaUtils"]
|
||||
"requiredBundles": [
|
||||
"data",
|
||||
"kibanaReact",
|
||||
"kibanaUtils"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import React from 'react';
|
|||
import { Observable } from 'rxjs';
|
||||
import { AppMountParameters, CoreStart } from 'src/core/public';
|
||||
import { ObservabilityPublicPluginsStart } from '../plugin';
|
||||
import { createObservabilityRuleRegistryMock } from '../rules/observability_rule_registry_mock';
|
||||
import { renderApp } from './';
|
||||
|
||||
describe('renderApp', () => {
|
||||
|
@ -51,7 +52,12 @@ describe('renderApp', () => {
|
|||
} as unknown) as AppMountParameters;
|
||||
|
||||
expect(() => {
|
||||
const unmount = renderApp(core, plugins, params);
|
||||
const unmount = renderApp({
|
||||
core,
|
||||
plugins,
|
||||
appMountParameters: params,
|
||||
observabilityRuleRegistry: createObservabilityRuleRegistryMock(),
|
||||
});
|
||||
unmount();
|
||||
}).not.toThrowError();
|
||||
});
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
import { PluginContext } from '../context/plugin_context';
|
||||
import { usePluginContext } from '../hooks/use_plugin_context';
|
||||
import { useRouteParams } from '../hooks/use_route_params';
|
||||
import { ObservabilityPublicPluginsStart } from '../plugin';
|
||||
import { ObservabilityPublicPluginsStart, ObservabilityRuleRegistry } from '../plugin';
|
||||
import { HasDataContextProvider } from '../context/has_data_context';
|
||||
import { Breadcrumbs, routes } from '../routes';
|
||||
import { Storage } from '../../../../../src/plugins/kibana_utils/public';
|
||||
|
@ -66,11 +66,17 @@ function App() {
|
|||
);
|
||||
}
|
||||
|
||||
export const renderApp = (
|
||||
core: CoreStart,
|
||||
plugins: ObservabilityPublicPluginsStart,
|
||||
appMountParameters: AppMountParameters
|
||||
) => {
|
||||
export const renderApp = ({
|
||||
core,
|
||||
plugins,
|
||||
appMountParameters,
|
||||
observabilityRuleRegistry,
|
||||
}: {
|
||||
core: CoreStart;
|
||||
plugins: ObservabilityPublicPluginsStart;
|
||||
observabilityRuleRegistry: ObservabilityRuleRegistry;
|
||||
appMountParameters: AppMountParameters;
|
||||
}) => {
|
||||
const { element, history } = appMountParameters;
|
||||
const i18nCore = core.i18n;
|
||||
const isDarkMode = core.uiSettings.get('theme:darkMode');
|
||||
|
@ -84,7 +90,9 @@ export const renderApp = (
|
|||
|
||||
ReactDOM.render(
|
||||
<KibanaContextProvider services={{ ...core, ...plugins, storage: new Storage(localStorage) }}>
|
||||
<PluginContext.Provider value={{ appMountParameters, core, plugins }}>
|
||||
<PluginContext.Provider
|
||||
value={{ appMountParameters, core, plugins, observabilityRuleRegistry }}
|
||||
>
|
||||
<Router history={history}>
|
||||
<EuiThemeProvider darkMode={isDarkMode}>
|
||||
<i18nCore.Context>
|
||||
|
|
|
@ -14,7 +14,7 @@ import * as hasDataHook from '../../../../hooks/use_has_data';
|
|||
import * as pluginContext from '../../../../hooks/use_plugin_context';
|
||||
import { HasDataContextValue } from '../../../../context/has_data_context';
|
||||
import { AppMountParameters, CoreStart } from 'kibana/public';
|
||||
import { ObservabilityPublicPluginsStart } from '../../../../plugin';
|
||||
import { ObservabilityPublicPluginsStart, ObservabilityRuleRegistry } from '../../../../plugin';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useLocation: () => ({
|
||||
|
@ -40,6 +40,10 @@ describe('APMSection', () => {
|
|||
http: { basePath: { prepend: jest.fn() } },
|
||||
} as unknown) as CoreStart,
|
||||
appMountParameters: {} as AppMountParameters,
|
||||
observabilityRuleRegistry: ({
|
||||
registerType: jest.fn(),
|
||||
getTypeByRuleId: jest.fn(),
|
||||
} as unknown) as ObservabilityRuleRegistry,
|
||||
plugins: ({
|
||||
data: {
|
||||
query: {
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { AppMountParameters, CoreStart } from 'kibana/public';
|
||||
import React from 'react';
|
||||
import { createObservabilityRuleRegistryMock } from '../../../../rules/observability_rule_registry_mock';
|
||||
import { HasDataContextValue } from '../../../../context/has_data_context';
|
||||
import * as fetcherHook from '../../../../hooks/use_fetcher';
|
||||
import * as hasDataHook from '../../../../hooks/use_has_data';
|
||||
|
@ -53,6 +54,7 @@ describe('UXSection', () => {
|
|||
},
|
||||
},
|
||||
} as unknown) as ObservabilityPublicPluginsStart,
|
||||
observabilityRuleRegistry: createObservabilityRuleRegistryMock(),
|
||||
}));
|
||||
});
|
||||
it('renders with core web vitals', () => {
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
import React from 'react';
|
||||
import moment from 'moment-timezone';
|
||||
import { TimestampTooltip } from './index';
|
||||
|
||||
function mockNow(date: string | number | Date) {
|
||||
const fakeNow = new Date(date).getTime();
|
||||
return jest.spyOn(Date, 'now').mockReturnValue(fakeNow);
|
||||
}
|
||||
|
||||
describe('TimestampTooltip', () => {
|
||||
const timestamp = 1570720000123; // Oct 10, 2019, 08:06:40.123 (UTC-7)
|
||||
|
||||
beforeAll(() => {
|
||||
// mock Date.now
|
||||
mockNow(1570737000000);
|
||||
|
||||
moment.tz.setDefault('America/Los_Angeles');
|
||||
});
|
||||
|
||||
afterAll(() => moment.tz.setDefault(''));
|
||||
|
||||
it('should render component with relative time in body and absolute time in tooltip', () => {
|
||||
expect(shallow(<TimestampTooltip time={timestamp} />)).toMatchInlineSnapshot(`
|
||||
<EuiToolTip
|
||||
content="Oct 10, 2019, 08:06:40.123 (UTC-7)"
|
||||
delay="regular"
|
||||
position="top"
|
||||
>
|
||||
5 hours ago
|
||||
</EuiToolTip>
|
||||
`);
|
||||
});
|
||||
|
||||
it('should format with precision in milliseconds by default', () => {
|
||||
expect(
|
||||
shallow(<TimestampTooltip time={timestamp} />)
|
||||
.find('EuiToolTip')
|
||||
.prop('content')
|
||||
).toBe('Oct 10, 2019, 08:06:40.123 (UTC-7)');
|
||||
});
|
||||
|
||||
it('should format with precision in seconds', () => {
|
||||
expect(
|
||||
shallow(<TimestampTooltip time={timestamp} timeUnit="seconds" />)
|
||||
.find('EuiToolTip')
|
||||
.prop('content')
|
||||
).toBe('Oct 10, 2019, 08:06:40 (UTC-7)');
|
||||
});
|
||||
|
||||
it('should format with precision in minutes', () => {
|
||||
expect(
|
||||
shallow(<TimestampTooltip time={timestamp} timeUnit="minutes" />)
|
||||
.find('EuiToolTip')
|
||||
.prop('content')
|
||||
).toBe('Oct 10, 2019, 08:06 (UTC-7)');
|
||||
});
|
||||
});
|
|
@ -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
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiToolTip } from '@elastic/eui';
|
||||
import moment from 'moment-timezone';
|
||||
import { asAbsoluteDateTime, TimeUnit } from '../../../../common/utils/formatters/datetime';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* timestamp in milliseconds
|
||||
*/
|
||||
time: number;
|
||||
timeUnit?: TimeUnit;
|
||||
}
|
||||
|
||||
export function TimestampTooltip({ time, timeUnit = 'milliseconds' }: Props) {
|
||||
const momentTime = moment(time);
|
||||
const relativeTimeLabel = momentTime.fromNow();
|
||||
const absoluteTimeLabel = asAbsoluteDateTime(time, timeUnit);
|
||||
|
||||
return (
|
||||
<EuiToolTip content={absoluteTimeLabel}>
|
||||
<>{relativeTimeLabel}</>
|
||||
</EuiToolTip>
|
||||
);
|
||||
}
|
|
@ -7,12 +7,13 @@
|
|||
|
||||
import { createContext } from 'react';
|
||||
import { AppMountParameters, CoreStart } from 'kibana/public';
|
||||
import { ObservabilityPublicPluginsStart } from '../plugin';
|
||||
import { ObservabilityPublicPluginsStart, ObservabilityRuleRegistry } from '../plugin';
|
||||
|
||||
export interface PluginContextValue {
|
||||
appMountParameters: AppMountParameters;
|
||||
core: CoreStart;
|
||||
plugins: ObservabilityPublicPluginsStart;
|
||||
observabilityRuleRegistry: ObservabilityRuleRegistry;
|
||||
}
|
||||
|
||||
export const PluginContext = createContext({} as PluginContextValue);
|
||||
|
|
|
@ -28,7 +28,7 @@ type InferResponseType<TReturn> = Exclude<TReturn, undefined> extends Promise<in
|
|||
: unknown;
|
||||
|
||||
export function useFetcher<TReturn>(
|
||||
fn: () => TReturn,
|
||||
fn: ({}: { signal: AbortSignal }) => TReturn,
|
||||
fnDeps: any[],
|
||||
options: {
|
||||
preservePreviousData?: boolean;
|
||||
|
@ -43,8 +43,16 @@ export function useFetcher<TReturn>(
|
|||
});
|
||||
const [counter, setCounter] = useState(0);
|
||||
useEffect(() => {
|
||||
let controller: AbortController = new AbortController();
|
||||
|
||||
async function doFetch() {
|
||||
const promise = fn();
|
||||
controller.abort();
|
||||
|
||||
controller = new AbortController();
|
||||
|
||||
const signal = controller.signal;
|
||||
|
||||
const promise = fn({ signal });
|
||||
if (!promise) {
|
||||
return;
|
||||
}
|
||||
|
@ -58,22 +66,34 @@ export function useFetcher<TReturn>(
|
|||
|
||||
try {
|
||||
const data = await promise;
|
||||
setResult({
|
||||
data,
|
||||
status: FETCH_STATUS.SUCCESS,
|
||||
error: undefined,
|
||||
} as FetcherResult<InferResponseType<TReturn>>);
|
||||
// when http fetches are aborted, the promise will be rejected
|
||||
// and this code is never reached. For async operations that are
|
||||
// not cancellable, we need to check whether the signal was
|
||||
// aborted before updating the result.
|
||||
if (!signal.aborted) {
|
||||
setResult({
|
||||
data,
|
||||
status: FETCH_STATUS.SUCCESS,
|
||||
error: undefined,
|
||||
} as FetcherResult<InferResponseType<TReturn>>);
|
||||
}
|
||||
} catch (e) {
|
||||
setResult((prevResult) => ({
|
||||
data: preservePreviousData ? prevResult.data : undefined,
|
||||
status: FETCH_STATUS.FAILURE,
|
||||
error: e,
|
||||
loading: false,
|
||||
}));
|
||||
if (!signal.aborted) {
|
||||
setResult((prevResult) => ({
|
||||
data: preservePreviousData ? prevResult.data : undefined,
|
||||
status: FETCH_STATUS.FAILURE,
|
||||
error: e,
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doFetch();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [counter, ...fnDeps]);
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ import * as pluginContext from './use_plugin_context';
|
|||
import { AppMountParameters, CoreStart } from 'kibana/public';
|
||||
import { ObservabilityPublicPluginsStart } from '../plugin';
|
||||
import * as kibanaUISettings from './use_kibana_ui_settings';
|
||||
import { createObservabilityRuleRegistryMock } from '../rules/observability_rule_registry_mock';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useLocation: () => ({
|
||||
|
@ -37,6 +38,7 @@ describe('useTimeRange', () => {
|
|||
},
|
||||
},
|
||||
} as unknown) as ObservabilityPublicPluginsStart,
|
||||
observabilityRuleRegistry: createObservabilityRuleRegistryMock(),
|
||||
}));
|
||||
jest.spyOn(kibanaUISettings, 'useKibanaUISettings').mockImplementation(() => ({
|
||||
from: '2020-10-08T05:00:00.000Z',
|
||||
|
@ -77,6 +79,7 @@ describe('useTimeRange', () => {
|
|||
},
|
||||
},
|
||||
} as unknown) as ObservabilityPublicPluginsStart,
|
||||
observabilityRuleRegistry: createObservabilityRuleRegistryMock(),
|
||||
}));
|
||||
});
|
||||
it('returns ranges and absolute times from kibana default settings', () => {
|
||||
|
|
|
@ -56,3 +56,5 @@ export { useChartTheme } from './hooks/use_chart_theme';
|
|||
export { useTheme } from './hooks/use_theme';
|
||||
export { getApmTraceUrl } from './utils/get_apm_trace_url';
|
||||
export { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils';
|
||||
|
||||
export { FormatterRuleRegistry } from './rules/formatter_rule_registry';
|
||||
|
|
|
@ -5,68 +5,98 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { StoryContext } from '@storybook/react';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { AlertsPage } from '.';
|
||||
import { HttpSetup } from '../../../../../../src/core/public';
|
||||
import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import { PluginContext, PluginContextValue } from '../../context/plugin_context';
|
||||
import { createObservabilityRuleRegistryMock } from '../../rules/observability_rule_registry_mock';
|
||||
import { createCallObservabilityApi } from '../../services/call_observability_api';
|
||||
import type { ObservabilityAPIReturnType } from '../../services/call_observability_api/types';
|
||||
import { AlertsFlyout } from './alerts_flyout';
|
||||
import { AlertItem } from './alerts_table';
|
||||
import { eventLogPocData, wireframeData } from './example_data';
|
||||
import { TopAlert } from './alerts_table';
|
||||
import { apmAlertResponseExample, dynamicIndexPattern, flyoutItemExample } from './example_data';
|
||||
|
||||
interface PageArgs {
|
||||
items: ObservabilityAPIReturnType<'GET /api/observability/rules/alerts/top'>;
|
||||
}
|
||||
|
||||
interface FlyoutArgs {
|
||||
alert: TopAlert;
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'app/Alerts',
|
||||
component: AlertsPage,
|
||||
decorators: [
|
||||
(Story: ComponentType) => {
|
||||
(Story: ComponentType, { args: { items = [] } }: StoryContext) => {
|
||||
createCallObservabilityApi(({
|
||||
get: async (endpoint: string) => {
|
||||
if (endpoint === '/api/observability/rules/alerts/top') {
|
||||
return items;
|
||||
} else if (endpoint === '/api/observability/rules/alerts/dynamic_index_pattern') {
|
||||
return dynamicIndexPattern;
|
||||
}
|
||||
},
|
||||
} as unknown) as HttpSetup);
|
||||
|
||||
return (
|
||||
<IntlProvider locale="en">
|
||||
<KibanaContextProvider
|
||||
services={{
|
||||
data: { query: {} },
|
||||
docLinks: { links: { query: {} } },
|
||||
storage: { get: () => {} },
|
||||
uiSettings: {
|
||||
get: (setting: string) => {
|
||||
if (setting === 'dateFormat') {
|
||||
return '';
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PluginContext.Provider
|
||||
value={
|
||||
({
|
||||
core: {
|
||||
http: { basePath: { prepend: (_: string) => '' } },
|
||||
<MemoryRouter>
|
||||
<IntlProvider locale="en">
|
||||
<KibanaContextProvider
|
||||
services={{
|
||||
data: { autocomplete: { hasQuerySuggestions: () => false }, query: {} },
|
||||
docLinks: { links: { query: {} } },
|
||||
storage: { get: () => {} },
|
||||
uiSettings: {
|
||||
get: (setting: string) => {
|
||||
if (setting === 'dateFormat') {
|
||||
return '';
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
} as unknown) as PluginContextValue
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</PluginContext.Provider>
|
||||
</KibanaContextProvider>
|
||||
</IntlProvider>
|
||||
<PluginContext.Provider
|
||||
value={
|
||||
({
|
||||
core: {
|
||||
http: { basePath: { prepend: (_: string) => '' } },
|
||||
},
|
||||
observabilityRuleRegistry: createObservabilityRuleRegistryMock(),
|
||||
} as unknown) as PluginContextValue
|
||||
}
|
||||
>
|
||||
<Story />
|
||||
</PluginContext.Provider>
|
||||
</KibanaContextProvider>
|
||||
</IntlProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function Example() {
|
||||
return <AlertsPage items={wireframeData} routeParams={{ query: {} }} />;
|
||||
export function Example(_args: PageArgs) {
|
||||
return (
|
||||
<AlertsPage routeParams={{ query: { rangeFrom: 'now-15m', rangeTo: 'now', kuery: '' } }} />
|
||||
);
|
||||
}
|
||||
Example.args = {
|
||||
items: apmAlertResponseExample,
|
||||
} as PageArgs;
|
||||
|
||||
export function EventLog() {
|
||||
return <AlertsPage items={eventLogPocData as AlertItem[]} routeParams={{ query: {} }} />;
|
||||
export function EmptyState(_args: PageArgs) {
|
||||
return <AlertsPage routeParams={{ query: {} }} />;
|
||||
}
|
||||
EmptyState.args = { items: [] } as PageArgs;
|
||||
|
||||
export function EmptyState() {
|
||||
return <AlertsPage items={[]} routeParams={{ query: {} }} />;
|
||||
}
|
||||
|
||||
export function Flyout() {
|
||||
return <AlertsFlyout {...wireframeData[0]} onClose={() => {}} />;
|
||||
export function Flyout({ alert }: FlyoutArgs) {
|
||||
return <AlertsFlyout alert={alert} onClose={() => {}} />;
|
||||
}
|
||||
Flyout.args = { alert: flyoutItemExample } as FlyoutArgs;
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
EuiBadge,
|
||||
EuiFlyout,
|
||||
EuiFlyoutHeader,
|
||||
EuiFlyoutProps,
|
||||
|
@ -17,57 +16,46 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import { AlertItem } from './alerts_table';
|
||||
import { asDuration } from '../../../common/utils/formatters';
|
||||
import { TopAlert } from './alerts_table';
|
||||
|
||||
type AlertsFlyoutProps = AlertItem & EuiFlyoutProps;
|
||||
type AlertsFlyoutProps = { alert: TopAlert } & EuiFlyoutProps;
|
||||
|
||||
export function AlertsFlyout(props: AlertsFlyoutProps) {
|
||||
const {
|
||||
actualValue,
|
||||
affectedEntity,
|
||||
expectedValue,
|
||||
onClose,
|
||||
reason,
|
||||
severity,
|
||||
severityLog,
|
||||
status,
|
||||
duration,
|
||||
type,
|
||||
} = props;
|
||||
const timestamp = props['@timestamp'];
|
||||
const { onClose, alert } = props;
|
||||
|
||||
const overviewListItems = [
|
||||
{
|
||||
title: 'Status',
|
||||
description: status || '-',
|
||||
description: alert.active ? 'Active' : 'Recovered',
|
||||
},
|
||||
{
|
||||
title: 'Severity',
|
||||
description: severity || '-', // TODO: badge and "(changed 2 min ago)"
|
||||
},
|
||||
{
|
||||
title: 'Affected entity',
|
||||
description: affectedEntity || '-', // TODO: link to entity
|
||||
description: alert.severityLevel || '-', // TODO: badge and "(changed 2 min ago)"
|
||||
},
|
||||
// {
|
||||
// title: 'Affected entity',
|
||||
// description: affectedEntity || '-', // TODO: link to entity
|
||||
// },
|
||||
{
|
||||
title: 'Triggered',
|
||||
description: timestamp, // TODO: format date
|
||||
description: alert.start, // TODO: format date
|
||||
},
|
||||
{
|
||||
title: 'Duration',
|
||||
description: duration || '-', // TODO: format duration
|
||||
description: asDuration(alert.duration, { extended: true }) || '-', // TODO: format duration
|
||||
},
|
||||
// {
|
||||
// title: 'Expected value',
|
||||
// description: expectedValue || '-',
|
||||
// },
|
||||
// {
|
||||
// title: 'Actual value',
|
||||
// description: actualValue || '-',
|
||||
// },
|
||||
{
|
||||
title: 'Expected value',
|
||||
description: expectedValue || '-',
|
||||
},
|
||||
{
|
||||
title: 'Actual value',
|
||||
description: actualValue || '-',
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
description: type || '-',
|
||||
title: 'Rule type',
|
||||
description: alert.ruleCategory || '-',
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -87,7 +75,7 @@ export function AlertsFlyout(props: AlertsFlyoutProps) {
|
|||
]}
|
||||
items={overviewListItems}
|
||||
/>
|
||||
<EuiSpacer />
|
||||
{/* <EuiSpacer />
|
||||
<EuiTitle size="xs">
|
||||
<h4>Severity log</h4>
|
||||
</EuiTitle>
|
||||
|
@ -105,7 +93,7 @@ export function AlertsFlyout(props: AlertsFlyoutProps) {
|
|||
},
|
||||
]}
|
||||
items={severityLog ?? []}
|
||||
/>
|
||||
/> */}
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
@ -123,7 +111,7 @@ export function AlertsFlyout(props: AlertsFlyoutProps) {
|
|||
<EuiFlyout onClose={onClose} size="s">
|
||||
<EuiFlyoutHeader>
|
||||
<EuiTitle size="xs">
|
||||
<h2>{reason}</h2>
|
||||
<h2>{alert.ruleName}</h2>
|
||||
</EuiTitle>
|
||||
<EuiTabbedContent size="s" tabs={tabs} />
|
||||
</EuiFlyoutHeader>
|
||||
|
|
|
@ -6,19 +6,56 @@
|
|||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { SearchBar, TimeHistory } from '../../../../../../src/plugins/data/public';
|
||||
import { Storage } from '../../../../../../src/plugins/kibana_utils/public';
|
||||
import { useFetcher } from '../../hooks/use_fetcher';
|
||||
import { callObservabilityApi } from '../../services/call_observability_api';
|
||||
|
||||
export function AlertsSearchBar({
|
||||
rangeFrom,
|
||||
rangeTo,
|
||||
onQueryChange,
|
||||
query,
|
||||
}: {
|
||||
rangeFrom?: string;
|
||||
rangeTo?: string;
|
||||
query?: string;
|
||||
onQueryChange: ({}: {
|
||||
dateRange: { from: string; to: string; mode?: 'absolute' | 'relative' };
|
||||
query?: string;
|
||||
}) => void;
|
||||
}) {
|
||||
const timeHistory = useMemo(() => {
|
||||
return new TimeHistory(new Storage(localStorage));
|
||||
}, []);
|
||||
|
||||
const { data: dynamicIndexPattern } = useFetcher(({ signal }) => {
|
||||
return callObservabilityApi({
|
||||
signal,
|
||||
endpoint: 'GET /api/observability/rules/alerts/dynamic_index_pattern',
|
||||
});
|
||||
}, []);
|
||||
|
||||
export function AlertsSearchBar() {
|
||||
return (
|
||||
<SearchBar
|
||||
indexPatterns={[]}
|
||||
indexPatterns={dynamicIndexPattern ? [dynamicIndexPattern] : []}
|
||||
placeholder={i18n.translate('xpack.observability.alerts.searchBarPlaceholder', {
|
||||
defaultMessage: '"domain": "ecommerce" AND ("service.name": "ProductCatalogService" …)',
|
||||
})}
|
||||
query={{ query: '', language: 'kuery' }}
|
||||
timeHistory={new TimeHistory(new Storage(localStorage))}
|
||||
query={{ query: query ?? '', language: 'kuery' }}
|
||||
timeHistory={timeHistory}
|
||||
dateRangeFrom={rangeFrom}
|
||||
dateRangeTo={rangeTo}
|
||||
onRefresh={({ dateRange }) => {
|
||||
onQueryChange({ dateRange, query });
|
||||
}}
|
||||
onQuerySubmit={({ dateRange, query: nextQuery }) => {
|
||||
onQueryChange({
|
||||
dateRange,
|
||||
query: typeof nextQuery?.query === 'string' ? nextQuery.query : '',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -12,66 +12,38 @@ import {
|
|||
DefaultItemAction,
|
||||
EuiTableSelectionType,
|
||||
EuiLink,
|
||||
EuiBadge,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { useState } from 'react';
|
||||
import { asDuration } from '../../../common/utils/formatters';
|
||||
import { TimestampTooltip } from '../../components/shared/timestamp_tooltip';
|
||||
import { usePluginContext } from '../../hooks/use_plugin_context';
|
||||
import { AlertsFlyout } from './alerts_flyout';
|
||||
|
||||
/**
|
||||
* The type of an item in the alert list.
|
||||
*
|
||||
* The fields here are the minimum to make this work at this time, but
|
||||
* eventually this type should be derived from the schema of what is returned in
|
||||
* the API response.
|
||||
*/
|
||||
export interface AlertItem {
|
||||
'@timestamp': number;
|
||||
export interface TopAlert {
|
||||
start: number;
|
||||
duration: number;
|
||||
reason: string;
|
||||
severity: string;
|
||||
// These are just made up so we can make example links
|
||||
service?: { name?: string };
|
||||
pod?: string;
|
||||
log?: boolean;
|
||||
// Other fields used in the flyout
|
||||
actualValue?: string;
|
||||
affectedEntity?: string;
|
||||
expectedValue?: string;
|
||||
severityLog?: Array<{ '@timestamp': number; severity: string; message: string }>;
|
||||
status?: string;
|
||||
duration?: string;
|
||||
type?: string;
|
||||
link?: string;
|
||||
severityLevel?: string;
|
||||
active: boolean;
|
||||
ruleName: string;
|
||||
ruleCategory: string;
|
||||
}
|
||||
|
||||
type AlertsTableProps = Omit<
|
||||
EuiBasicTableProps<AlertItem>,
|
||||
EuiBasicTableProps<TopAlert>,
|
||||
'columns' | 'isSelectable' | 'pagination' | 'selection'
|
||||
>;
|
||||
|
||||
export function AlertsTable(props: AlertsTableProps) {
|
||||
const [flyoutAlert, setFlyoutAlert] = useState<AlertItem | undefined>(undefined);
|
||||
const [flyoutAlert, setFlyoutAlert] = useState<TopAlert | undefined>(undefined);
|
||||
const handleFlyoutClose = () => setFlyoutAlert(undefined);
|
||||
const { prepend } = usePluginContext().core.http.basePath;
|
||||
const { core } = usePluginContext();
|
||||
const { prepend } = core.http.basePath;
|
||||
|
||||
// This is a contrived implementation of the reason field that shows how
|
||||
// you could link to certain types of resources based on what's contained
|
||||
// in their alert data.
|
||||
function reasonRenderer(text: string, item: AlertItem) {
|
||||
const serviceName = item.service?.name;
|
||||
const pod = item.pod;
|
||||
const log = item.log;
|
||||
|
||||
if (serviceName) {
|
||||
return <EuiLink href={prepend(`/app/apm/services/${serviceName}`)}>{text}</EuiLink>;
|
||||
} else if (pod) {
|
||||
return <EuiLink href={prepend(`/app/metrics/link-to/host-detail/${pod}`)}>{text}</EuiLink>;
|
||||
} else if (log) {
|
||||
return <EuiLink href={prepend(`/app/logs/stream`)}>{text}</EuiLink>;
|
||||
} else {
|
||||
return <>{text}</>;
|
||||
}
|
||||
}
|
||||
|
||||
const actions: Array<DefaultItemAction<AlertItem>> = [
|
||||
const actions: Array<DefaultItemAction<TopAlert>> = [
|
||||
{
|
||||
name: 'Alert details',
|
||||
description: 'Alert details',
|
||||
|
@ -82,25 +54,53 @@ export function AlertsTable(props: AlertsTableProps) {
|
|||
},
|
||||
];
|
||||
|
||||
const columns: Array<EuiBasicTableColumn<AlertItem>> = [
|
||||
const columns: Array<EuiBasicTableColumn<TopAlert>> = [
|
||||
{
|
||||
field: '@timestamp',
|
||||
field: 'active',
|
||||
name: 'Status',
|
||||
width: '112px',
|
||||
render: (_, { active }) => {
|
||||
const style = {
|
||||
width: '96px',
|
||||
textAlign: 'center' as const,
|
||||
};
|
||||
|
||||
return active ? (
|
||||
<EuiBadge iconType="alert" color="danger" style={style}>
|
||||
{i18n.translate('xpack.observability.alertsTable.status.active', {
|
||||
defaultMessage: 'Active',
|
||||
})}
|
||||
</EuiBadge>
|
||||
) : (
|
||||
<EuiBadge iconType="check" color="hollow" style={style}>
|
||||
{i18n.translate('xpack.observability.alertsTable.status.recovered', {
|
||||
defaultMessage: 'Recovered',
|
||||
})}
|
||||
</EuiBadge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'start',
|
||||
name: 'Triggered',
|
||||
dataType: 'date',
|
||||
render: (_, item) => {
|
||||
return <TimestampTooltip time={new Date(item.start).getTime()} timeUnit="milliseconds" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'duration',
|
||||
name: 'Duration',
|
||||
},
|
||||
{
|
||||
field: 'severity',
|
||||
name: 'Severity',
|
||||
render: (_, { duration, active }) => {
|
||||
return active ? null : asDuration(duration, { extended: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'reason',
|
||||
name: 'Reason',
|
||||
dataType: 'string',
|
||||
render: reasonRenderer,
|
||||
render: (_, item) => {
|
||||
return item.link ? <EuiLink href={prepend(item.link)}>{item.reason}</EuiLink> : item.reason;
|
||||
},
|
||||
},
|
||||
{
|
||||
actions,
|
||||
|
@ -110,12 +110,13 @@ export function AlertsTable(props: AlertsTableProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
{flyoutAlert && <AlertsFlyout {...flyoutAlert} onClose={handleFlyoutClose} />}
|
||||
<EuiBasicTable<AlertItem>
|
||||
{flyoutAlert && <AlertsFlyout alert={flyoutAlert} onClose={handleFlyoutClose} />}
|
||||
<EuiBasicTable<TopAlert>
|
||||
{...props}
|
||||
isSelectable={true}
|
||||
selection={{} as EuiTableSelectionType<AlertItem>}
|
||||
selection={{} as EuiTableSelectionType<TopAlert>}
|
||||
columns={columns}
|
||||
tableLayout="auto"
|
||||
pagination={{ pageIndex: 0, pageSize: 0, totalItemCount: 0 }}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -5,505 +5,237 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Example data from Whimsical wireframes: https://whimsical.com/observability-alerting-user-journeys-8TFDcHRPMQDJgtLpJ7XuBj
|
||||
*/
|
||||
export const wireframeData = [
|
||||
export const apmAlertResponseExample = [
|
||||
{
|
||||
'@timestamp': 1615392661000,
|
||||
duration: '10 min 2 s',
|
||||
severity: '-',
|
||||
reason: 'Error count is greater than 100 (current value is 135) on shippingService',
|
||||
service: { name: 'opbeans-go' },
|
||||
affectedEntity: 'opbeans-go service',
|
||||
status: 'Active',
|
||||
expectedValue: '< 100',
|
||||
actualValue: '135',
|
||||
severityLog: [
|
||||
{ '@timestamp': 1615392661000, severity: 'critical', message: 'Load is 3.5' },
|
||||
{ '@timestamp': 1615392600000, severity: 'warning', message: 'Load is 2.5' },
|
||||
{ '@timestamp': 1615392552000, severity: 'critical', message: 'Load is 3.5' },
|
||||
],
|
||||
type: 'APM Error count',
|
||||
'rule.id': 'apm.error_rate',
|
||||
'service.name': 'opbeans-java',
|
||||
'rule.name': 'Error count threshold | opbeans-java (smith test)',
|
||||
'kibana.rac.alert.duration.us': 180057000,
|
||||
'kibana.rac.alert.status': 'open',
|
||||
tags: ['apm', 'service.name:opbeans-java'],
|
||||
'kibana.rac.alert.uuid': '0175ec0a-a3b1-4d41-b557-e21c2d024352',
|
||||
'rule.uuid': '474920d0-93e9-11eb-ac86-0b455460de81',
|
||||
'event.action': 'active',
|
||||
'@timestamp': '2021-04-12T13:53:49.550Z',
|
||||
'kibana.rac.alert.id': 'apm.error_rate_opbeans-java_production',
|
||||
'kibana.rac.alert.start': '2021-04-12T13:50:49.493Z',
|
||||
'kibana.rac.producer': 'apm',
|
||||
'event.kind': 'state',
|
||||
'rule.category': 'Error count threshold',
|
||||
'service.environment': ['production'],
|
||||
'processor.event': ['error'],
|
||||
},
|
||||
{
|
||||
'@timestamp': 1615392600000,
|
||||
duration: '11 min 1 s',
|
||||
severity: '-',
|
||||
reason: 'Latency is greater than 1500ms (current value is 1700ms) on frontend',
|
||||
service: { name: 'opbeans-go' },
|
||||
severityLog: [],
|
||||
},
|
||||
{
|
||||
'@timestamp': 1615392552000,
|
||||
duration: '10 min 2 s',
|
||||
severity: 'critical',
|
||||
reason: 'Latency anomaly score is 84 on checkoutService',
|
||||
service: { name: 'opbeans-go' },
|
||||
severityLog: [],
|
||||
},
|
||||
{
|
||||
'@timestamp': 1615392391000,
|
||||
duration: '10 min 2 s',
|
||||
severity: '-',
|
||||
reason:
|
||||
'CPU is greater than a threshold of 75% (current value is 83%) on gke-eden-3-prod-pool-2-395ef018-06xg',
|
||||
pod: 'gke-dev-oblt-dev-oblt-pool-30f1ba48-skw',
|
||||
severityLog: [],
|
||||
},
|
||||
{
|
||||
'@timestamp': 1615392363000,
|
||||
duration: '10 min 2 s',
|
||||
severity: '-',
|
||||
reason:
|
||||
"Log count with 'Log.level.error' and 'service.name; frontend' is greater than 75 (current value 122)",
|
||||
log: true,
|
||||
severityLog: [],
|
||||
},
|
||||
{
|
||||
'@timestamp': 1615392361000,
|
||||
duration: '10 min 2 s',
|
||||
severity: 'critical',
|
||||
reason: 'Load is greater than 2 (current value is 3.5) on gke-eden-3-prod-pool-2-395ef018-06xg',
|
||||
pod: 'gke-dev-oblt-dev-oblt-pool-30f1ba48-skw',
|
||||
severityLog: [],
|
||||
'rule.id': 'apm.error_rate',
|
||||
'service.name': 'opbeans-java',
|
||||
'rule.name': 'Error count threshold | opbeans-java (smith test)',
|
||||
'kibana.rac.alert.duration.us': 2419005000,
|
||||
'kibana.rac.alert.end': '2021-04-12T13:49:49.446Z',
|
||||
'kibana.rac.alert.status': 'closed',
|
||||
tags: ['apm', 'service.name:opbeans-java'],
|
||||
'kibana.rac.alert.uuid': '32b940e1-3809-4c12-8eee-f027cbb385e2',
|
||||
'rule.uuid': '474920d0-93e9-11eb-ac86-0b455460de81',
|
||||
'event.action': 'close',
|
||||
'@timestamp': '2021-04-12T13:49:49.446Z',
|
||||
'kibana.rac.alert.id': 'apm.error_rate_opbeans-java_production',
|
||||
'kibana.rac.alert.start': '2021-04-12T13:09:30.441Z',
|
||||
'kibana.rac.producer': 'apm',
|
||||
'event.kind': 'state',
|
||||
'rule.category': 'Error count threshold',
|
||||
'service.environment': ['production'],
|
||||
'processor.event': ['error'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Example data from this proof of concept: https://github.com/dgieselaar/kibana/tree/alerting-event-log-poc
|
||||
*/
|
||||
export const eventLogPocData = [
|
||||
{
|
||||
'@timestamp': 1615395754597,
|
||||
first_seen: 1615362488702,
|
||||
severity: 'warning',
|
||||
severity_value: 1241.4546,
|
||||
reason:
|
||||
'Transaction duration for opbeans-java/request in production was above the threshold of 1.0 ms (1.2 ms)',
|
||||
rule_id: 'cb1fc3e0-7fef-11eb-827d-d94e80a23d8d',
|
||||
rule_name: 'Latency threshold | opbeans-java',
|
||||
rule_type_id: 'apm.transaction_duration',
|
||||
rule_type_name: 'Latency threshold',
|
||||
alert_instance_title: ['opbeans-java/request:production'],
|
||||
alert_instance_name: 'apm.transaction_duration_production',
|
||||
unique: 1,
|
||||
group_by_field: 'alert_instance.uuid',
|
||||
group_by_value: '1b354805-4bf3-4626-b6be-5801d7d1e256',
|
||||
influencers: [
|
||||
'service.name:opbeans-java',
|
||||
'service.environment:production',
|
||||
'transaction.type:request',
|
||||
],
|
||||
fields: {
|
||||
'processor.event': 'transaction',
|
||||
'service.name': 'opbeans-java',
|
||||
'service.environment': 'production',
|
||||
'transaction.type': 'request',
|
||||
export const flyoutItemExample = {
|
||||
link: '/app/apm/services/opbeans-java?rangeFrom=now-15m&rangeTo=now',
|
||||
reason: 'Error count for opbeans-java was above the threshold',
|
||||
active: true,
|
||||
start: 1618235449493,
|
||||
duration: 180057000,
|
||||
ruleCategory: 'Error count threshold',
|
||||
ruleName: 'Error count threshold | opbeans-java (smith test)',
|
||||
};
|
||||
|
||||
export const dynamicIndexPattern = {
|
||||
fields: [
|
||||
{
|
||||
name: '@timestamp',
|
||||
type: 'date',
|
||||
esTypes: ['date'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
timeseries: [
|
||||
{
|
||||
x: 1615359600000,
|
||||
y: 48805,
|
||||
threshold: 1000,
|
||||
},
|
||||
{
|
||||
x: 1615370400000,
|
||||
y: 3992.5,
|
||||
threshold: 1000,
|
||||
},
|
||||
{
|
||||
x: 1615381200000,
|
||||
y: 4296.7998046875,
|
||||
threshold: 1000,
|
||||
},
|
||||
{
|
||||
x: 1615392000000,
|
||||
y: 1633.8182373046875,
|
||||
threshold: 1000,
|
||||
},
|
||||
],
|
||||
recovered: false,
|
||||
},
|
||||
{
|
||||
'@timestamp': 1615326143423,
|
||||
first_seen: 1615323802378,
|
||||
severity: 'warning',
|
||||
severity_value: 27,
|
||||
reason: 'Error count for opbeans-node in production was above the threshold of 2 (27)',
|
||||
rule_id: '335b38d0-80be-11eb-9fd1-d3725789930d',
|
||||
rule_name: 'Error count threshold',
|
||||
rule_type_id: 'apm.error_rate',
|
||||
rule_type_name: 'Error count threshold',
|
||||
alert_instance_title: ['opbeans-node:production'],
|
||||
alert_instance_name: 'opbeans-node_production',
|
||||
unique: 1,
|
||||
group_by_field: 'alert_instance.uuid',
|
||||
group_by_value: '19165a4f-296a-4045-9448-40c793d97d02',
|
||||
influencers: ['service.name:opbeans-node', 'service.environment:production'],
|
||||
fields: {
|
||||
'processor.event': 'error',
|
||||
'service.name': 'opbeans-node',
|
||||
'service.environment': 'production',
|
||||
{
|
||||
name: 'event.action',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
timeseries: [
|
||||
{
|
||||
x: 1615323780000,
|
||||
y: 32,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615324080000,
|
||||
y: 34,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615324380000,
|
||||
y: 32,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615324680000,
|
||||
y: 34,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615324980000,
|
||||
y: 35,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615325280000,
|
||||
y: 31,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615325580000,
|
||||
y: 36,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615325880000,
|
||||
y: 35,
|
||||
threshold: 2,
|
||||
},
|
||||
],
|
||||
recovered: true,
|
||||
},
|
||||
{
|
||||
'@timestamp': 1615326143423,
|
||||
first_seen: 1615325783256,
|
||||
severity: 'warning',
|
||||
severity_value: 27,
|
||||
reason: 'Error count for opbeans-java in production was above the threshold of 2 (27)',
|
||||
rule_id: '335b38d0-80be-11eb-9fd1-d3725789930d',
|
||||
rule_name: 'Error count threshold',
|
||||
rule_type_id: 'apm.error_rate',
|
||||
rule_type_name: 'Error count threshold',
|
||||
alert_instance_title: ['opbeans-java:production'],
|
||||
alert_instance_name: 'opbeans-java_production',
|
||||
unique: 1,
|
||||
group_by_field: 'alert_instance.uuid',
|
||||
group_by_value: '73075d90-e27a-4e20-9ba0-3512a16c2829',
|
||||
influencers: ['service.name:opbeans-java', 'service.environment:production'],
|
||||
fields: {
|
||||
'processor.event': 'error',
|
||||
'service.name': 'opbeans-java',
|
||||
'service.environment': 'production',
|
||||
{
|
||||
name: 'event.kind',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
timeseries: [
|
||||
{
|
||||
x: 1615325760000,
|
||||
y: 36,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615325820000,
|
||||
y: 26,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615325880000,
|
||||
y: 28,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615325940000,
|
||||
y: 35,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615326000000,
|
||||
y: 32,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615326060000,
|
||||
y: 23,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615326120000,
|
||||
y: 27,
|
||||
threshold: 2,
|
||||
},
|
||||
],
|
||||
recovered: true,
|
||||
},
|
||||
{
|
||||
'@timestamp': 1615326143423,
|
||||
first_seen: 1615323802378,
|
||||
severity: 'warning',
|
||||
severity_value: 4759.9116,
|
||||
reason:
|
||||
'Transaction duration for opbeans-java/request in production was above the threshold of 1.0 ms (4.8 ms)',
|
||||
rule_id: 'cb1fc3e0-7fef-11eb-827d-d94e80a23d8d',
|
||||
rule_name: 'Latency threshold | opbeans-java',
|
||||
rule_type_id: 'apm.transaction_duration',
|
||||
rule_type_name: 'Latency threshold',
|
||||
alert_instance_title: ['opbeans-java/request:production'],
|
||||
alert_instance_name: 'apm.transaction_duration_production',
|
||||
unique: 1,
|
||||
group_by_field: 'alert_instance.uuid',
|
||||
group_by_value: 'ffa0437d-6656-4553-a1cd-c170fc6e2f81',
|
||||
influencers: [
|
||||
'service.name:opbeans-java',
|
||||
'service.environment:production',
|
||||
'transaction.type:request',
|
||||
],
|
||||
fields: {
|
||||
'processor.event': 'transaction',
|
||||
'service.name': 'opbeans-java',
|
||||
'service.environment': 'production',
|
||||
'transaction.type': 'request',
|
||||
{
|
||||
name: 'host.name',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
timeseries: [
|
||||
{
|
||||
x: 1615323780000,
|
||||
y: 13145.51171875,
|
||||
threshold: 1000,
|
||||
},
|
||||
{
|
||||
x: 1615324080000,
|
||||
y: 15995.15625,
|
||||
threshold: 1000,
|
||||
},
|
||||
{
|
||||
x: 1615324380000,
|
||||
y: 18974.59375,
|
||||
threshold: 1000,
|
||||
},
|
||||
{
|
||||
x: 1615324680000,
|
||||
y: 11604.87890625,
|
||||
threshold: 1000,
|
||||
},
|
||||
{
|
||||
x: 1615324980000,
|
||||
y: 17945.9609375,
|
||||
threshold: 1000,
|
||||
},
|
||||
{
|
||||
x: 1615325280000,
|
||||
y: 9933.22265625,
|
||||
threshold: 1000,
|
||||
},
|
||||
{
|
||||
x: 1615325580000,
|
||||
y: 10011.58984375,
|
||||
threshold: 1000,
|
||||
},
|
||||
{
|
||||
x: 1615325880000,
|
||||
y: 10953.1845703125,
|
||||
threshold: 1000,
|
||||
},
|
||||
],
|
||||
recovered: true,
|
||||
},
|
||||
{
|
||||
'@timestamp': 1615325663207,
|
||||
first_seen: 1615324762861,
|
||||
severity: 'warning',
|
||||
severity_value: 27,
|
||||
reason: 'Error count for opbeans-java in production was above the threshold of 2 (27)',
|
||||
rule_id: '335b38d0-80be-11eb-9fd1-d3725789930d',
|
||||
rule_name: 'Error count threshold',
|
||||
rule_type_id: 'apm.error_rate',
|
||||
rule_type_name: 'Error count threshold',
|
||||
alert_instance_title: ['opbeans-java:production'],
|
||||
alert_instance_name: 'opbeans-java_production',
|
||||
unique: 1,
|
||||
group_by_field: 'alert_instance.uuid',
|
||||
group_by_value: 'bf5f9574-57c8-44ed-9a3c-512b446695cf',
|
||||
influencers: ['service.name:opbeans-java', 'service.environment:production'],
|
||||
fields: {
|
||||
'processor.event': 'error',
|
||||
'service.name': 'opbeans-java',
|
||||
'service.environment': 'production',
|
||||
{
|
||||
name: 'kibana.rac.alert.duration.us',
|
||||
type: 'number',
|
||||
esTypes: ['long'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
timeseries: [
|
||||
{
|
||||
x: 1615324740000,
|
||||
y: 34,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615325040000,
|
||||
y: 35,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615325340000,
|
||||
y: 31,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615325640000,
|
||||
y: 27,
|
||||
threshold: 2,
|
||||
},
|
||||
],
|
||||
recovered: true,
|
||||
},
|
||||
{
|
||||
'@timestamp': 1615324642764,
|
||||
first_seen: 1615324402620,
|
||||
severity: 'warning',
|
||||
severity_value: 32,
|
||||
reason: 'Error count for opbeans-java in production was above the threshold of 2 (32)',
|
||||
rule_id: '335b38d0-80be-11eb-9fd1-d3725789930d',
|
||||
rule_name: 'Error count threshold',
|
||||
rule_type_id: 'apm.error_rate',
|
||||
rule_type_name: 'Error count threshold',
|
||||
alert_instance_title: ['opbeans-java:production'],
|
||||
alert_instance_name: 'opbeans-java_production',
|
||||
unique: 1,
|
||||
group_by_field: 'alert_instance.uuid',
|
||||
group_by_value: '87768bef-67a3-4ddd-b95d-7ab8830b30ef',
|
||||
influencers: ['service.name:opbeans-java', 'service.environment:production'],
|
||||
fields: {
|
||||
'processor.event': 'error',
|
||||
'service.name': 'opbeans-java',
|
||||
'service.environment': 'production',
|
||||
{
|
||||
name: 'kibana.rac.alert.end',
|
||||
type: 'date',
|
||||
esTypes: ['date'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
timeseries: [
|
||||
{
|
||||
x: 1615324402000,
|
||||
y: 30,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615324432000,
|
||||
y: null,
|
||||
threshold: null,
|
||||
},
|
||||
{
|
||||
x: 1615324462000,
|
||||
y: 28,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615324492000,
|
||||
y: null,
|
||||
threshold: null,
|
||||
},
|
||||
{
|
||||
x: 1615324522000,
|
||||
y: 30,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615324552000,
|
||||
y: null,
|
||||
threshold: null,
|
||||
},
|
||||
{
|
||||
x: 1615324582000,
|
||||
y: 18,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615324612000,
|
||||
y: null,
|
||||
threshold: null,
|
||||
},
|
||||
{
|
||||
x: 1615324642000,
|
||||
y: 32,
|
||||
threshold: 2,
|
||||
},
|
||||
],
|
||||
recovered: true,
|
||||
},
|
||||
{
|
||||
'@timestamp': 1615324282583,
|
||||
first_seen: 1615323802378,
|
||||
severity: 'warning',
|
||||
severity_value: 30,
|
||||
reason: 'Error count for opbeans-java in production was above the threshold of 2 (30)',
|
||||
rule_id: '335b38d0-80be-11eb-9fd1-d3725789930d',
|
||||
rule_name: 'Error count threshold',
|
||||
rule_type_id: 'apm.error_rate',
|
||||
rule_type_name: 'Error count threshold',
|
||||
alert_instance_title: ['opbeans-java:production'],
|
||||
alert_instance_name: 'opbeans-java_production',
|
||||
unique: 1,
|
||||
group_by_field: 'alert_instance.uuid',
|
||||
group_by_value: '31d087bd-51ae-419d-81c0-d0671eb97392',
|
||||
influencers: ['service.name:opbeans-java', 'service.environment:production'],
|
||||
fields: {
|
||||
'processor.event': 'error',
|
||||
'service.name': 'opbeans-java',
|
||||
'service.environment': 'production',
|
||||
{
|
||||
name: 'kibana.rac.alert.id',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
timeseries: [
|
||||
{
|
||||
x: 1615323780000,
|
||||
y: 31,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615323840000,
|
||||
y: 30,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615323900000,
|
||||
y: 24,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615323960000,
|
||||
y: 32,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615324020000,
|
||||
y: 32,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615324080000,
|
||||
y: 30,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615324140000,
|
||||
y: 25,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615324200000,
|
||||
y: 34,
|
||||
threshold: 2,
|
||||
},
|
||||
{
|
||||
x: 1615324260000,
|
||||
y: 30,
|
||||
threshold: 2,
|
||||
},
|
||||
],
|
||||
recovered: true,
|
||||
},
|
||||
];
|
||||
{
|
||||
name: 'kibana.rac.alert.severity.level',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
{
|
||||
name: 'kibana.rac.alert.severity.value',
|
||||
type: 'number',
|
||||
esTypes: ['long'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
{
|
||||
name: 'kibana.rac.alert.start',
|
||||
type: 'date',
|
||||
esTypes: ['date'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
{
|
||||
name: 'kibana.rac.alert.status',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
{
|
||||
name: 'kibana.rac.alert.uuid',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
{
|
||||
name: 'kibana.rac.producer',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
{
|
||||
name: 'processor.event',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
{
|
||||
name: 'rule.category',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
{
|
||||
name: 'rule.id',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
{
|
||||
name: 'rule.name',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
{
|
||||
name: 'rule.uuid',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
{
|
||||
name: 'service.environment',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
{
|
||||
name: 'service.name',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
{
|
||||
name: 'transaction.type',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
],
|
||||
timeFieldName: '@timestamp',
|
||||
title: '.kibana_smith-alerts-observability*',
|
||||
};
|
||||
|
|
|
@ -16,26 +16,28 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { format, parse } from 'url';
|
||||
import { ExperimentalBadge } from '../../components/shared/experimental_badge';
|
||||
import { useFetcher } from '../../hooks/use_fetcher';
|
||||
import { usePluginContext } from '../../hooks/use_plugin_context';
|
||||
import { RouteParams } from '../../routes';
|
||||
import { callObservabilityApi } from '../../services/call_observability_api';
|
||||
import { getAbsoluteDateRange } from '../../utils/date';
|
||||
import { AlertsSearchBar } from './alerts_search_bar';
|
||||
import { AlertItem, AlertsTable } from './alerts_table';
|
||||
import { wireframeData } from './example_data';
|
||||
import { AlertsTable } from './alerts_table';
|
||||
|
||||
interface AlertsPageProps {
|
||||
items?: AlertItem[];
|
||||
routeParams: RouteParams<'/alerts'>;
|
||||
}
|
||||
|
||||
export function AlertsPage({ items }: AlertsPageProps) {
|
||||
// For now, if we're not passed any items load the example wireframe data.
|
||||
if (!items) {
|
||||
items = wireframeData;
|
||||
}
|
||||
|
||||
const { core } = usePluginContext();
|
||||
export function AlertsPage({ routeParams }: AlertsPageProps) {
|
||||
const { core, observabilityRuleRegistry } = usePluginContext();
|
||||
const { prepend } = core.http.basePath;
|
||||
const history = useHistory();
|
||||
const {
|
||||
query: { rangeFrom = 'now-15m', rangeTo = 'now', kuery = '' },
|
||||
} = routeParams;
|
||||
|
||||
// In a future milestone we'll have a page dedicated to rule management in
|
||||
// observability. For now link to the settings page.
|
||||
|
@ -43,6 +45,59 @@ export function AlertsPage({ items }: AlertsPageProps) {
|
|||
'/app/management/insightsAndAlerting/triggersActions/alerts'
|
||||
);
|
||||
|
||||
const { data: topAlerts } = useFetcher(
|
||||
({ signal }) => {
|
||||
const { start, end } = getAbsoluteDateRange({ rangeFrom, rangeTo });
|
||||
|
||||
if (!start || !end) {
|
||||
return;
|
||||
}
|
||||
return callObservabilityApi({
|
||||
signal,
|
||||
endpoint: 'GET /api/observability/rules/alerts/top',
|
||||
params: {
|
||||
query: {
|
||||
start,
|
||||
end,
|
||||
kuery,
|
||||
},
|
||||
},
|
||||
}).then((alerts) => {
|
||||
return alerts.map((alert) => {
|
||||
const ruleType = observabilityRuleRegistry.getTypeByRuleId(alert['rule.id']);
|
||||
const formatted = {
|
||||
link: undefined,
|
||||
reason: alert['rule.name'],
|
||||
...(ruleType?.format?.({ alert }) ?? {}),
|
||||
};
|
||||
|
||||
const parsedLink = formatted.link ? parse(formatted.link, true) : undefined;
|
||||
|
||||
return {
|
||||
...formatted,
|
||||
link: parsedLink
|
||||
? format({
|
||||
...parsedLink,
|
||||
query: {
|
||||
...parsedLink.query,
|
||||
rangeFrom,
|
||||
rangeTo,
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
active: alert['event.action'] !== 'close',
|
||||
severityLevel: alert['kibana.rac.alert.severity.level'],
|
||||
start: new Date(alert['kibana.rac.alert.start']).getTime(),
|
||||
duration: alert['kibana.rac.alert.duration.us'],
|
||||
ruleCategory: alert['rule.category'],
|
||||
ruleName: alert['rule.name'],
|
||||
};
|
||||
});
|
||||
});
|
||||
},
|
||||
[kuery, observabilityRuleRegistry, rangeFrom, rangeTo]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiPage>
|
||||
<EuiPageHeader
|
||||
|
@ -87,10 +142,26 @@ export function AlertsPage({ items }: AlertsPageProps) {
|
|||
</EuiCallOut>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<AlertsSearchBar />
|
||||
<AlertsSearchBar
|
||||
rangeFrom={rangeFrom}
|
||||
rangeTo={rangeTo}
|
||||
query={kuery}
|
||||
onQueryChange={({ dateRange, query }) => {
|
||||
const nextSearchParams = new URLSearchParams(history.location.search);
|
||||
|
||||
nextSearchParams.set('rangeFrom', dateRange.from);
|
||||
nextSearchParams.set('rangeTo', dateRange.to);
|
||||
nextSearchParams.set('kuery', query ?? '');
|
||||
|
||||
history.push({
|
||||
...history.location,
|
||||
search: nextSearchParams.toString(),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<AlertsTable items={items} />
|
||||
<AlertsTable items={topAlerts ?? []} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPageHeader>
|
||||
|
|
|
@ -23,6 +23,7 @@ import { emptyResponse as emptyLogsResponse, fetchLogsData } from './mock/logs.m
|
|||
import { emptyResponse as emptyMetricsResponse, fetchMetricsData } from './mock/metrics.mock';
|
||||
import { newsFeedFetchData } from './mock/news_feed.mock';
|
||||
import { emptyResponse as emptyUptimeResponse, fetchUptimeData } from './mock/uptime.mock';
|
||||
import { createObservabilityRuleRegistryMock } from '../../rules/observability_rule_registry_mock';
|
||||
|
||||
function unregisterAll() {
|
||||
unregisterDataHandler({ appName: 'apm' });
|
||||
|
@ -52,6 +53,7 @@ const withCore = makeDecorator({
|
|||
},
|
||||
},
|
||||
} as unknown) as ObservabilityPublicPluginsStart,
|
||||
observabilityRuleRegistry: createObservabilityRuleRegistryMock(),
|
||||
}}
|
||||
>
|
||||
<EuiThemeProvider>
|
||||
|
|
|
@ -7,7 +7,11 @@
|
|||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public';
|
||||
import type { RuleRegistryPublicPluginSetupContract } from '../../rule_registry/public';
|
||||
import type {
|
||||
DataPublicPluginSetup,
|
||||
DataPublicPluginStart,
|
||||
} from '../../../../src/plugins/data/public';
|
||||
import {
|
||||
AppMountParameters,
|
||||
AppUpdater,
|
||||
|
@ -17,17 +21,23 @@ import {
|
|||
PluginInitializerContext,
|
||||
CoreStart,
|
||||
} from '../../../../src/core/public';
|
||||
import { HomePublicPluginSetup, HomePublicPluginStart } from '../../../../src/plugins/home/public';
|
||||
import type {
|
||||
HomePublicPluginSetup,
|
||||
HomePublicPluginStart,
|
||||
} from '../../../../src/plugins/home/public';
|
||||
import { registerDataHandler } from './data_handler';
|
||||
import { toggleOverviewLinkInNav } from './toggle_overview_link_in_nav';
|
||||
import { LensPublicStart } from '../../lens/public';
|
||||
import type { LensPublicStart } from '../../lens/public';
|
||||
import { createCallObservabilityApi } from './services/call_observability_api';
|
||||
import { observabilityRuleRegistrySettings } from '../common/observability_rule_registry';
|
||||
import { FormatterRuleRegistry } from './rules/formatter_rule_registry';
|
||||
|
||||
export interface ObservabilityPublicSetup {
|
||||
dashboard: { register: typeof registerDataHandler };
|
||||
}
|
||||
export type ObservabilityPublicSetup = ReturnType<Plugin['setup']>;
|
||||
export type ObservabilityRuleRegistry = ObservabilityPublicSetup['ruleRegistry'];
|
||||
|
||||
export interface ObservabilityPublicPluginsSetup {
|
||||
data: DataPublicPluginSetup;
|
||||
ruleRegistry: RuleRegistryPublicPluginSetupContract;
|
||||
home?: HomePublicPluginSetup;
|
||||
}
|
||||
|
||||
|
@ -52,22 +62,36 @@ export class Plugin
|
|||
constructor(context: PluginInitializerContext) {}
|
||||
|
||||
public setup(
|
||||
core: CoreSetup<ObservabilityPublicPluginsStart>,
|
||||
plugins: ObservabilityPublicPluginsSetup
|
||||
coreSetup: CoreSetup<ObservabilityPublicPluginsStart>,
|
||||
pluginsSetup: ObservabilityPublicPluginsSetup
|
||||
) {
|
||||
const category = DEFAULT_APP_CATEGORIES.observability;
|
||||
const euiIconType = 'logoObservability';
|
||||
|
||||
createCallObservabilityApi(coreSetup.http);
|
||||
|
||||
const observabilityRuleRegistry = pluginsSetup.ruleRegistry.registry.create({
|
||||
...observabilityRuleRegistrySettings,
|
||||
ctor: FormatterRuleRegistry,
|
||||
});
|
||||
|
||||
const mount = async (params: AppMountParameters<unknown>) => {
|
||||
// Load application bundle
|
||||
const { renderApp } = await import('./application');
|
||||
// Get start services
|
||||
const [coreStart, startPlugins] = await core.getStartServices();
|
||||
const [coreStart, pluginsStart] = await coreSetup.getStartServices();
|
||||
|
||||
return renderApp(coreStart, startPlugins, params);
|
||||
return renderApp({
|
||||
core: coreStart,
|
||||
plugins: pluginsStart,
|
||||
appMountParameters: params,
|
||||
observabilityRuleRegistry,
|
||||
});
|
||||
};
|
||||
|
||||
const updater$ = this.appUpdater$;
|
||||
|
||||
core.application.register({
|
||||
coreSetup.application.register({
|
||||
id: 'observability-overview',
|
||||
title: 'Overview',
|
||||
appRoute: '/app/observability',
|
||||
|
@ -78,8 +102,8 @@ export class Plugin
|
|||
updater$,
|
||||
});
|
||||
|
||||
if (core.uiSettings.get('observability:enableAlertingExperience')) {
|
||||
core.application.register({
|
||||
if (coreSetup.uiSettings.get('observability:enableAlertingExperience')) {
|
||||
coreSetup.application.register({
|
||||
id: 'observability-alerts',
|
||||
title: 'Alerts',
|
||||
appRoute: '/app/observability/alerts',
|
||||
|
@ -90,7 +114,7 @@ export class Plugin
|
|||
updater$,
|
||||
});
|
||||
|
||||
core.application.register({
|
||||
coreSetup.application.register({
|
||||
id: 'observability-cases',
|
||||
title: 'Cases',
|
||||
appRoute: '/app/observability/cases',
|
||||
|
@ -102,8 +126,8 @@ export class Plugin
|
|||
});
|
||||
}
|
||||
|
||||
if (plugins.home) {
|
||||
plugins.home.featureCatalogue.registerSolution({
|
||||
if (pluginsSetup.home) {
|
||||
pluginsSetup.home.featureCatalogue.registerSolution({
|
||||
id: 'observability',
|
||||
title: i18n.translate('xpack.observability.featureCatalogueTitle', {
|
||||
defaultMessage: 'Observability',
|
||||
|
@ -134,6 +158,7 @@ export class Plugin
|
|||
|
||||
return {
|
||||
dashboard: { register: registerDataHandler },
|
||||
ruleRegistry: observabilityRuleRegistry,
|
||||
};
|
||||
}
|
||||
public start({ application }: CoreStart) {
|
||||
|
|
|
@ -104,6 +104,7 @@ export const routes = {
|
|||
query: t.partial({
|
||||
rangeFrom: t.string,
|
||||
rangeTo: t.string,
|
||||
kuery: t.string,
|
||||
refreshPaused: jsonRt.pipe(t.boolean),
|
||||
refreshInterval: jsonRt.pipe(t.number),
|
||||
}),
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import type { RuleType } from '../../../rule_registry/public';
|
||||
import type { BaseRuleFieldMap, OutputOfFieldMap } from '../../../rule_registry/common';
|
||||
import { RuleRegistry } from '../../../rule_registry/public';
|
||||
|
||||
type AlertTypeOf<TFieldMap extends BaseRuleFieldMap> = OutputOfFieldMap<TFieldMap>;
|
||||
|
||||
type FormattableRuleType<TFieldMap extends BaseRuleFieldMap> = RuleType & {
|
||||
format?: (options: {
|
||||
alert: AlertTypeOf<TFieldMap>;
|
||||
}) => {
|
||||
reason?: string;
|
||||
link?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export class FormatterRuleRegistry<TFieldMap extends BaseRuleFieldMap> extends RuleRegistry<
|
||||
TFieldMap,
|
||||
FormattableRuleType<TFieldMap>
|
||||
> {}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ObservabilityRuleRegistry } from '../plugin';
|
||||
|
||||
const createRuleRegistryMock = () => ({
|
||||
registerType: () => {},
|
||||
getTypeByRuleId: () => {},
|
||||
create: () => createRuleRegistryMock(),
|
||||
});
|
||||
|
||||
export const createObservabilityRuleRegistryMock = () =>
|
||||
createRuleRegistryMock() as ObservabilityRuleRegistry & ReturnType<typeof createRuleRegistryMock>;
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { formatRequest } from '@kbn/server-route-repository/target/format_request';
|
||||
import type { HttpSetup } from 'kibana/public';
|
||||
import type { AbstractObservabilityClient, ObservabilityClient } from './types';
|
||||
|
||||
export let callObservabilityApi: ObservabilityClient = () => {
|
||||
throw new Error('callObservabilityApi has not been initialized via createCallObservabilityApi');
|
||||
};
|
||||
|
||||
export function createCallObservabilityApi(http: HttpSetup) {
|
||||
const client: AbstractObservabilityClient = (options) => {
|
||||
const { params: { path, body, query } = {}, endpoint, ...rest } = options;
|
||||
|
||||
const { method, pathname } = formatRequest(endpoint, path);
|
||||
|
||||
return http[method](pathname, {
|
||||
...rest,
|
||||
body,
|
||||
query,
|
||||
});
|
||||
};
|
||||
|
||||
callObservabilityApi = client;
|
||||
}
|
|
@ -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
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { RouteRepositoryClient } from '@kbn/server-route-repository';
|
||||
import { HttpFetchOptions } from 'kibana/public';
|
||||
import type {
|
||||
AbstractObservabilityServerRouteRepository,
|
||||
ObservabilityServerRouteRepository,
|
||||
ObservabilityAPIReturnType,
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
} from '../../../server';
|
||||
|
||||
export type ObservabilityClientOptions = Omit<
|
||||
HttpFetchOptions,
|
||||
'query' | 'body' | 'pathname' | 'signal'
|
||||
> & {
|
||||
signal: AbortSignal | null;
|
||||
};
|
||||
|
||||
export type AbstractObservabilityClient = RouteRepositoryClient<
|
||||
AbstractObservabilityServerRouteRepository,
|
||||
ObservabilityClientOptions & { params?: Record<string, any> }
|
||||
>;
|
||||
|
||||
export type ObservabilityClient = RouteRepositoryClient<
|
||||
ObservabilityServerRouteRepository,
|
||||
ObservabilityClientOptions
|
||||
>;
|
||||
|
||||
export { ObservabilityAPIReturnType };
|
|
@ -7,9 +7,33 @@
|
|||
|
||||
import datemath from '@elastic/datemath';
|
||||
|
||||
export function getAbsoluteTime(range: string, opts = {}) {
|
||||
export function getAbsoluteTime(range: string, opts: Parameters<typeof datemath.parse>[1] = {}) {
|
||||
const parsed = datemath.parse(range, opts);
|
||||
if (parsed) {
|
||||
return parsed.valueOf();
|
||||
}
|
||||
}
|
||||
|
||||
export function getAbsoluteDateRange({
|
||||
rangeFrom,
|
||||
rangeTo,
|
||||
}: {
|
||||
rangeFrom?: string;
|
||||
rangeTo?: string;
|
||||
}) {
|
||||
if (!rangeFrom || !rangeTo) {
|
||||
return { start: undefined, end: undefined };
|
||||
}
|
||||
|
||||
const absoluteStart = getAbsoluteTime(rangeFrom);
|
||||
const absoluteEnd = getAbsoluteTime(rangeTo, { roundUp: true });
|
||||
|
||||
if (!absoluteStart || !absoluteEnd) {
|
||||
throw new Error('Could not parse date range');
|
||||
}
|
||||
|
||||
return {
|
||||
start: new Date(absoluteStart).toISOString(),
|
||||
end: new Date(absoluteEnd).toISOString(),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ import translations from '../../../translations/translations/ja-JP.json';
|
|||
import { PluginContext } from '../context/plugin_context';
|
||||
import { ObservabilityPublicPluginsStart } from '../plugin';
|
||||
import { EuiThemeProvider } from '../../../../../src/plugins/kibana_react/common';
|
||||
import { createObservabilityRuleRegistryMock } from '../rules/observability_rule_registry_mock';
|
||||
|
||||
const appMountParameters = ({ setHeaderActionMenu: () => {} } as unknown) as AppMountParameters;
|
||||
|
||||
|
@ -34,11 +35,15 @@ const plugins = ({
|
|||
data: { query: { timefilter: { timefilter: { setTime: jest.fn() } } } },
|
||||
} as unknown) as ObservabilityPublicPluginsStart;
|
||||
|
||||
const observabilityRuleRegistry = createObservabilityRuleRegistryMock();
|
||||
|
||||
export const render = (component: React.ReactNode) => {
|
||||
return testLibRender(
|
||||
<IntlProvider locale="en-US" messages={translations.messages}>
|
||||
<KibanaContextProvider services={{ ...core }}>
|
||||
<PluginContext.Provider value={{ appMountParameters, core, plugins }}>
|
||||
<PluginContext.Provider
|
||||
value={{ appMountParameters, core, plugins, observabilityRuleRegistry }}
|
||||
>
|
||||
<EuiThemeProvider>{component}</EuiThemeProvider>
|
||||
</PluginContext.Provider>
|
||||
</KibanaContextProvider>
|
||||
|
|
|
@ -11,6 +11,9 @@ import { ObservabilityPlugin, ObservabilityPluginSetup } from './plugin';
|
|||
import { createOrUpdateIndex, Mappings } from './utils/create_or_update_index';
|
||||
import { ScopedAnnotationsClient } from './lib/annotations/bootstrap_annotations';
|
||||
import { unwrapEsResponse, WrappedElasticsearchClientError } from './utils/unwrap_es_response';
|
||||
export { rangeQuery, kqlQuery } from './utils/queries';
|
||||
|
||||
export * from './types';
|
||||
|
||||
export const config = {
|
||||
schema: schema.object({
|
||||
|
|
|
@ -5,11 +5,16 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { CoreSetup, PluginInitializerContext, KibanaRequest } from 'kibana/server';
|
||||
import {
|
||||
CoreSetup,
|
||||
PluginInitializerContext,
|
||||
KibanaRequest,
|
||||
RequestHandlerContext,
|
||||
} from 'kibana/server';
|
||||
import { LicensingApiRequestHandlerContext } from '../../../../licensing/server';
|
||||
import { PromiseReturnType } from '../../../typings/common';
|
||||
import { createAnnotationsClient } from './create_annotations_client';
|
||||
import { registerAnnotationAPIs } from './register_annotation_apis';
|
||||
import type { ObservabilityRequestHandlerContext } from '../../types';
|
||||
|
||||
interface Params {
|
||||
index: string;
|
||||
|
@ -35,7 +40,7 @@ export async function bootstrapAnnotations({ index, core, context }: Params) {
|
|||
|
||||
return {
|
||||
getScopedAnnotationsClient: (
|
||||
requestContext: ObservabilityRequestHandlerContext,
|
||||
requestContext: RequestHandlerContext & { licensing: LicensingApiRequestHandlerContext },
|
||||
request: KibanaRequest
|
||||
) => {
|
||||
return createAnnotationsClient({
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { Required } from 'utility-types';
|
||||
import { ObservabilityRuleRegistryClient } from '../../types';
|
||||
import { kqlQuery, rangeQuery } from '../../utils/queries';
|
||||
|
||||
export async function getTopAlerts({
|
||||
ruleRegistryClient,
|
||||
start,
|
||||
end,
|
||||
kuery,
|
||||
size,
|
||||
}: {
|
||||
ruleRegistryClient: ObservabilityRuleRegistryClient;
|
||||
start: number;
|
||||
end: number;
|
||||
kuery?: string;
|
||||
size: number;
|
||||
}) {
|
||||
const response = await ruleRegistryClient.search({
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
filter: [...rangeQuery(start, end), ...kqlQuery(kuery)],
|
||||
},
|
||||
},
|
||||
fields: ['*'],
|
||||
collapse: {
|
||||
field: 'kibana.rac.alert.uuid',
|
||||
},
|
||||
size,
|
||||
sort: {
|
||||
'@timestamp': 'desc',
|
||||
},
|
||||
_source: false,
|
||||
},
|
||||
});
|
||||
|
||||
return response.events.map((event) => {
|
||||
return event as Required<
|
||||
typeof event,
|
||||
| 'rule.id'
|
||||
| 'rule.name'
|
||||
| 'kibana.rac.alert.start'
|
||||
| 'event.action'
|
||||
| 'rule.category'
|
||||
| 'rule.name'
|
||||
| 'kibana.rac.alert.duration.us'
|
||||
>;
|
||||
});
|
||||
}
|
|
@ -6,7 +6,6 @@
|
|||
*/
|
||||
|
||||
import { PluginInitializerContext, Plugin, CoreSetup } from 'src/core/server';
|
||||
import { pickWithPatterns } from '../../rule_registry/server';
|
||||
import { ObservabilityConfig } from '.';
|
||||
import {
|
||||
bootstrapAnnotations,
|
||||
|
@ -15,9 +14,12 @@ import {
|
|||
} from './lib/annotations/bootstrap_annotations';
|
||||
import type { RuleRegistryPluginSetupContract } from '../../rule_registry/server';
|
||||
import { uiSettings } from './ui_settings';
|
||||
import { ecsFieldMap } from '../../rule_registry/server';
|
||||
import { registerRoutes } from './routes/register_routes';
|
||||
import { getGlobalObservabilityServerRouteRepository } from './routes/get_global_observability_server_route_repository';
|
||||
import { observabilityRuleRegistrySettings } from '../common/observability_rule_registry';
|
||||
|
||||
export type ObservabilityPluginSetup = ReturnType<ObservabilityPlugin['setup']>;
|
||||
export type ObservabilityRuleRegistry = ObservabilityPluginSetup['ruleRegistry'];
|
||||
|
||||
export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
|
||||
constructor(private readonly initContext: PluginInitializerContext) {
|
||||
|
@ -48,17 +50,26 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
|
|||
});
|
||||
}
|
||||
|
||||
const observabilityRuleRegistry = plugins.ruleRegistry.create(
|
||||
observabilityRuleRegistrySettings
|
||||
);
|
||||
|
||||
registerRoutes({
|
||||
core: {
|
||||
setup: core,
|
||||
start: () => core.getStartServices().then(([coreStart]) => coreStart),
|
||||
},
|
||||
ruleRegistry: observabilityRuleRegistry,
|
||||
logger: this.initContext.logger.get(),
|
||||
repository: getGlobalObservabilityServerRouteRepository(),
|
||||
});
|
||||
|
||||
return {
|
||||
getScopedAnnotationsClient: async (...args: Parameters<ScopedAnnotationsClientFactory>) => {
|
||||
const api = await annotationsApiPromise;
|
||||
return api?.getScopedAnnotationsClient(...args);
|
||||
},
|
||||
ruleRegistry: plugins.ruleRegistry.create({
|
||||
name: 'observability',
|
||||
fieldMap: {
|
||||
...pickWithPatterns(ecsFieldMap, 'host.name', 'service.name'),
|
||||
},
|
||||
}),
|
||||
ruleRegistry: observabilityRuleRegistry,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { createServerRouteFactory } from '@kbn/server-route-repository';
|
||||
import { ObservabilityRouteCreateOptions, ObservabilityRouteHandlerResources } from './types';
|
||||
|
||||
export const createObservabilityServerRoute = createServerRouteFactory<
|
||||
ObservabilityRouteHandlerResources,
|
||||
ObservabilityRouteCreateOptions
|
||||
>();
|
|
@ -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
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { createServerRouteRepository } from '@kbn/server-route-repository';
|
||||
import { ObservabilityRouteHandlerResources, ObservabilityRouteCreateOptions } from './types';
|
||||
|
||||
export const createObservabilityServerRouteRepository = () => {
|
||||
return createServerRouteRepository<
|
||||
ObservabilityRouteHandlerResources,
|
||||
ObservabilityRouteCreateOptions
|
||||
>();
|
||||
};
|
|
@ -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
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { rulesRouteRepository } from './rules';
|
||||
|
||||
export function getGlobalObservabilityServerRouteRepository() {
|
||||
return rulesRouteRepository;
|
||||
}
|
||||
|
||||
export type ObservabilityServerRouteRepository = ReturnType<
|
||||
typeof getGlobalObservabilityServerRouteRepository
|
||||
>;
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import * as t from 'io-ts';
|
||||
import {
|
||||
decodeRequestParams,
|
||||
parseEndpoint,
|
||||
routeValidationObject,
|
||||
} from '@kbn/server-route-repository';
|
||||
import { CoreSetup, CoreStart, Logger, RouteRegistrar } from 'kibana/server';
|
||||
import Boom from '@hapi/boom';
|
||||
import { RequestAbortedError } from '@elastic/elasticsearch/lib/errors';
|
||||
import { ObservabilityRuleRegistry } from '../plugin';
|
||||
import { ObservabilityRequestHandlerContext } from '../types';
|
||||
import { AbstractObservabilityServerRouteRepository } from './types';
|
||||
|
||||
export function registerRoutes({
|
||||
ruleRegistry,
|
||||
repository,
|
||||
core,
|
||||
logger,
|
||||
}: {
|
||||
core: {
|
||||
setup: CoreSetup;
|
||||
start: () => Promise<CoreStart>;
|
||||
};
|
||||
ruleRegistry: ObservabilityRuleRegistry;
|
||||
repository: AbstractObservabilityServerRouteRepository;
|
||||
logger: Logger;
|
||||
}) {
|
||||
const routes = repository.getRoutes();
|
||||
|
||||
const router = core.setup.http.createRouter();
|
||||
|
||||
routes.forEach((route) => {
|
||||
const { endpoint, options, handler, params } = route;
|
||||
const { pathname, method } = parseEndpoint(endpoint);
|
||||
|
||||
(router[method] as RouteRegistrar<typeof method, ObservabilityRequestHandlerContext>)(
|
||||
{
|
||||
path: pathname,
|
||||
validate: routeValidationObject,
|
||||
options,
|
||||
},
|
||||
async (context, request, response) => {
|
||||
try {
|
||||
const decodedParams = decodeRequestParams(
|
||||
{
|
||||
params: request.params,
|
||||
body: request.body,
|
||||
query: request.query,
|
||||
},
|
||||
params ?? t.strict({})
|
||||
);
|
||||
|
||||
const data = (await handler({
|
||||
context,
|
||||
request,
|
||||
ruleRegistry,
|
||||
core,
|
||||
logger,
|
||||
params: decodedParams,
|
||||
})) as any;
|
||||
|
||||
return response.ok({ body: data });
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
const opts = {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
message: error.message,
|
||||
},
|
||||
};
|
||||
|
||||
if (Boom.isBoom(error)) {
|
||||
opts.statusCode = error.output.statusCode;
|
||||
}
|
||||
|
||||
if (error instanceof RequestAbortedError) {
|
||||
opts.statusCode = 499;
|
||||
opts.body.message = 'Client closed request';
|
||||
}
|
||||
|
||||
return response.custom(opts);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
76
x-pack/plugins/observability/server/routes/rules.ts
Normal file
76
x-pack/plugins/observability/server/routes/rules.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import * as t from 'io-ts';
|
||||
import { isoToEpochRt, toNumberRt } from '@kbn/io-ts-utils';
|
||||
import Boom from '@hapi/boom';
|
||||
import { createObservabilityServerRoute } from './create_observability_server_route';
|
||||
import { createObservabilityServerRouteRepository } from './create_observability_server_route_repository';
|
||||
import { getTopAlerts } from '../lib/rules/get_top_alerts';
|
||||
|
||||
const alertsListRoute = createObservabilityServerRoute({
|
||||
endpoint: 'GET /api/observability/rules/alerts/top',
|
||||
options: {
|
||||
tags: [],
|
||||
},
|
||||
params: t.type({
|
||||
query: t.intersection([
|
||||
t.type({
|
||||
start: isoToEpochRt,
|
||||
end: isoToEpochRt,
|
||||
}),
|
||||
t.partial({
|
||||
kuery: t.string,
|
||||
size: toNumberRt,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
handler: async ({ ruleRegistry, context, params }) => {
|
||||
const ruleRegistryClient = await ruleRegistry.createScopedRuleRegistryClient({
|
||||
context,
|
||||
alertsClient: context.alerting.getAlertsClient(),
|
||||
});
|
||||
|
||||
if (!ruleRegistryClient) {
|
||||
throw Boom.failedDependency();
|
||||
}
|
||||
|
||||
const {
|
||||
query: { start, end, kuery, size = 100 },
|
||||
} = params;
|
||||
|
||||
return getTopAlerts({
|
||||
ruleRegistryClient,
|
||||
start,
|
||||
end,
|
||||
kuery,
|
||||
size,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const alertsDynamicIndexPatternRoute = createObservabilityServerRoute({
|
||||
endpoint: 'GET /api/observability/rules/alerts/dynamic_index_pattern',
|
||||
options: {
|
||||
tags: [],
|
||||
},
|
||||
handler: async ({ ruleRegistry, context }) => {
|
||||
const ruleRegistryClient = await ruleRegistry.createScopedRuleRegistryClient({
|
||||
context,
|
||||
alertsClient: context.alerting.getAlertsClient(),
|
||||
});
|
||||
|
||||
if (!ruleRegistryClient) {
|
||||
throw Boom.failedDependency();
|
||||
}
|
||||
|
||||
return ruleRegistryClient.getDynamicIndexPattern();
|
||||
},
|
||||
});
|
||||
|
||||
export const rulesRouteRepository = createObservabilityServerRouteRepository()
|
||||
.add(alertsListRoute)
|
||||
.add(alertsDynamicIndexPatternRoute);
|
56
x-pack/plugins/observability/server/routes/types.ts
Normal file
56
x-pack/plugins/observability/server/routes/types.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import * as t from 'io-ts';
|
||||
import type {
|
||||
EndpointOf,
|
||||
ReturnOf,
|
||||
ServerRoute,
|
||||
ServerRouteRepository,
|
||||
} from '@kbn/server-route-repository';
|
||||
import { CoreSetup, CoreStart, KibanaRequest, Logger } from 'kibana/server';
|
||||
import { ObservabilityRuleRegistry } from '../plugin';
|
||||
|
||||
import { ObservabilityServerRouteRepository } from './get_global_observability_server_route_repository';
|
||||
import { ObservabilityRequestHandlerContext } from '../types';
|
||||
|
||||
export { ObservabilityServerRouteRepository };
|
||||
|
||||
export interface ObservabilityRouteHandlerResources {
|
||||
core: {
|
||||
start: () => Promise<CoreStart>;
|
||||
setup: CoreSetup;
|
||||
};
|
||||
ruleRegistry: ObservabilityRuleRegistry;
|
||||
request: KibanaRequest;
|
||||
context: ObservabilityRequestHandlerContext;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export interface ObservabilityRouteCreateOptions {
|
||||
options: {
|
||||
tags: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export type AbstractObservabilityServerRouteRepository = ServerRouteRepository<
|
||||
ObservabilityRouteHandlerResources,
|
||||
ObservabilityRouteCreateOptions,
|
||||
Record<
|
||||
string,
|
||||
ServerRoute<
|
||||
string,
|
||||
t.Mixed | undefined,
|
||||
ObservabilityRouteHandlerResources,
|
||||
any,
|
||||
ObservabilityRouteCreateOptions
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
export type ObservabilityAPIReturnType<
|
||||
TEndpoint extends EndpointOf<ObservabilityServerRouteRepository>
|
||||
> = ReturnOf<ObservabilityServerRouteRepository, TEndpoint>;
|
|
@ -6,16 +6,32 @@
|
|||
*/
|
||||
|
||||
import type { IRouter, RequestHandlerContext } from 'src/core/server';
|
||||
import type { AlertingApiRequestHandlerContext } from '../../alerting/server';
|
||||
import type { ScopedRuleRegistryClient, FieldMapOf } from '../../rule_registry/server';
|
||||
import type { LicensingApiRequestHandlerContext } from '../../licensing/server';
|
||||
import type { ObservabilityRuleRegistry } from './plugin';
|
||||
|
||||
export type {
|
||||
ObservabilityRouteCreateOptions,
|
||||
ObservabilityRouteHandlerResources,
|
||||
AbstractObservabilityServerRouteRepository,
|
||||
ObservabilityServerRouteRepository,
|
||||
ObservabilityAPIReturnType,
|
||||
} from './routes/types';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface ObservabilityRequestHandlerContext extends RequestHandlerContext {
|
||||
licensing: LicensingApiRequestHandlerContext;
|
||||
alerting: AlertingApiRequestHandlerContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export type ObservabilityPluginRouter = IRouter<ObservabilityRequestHandlerContext>;
|
||||
|
||||
export type ObservabilityRuleRegistryClient = ScopedRuleRegistryClient<
|
||||
FieldMapOf<ObservabilityRuleRegistry>
|
||||
>;
|
||||
|
|
32
x-pack/plugins/observability/server/utils/queries.ts
Normal file
32
x-pack/plugins/observability/server/utils/queries.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { QueryContainer } from '@elastic/elasticsearch/api/types';
|
||||
import { esKuery } from '../../../../../src/plugins/data/server';
|
||||
|
||||
export function rangeQuery(start: number, end: number, field = '@timestamp'): QueryContainer[] {
|
||||
return [
|
||||
{
|
||||
range: {
|
||||
[field]: {
|
||||
gte: start,
|
||||
lte: end,
|
||||
format: 'epoch_millis',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function kqlQuery(kql?: string): QueryContainer[] {
|
||||
if (!kql) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ast = esKuery.fromKueryExpression(kql);
|
||||
return [esKuery.toElasticsearchQuery(ast)];
|
||||
}
|
|
@ -23,9 +23,9 @@
|
|||
{ "path": "../../../src/plugins/kibana_utils/tsconfig.json" },
|
||||
{ "path": "../../../src/plugins/usage_collection/tsconfig.json" },
|
||||
{ "path": "../alerting/tsconfig.json" },
|
||||
{ "path": "../rule_registry/tsconfig.json" },
|
||||
{ "path": "../licensing/tsconfig.json" },
|
||||
{ "path": "../lens/tsconfig.json" },
|
||||
{ "path": "../rule_registry/tsconfig.json" },
|
||||
{ "path": "../translations/tsconfig.json" }
|
||||
]
|
||||
}
|
||||
|
|
|
@ -4,11 +4,10 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { ecsFieldMap } from './ecs_field_map';
|
||||
import { pickWithPatterns } from '../pick_with_patterns';
|
||||
|
||||
import { ecsFieldMap } from '../../generated/ecs_field_map';
|
||||
import { pickWithPatterns } from '../field_map/pick_with_patterns';
|
||||
|
||||
export const defaultFieldMap = {
|
||||
export const baseRuleFieldMap = {
|
||||
...pickWithPatterns(
|
||||
ecsFieldMap,
|
||||
'@timestamp',
|
||||
|
@ -31,4 +30,4 @@ export const defaultFieldMap = {
|
|||
'kibana.rac.alert.status': { type: 'keyword' },
|
||||
} as const;
|
||||
|
||||
export type DefaultFieldMap = typeof defaultFieldMap;
|
||||
export type BaseRuleFieldMap = typeof baseRuleFieldMap;
|
|
@ -5,6 +5,10 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
/* This file is generated by x-pack/plugins/rule_registry/scripts/generate_ecs_fieldmap/index.js,
|
||||
do not manually edit
|
||||
*/
|
||||
|
||||
export const ecsFieldMap = {
|
||||
'@timestamp': {
|
||||
type: 'date',
|
||||
|
@ -3372,3 +3376,5 @@ export const ecsFieldMap = {
|
|||
required: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type EcsFieldMap = typeof ecsFieldMap;
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import * as t from 'io-ts';
|
||||
|
||||
export const esFieldTypeMap = {
|
||||
keyword: t.string,
|
||||
text: t.string,
|
||||
date: t.string,
|
||||
boolean: t.boolean,
|
||||
byte: t.number,
|
||||
long: t.number,
|
||||
integer: t.number,
|
||||
short: t.number,
|
||||
double: t.number,
|
||||
float: t.number,
|
||||
scaled_float: t.number,
|
||||
unsigned_long: t.number,
|
||||
flattened: t.record(t.string, t.array(t.string)),
|
||||
};
|
12
x-pack/plugins/rule_registry/common/field_map/index.ts
Normal file
12
x-pack/plugins/rule_registry/common/field_map/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export * from './base_rule_field_map';
|
||||
export * from './ecs_field_map';
|
||||
export * from './merge_field_maps';
|
||||
export * from './runtime_type_from_fieldmap';
|
||||
export * from './types';
|
|
@ -4,8 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import util from 'util';
|
||||
import { FieldMap } from '../types';
|
||||
import { FieldMap } from './types';
|
||||
|
||||
export function mergeFieldMaps<T1 extends FieldMap, T2 extends FieldMap>(
|
||||
first: T1,
|
||||
|
@ -39,7 +38,7 @@ export function mergeFieldMaps<T1 extends FieldMap, T2 extends FieldMap>(
|
|||
|
||||
if (conflicts.length) {
|
||||
const err = new Error(`Could not merge mapping due to conflicts`);
|
||||
Object.assign(err, { conflicts: util.inspect(conflicts, { depth: null }) });
|
||||
Object.assign(err, { conflicts });
|
||||
throw err;
|
||||
}
|
||||
|
|
@ -4,11 +4,10 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { Optional } from 'utility-types';
|
||||
import { mapValues, pickBy } from 'lodash';
|
||||
import * as t from 'io-ts';
|
||||
import { Mutable, PickByValueExact } from 'utility-types';
|
||||
import { FieldMap } from '../types';
|
||||
import { FieldMap } from './types';
|
||||
|
||||
const esFieldTypeMap = {
|
||||
keyword: t.string,
|
||||
|
@ -32,22 +31,6 @@ type EsFieldTypeOf<T extends string> = T extends keyof EsFieldTypeMap
|
|||
? EsFieldTypeMap[T]
|
||||
: t.UnknownC;
|
||||
|
||||
type RequiredKeysOf<T extends Record<string, { required?: boolean }>> = keyof PickByValueExact<
|
||||
{
|
||||
[key in keyof T]: T[key]['required'];
|
||||
},
|
||||
true
|
||||
>;
|
||||
|
||||
type IntersectionTypeOf<
|
||||
T extends Record<string, { required?: boolean; type: t.Any }>
|
||||
> = t.IntersectionC<
|
||||
[
|
||||
t.TypeC<Pick<{ [key in keyof T]: T[key]['type'] }, RequiredKeysOf<T>>>,
|
||||
t.PartialC<{ [key in keyof T]: T[key]['type'] }>
|
||||
]
|
||||
>;
|
||||
|
||||
type CastArray<T extends t.Type<any>> = t.Type<
|
||||
t.TypeOf<T> | Array<t.TypeOf<T>>,
|
||||
Array<t.TypeOf<T>>,
|
||||
|
@ -71,25 +54,39 @@ const createCastSingleRt = <T extends t.Type<any>>(type: T): CastSingle<T> => {
|
|||
return new t.Type('castSingle', union.is, union.validate, (a) => (Array.isArray(a) ? a[0] : a));
|
||||
};
|
||||
|
||||
type MapTypeValues<T extends FieldMap> = {
|
||||
[key in keyof T]: {
|
||||
required: T[key]['required'];
|
||||
type: T[key]['array'] extends true
|
||||
? CastArray<EsFieldTypeOf<T[key]['type']>>
|
||||
: CastSingle<EsFieldTypeOf<T[key]['type']>>;
|
||||
};
|
||||
type SetOptional<T extends FieldMap> = Optional<
|
||||
T,
|
||||
{
|
||||
[key in keyof T]: T[key]['required'] extends true ? never : key;
|
||||
}[keyof T]
|
||||
>;
|
||||
|
||||
type OutputOfField<T extends { type: string; array?: boolean }> = T['array'] extends true
|
||||
? Array<t.OutputOf<EsFieldTypeOf<T['type']>>>
|
||||
: t.OutputOf<EsFieldTypeOf<T['type']>>;
|
||||
|
||||
type TypeOfField<T extends { type: string; array?: boolean }> =
|
||||
| t.TypeOf<EsFieldTypeOf<T['type']>>
|
||||
| Array<t.TypeOf<EsFieldTypeOf<T['type']>>>;
|
||||
|
||||
type OutputOf<T extends FieldMap> = {
|
||||
[key in keyof T]: OutputOfField<Exclude<T[key], undefined>>;
|
||||
};
|
||||
|
||||
type FieldMapType<T extends FieldMap> = IntersectionTypeOf<MapTypeValues<T>>;
|
||||
type TypeOf<T extends FieldMap> = {
|
||||
[key in keyof T]: TypeOfField<Exclude<T[key], undefined>>;
|
||||
};
|
||||
|
||||
export type TypeOfFieldMap<T extends FieldMap> = Mutable<t.TypeOf<FieldMapType<T>>>;
|
||||
export type OutputOfFieldMap<T extends FieldMap> = Mutable<t.OutputOf<FieldMapType<T>>>;
|
||||
export type TypeOfFieldMap<T extends FieldMap> = TypeOf<SetOptional<T>>;
|
||||
export type OutputOfFieldMap<T extends FieldMap> = OutputOf<SetOptional<T>>;
|
||||
|
||||
export type FieldMapType<T extends FieldMap> = t.Type<TypeOfFieldMap<T>, OutputOfFieldMap<T>>;
|
||||
|
||||
export function runtimeTypeFromFieldMap<TFieldMap extends FieldMap>(
|
||||
fieldMap: TFieldMap
|
||||
): FieldMapType<TFieldMap> {
|
||||
function mapToType(fields: FieldMap) {
|
||||
return mapValues(fields, (field, key) => {
|
||||
return mapValues(fields, (field) => {
|
||||
const type =
|
||||
field.type in esFieldTypeMap
|
||||
? esFieldTypeMap[field.type as keyof EsFieldTypeMap]
|
10
x-pack/plugins/rule_registry/common/field_map/types.ts
Normal file
10
x-pack/plugins/rule_registry/common/field_map/types.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export interface FieldMap {
|
||||
[key: string]: { type: string; required?: boolean; array?: boolean };
|
||||
}
|
|
@ -4,5 +4,5 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export * from './field_map';
|
||||
export * from './pick_with_patterns';
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { pickWithPatterns } from './pick_with_patterns';
|
||||
import { pickWithPatterns } from './';
|
||||
|
||||
describe('pickWithPatterns', () => {
|
||||
const fieldMap = {
|
|
@ -1,20 +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
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export enum AlertSeverityLevel {
|
||||
warning = 'warning',
|
||||
critical = 'critical',
|
||||
}
|
||||
|
||||
const alertSeverityLevelValues = {
|
||||
[AlertSeverityLevel.warning]: 70,
|
||||
[AlertSeverityLevel.critical]: 90,
|
||||
};
|
||||
|
||||
export function getAlertSeverityLevelValue(level: AlertSeverityLevel) {
|
||||
return alertSeverityLevelValues[level];
|
||||
}
|
|
@ -7,7 +7,12 @@
|
|||
"ruleRegistry"
|
||||
],
|
||||
"requiredPlugins": [
|
||||
"alerting"
|
||||
"alerting",
|
||||
"triggersActionsUi"
|
||||
],
|
||||
"server": true
|
||||
"server": true,
|
||||
"ui": true,
|
||||
"extraPublicDirs": [
|
||||
"common"
|
||||
]
|
||||
}
|
||||
|
|
17
x-pack/plugins/rule_registry/public/index.ts
Normal file
17
x-pack/plugins/rule_registry/public/index.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { PluginInitializerContext } from 'kibana/public';
|
||||
import { Plugin } from './plugin';
|
||||
|
||||
export { RuleRegistryPublicPluginSetupContract } from './plugin';
|
||||
export { RuleRegistry } from './rule_registry';
|
||||
export type { IRuleRegistry, RuleType } from './rule_registry/types';
|
||||
|
||||
export const plugin = (context: PluginInitializerContext) => {
|
||||
return new Plugin(context);
|
||||
};
|
56
x-pack/plugins/rule_registry/public/plugin.ts
Normal file
56
x-pack/plugins/rule_registry/public/plugin.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CoreSetup,
|
||||
CoreStart,
|
||||
Plugin as PluginClass,
|
||||
PluginInitializerContext,
|
||||
} from '../../../../src/core/public';
|
||||
import type {
|
||||
PluginSetupContract as AlertingPluginPublicSetupContract,
|
||||
PluginStartContract as AlertingPluginPublicStartContract,
|
||||
} from '../../alerting/public';
|
||||
import type {
|
||||
TriggersAndActionsUIPublicPluginSetup,
|
||||
TriggersAndActionsUIPublicPluginStart,
|
||||
} from '../../triggers_actions_ui/public';
|
||||
import { baseRuleFieldMap } from '../common';
|
||||
import { RuleRegistry } from './rule_registry';
|
||||
|
||||
interface RuleRegistrySetupPlugins {
|
||||
alerting: AlertingPluginPublicSetupContract;
|
||||
triggersActionsUi: TriggersAndActionsUIPublicPluginSetup;
|
||||
}
|
||||
|
||||
interface RuleRegistryStartPlugins {
|
||||
alerting: AlertingPluginPublicStartContract;
|
||||
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
|
||||
}
|
||||
|
||||
export type RuleRegistryPublicPluginSetupContract = ReturnType<Plugin['setup']>;
|
||||
|
||||
export class Plugin
|
||||
implements PluginClass<void, void, RuleRegistrySetupPlugins, RuleRegistryStartPlugins> {
|
||||
constructor(context: PluginInitializerContext) {}
|
||||
|
||||
public setup(core: CoreSetup<RuleRegistryStartPlugins>, plugins: RuleRegistrySetupPlugins) {
|
||||
const rootRegistry = new RuleRegistry({
|
||||
fieldMap: baseRuleFieldMap,
|
||||
alertTypeRegistry: plugins.triggersActionsUi.alertTypeRegistry,
|
||||
});
|
||||
return {
|
||||
registry: rootRegistry,
|
||||
};
|
||||
}
|
||||
|
||||
start(core: CoreStart, plugins: RuleRegistryStartPlugins) {
|
||||
return {
|
||||
registerType: plugins.triggersActionsUi.alertTypeRegistry,
|
||||
};
|
||||
}
|
||||
}
|
47
x-pack/plugins/rule_registry/public/rule_registry/index.ts
Normal file
47
x-pack/plugins/rule_registry/public/rule_registry/index.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { BaseRuleFieldMap } from '../../common';
|
||||
import type { RuleType, CreateRuleRegistry, RuleRegistryConstructorOptions } from './types';
|
||||
|
||||
export class RuleRegistry<TFieldMap extends BaseRuleFieldMap, TRuleType extends RuleType> {
|
||||
protected types: TRuleType[] = [];
|
||||
|
||||
constructor(private readonly options: RuleRegistryConstructorOptions<TFieldMap>) {}
|
||||
|
||||
getTypes(): TRuleType[] {
|
||||
return this.types;
|
||||
}
|
||||
|
||||
getTypeByRuleId(id: string): TRuleType | undefined {
|
||||
return this.types.find((type) => type.id === id);
|
||||
}
|
||||
|
||||
registerType(type: TRuleType) {
|
||||
this.types.push(type);
|
||||
if (this.options.parent) {
|
||||
this.options.parent.registerType(type);
|
||||
} else {
|
||||
this.options.alertTypeRegistry.register(type);
|
||||
}
|
||||
}
|
||||
|
||||
create: CreateRuleRegistry<TFieldMap, TRuleType> = ({ fieldMap, ctor }) => {
|
||||
const createOptions = {
|
||||
fieldMap: {
|
||||
...this.options.fieldMap,
|
||||
...fieldMap,
|
||||
},
|
||||
alertTypeRegistry: this.options.alertTypeRegistry,
|
||||
parent: this,
|
||||
};
|
||||
|
||||
const registry = ctor ? new ctor(createOptions) : new RuleRegistry(createOptions);
|
||||
|
||||
return registry as any;
|
||||
};
|
||||
}
|
63
x-pack/plugins/rule_registry/public/rule_registry/types.ts
Normal file
63
x-pack/plugins/rule_registry/public/rule_registry/types.ts
Normal 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
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { AlertTypeRegistryContract } from '../../../triggers_actions_ui/public';
|
||||
import { BaseRuleFieldMap, FieldMap } from '../../common';
|
||||
|
||||
export interface RuleRegistryConstructorOptions<TFieldMap extends BaseRuleFieldMap> {
|
||||
fieldMap: TFieldMap;
|
||||
alertTypeRegistry: AlertTypeRegistryContract;
|
||||
parent?: IRuleRegistry<any, any>;
|
||||
}
|
||||
|
||||
export type RuleType = Parameters<AlertTypeRegistryContract['register']>[0];
|
||||
|
||||
export type RegisterRuleType<
|
||||
TFieldMap extends BaseRuleFieldMap,
|
||||
TAdditionalRegisterOptions = {}
|
||||
> = (type: RuleType & TAdditionalRegisterOptions) => void;
|
||||
|
||||
export type RuleRegistryExtensions<T extends keyof any = never> = Record<
|
||||
T,
|
||||
(...args: any[]) => any
|
||||
>;
|
||||
|
||||
export type CreateRuleRegistry<
|
||||
TFieldMap extends BaseRuleFieldMap,
|
||||
TRuleType extends RuleType,
|
||||
TInstanceType = undefined
|
||||
> = <
|
||||
TNextFieldMap extends FieldMap,
|
||||
TRuleRegistryInstance extends IRuleRegistry<
|
||||
TFieldMap & TNextFieldMap,
|
||||
any
|
||||
> = TInstanceType extends IRuleRegistry<TFieldMap & TNextFieldMap, TRuleType>
|
||||
? TInstanceType
|
||||
: IRuleRegistry<TFieldMap & TNextFieldMap, TRuleType>
|
||||
>(options: {
|
||||
fieldMap: TNextFieldMap;
|
||||
ctor?: new (
|
||||
options: RuleRegistryConstructorOptions<TFieldMap & TNextFieldMap>
|
||||
) => TRuleRegistryInstance;
|
||||
}) => TRuleRegistryInstance;
|
||||
|
||||
export interface IRuleRegistry<
|
||||
TFieldMap extends BaseRuleFieldMap,
|
||||
TRuleType extends RuleType,
|
||||
TInstanceType = undefined
|
||||
> {
|
||||
create: CreateRuleRegistry<TFieldMap, TRuleType, TInstanceType>;
|
||||
registerType(type: TRuleType): void;
|
||||
getTypeByRuleId(ruleId: string): TRuleType;
|
||||
getTypes(): TRuleType[];
|
||||
}
|
||||
|
||||
export type FieldMapOfRuleRegistry<TRuleRegistry> = TRuleRegistry extends IRuleRegistry<
|
||||
infer TFieldMap,
|
||||
any
|
||||
>
|
||||
? TFieldMap
|
||||
: never;
|
|
@ -14,36 +14,23 @@ const { mapValues } = require('lodash');
|
|||
const exists = util.promisify(fs.exists);
|
||||
const readFile = util.promisify(fs.readFile);
|
||||
const writeFile = util.promisify(fs.writeFile);
|
||||
const mkdir = util.promisify(fs.mkdir);
|
||||
const rmdir = util.promisify(fs.rmdir);
|
||||
const exec = util.promisify(execCb);
|
||||
|
||||
const ecsDir = path.resolve(__dirname, '../../../../../../ecs');
|
||||
const ecsTemplateFilename = path.join(ecsDir, 'generated/elasticsearch/7/template.json');
|
||||
const flatYamlFilename = path.join(ecsDir, 'generated/ecs/ecs_flat.yml');
|
||||
const ecsYamlFilename = path.join(ecsDir, 'generated/ecs/ecs_flat.yml');
|
||||
|
||||
const outputDir = path.join(__dirname, '../../server/generated');
|
||||
const outputDir = path.join(__dirname, '../../common/field_map');
|
||||
|
||||
const outputFieldMapFilename = path.join(outputDir, 'ecs_field_map.ts');
|
||||
const outputMappingFilename = path.join(outputDir, 'ecs_mappings.json');
|
||||
|
||||
async function generate() {
|
||||
const allExists = await Promise.all([exists(ecsDir), exists(ecsTemplateFilename)]);
|
||||
|
||||
if (!allExists.every(Boolean)) {
|
||||
if (!(await exists(ecsYamlFilename))) {
|
||||
throw new Error(
|
||||
`Directory not found: ${ecsDir} - did you checkout elastic/ecs as a peer of this repo?`
|
||||
`Directory not found: ${ecsYamlFilename} - did you checkout elastic/ecs as a peer of this repo?`
|
||||
);
|
||||
}
|
||||
|
||||
const [template, flatYaml] = await Promise.all([
|
||||
readFile(ecsTemplateFilename, { encoding: 'utf-8' }).then((str) => JSON.parse(str)),
|
||||
(async () => yaml.safeLoad(await readFile(flatYamlFilename)))(),
|
||||
]);
|
||||
|
||||
const mappings = {
|
||||
properties: template.mappings.properties,
|
||||
};
|
||||
const flatYaml = await yaml.safeLoad(await readFile(ecsYamlFilename));
|
||||
|
||||
const fields = mapValues(flatYaml, (description) => {
|
||||
return {
|
||||
|
@ -53,25 +40,22 @@ async function generate() {
|
|||
};
|
||||
});
|
||||
|
||||
const hasOutputDir = await exists(outputDir);
|
||||
|
||||
if (hasOutputDir) {
|
||||
await rmdir(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
await mkdir(outputDir);
|
||||
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
outputFieldMapFilename,
|
||||
`
|
||||
/* This file is generated by x-pack/plugins/rule_registry/scripts/generate_ecs_fieldmap/index.js,
|
||||
do not manually edit
|
||||
*/
|
||||
|
||||
export const ecsFieldMap = ${JSON.stringify(fields, null, 2)} as const
|
||||
|
||||
export type EcsFieldMap = typeof ecsFieldMap;
|
||||
`,
|
||||
{ encoding: 'utf-8' }
|
||||
).then(() => {
|
||||
return exec(`node scripts/eslint --fix ${outputFieldMapFilename}`);
|
||||
}),
|
||||
writeFile(outputMappingFilename, JSON.stringify(mappings, null, 2)),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -11,8 +11,6 @@ import { RuleRegistryPlugin } from './plugin';
|
|||
|
||||
export { RuleRegistryPluginSetupContract } from './plugin';
|
||||
export { createLifecycleRuleTypeFactory } from './rule_registry/rule_type_helpers/create_lifecycle_rule_type_factory';
|
||||
export { ecsFieldMap } from './generated/ecs_field_map';
|
||||
export { pickWithPatterns } from './rule_registry/field_map/pick_with_patterns';
|
||||
export { FieldMapOf } from './types';
|
||||
export { ScopedRuleRegistryClient } from './rule_registry/create_scoped_rule_registry_client/types';
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue