Observability side navigation for cases and alerts (#102556) (#103511)

Co-authored-by: Nathan L Smith <nathan.smith@elastic.co>
This commit is contained in:
Kibana Machine 2021-06-28 14:53:24 -04:00 committed by GitHub
parent db2c8c3b20
commit b92ce4d7d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 522 additions and 347 deletions

View file

@ -148,7 +148,6 @@ export const applicationUsageSchema = {
maps: commonSchema,
ml: commonSchema,
monitoring: commonSchema,
observabilityCases: commonSchema,
'observability-overview': commonSchema,
osquery: commonSchema,
security_account: commonSchema,

View file

@ -3970,137 +3970,6 @@
}
}
},
"observabilityCases": {
"properties": {
"appId": {
"type": "keyword",
"_meta": {
"description": "The application being tracked"
}
},
"viewId": {
"type": "keyword",
"_meta": {
"description": "Always `main`"
}
},
"clicks_total": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application since we started counting them"
}
},
"clicks_7_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 7 days"
}
},
"clicks_30_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 30 days"
}
},
"clicks_90_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 90 days"
}
},
"minutes_on_screen_total": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen since we started counting them."
}
},
"minutes_on_screen_7_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 7 days"
}
},
"minutes_on_screen_30_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 30 days"
}
},
"minutes_on_screen_90_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 90 days"
}
},
"views": {
"type": "array",
"items": {
"properties": {
"appId": {
"type": "keyword",
"_meta": {
"description": "The application being tracked"
}
},
"viewId": {
"type": "keyword",
"_meta": {
"description": "The application view being tracked"
}
},
"clicks_total": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application sub view since we started counting them"
}
},
"clicks_7_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 7 days"
}
},
"clicks_30_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 30 days"
}
},
"clicks_90_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 90 days"
}
},
"minutes_on_screen_total": {
"type": "float",
"_meta": {
"description": "Minutes the application sub view is active and on-screen since we started counting them."
}
},
"minutes_on_screen_7_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 7 days"
}
},
"minutes_on_screen_30_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 30 days"
}
},
"minutes_on_screen_90_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 90 days"
}
}
}
}
}
}
},
"observability-overview": {
"properties": {
"appId": {

View file

@ -8,6 +8,7 @@
import { schema } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n';
import { UiSettingsParams } from '../../../../src/core/types';
import { observabilityFeatureId } from '../../observability/common';
import { enableServiceOverview } from '../common/ui_settings_keys';
/**
@ -15,7 +16,7 @@ import { enableServiceOverview } from '../common/ui_settings_keys';
*/
export const uiSettings: Record<string, UiSettingsParams<boolean>> = {
[enableServiceOverview]: {
category: ['observability'],
category: [observabilityFeatureId],
name: i18n.translate('xpack.apm.enableServiceOverviewExperimentName', {
defaultMessage: 'APM Service overview',
}),

View file

@ -1,9 +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 const CASES_APP_ID = 'observabilityCases';
export const OBSERVABILITY = 'observability';

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const casesFeatureId = 'observabilityCases';
// The ID of the observability app. Should more appropriately be called
// 'observability' but it's used in telemetry by applicationUsage so we don't
// want to change it.
export const observabilityAppId = 'observability-overview';
// Used by feature and "solution" registration
export const observabilityFeatureId = 'observability';

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { observabilityFeatureId } from '..';
export const observabilityRuleRegistrySettings = {
name: 'observability',
name: observabilityFeatureId,
};

View file

@ -6,6 +6,7 @@
*/
import React from 'react';
import { observabilityAppId } from '../../../../../common';
import {
getCaseDetailsUrl,
@ -14,7 +15,7 @@ import {
useFormatUrl,
} from '../../../../pages/cases/links';
import { useKibana } from '../../../../utils/kibana_react';
import { CASES_APP_ID, CASES_OWNER } from '../constants';
import { CASES_OWNER } from '../constants';
export interface AllCasesNavProps {
detailName: string;
@ -30,9 +31,9 @@ export const AllCases = React.memo<AllCasesProps>(({ userCanCrud }) => {
cases: casesUi,
application: { getUrlForApp, navigateToUrl },
} = useKibana().services;
const { formatUrl } = useFormatUrl(CASES_APP_ID);
const { formatUrl } = useFormatUrl();
const casesUrl = getUrlForApp(CASES_APP_ID);
const casesUrl = `${getUrlForApp(observabilityAppId)}/cases`;
return casesUi.getAllCases({
caseDetailsNavigation: {
href: ({ detailName, subCaseId }: AllCasesNavProps) => {

View file

@ -12,6 +12,7 @@ import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_rea
import { useMessagesStorage } from '../../../../hooks/use_messages_storage';
import { createCalloutId } from './helpers';
import { CaseCallOut, CaseCallOutProps } from '.';
import { observabilityAppId } from '../../../../../common';
jest.mock('../../../../hooks/use_messages_storage');
const useSecurityLocalStorageMock = useMessagesStorage as jest.Mock;
@ -110,9 +111,9 @@ describe('CaseCallOut ', () => {
);
const id = createCalloutId(['message-one']);
expect(securityLocalStorageMock.getMessages).toHaveBeenCalledWith('observability');
expect(securityLocalStorageMock.getMessages).toHaveBeenCalledWith(observabilityAppId);
wrapper.find(`[data-test-subj="callout-dismiss-${id}"]`).last().simulate('click');
expect(securityLocalStorageMock.addMessage).toHaveBeenCalledWith('observability', id);
expect(securityLocalStorageMock.addMessage).toHaveBeenCalledWith(observabilityAppId, id);
});
it('do not show the callout if is in the localStorage', () => {

View file

@ -12,7 +12,7 @@ import { CallOut } from './callout';
import { ErrorMessage } from './types';
import { createCalloutId } from './helpers';
import { useMessagesStorage } from '../../../../hooks/use_messages_storage';
import { OBSERVABILITY } from '../../../../../common/const';
import { observabilityAppId } from '../../../../../common';
export * from './helpers';
@ -35,7 +35,7 @@ interface CalloutVisibility {
function CaseCallOutComponent({ title, messages = [] }: CaseCallOutProps) {
const { getMessages, addMessage } = useMessagesStorage();
const caseMessages = useMemo(() => getMessages(OBSERVABILITY), [getMessages]);
const caseMessages = useMemo(() => getMessages(observabilityAppId), [getMessages]);
const dismissedCallouts = useMemo(
() =>
caseMessages.reduce<CalloutVisibility>(
@ -53,7 +53,7 @@ function CaseCallOutComponent({ title, messages = [] }: CaseCallOutProps) {
(id, type) => {
setCalloutVisibility((prevState) => ({ ...prevState, [id]: false }));
if (type === 'primary') {
addMessage(OBSERVABILITY, id);
addMessage(observabilityAppId, id);
}
},
[setCalloutVisibility, addMessage]

View file

@ -17,8 +17,8 @@ import {
import { Case } from '../../../../../../cases/common';
import { useFetchAlertData } from './helpers';
import { useKibana } from '../../../../utils/kibana_react';
import { CASES_APP_ID } from '../constants';
import { useBreadcrumbs } from '../../../../hooks/use_breadcrumbs';
import { observabilityAppId } from '../../../../../common';
interface Props {
caseId: string;
@ -47,7 +47,7 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) =
application: { getUrlForApp, navigateToUrl },
} = useKibana().services;
const allCasesLink = getCaseUrl();
const { formatUrl } = useFormatUrl(CASES_APP_ID);
const { formatUrl } = useFormatUrl();
const href = formatUrl(allCasesLink);
useBreadcrumbs([
{ ...casesBreadcrumbs.cases, href },
@ -81,7 +81,7 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) =
[caseId, formatUrl, subCaseId]
);
const casesUrl = getUrlForApp(CASES_APP_ID);
const casesUrl = `${getUrlForApp(observabilityAppId)}/cases`;
return casesUi.getCaseView({
allCasesNavigation: {
href: allCasesHref,

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import { CASES_APP_ID } from '../../../../common/const';
import { observabilityFeatureId } from '../../../../common';
export { CASES_APP_ID };
export const CASES_OWNER = 'observability';
export const CASES_OWNER = observabilityFeatureId;

View file

@ -21,7 +21,7 @@ jest.mock('../../../../utils/kibana_react');
describe('Create case', () => {
const mockCreateCase = jest.fn();
const mockNavigateToUrl = jest.fn();
const mockCasesUrl = 'https://elastic.co/app/observability/cases';
const mockObservabilityUrl = 'https://elastic.co/app/observability';
beforeEach(() => {
jest.resetAllMocks();
(useKibana as jest.Mock).mockReturnValue({
@ -29,7 +29,7 @@ describe('Create case', () => {
cases: {
getCreateCase: mockCreateCase,
},
application: { navigateToUrl: mockNavigateToUrl, getUrlForApp: () => mockCasesUrl },
application: { navigateToUrl: mockNavigateToUrl, getUrlForApp: () => mockObservabilityUrl },
},
});
});
@ -53,7 +53,7 @@ describe('Create case', () => {
onCancel();
},
},
application: { navigateToUrl: mockNavigateToUrl, getUrlForApp: () => mockCasesUrl },
application: { navigateToUrl: mockNavigateToUrl, getUrlForApp: () => mockObservabilityUrl },
},
});
mount(
@ -62,7 +62,9 @@ describe('Create case', () => {
</EuiThemeProvider>
);
await waitFor(() => expect(mockNavigateToUrl).toHaveBeenCalledWith(`${mockCasesUrl}`));
await waitFor(() =>
expect(mockNavigateToUrl).toHaveBeenCalledWith(`${mockObservabilityUrl}/cases`)
);
});
it('should redirect to new case when posting the case', async () => {
@ -73,7 +75,7 @@ describe('Create case', () => {
onSuccess(basicCase);
},
},
application: { navigateToUrl: mockNavigateToUrl, getUrlForApp: () => mockCasesUrl },
application: { navigateToUrl: mockNavigateToUrl, getUrlForApp: () => mockObservabilityUrl },
},
});
mount(
@ -85,7 +87,7 @@ describe('Create case', () => {
await waitFor(() =>
expect(mockNavigateToUrl).toHaveBeenNthCalledWith(
1,
`${mockCasesUrl}${getCaseDetailsUrl({ id: basicCase.id })}`
`${mockObservabilityUrl}/cases${getCaseDetailsUrl({ id: basicCase.id })}`
)
);
});

View file

@ -10,14 +10,15 @@ import { EuiPanel } from '@elastic/eui';
import { useKibana } from '../../../../utils/kibana_react';
import { getCaseDetailsUrl } from '../../../../pages/cases/links';
import { CASES_APP_ID, CASES_OWNER } from '../constants';
import { CASES_OWNER } from '../constants';
import { observabilityAppId } from '../../../../../common';
export const Create = React.memo(() => {
const {
cases,
application: { getUrlForApp, navigateToUrl },
} = useKibana().services;
const casesUrl = getUrlForApp(CASES_APP_ID);
const casesUrl = `${getUrlForApp(observabilityAppId)}/cases`;
const onSuccess = useCallback(
async ({ id }) => navigateToUrl(`${casesUrl}${getCaseDetailsUrl({ id })}`),
[casesUrl, navigateToUrl]

View file

@ -7,7 +7,7 @@
import { useEffect, useState } from 'react';
import { useKibana } from '../utils/kibana_react';
import { CASES_APP_ID } from '../../common/const';
import { casesFeatureId } from '../../common';
export interface UseGetUserCasesPermissions {
crud: boolean;
@ -20,12 +20,12 @@ export function useGetUserCasesPermissions() {
useEffect(() => {
const capabilitiesCanUserCRUD: boolean =
typeof uiCapabilities[CASES_APP_ID].crud_cases === 'boolean'
? (uiCapabilities[CASES_APP_ID].crud_cases as boolean)
typeof uiCapabilities[casesFeatureId].crud_cases === 'boolean'
? (uiCapabilities[casesFeatureId].crud_cases as boolean)
: false;
const capabilitiesCanUserRead: boolean =
typeof uiCapabilities[CASES_APP_ID].read_cases === 'boolean'
? (uiCapabilities[CASES_APP_ID].read_cases as boolean)
typeof uiCapabilities[casesFeatureId].read_cases === 'boolean'
? (uiCapabilities[casesFeatureId].read_cases as boolean)
: false;
setCasesPermissions({
crud: capabilitiesCanUserCRUD,

View file

@ -11,14 +11,16 @@ import { useParams } from 'react-router-dom';
import { CaseView } from '../../components/app/cases/case_view';
import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions';
import { useKibana } from '../../utils/kibana_react';
import { CASES_APP_ID } from '../../components/app/cases/constants';
import { useReadonlyHeader } from '../../hooks/use_readonly_header';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { observabilityAppId } from '../../../common';
export const CaseDetailsPage = React.memo(() => {
const {
application: { getUrlForApp, navigateToUrl },
} = useKibana().services;
const casesUrl = getUrlForApp(CASES_APP_ID);
const { ObservabilityPageTemplate } = usePluginContext();
const casesUrl = `${getUrlForApp(observabilityAppId)}/cases`;
const userPermissions = useGetUserCasesPermissions();
const { detailName: caseId, subCaseId } = useParams<{
detailName?: string;
@ -33,7 +35,13 @@ export const CaseDetailsPage = React.memo(() => {
}, [casesUrl, navigateToUrl, userPermissions]);
return caseId != null ? (
<CaseView caseId={caseId} subCaseId={subCaseId} userCanCrud={userPermissions?.crud ?? false} />
<ObservabilityPageTemplate>
<CaseView
caseId={caseId}
subCaseId={subCaseId}
userCanCrud={userPermissions?.crud ?? false}
/>
</ObservabilityPageTemplate>
) : null;
});

View file

@ -10,12 +10,13 @@ import styled from 'styled-components';
import { EuiButtonEmpty } from '@elastic/eui';
import * as i18n from '../../components/app/cases/translations';
import { CASES_APP_ID, CASES_OWNER } from '../../components/app/cases/constants';
import { CASES_OWNER } from '../../components/app/cases/constants';
import { useKibana } from '../../utils/kibana_react';
import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
import { casesBreadcrumbs, getCaseUrl, useFormatUrl } from './links';
import { observabilityAppId } from '../../../common';
const ButtonEmpty = styled(EuiButtonEmpty)`
display: block;
@ -25,7 +26,7 @@ function ConfigureCasesPageComponent() {
cases,
application: { getUrlForApp, navigateToUrl },
} = useKibana().services;
const casesUrl = getUrlForApp(CASES_APP_ID);
const casesUrl = `${getUrlForApp(observabilityAppId)}/cases`;
const userPermissions = useGetUserCasesPermissions();
const { ObservabilityPageTemplate } = usePluginContext();
const onClickGoToCases = useCallback(
@ -35,7 +36,7 @@ function ConfigureCasesPageComponent() {
},
[casesUrl, navigateToUrl]
);
const { formatUrl } = useFormatUrl(CASES_APP_ID);
const { formatUrl } = useFormatUrl();
const href = formatUrl(getCaseUrl());
useBreadcrumbs([{ ...casesBreadcrumbs.cases, href }, casesBreadcrumbs.configure]);

View file

@ -10,12 +10,12 @@ import { EuiButtonEmpty } from '@elastic/eui';
import styled from 'styled-components';
import * as i18n from '../../components/app/cases/translations';
import { Create } from '../../components/app/cases/create';
import { CASES_APP_ID } from '../../components/app/cases/constants';
import { useKibana } from '../../utils/kibana_react';
import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { casesBreadcrumbs, getCaseUrl, useFormatUrl } from './links';
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
import { observabilityAppId } from '../../../common';
const ButtonEmpty = styled(EuiButtonEmpty)`
display: block;
@ -28,7 +28,7 @@ export const CreateCasePage = React.memo(() => {
application: { getUrlForApp, navigateToUrl },
} = useKibana().services;
const casesUrl = getUrlForApp(CASES_APP_ID);
const casesUrl = `${getUrlForApp(observabilityAppId)}/cases`;
const goTo = useCallback(
async (ev) => {
ev.preventDefault();
@ -37,7 +37,7 @@ export const CreateCasePage = React.memo(() => {
[casesUrl, navigateToUrl]
);
const { formatUrl } = useFormatUrl(CASES_APP_ID);
const { formatUrl } = useFormatUrl();
const href = formatUrl(getCaseUrl());
useBreadcrumbs([{ ...casesBreadcrumbs.cases, href }, casesBreadcrumbs.create]);

View file

@ -8,6 +8,7 @@
import { i18n } from '@kbn/i18n';
import { isEmpty } from 'lodash/fp';
import { useCallback } from 'react';
import { observabilityAppId } from '../../../common';
import { useKibana } from '../../utils/kibana_react';
export const casesBreadcrumbs = {
@ -39,18 +40,18 @@ interface FormatUrlOptions {
}
export type FormatUrl = (path: string, options?: Partial<FormatUrlOptions>) => string;
export const useFormatUrl = (appId: string) => {
export const useFormatUrl = () => {
const { getUrlForApp } = useKibana().services.application;
const formatUrl = useCallback<FormatUrl>(
(path: string, { absolute = false } = {}) => {
const pathArr = path.split('?');
const formattedPath = `${pathArr[0]}${isEmpty(pathArr[1]) ? '' : `?${pathArr[1]}`}`;
return getUrlForApp(`${appId}`, {
const formattedPath = `/cases/${pathArr[0]}${isEmpty(pathArr[1]) ? '' : `?${pathArr[1]}`}`;
return getUrlForApp(observabilityAppId, {
path: formattedPath,
absolute,
});
},
[appId, getUrlForApp]
[getUrlForApp]
);
return { formatUrl };
};

View file

@ -6,13 +6,13 @@
*/
import { i18n } from '@kbn/i18n';
import { BehaviorSubject, of } from 'rxjs';
import {
TriggersAndActionsUIPublicPluginSetup,
TriggersAndActionsUIPublicPluginStart,
} from '../../triggers_actions_ui/public';
import { BehaviorSubject, from } from 'rxjs';
import { map } from 'rxjs/operators';
import { ConfigSchema } from '.';
import {
AppDeepLink,
AppMountParameters,
AppNavLinkStatus,
AppUpdater,
CoreSetup,
CoreStart,
@ -28,16 +28,19 @@ import type {
HomePublicPluginSetup,
HomePublicPluginStart,
} from '../../../../src/plugins/home/public';
import type { LensPublicStart } from '../../lens/public';
import { registerDataHandler } from './data_handler';
import { createCallObservabilityApi } from './services/call_observability_api';
import { createNavigationRegistry } from './services/navigation_registry';
import { toggleOverviewLinkInNav } from './toggle_overview_link_in_nav';
import { ConfigSchema } from '.';
import { createObservabilityRuleTypeRegistry } from './rules/create_observability_rule_type_registry';
import { createLazyObservabilityPageTemplate } from './components/shared';
import { CASES_APP_ID } from './components/app/cases/constants';
import { CasesUiStart } from '../../cases/public';
import type { LensPublicStart } from '../../lens/public';
import {
TriggersAndActionsUIPublicPluginSetup,
TriggersAndActionsUIPublicPluginStart,
} from '../../triggers_actions_ui/public';
import { observabilityAppId, observabilityFeatureId } from '../common';
import { createLazyObservabilityPageTemplate } from './components/shared';
import { registerDataHandler } from './data_handler';
import { createObservabilityRuleTypeRegistry } from './rules/create_observability_rule_type_registry';
import { createCallObservabilityApi } from './services/call_observability_api';
import { createNavigationRegistry, NavigationEntry } from './services/navigation_registry';
import { updateGlobalNavigation } from './update_global_navigation';
export type ObservabilityPublicSetup = ReturnType<Plugin['setup']>;
@ -66,9 +69,31 @@ export class Plugin
ObservabilityPublicPluginsStart
> {
private readonly appUpdater$ = new BehaviorSubject<AppUpdater>(() => ({}));
private readonly casesAppUpdater$ = new BehaviorSubject<AppUpdater>(() => ({}));
private readonly navigationRegistry = createNavigationRegistry();
// Define deep links as constant and hidden. Whether they are shown or hidden
// in the global navigation will happen in `updateGlobalNavigation`.
private readonly deepLinks: AppDeepLink[] = [
{
id: 'alerts',
title: i18n.translate('xpack.observability.alertsLinkTitle', {
defaultMessage: 'Alerts',
}),
order: 8001,
path: '/alerts',
navLinkStatus: AppNavLinkStatus.hidden,
},
{
id: 'cases',
title: i18n.translate('xpack.observability.casesLinkTitle', {
defaultMessage: 'Cases',
}),
order: 8002,
path: '/cases',
navLinkStatus: AppNavLinkStatus.hidden,
},
];
constructor(private readonly initializerContext: PluginInitializerContext<ConfigSchema>) {
this.initializerContext = initializerContext;
}
@ -103,47 +128,26 @@ export class Plugin
});
};
const updater$ = this.appUpdater$;
coreSetup.application.register({
id: 'observability-overview',
title: 'Overview',
const appUpdater$ = this.appUpdater$;
const app = {
appRoute: '/app/observability',
order: 8000,
category,
deepLinks: this.deepLinks,
euiIconType,
id: observabilityAppId,
mount,
updater$,
});
if (config.unsafe.alertingExperience.enabled) {
coreSetup.application.register({
id: 'observability-alerts',
title: 'Alerts',
appRoute: '/app/observability/alerts',
order: 8025,
category,
euiIconType,
mount,
updater$,
});
}
order: 8000,
title: i18n.translate('xpack.observability.overviewLinkTitle', {
defaultMessage: 'Overview',
}),
updater$: appUpdater$,
};
if (config.unsafe.cases.enabled) {
coreSetup.application.register({
id: CASES_APP_ID,
title: 'Cases',
appRoute: '/app/observability/cases',
order: 8050,
category,
euiIconType,
mount,
updater$: this.casesAppUpdater$,
});
}
coreSetup.application.register(app);
if (pluginsSetup.home) {
pluginsSetup.home.featureCatalogue.registerSolution({
id: 'observability',
id: observabilityFeatureId,
title: i18n.translate('xpack.observability.featureCatalogueTitle', {
defaultMessage: 'Observability',
}),
@ -172,13 +176,47 @@ export class Plugin
}
this.navigationRegistry.registerSections(
of([
{
label: '',
sortKey: 100,
entries: [{ label: 'Overview', app: 'observability-overview', path: '/overview' }],
},
])
from(appUpdater$).pipe(
map((value) => {
const deepLinks = value(app)?.deepLinks ?? [];
const overviewLink = {
label: i18n.translate('xpack.observability.overviewLinkTitle', {
defaultMessage: 'Overview',
}),
app: observabilityAppId,
path: '/overview',
};
// Reformat the visible links to be NavigationEntry objects instead of
// AppDeepLink objects.
//
// In our case the deep links and sections being registered are the
// same, and the logic to hide them based on flags or capabilities is
// the same, so we just want to make a new list with the properties
// needed by `registerSections`, which are different than the
// properties used by the deepLinks.
//
// See https://github.com/elastic/kibana/issues/103325.
const otherLinks: NavigationEntry[] = deepLinks
.filter((link) => link.navLinkStatus === AppNavLinkStatus.visible)
.map((link) => ({
app: observabilityAppId,
label: link.title,
path: link.path ?? '',
}));
const sections = [
{
label: '',
sortKey: 100,
entries: [overviewLink, ...otherLinks],
},
];
return sections;
})
)
);
return {
@ -190,8 +228,16 @@ export class Plugin
},
};
}
public start({ application }: CoreStart) {
toggleOverviewLinkInNav(this.appUpdater$, this.casesAppUpdater$, application);
const config = this.initializerContext.config.get();
updateGlobalNavigation({
capabilities: application.capabilities,
config,
deepLinks: this.deepLinks,
updater$: this.appUpdater$,
});
const PageTemplate = createLazyObservabilityPageTemplate({
currentAppId$: application.currentAppId$,

View file

@ -1,62 +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 { Subject } from 'rxjs';
import { AppUpdater, AppNavLinkStatus } from '../../../../src/core/public';
import { applicationServiceMock } from '../../../../src/core/public/mocks';
import { toggleOverviewLinkInNav } from './toggle_overview_link_in_nav';
describe('toggleOverviewLinkInNav', () => {
let applicationStart: ReturnType<typeof applicationServiceMock.createStartContract>;
let subjectMock: jest.Mocked<Subject<AppUpdater>>;
let casesMock: jest.Mocked<Subject<AppUpdater>>;
beforeEach(() => {
applicationStart = applicationServiceMock.createStartContract();
subjectMock = {
next: jest.fn(),
} as any;
});
it('hides overview menu', () => {
applicationStart.capabilities = {
management: {},
catalogue: {},
navLinks: {
apm: false,
logs: false,
metrics: false,
uptime: false,
},
};
toggleOverviewLinkInNav(subjectMock, casesMock, applicationStart);
expect(subjectMock.next).toHaveBeenCalledTimes(1);
const updater = subjectMock.next.mock.calls[0][0]!;
expect(updater({} as any)).toEqual({
navLinkStatus: AppNavLinkStatus.hidden,
});
});
it('shows overview menu', () => {
applicationStart.capabilities = {
management: {},
catalogue: {},
navLinks: {
apm: true,
logs: false,
metrics: false,
uptime: false,
},
};
toggleOverviewLinkInNav(subjectMock, casesMock, applicationStart);
expect(subjectMock.next).not.toHaveBeenCalled();
});
});

View file

@ -1,31 +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 { Subject } from 'rxjs';
import { AppNavLinkStatus, AppUpdater, ApplicationStart } from '../../../../src/core/public';
import { CASES_APP_ID } from '../common/const';
export function toggleOverviewLinkInNav(
updater$: Subject<AppUpdater>,
casesUpdater$: Subject<AppUpdater>,
{ capabilities }: ApplicationStart
) {
const { apm, logs, metrics, uptime, [CASES_APP_ID]: cases } = capabilities.navLinks;
const someVisible = Object.values({ apm, logs, metrics, uptime }).some((visible) => visible);
// if cases is enabled then we want to show it in the sidebar but not the navigation unless one of the other features
// is enabled
if (cases) {
casesUpdater$.next(() => ({ navLinkStatus: AppNavLinkStatus.visible }));
}
if (!someVisible) {
updater$.next(() => ({
navLinkStatus: AppNavLinkStatus.hidden,
}));
}
}

View file

@ -0,0 +1,269 @@
/*
* 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 { Subject } from 'rxjs';
import { ConfigSchema } from '.';
import {
App,
AppDeepLink,
ApplicationStart,
AppNavLinkStatus,
AppUpdater,
} from '../../../../src/core/public';
import { casesFeatureId } from '../common';
import { updateGlobalNavigation } from './update_global_navigation';
// Used in updater callback
const app = ({} as unknown) as App;
describe('updateGlobalNavigation', () => {
describe('when no observability apps are enabled', () => {
it('hides the overview link', () => {
const capabilities = ({
navLinks: { apm: false, logs: false, metrics: false, uptime: false },
} as unknown) as ApplicationStart['capabilities'];
const config = {
unsafe: { alertingExperience: { enabled: true }, cases: { enabled: true } },
} as ConfigSchema;
const deepLinks: AppDeepLink[] = [];
const callback = jest.fn();
const updater$ = ({
next: (cb: AppUpdater) => callback(cb(app)),
} as unknown) as Subject<AppUpdater>;
updateGlobalNavigation({ capabilities, config, deepLinks, updater$ });
expect(callback).toHaveBeenCalledWith({
deepLinks,
navLinkStatus: AppNavLinkStatus.hidden,
});
});
});
describe('when one observability app is enabled', () => {
it('shows the overview link', () => {
const capabilities = ({
navLinks: { apm: true, logs: false, metrics: false, uptime: false },
} as unknown) as ApplicationStart['capabilities'];
const config = {
unsafe: { alertingExperience: { enabled: true }, cases: { enabled: true } },
} as ConfigSchema;
const deepLinks: AppDeepLink[] = [];
const callback = jest.fn();
const updater$ = ({
next: (cb: AppUpdater) => callback(cb(app)),
} as unknown) as Subject<AppUpdater>;
updateGlobalNavigation({ capabilities, config, deepLinks, updater$ });
expect(callback).toHaveBeenCalledWith({
deepLinks,
navLinkStatus: AppNavLinkStatus.visible,
});
});
describe('when cases are enabled', () => {
it('shows the cases deep link', () => {
const capabilities = ({
[casesFeatureId]: { read_cases: true },
navLinks: { apm: true, logs: false, metrics: false, uptime: false },
} as unknown) as ApplicationStart['capabilities'];
const config = {
unsafe: { alertingExperience: { enabled: true }, cases: { enabled: true } },
} as ConfigSchema;
const deepLinks = [
{
id: 'cases',
title: 'Cases',
order: 8002,
path: '/cases',
navLinkStatus: AppNavLinkStatus.hidden,
},
];
const callback = jest.fn();
const updater$ = ({
next: (cb: AppUpdater) => callback(cb(app)),
} as unknown) as Subject<AppUpdater>;
updateGlobalNavigation({ capabilities, config, deepLinks, updater$ });
expect(callback).toHaveBeenCalledWith({
deepLinks: [
{
id: 'cases',
title: 'Cases',
order: 8002,
path: '/cases',
navLinkStatus: AppNavLinkStatus.visible,
},
],
navLinkStatus: AppNavLinkStatus.visible,
});
});
});
describe('when cases are disabled', () => {
it('hides the cases deep link', () => {
const capabilities = ({
[casesFeatureId]: { read_cases: true },
navLinks: { apm: true, logs: false, metrics: false, uptime: false },
} as unknown) as ApplicationStart['capabilities'];
const config = {
unsafe: { alertingExperience: { enabled: true }, cases: { enabled: false } },
} as ConfigSchema;
const deepLinks = [
{
id: 'cases',
title: 'Cases',
order: 8002,
path: '/cases',
navLinkStatus: AppNavLinkStatus.hidden,
},
];
const callback = jest.fn();
const updater$ = ({
next: (cb: AppUpdater) => callback(cb(app)),
} as unknown) as Subject<AppUpdater>;
updateGlobalNavigation({ capabilities, config, deepLinks, updater$ });
expect(callback).toHaveBeenCalledWith({
deepLinks: [
{
id: 'cases',
title: 'Cases',
order: 8002,
path: '/cases',
navLinkStatus: AppNavLinkStatus.hidden,
},
],
navLinkStatus: AppNavLinkStatus.visible,
});
});
});
describe('with no case read capabilities', () => {
it('hides the cases deep link', () => {
const capabilities = ({
[casesFeatureId]: { read_cases: false },
navLinks: { apm: true, logs: false, metrics: false, uptime: false },
} as unknown) as ApplicationStart['capabilities'];
const config = {
unsafe: { alertingExperience: { enabled: true }, cases: { enabled: true } },
} as ConfigSchema;
const deepLinks = [
{
id: 'cases',
title: 'Cases',
order: 8002,
path: '/cases',
navLinkStatus: AppNavLinkStatus.hidden,
},
];
const callback = jest.fn();
const updater$ = ({
next: (cb: AppUpdater) => callback(cb(app)),
} as unknown) as Subject<AppUpdater>;
updateGlobalNavigation({ capabilities, config, deepLinks, updater$ });
expect(callback).toHaveBeenCalledWith({
deepLinks: [
{
id: 'cases',
title: 'Cases',
order: 8002,
path: '/cases',
navLinkStatus: AppNavLinkStatus.hidden,
},
],
navLinkStatus: AppNavLinkStatus.visible,
});
});
});
describe('when alerts are enabled', () => {
it('shows the alerts deep link', () => {
const capabilities = ({
[casesFeatureId]: { read_cases: true },
navLinks: { apm: true, logs: false, metrics: false, uptime: false },
} as unknown) as ApplicationStart['capabilities'];
const config = {
unsafe: { alertingExperience: { enabled: true }, cases: { enabled: true } },
} as ConfigSchema;
const deepLinks = [
{
id: 'alerts',
title: 'Alerts',
order: 8001,
path: '/alerts',
navLinkStatus: AppNavLinkStatus.hidden,
},
];
const callback = jest.fn();
const updater$ = ({
next: (cb: AppUpdater) => callback(cb(app)),
} as unknown) as Subject<AppUpdater>;
updateGlobalNavigation({ capabilities, config, deepLinks, updater$ });
expect(callback).toHaveBeenCalledWith({
deepLinks: [
{
id: 'alerts',
title: 'Alerts',
order: 8001,
path: '/alerts',
navLinkStatus: AppNavLinkStatus.visible,
},
],
navLinkStatus: AppNavLinkStatus.visible,
});
});
});
describe('when alerts are disabled', () => {
it('hides the alerts deep link', () => {
const capabilities = ({
[casesFeatureId]: { read_cases: true },
navLinks: { apm: true, logs: false, metrics: false, uptime: false },
} as unknown) as ApplicationStart['capabilities'];
const config = {
unsafe: { alertingExperience: { enabled: false }, cases: { enabled: false } },
} as ConfigSchema;
const deepLinks = [
{
id: 'alerts',
title: 'Alerts',
order: 8001,
path: '/alerts',
navLinkStatus: AppNavLinkStatus.hidden,
},
];
const callback = jest.fn();
const updater$ = ({
next: (cb: AppUpdater) => callback(cb(app)),
} as unknown) as Subject<AppUpdater>;
updateGlobalNavigation({ capabilities, config, deepLinks, updater$ });
expect(callback).toHaveBeenCalledWith({
deepLinks: [
{
id: 'alerts',
title: 'Alerts',
order: 8001,
path: '/alerts',
navLinkStatus: AppNavLinkStatus.hidden,
},
],
navLinkStatus: AppNavLinkStatus.visible,
});
});
});
});
});

View file

@ -0,0 +1,59 @@
/*
* 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 { Subject } from 'rxjs';
import { ConfigSchema } from '.';
import {
AppNavLinkStatus,
AppUpdater,
ApplicationStart,
AppDeepLink,
} from '../../../../src/core/public';
import { casesFeatureId } from '../common';
export function updateGlobalNavigation({
capabilities,
config,
deepLinks,
updater$,
}: {
capabilities: ApplicationStart['capabilities'];
config: ConfigSchema;
deepLinks: AppDeepLink[];
updater$: Subject<AppUpdater>;
}) {
const { apm, logs, metrics, uptime } = capabilities.navLinks;
const someVisible = Object.values({ apm, logs, metrics, uptime }).some((visible) => visible);
const updatedDeepLinks = deepLinks.map((link) => {
switch (link.id) {
case 'cases':
return {
...link,
navLinkStatus:
config.unsafe.cases.enabled && capabilities[casesFeatureId].read_cases && someVisible
? AppNavLinkStatus.visible
: AppNavLinkStatus.hidden,
};
case 'alerts':
return {
...link,
navLinkStatus:
config.unsafe.alertingExperience.enabled && someVisible
? AppNavLinkStatus.visible
: AppNavLinkStatus.hidden,
};
default:
return link;
}
});
updater$.next(() => ({
deepLinks: updatedDeepLinks,
navLinkStatus: someVisible ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden,
}));
}

View file

@ -24,7 +24,7 @@ import { PluginSetupContract as FeaturesSetup } from '../../features/server';
import { uiSettings } from './ui_settings';
import { registerRoutes } from './routes/register_routes';
import { getGlobalObservabilityServerRouteRepository } from './routes/get_global_observability_server_route_repository';
import { CASES_APP_ID, OBSERVABILITY } from '../common/const';
import { casesFeatureId, observabilityFeatureId } from '../common';
export type ObservabilityPluginSetup = ReturnType<ObservabilityPlugin['setup']>;
@ -40,41 +40,41 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
public setup(core: CoreSetup, plugins: PluginSetup) {
plugins.features.registerKibanaFeature({
id: CASES_APP_ID,
id: casesFeatureId,
name: i18n.translate('xpack.observability.featureRegistry.linkObservabilityTitle', {
defaultMessage: 'Cases',
}),
order: 1100,
category: DEFAULT_APP_CATEGORIES.observability,
app: [CASES_APP_ID, 'kibana'],
catalogue: [OBSERVABILITY],
cases: [OBSERVABILITY],
app: [casesFeatureId, 'kibana'],
catalogue: [observabilityFeatureId],
cases: [observabilityFeatureId],
privileges: {
all: {
app: [CASES_APP_ID, 'kibana'],
catalogue: [OBSERVABILITY],
app: [casesFeatureId, 'kibana'],
catalogue: [observabilityFeatureId],
cases: {
all: [OBSERVABILITY],
all: [observabilityFeatureId],
},
api: [],
savedObject: {
all: [],
read: [],
},
ui: ['crud_cases', 'read_cases'], // uiCapabilities[CASES_APP_ID].crud_cases or read_cases
ui: ['crud_cases', 'read_cases'], // uiCapabilities[casesFeatureId].crud_cases or read_cases
},
read: {
app: [CASES_APP_ID, 'kibana'],
catalogue: [OBSERVABILITY],
app: [casesFeatureId, 'kibana'],
catalogue: [observabilityFeatureId],
cases: {
read: [OBSERVABILITY],
read: [observabilityFeatureId],
},
api: [],
savedObject: {
all: [],
read: [],
},
ui: ['read_cases'], // uiCapabilities[uiCapabilities[CASES_APP_ID]].read_cases
ui: ['read_cases'], // uiCapabilities[uiCapabilities[casesFeatureId]].read_cases
},
},
});

View file

@ -6,6 +6,7 @@
*/
import { isoToEpochRt, toNumberRt } from '@kbn/io-ts-utils';
import * as t from 'io-ts';
import { observabilityFeatureId } from '../../common';
import { alertStatusRt } from '../../common/typings';
import { getTopAlerts } from '../lib/rules/get_top_alerts';
import { createObservabilityServerRoute } from './create_observability_server_route';
@ -51,7 +52,7 @@ const alertsDynamicIndexPatternRoute = createObservabilityServerRoute({
tags: [],
},
handler: async ({ ruleDataClient }) => {
const reader = ruleDataClient.getReader({ namespace: 'observability' });
const reader = ruleDataClient.getReader({ namespace: observabilityFeatureId });
return reader.getDynamicIndexPattern();
},

View file

@ -8,6 +8,7 @@
import { schema } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n';
import { UiSettingsParams } from '../../../../src/core/types';
import { observabilityFeatureId } from '../common';
import { enableInspectEsQueries } from '../common/ui_settings_keys';
/**
@ -15,7 +16,7 @@ import { enableInspectEsQueries } from '../common/ui_settings_keys';
*/
export const uiSettings: Record<string, UiSettingsParams<boolean>> = {
[enableInspectEsQueries]: {
category: ['observability'],
category: [observabilityFeatureId],
name: i18n.translate('xpack.observability.enableInspectEsQueriesExperimentName', {
defaultMessage: 'inspect ES queries',
}),