Single view in app function for rule actions variables and UI page (#148671)

Resolves: https://github.com/elastic/kibana/issues/145132.

In this PR, I'm adding a new function to the server-side rule type
definition called `viewInAppRelativeUrl`. This function returns a
relative path to view the rule in the proper application that will
provide more context. This relative path is used to build the `rule.url`
mustache variable for the actions (overriding the rule details page link
when defined) as well as a fallback for the UI's `View in App` button if
no navigation is registered on the front-end.

Follow up issues:
- https://github.com/elastic/kibana/issues/149608
- https://github.com/elastic/kibana/issues/151355

## ML to verify

1.  Create an anomaly detection rule from the ML application
2. Go to stack management rule details page
3. Click "View in App"
4. Ensure it brings you to the ML app properly.
5. Repeat step 1 to 4 in a space that isn't the default

Note: ML won't take advantage of the new functionality yet, but we plan
to help in a follow up https://github.com/elastic/kibana/issues/149608
so that ML anomaly detection rules can provide a view in app URL within
the rule action messages.

## ResponseOps to verify

1. Set `server.publicBaseUrl` to the proper value in your kibana.yml
6. Modify the [index threshold rule
type](https://github.com/elastic/kibana/blob/main/x-pack/plugins/stack_alerts/server/rule_types/index_threshold/rule_type.ts#L108-L136)
to have a `getViewInAppRelativeUrl` function returning
`/app/management/insightsAndAlerting/triggersActionsConnectors/connectors`.
7. Create an index threshold rule that always fires. Make sure to add a
a server log action that contains the `{{rule.url}}` variable.
8. Pull the printed URL from the server logs and make sure it works and
brings you to the connectors page.
9. Navigate to the rule details page, click the "View in App" button and
ensure it also brings you to the connectors page.
10. Create a Kibana space.
11. Go into that created space and repeat step 3 to 5. Ensure the URL
and View in App keep you in the same space.

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Mike Côté 2023-02-16 14:09:48 -05:00 committed by GitHub
parent 2406ada16a
commit ccd78c9f0e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 156 additions and 89 deletions

View file

@ -28,7 +28,7 @@ export function registerNavigation(alerting: AlertingSetup) {
alerting.registerNavigation(
ALERTING_EXAMPLE_APP_ID,
'example.people-in-space',
(rule: SanitizedRule) => `/astros/${rule.id}`
(rule: SanitizedRule) => `/app/${ALERTING_EXAMPLE_APP_ID}/astros/${rule.id}`
);
}

View file

@ -14,7 +14,7 @@ export function registerNavigation(alerting: AlertingSetup) {
// register default navigation
alerting.registerDefaultNavigation(
ALERTING_EXAMPLE_APP_ID,
(rule: SanitizedRule) => `/rule/${rule.id}`
(rule: SanitizedRule) => `/app/${ALERTING_EXAMPLE_APP_ID}/rule/${rule.id}`
);
registerPeopleInSpaceNavigation(alerting);

View file

@ -81,4 +81,7 @@ export const alertType: RuleType<
};
},
producer: ALERTING_EXAMPLE_APP_ID,
getViewInAppRelativeUrl({ rule }) {
return `/app/${ALERTING_EXAMPLE_APP_ID}/astros/${rule.id}`;
},
};

View file

@ -695,7 +695,7 @@ alerting.registerNavigation(
This tells the Alerting Framework that, given a rule of the RuleType whose ID is `my-application-id.my-unique-rule-type`, if that rule's `consumer` value (which is set when the rule is created by your plugin) is your application (whose id is `my-application-id`), then it will navigate to your application using the path `/my-unique-rule/${the id of the rule}`.
The navigation is handled using the `navigateToApp` API, meaning that the path will be automatically picked up by your `react-router-dom` **Route** component, so all you have top do is configure a Route that handles the path `/my-unique-rule/:id`.
The navigation is handled using the `navigateToUrl` API, meaning that the path will be automatically picked up by your `react-router-dom` **Route** component, so all you have top do is configure a Route that handles the path `/my-unique-rule/:id`.
You can look at the `alerting-example` plugin to see an example of using this API, which is enabled using the `--run-examples` flag when you run `yarn start`.

View file

@ -14,7 +14,6 @@ export * from './rule';
export * from './rules_settings';
export * from './rule_type';
export * from './rule_task_instance';
export * from './rule_navigation';
export * from './alert_instance';
export * from './alert_summary';
export * from './builtin_action_groups';

View file

@ -147,6 +147,7 @@ export interface Rule<Params extends RuleTypeParams = never> {
lastRun?: RuleLastRun | null;
nextRun?: Date | null;
running?: boolean | null;
viewInAppRelativeUrl?: string;
}
export type SanitizedRule<Params extends RuleTypeParams = never> = Omit<Rule<Params>, 'apiKey'>;

View file

@ -1,15 +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.
*/
import { JsonObject } from '@kbn/utility-types';
export interface RuleUrlNavigation {
path: string;
}
export interface RuleStateNavigation {
state: JsonObject;
}
export type RuleNavigation = RuleUrlNavigation | RuleStateNavigation;

View file

@ -31,7 +31,7 @@ const mockRuleType = (id: string): RuleType => ({
describe('AlertNavigationRegistry', () => {
function handler(rule: SanitizedRule) {
return {};
return '';
}
describe('has()', () => {
@ -151,7 +151,7 @@ describe('AlertNavigationRegistry', () => {
const registry = new AlertNavigationRegistry();
function indexThresholdHandler(rule: SanitizedRule) {
return {};
return '';
}
const indexThresholdRuleType = mockRuleType('indexThreshold');
@ -163,7 +163,7 @@ describe('AlertNavigationRegistry', () => {
const registry = new AlertNavigationRegistry();
function defaultHandler(rule: SanitizedRule) {
return {};
return '';
}
registry.registerDefault('siem', defaultHandler);
@ -173,10 +173,10 @@ describe('AlertNavigationRegistry', () => {
test('returns default handlers by consumer when there are other rule type handler', () => {
const registry = new AlertNavigationRegistry();
registry.register('siem', mockRuleType('indexThreshold').id, () => ({}));
registry.register('siem', mockRuleType('indexThreshold').id, () => '');
function defaultHandler(rule: SanitizedRule) {
return {};
return '';
}
registry.registerDefault('siem', defaultHandler);

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import { JsonObject } from '@kbn/utility-types';
import { SanitizedRule } from '../../common';
/**
@ -17,4 +16,4 @@ import { SanitizedRule } from '../../common';
* originally registered to {@link PluginSetupContract.registerNavigation}.
*
*/
export type AlertNavigationHandler = (rule: SanitizedRule) => JsonObject | string;
export type AlertNavigationHandler = (rule: SanitizedRule) => string;

View file

@ -118,6 +118,7 @@ export function transformRule(input: ApiRule): Rule {
next_run: nextRun,
last_run: lastRun,
monitoring: monitoring,
view_in_app_relative_url: viewInAppRelativeUrl,
...rest
} = input;
@ -135,6 +136,7 @@ export function transformRule(input: ApiRule): Rule {
executionStatus: transformExecutionStatus(executionStatusAPI),
actions: actionsAPI ? actionsAPI.map((action) => transformAction(action)) : [],
scheduledTaskId,
...(viewInAppRelativeUrl ? { viewInAppRelativeUrl } : {}),
...(nextRun ? { nextRun: new Date(nextRun) } : {}),
...(monitoring ? { monitoring: transformMonitoring(monitoring) } : {}),
...(lastRun ? { lastRun: transformLastRun(lastRun) } : {}),

View file

@ -0,0 +1,35 @@
/*
* 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 { AlertingPublicPlugin } from './plugin';
import { coreMock } from '@kbn/core/public/mocks';
jest.mock('./alert_api', () => ({
loadRule: jest.fn(),
loadRuleType: jest.fn(),
}));
describe('Alerting Public Plugin', () => {
describe('start()', () => {
it(`should fallback to the viewInAppRelativeUrl part of the rule object if navigation isn't registered`, async () => {
const { loadRule, loadRuleType } = jest.requireMock('./alert_api');
loadRule.mockResolvedValue({
alertTypeId: 'foo',
consumer: 'abc',
viewInAppRelativeUrl: '/my/custom/path',
});
loadRuleType.mockResolvedValue({});
const plugin = new AlertingPublicPlugin();
plugin.setup();
const pluginStart = plugin.start(coreMock.createStart());
const navigationPath = await pluginStart.getNavigation('123');
expect(navigationPath).toEqual('/my/custom/path');
});
});
});

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import { CoreSetup, Plugin, CoreStart } from '@kbn/core/public';
import { Plugin, CoreStart } from '@kbn/core/public';
import { AlertNavigationRegistry, AlertNavigationHandler } from './alert_navigation_registry';
import { loadRule, loadRuleType } from './alert_api';
import { Rule, RuleNavigation } from '../common';
import { Rule } from '../common';
export interface PluginSetupContract {
/**
@ -26,6 +26,8 @@ export interface PluginSetupContract {
* @param handler The navigation handler should return either a relative URL, or a state object. This information can be used,
* in conjunction with the consumer id, to navigate the user to a custom URL to view a rule's details.
* @throws an error if the given applicationId and ruleType combination has already been registered.
*
* @deprecated use "getViewInAppRelativeUrl" on the server side rule type instead.
*/
registerNavigation: (
applicationId: string,
@ -42,16 +44,18 @@ export interface PluginSetupContract {
* @param applicationId The application id that the user should be navigated to, to view a particular rule in a custom way.
* @param handler The navigation handler should return either a relative URL, or a state object. This information can be used,
* in conjunction with the consumer id, to navigate the user to a custom URL to view a rule's details.
*
* @deprecated use "getViewInAppRelativeUrl" on the server side rule type instead.
*/
registerDefaultNavigation: (applicationId: string, handler: AlertNavigationHandler) => void;
}
export interface PluginStartContract {
getNavigation: (ruleId: Rule['id']) => Promise<RuleNavigation | undefined>;
getNavigation: (ruleId: Rule['id']) => Promise<string | undefined>;
}
export class AlertingPublicPlugin implements Plugin<PluginSetupContract, PluginStartContract> {
private alertNavigationRegistry?: AlertNavigationRegistry;
public setup(core: CoreSetup) {
public setup() {
this.alertNavigationRegistry = new AlertNavigationRegistry();
const registerNavigation = async (
@ -89,8 +93,11 @@ export class AlertingPublicPlugin implements Plugin<PluginSetupContract, PluginS
if (this.alertNavigationRegistry!.has(rule.consumer, ruleType)) {
const navigationHandler = this.alertNavigationRegistry!.get(rule.consumer, ruleType);
const state = navigationHandler(rule);
return typeof state === 'string' ? { path: state } : { state };
return navigationHandler(rule);
}
if (rule.viewInAppRelativeUrl) {
return rule.viewInAppRelativeUrl;
}
},
};

View file

@ -39,6 +39,7 @@ const rewriteBodyRes: RewriteResponseCase<SanitizedRule<RuleTypeParams>> = ({
isSnoozedUntil,
lastRun,
nextRun,
viewInAppRelativeUrl,
...rest
}) => ({
...rest,
@ -74,6 +75,7 @@ const rewriteBodyRes: RewriteResponseCase<SanitizedRule<RuleTypeParams>> = ({
})),
...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}),
...(nextRun ? { next_run: nextRun } : {}),
...(viewInAppRelativeUrl ? { view_in_app_relative_url: viewInAppRelativeUrl } : {}),
});
interface BuildGetRulesRouteParams {

View file

@ -1,17 +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.
*/
import { RuleSnooze } from '../../types';
import { getRuleSnoozeEndTime } from '../../lib';
export function calculateIsSnoozedUntil(rule: {
muteAll: boolean;
snoozeSchedule?: RuleSnooze;
}): string | null {
const isSnoozedUntil = getRuleSnoozeEndTime(rule);
return isSnoozedUntil ? isSnoozedUntil.toISOString() : null;
}

View file

@ -14,7 +14,6 @@ export { buildKueryNodeFilter } from './build_kuery_node_filter';
export { generateAPIKeyName } from './generate_api_key_name';
export * from './mapped_params_utils';
export { apiKeyAsAlertAttributes } from './api_key_as_alert_attributes';
export { calculateIsSnoozedUntil } from './calculate_is_snoozed_until';
export * from './inject_references';
export { parseDate } from './parse_date';
export { includeFieldsRequiredForAuthentication } from './include_fields_required_for_authentication';

View file

@ -16,14 +16,14 @@ import {
RuleWithLegacyId,
PartialRuleWithLegacyId,
} from '../../types';
import { ruleExecutionStatusFromRaw, convertMonitoringFromRawAndVerify } from '../../lib';
import {
ruleExecutionStatusFromRaw,
convertMonitoringFromRawAndVerify,
getRuleSnoozeEndTime,
} from '../../lib';
import { UntypedNormalizedRuleType } from '../../rule_type_registry';
import { getActiveScheduledSnoozes } from '../../lib/is_rule_snoozed';
import {
calculateIsSnoozedUntil,
injectReferencesIntoActions,
injectReferencesIntoParams,
} from '../common';
import { injectReferencesIntoActions, injectReferencesIntoParams } from '../common';
import { RulesClientContext } from '../types';
export interface GetAlertFromRawParams {
@ -98,20 +98,20 @@ export function getPartialRuleFromRaw<Params extends RuleTypeParams>(
...s,
rRule: {
...s.rRule,
dtstart: new Date(s.rRule.dtstart),
...(s.rRule.until ? { until: new Date(s.rRule.until) } : {}),
dtstart: new Date(s.rRule.dtstart).toISOString(),
...(s.rRule.until ? { until: new Date(s.rRule.until).toISOString() } : {}),
},
}));
const includeSnoozeSchedule =
snoozeSchedule !== undefined && !isEmpty(snoozeSchedule) && !excludeFromPublicApi;
const isSnoozedUntil = includeSnoozeSchedule
? calculateIsSnoozedUntil({
? getRuleSnoozeEndTime({
muteAll: partialRawRule.muteAll ?? false,
snoozeSchedule,
})
: null;
const includeMonitoring = monitoring && !excludeFromPublicApi;
const rule = {
const rule: PartialRule<Params> = {
id,
notifyWhen,
...omit(partialRawRule, excludeFromPublicApi ? [...context.fieldsToExcludeFromPublicApi] : ''),
@ -152,7 +152,23 @@ export function getPartialRuleFromRaw<Params extends RuleTypeParams>(
: {}),
};
return includeLegacyId
? ({ ...rule, legacyId } as PartialRuleWithLegacyId<Params>)
: (rule as PartialRule<Params>);
// Need the `rule` object to build a URL
if (!excludeFromPublicApi) {
const viewInAppRelativeUrl =
ruleType.getViewInAppRelativeUrl &&
ruleType.getViewInAppRelativeUrl({ rule: rule as Rule<Params> });
if (viewInAppRelativeUrl) {
rule.viewInAppRelativeUrl = viewInAppRelativeUrl;
}
}
if (includeLegacyId) {
const result: PartialRuleWithLegacyId<Params> = {
...rule,
legacyId,
};
return result;
}
return rule;
}

View file

@ -1472,5 +1472,38 @@ describe('Execution Handler', () => {
]
`);
});
it('sets the rule.url to the value from getViewInAppRelativeUrl when the rule type has it defined', async () => {
const execParams = {
...defaultExecutionParams,
rule: ruleWithUrl,
taskRunnerContext: {
...defaultExecutionParams.taskRunnerContext,
kibanaBaseUrl: 'http://localhost:12345',
},
ruleType: {
...ruleType,
getViewInAppRelativeUrl() {
return '/app/management/some/other/place';
},
},
};
const executionHandler = new ExecutionHandler(generateExecutionParams(execParams));
await executionHandler.run(generateAlert({ id: 1 }));
expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"actionParams": Object {
"val": "rule url: http://localhost:12345/s/test1/app/management/some/other/place",
},
"actionTypeId": "test",
"ruleId": "1",
"spaceId": "test1",
},
]
`);
});
});
});

View file

@ -270,8 +270,8 @@ export class ExecutionHandler<
kibanaBaseUrl: this.taskRunnerContext.kibanaBaseUrl,
alertParams: this.rule.params,
actionParams: action.params,
ruleUrl: this.buildRuleUrl(spaceId),
flapping: executableAlert.getFlapping(),
ruleUrl: this.buildRuleUrl(spaceId),
}),
}),
};
@ -409,11 +409,13 @@ export class ExecutionHandler<
return;
}
const relativePath = this.ruleType.getViewInAppRelativeUrl
? this.ruleType.getViewInAppRelativeUrl({ rule: this.rule })
: `${triggersActionsRoute}${getRuleDetailsRoute(this.rule.id)}`;
try {
const ruleUrl = new URL(
`${
spaceId !== 'default' ? `/s/${spaceId}` : ''
}${triggersActionsRoute}${getRuleDetailsRoute(this.rule.id)}`,
`${spaceId !== 'default' ? `/s/${spaceId}` : ''}${relativePath}`,
this.taskRunnerContext.kibanaBaseUrl
);

View file

@ -48,6 +48,7 @@ import {
RuleSnooze,
IntervalSchedule,
RuleLastRun,
SanitizedRule,
} from '../common';
import { PublicAlertFactory } from './alert/create_alert_factory';
import { FieldMap } from '../common/alert_schema/field_maps/types';
@ -161,6 +162,12 @@ export interface SummarizedAlerts {
};
}
export type GetSummarizedAlertsFn = (opts: GetSummarizedAlertsFnOpts) => Promise<SummarizedAlerts>;
export interface GetViewInAppRelativeUrlFnOpts<Params extends RuleTypeParams> {
rule: Omit<SanitizedRule<Params>, 'viewInAppRelativeUrl'>;
}
export type GetViewInAppRelativeUrlFn<Params extends RuleTypeParams> = (
opts: GetViewInAppRelativeUrlFnOpts<Params>
) => string;
export interface IRuleTypeAlerts {
context: string;
namespace?: string;
@ -218,6 +225,7 @@ export interface RuleType<
* automatically make recovery determination. Defaults to true.
*/
autoRecoverAlerts?: boolean;
getViewInAppRelativeUrl?: GetViewInAppRelativeUrlFn<Params>;
}
export type UntypedRuleType = RuleType<
RuleTypeParams,

View file

@ -149,6 +149,6 @@ export function registerNavigation(alerting: AlertingSetup) {
]),
];
return formatExplorerUrl('', { jobIds });
return formatExplorerUrl('/app/ml', { jobIds });
});
}

View file

@ -48,7 +48,7 @@ function registerNavigation(alerting: AlertingSetup) {
PLUGIN_ID,
ES_QUERY_ALERT_TYPE,
(alert: SanitizedRule<EsQueryRuleParams<SearchType.searchSource>>) => {
return `#/viewAlert/${alert.id}`;
return `/app/discover#/viewAlert/${alert.id}`;
}
);
}

View file

@ -45,7 +45,7 @@ const expectedTransformResult = [
{ description: 'The type of rule.', name: 'rule.type' },
{
description:
'The URL to the Stack Management rule page that generated the alert. This will be an empty string if the server.publicBaseUrl is not configured.',
'The URL to the rule that generated the alert. This will be an empty string if the server.publicBaseUrl is not configured.',
name: 'rule.url',
usesPublicBaseUrl: true,
},
@ -171,7 +171,7 @@ const expectedSummaryTransformResult = [
},
{
description:
'The URL to the Stack Management rule page that generated the alert. This will be an empty string if the server.publicBaseUrl is not configured.',
'The URL to the rule that generated the alert. This will be an empty string if the server.publicBaseUrl is not configured.',
name: 'rule.url',
usesPublicBaseUrl: true,
},

View file

@ -114,7 +114,7 @@ const AlertProvidedActionVariableDescriptions = {
name: AlertProvidedActionVariables.ruleUrl,
description: i18n.translate('xpack.triggersActionsUI.actionVariables.ruleUrlLabel', {
defaultMessage:
'The URL to the Stack Management rule page that generated the alert. This will be an empty string if the server.publicBaseUrl is not configured.',
'The URL to the rule that generated the alert. This will be an empty string if the server.publicBaseUrl is not configured.',
}),
usesPublicBaseUrl: true,
},

View file

@ -12,11 +12,6 @@ import { CoreStart } from '@kbn/core/public';
import { fromNullable, fold } from 'fp-ts/lib/Option';
import { pipe } from 'fp-ts/lib/pipeable';
import {
RuleNavigation,
RuleStateNavigation,
RuleUrlNavigation,
} from '@kbn/alerting-plugin/common';
import { Rule } from '../../../../types';
import { useKibana } from '../../../../common/lib/kibana';
@ -26,11 +21,12 @@ export interface ViewInAppProps {
const NO_NAVIGATION = false;
type RuleNavigationLoadingState = RuleNavigation | false | null;
type RuleNavigationLoadingState = string | false | null;
export const ViewInApp: React.FunctionComponent<ViewInAppProps> = ({ rule }) => {
const {
application: { navigateToApp },
application: { navigateToUrl },
http: { basePath },
alerting: maybeAlerting,
} = useKibana().services;
@ -62,7 +58,7 @@ export const ViewInApp: React.FunctionComponent<ViewInAppProps> = ({ rule }) =>
isLoading={ruleNavigation === null}
disabled={!hasNavigation(ruleNavigation)}
iconType="popout"
{...getNavigationHandler(ruleNavigation, rule, navigateToApp)}
{...getNavigationHandler(ruleNavigation, rule, navigateToUrl, basePath)}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.viewRuleInAppButtonLabel"
@ -72,23 +68,20 @@ export const ViewInApp: React.FunctionComponent<ViewInAppProps> = ({ rule }) =>
);
};
function hasNavigation(
ruleNavigation: RuleNavigationLoadingState
): ruleNavigation is RuleStateNavigation | RuleUrlNavigation {
return ruleNavigation
? ruleNavigation.hasOwnProperty('state') || ruleNavigation.hasOwnProperty('path')
: NO_NAVIGATION;
function hasNavigation(ruleNavigation: RuleNavigationLoadingState): ruleNavigation is string {
return typeof ruleNavigation === 'string';
}
function getNavigationHandler(
ruleNavigation: RuleNavigationLoadingState,
rule: Rule,
navigateToApp: CoreStart['application']['navigateToApp']
navigateToUrl: CoreStart['application']['navigateToUrl'],
basePath: CoreStart['http']['basePath']
): object {
return hasNavigation(ruleNavigation)
? {
onClick: () => {
navigateToApp(rule.consumer, ruleNavigation);
navigateToUrl(basePath.prepend(ruleNavigation));
},
}
: {};

View file

@ -24,7 +24,7 @@ export class AlertingFixturePlugin implements Plugin<Setup, Start, AlertingExamp
alerting.registerNavigation(
'alerting_fixture',
'test.noop',
(alert: SanitizedRule) => `/rule/${alert.id}`
(alert: SanitizedRule) => `/app/alerting_fixture/rule/${alert.id}`
);
triggersActionsUi.ruleTypeRegistry.register({