feat(slo): introduce slo feature (#150554)

This commit is contained in:
Kevin Delemme 2023-02-16 12:52:57 -05:00 committed by GitHub
parent 833456e1b8
commit aff771c287
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 157 additions and 28 deletions

View file

@ -20,6 +20,7 @@ export const AlertConsumers = {
LOGS: 'logs',
INFRASTRUCTURE: 'infrastructure',
OBSERVABILITY: 'observability',
SLO: 'slo',
SIEM: 'siem',
UPTIME: 'uptime',
} as const;

View file

@ -38,6 +38,8 @@ export {
getProbabilityFromProgressiveLoadingQuality,
} from './progressive_loading';
export const sloFeatureId = 'slo';
export const casesFeatureId = 'observabilityCases';
// The ID of the observability app. Should more appropriately be called

View file

@ -13,4 +13,5 @@ export const observabilityAlertFeatureIds: ValidFeatureId[] = [
AlertConsumers.INFRASTRUCTURE,
AlertConsumers.LOGS,
AlertConsumers.UPTIME,
AlertConsumers.SLO,
];

View file

@ -0,0 +1,19 @@
/*
* 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 { sloFeatureId } from '../../../common';
import { useKibana } from '../../utils/kibana_react';
export function useCapabilities() {
const {
application: { capabilities },
} = useKibana().services;
return {
hasReadCapabilities: !!capabilities[sloFeatureId].read ?? false,
hasWriteCapabilities: !!capabilities[sloFeatureId].write ?? false,
};
}

View file

@ -19,6 +19,7 @@ import {
import { i18n } from '@kbn/i18n';
import { HistoricalSummaryResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useCapabilities } from '../../../hooks/slo/use_capabilities';
import { useKibana } from '../../../utils/kibana_react';
import { useCreateOrUpdateSlo } from '../../../hooks/slo/use_create_slo';
import { SloSummary } from './slo_summary';
@ -53,6 +54,7 @@ export function SloListItem({
application: { navigateToUrl },
http: { basePath },
} = useKibana().services;
const { hasWriteCapabilities } = useCapabilities();
const { createSlo, loading: isCloning, success: isCloned } = useCreateOrUpdateSlo();
@ -158,6 +160,7 @@ export function SloListItem({
<EuiContextMenuItem
key="edit"
icon="pencil"
disabled={!hasWriteCapabilities}
onClick={handleEdit}
data-test-subj="sloActionsEdit"
>
@ -167,6 +170,7 @@ export function SloListItem({
</EuiContextMenuItem>,
<EuiContextMenuItem
key="clone"
disabled={!hasWriteCapabilities}
icon="copy"
onClick={handleClone}
data-test-subj="sloActionsClone"
@ -178,6 +182,7 @@ export function SloListItem({
<EuiContextMenuItem
key="delete"
icon="trash"
disabled={!hasWriteCapabilities}
onClick={handleDelete}
data-test-subj="sloActionsDelete"
>

View file

@ -23,6 +23,7 @@ import { emptySloList, sloList } from '../../data/slo/slo';
import type { ConfigSchema } from '../../plugin';
import type { Subset } from '../../typings';
import { historicalSummaryData } from '../../data/slo/historical_summary_data';
import { useCapabilities } from '../../hooks/slo/use_capabilities';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@ -36,6 +37,7 @@ jest.mock('../../hooks/slo/use_fetch_slo_list');
jest.mock('../../hooks/slo/use_create_slo');
jest.mock('../../hooks/slo/use_delete_slo');
jest.mock('../../hooks/slo/use_fetch_historical_summary');
jest.mock('../../hooks/slo/use_capabilities');
const useKibanaMock = useKibana as jest.Mock;
const useLicenseMock = useLicense as jest.Mock;
@ -43,6 +45,7 @@ const useFetchSloListMock = useFetchSloList as jest.Mock;
const useCreateOrUpdateSloMock = useCreateOrUpdateSlo as jest.Mock;
const useDeleteSloMock = useDeleteSlo as jest.Mock;
const useFetchHistoricalSummaryMock = useFetchHistoricalSummary as jest.Mock;
const useCapabilitiesMock = useCapabilities as jest.Mock;
const mockCreateSlo = jest.fn();
useCreateOrUpdateSloMock.mockReturnValue({ createSlo: mockCreateSlo });
@ -84,6 +87,7 @@ describe('SLOs Page', () => {
beforeEach(() => {
jest.clearAllMocks();
mockKibana();
useCapabilitiesMock.mockReturnValue({ hasWriteCapabilities: true, hasReadCapabilities: true });
});
describe('when the feature flag is not enabled', () => {

View file

@ -20,6 +20,7 @@ import PageNotFound from '../404';
import { paths } from '../../config';
import { isSloFeatureEnabled } from './helpers/is_slo_feature_enabled';
import type { ObservabilityAppServices } from '../../application/types';
import { useCapabilities } from '../../hooks/slo/use_capabilities';
export function SlosPage() {
const {
@ -27,7 +28,7 @@ export function SlosPage() {
http: { basePath },
} = useKibana<ObservabilityAppServices>().services;
const { ObservabilityPageTemplate, config } = usePluginContext();
const { hasWriteCapabilities } = useCapabilities();
const { hasAtLeast } = useLicense();
const {
@ -68,6 +69,7 @@ export function SlosPage() {
}),
rightSideItems: [
<EuiButton
disabled={!hasWriteCapabilities}
color="primary"
fill
onClick={handleClickCreateSlo}

View file

@ -235,6 +235,7 @@ export class Plugin
'logs',
'metrics',
'apm',
'slo',
'performance',
'trace',
'agent',
@ -289,7 +290,7 @@ export class Plugin
// See https://github.com/elastic/kibana/issues/103325.
const otherLinks: NavigationEntry[] = deepLinks
.filter((link) => link.navLinkStatus === AppNavLinkStatus.visible)
.filter((link) => (link.id === 'slos' ? config.unsafe.slo.enabled : link))
.filter((link) => (link.id === 'slos' ? config.unsafe.slo.enabled : link)) // might not be useful anymore
.map((link) => ({
app: observabilityAppId,
label: link.title,

View file

@ -7,7 +7,7 @@
import { Subject } from 'rxjs';
import { App, AppDeepLink, ApplicationStart, AppNavLinkStatus, AppUpdater } from '@kbn/core/public';
import { casesFeatureId } from '../common';
import { casesFeatureId, sloFeatureId } from '../common';
import { updateGlobalNavigation } from './update_global_navigation';
// Used in updater callback
@ -163,11 +163,45 @@ describe('updateGlobalNavigation', () => {
});
});
it("hides the slo link when the capabilities don't include it", () => {
const capabilities = {
navLinks: { apm: true, logs: false, metrics: false, uptime: false },
} as unknown as ApplicationStart['capabilities'];
const sloRoute = {
id: 'slos',
title: 'SLOs',
order: 8002,
path: '/slos',
navLinkStatus: AppNavLinkStatus.hidden,
};
const deepLinks = [sloRoute];
const callback = jest.fn();
const updater$ = {
next: (cb: AppUpdater) => callback(cb(app)),
} as unknown as Subject<AppUpdater>;
updateGlobalNavigation({ capabilities, deepLinks, updater$ });
expect(callback).toHaveBeenCalledWith({
deepLinks: [
{
...sloRoute,
navLinkStatus: AppNavLinkStatus.hidden,
},
],
navLinkStatus: AppNavLinkStatus.visible,
});
});
describe('when slos are enabled', () => {
it('shows the slos deep link', () => {
const capabilities = {
[casesFeatureId]: { read_cases: true },
navLinks: { apm: true, logs: false, metrics: false, uptime: false },
[sloFeatureId]: { read: true },
navLinks: { apm: false, logs: false, metrics: false, uptime: false },
} as unknown as ApplicationStart['capabilities'];
const sloRoute = {

View file

@ -8,7 +8,7 @@
import { Subject } from 'rxjs';
import { AppNavLinkStatus, AppUpdater, ApplicationStart, AppDeepLink } from '@kbn/core/public';
import { CasesDeepLinkId } from '@kbn/cases-plugin/public';
import { casesFeatureId } from '../common';
import { casesFeatureId, sloFeatureId } from '../common';
export function updateGlobalNavigation({
capabilities,
@ -20,7 +20,12 @@ export function updateGlobalNavigation({
updater$: Subject<AppUpdater>;
}) {
const { apm, logs, metrics, uptime } = capabilities.navLinks;
const someVisible = Object.values({ apm, logs, metrics, uptime }).some((visible) => visible);
const someVisible = Object.values({
apm,
logs,
metrics,
uptime,
}).some((visible) => visible);
const updatedDeepLinks = deepLinks.map((link) => {
switch (link.id) {
@ -37,16 +42,18 @@ export function updateGlobalNavigation({
...link,
navLinkStatus: someVisible ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden,
};
case 'slos':
return {
...link,
navLinkStatus: someVisible ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden,
};
case 'rules':
return {
...link,
navLinkStatus: someVisible ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden,
};
case 'slos':
return {
...link,
navLinkStatus: !!capabilities[sloFeatureId]?.read
? AppNavLinkStatus.visible
: AppNavLinkStatus.hidden,
};
default:
return link;
}
@ -54,6 +61,9 @@ export function updateGlobalNavigation({
updater$.next(() => ({
deepLinks: updatedDeepLinks,
navLinkStatus: someVisible ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden,
navLinkStatus:
someVisible || !!capabilities[sloFeatureId]?.read
? AppNavLinkStatus.visible
: AppNavLinkStatus.hidden,
}));
}

View file

@ -5,5 +5,4 @@
* 2.0.
*/
export const OBSERVABILITY_FEATURE_ID = 'observability';
export const RULE_REGISTRATION_CONTEXT = 'observability.slo';
export const SLO_RULE_REGISTRATION_CONTEXT = 'observability.slo';

View file

@ -37,7 +37,7 @@ export function sloBurnRateRuleType(createLifecycleRuleExecutor: CreateLifecycle
},
defaultActionGroupId: FIRED_ACTION.id,
actionGroups: [FIRED_ACTION],
producer: 'observability',
producer: 'slo',
minimumLicenseRequired: 'basic' as LicenseType,
isExportable: true,
executor: createLifecycleRuleExecutor(getRuleExecutor()),

View file

@ -37,10 +37,11 @@ import {
import { uiSettings } from './ui_settings';
import { registerRoutes } from './routes/register_routes';
import { getGlobalObservabilityServerRouteRepository } from './routes/get_global_observability_server_route_repository';
import { casesFeatureId, observabilityFeatureId } from '../common';
import { slo } from './saved_objects';
import { OBSERVABILITY_FEATURE_ID, RULE_REGISTRATION_CONTEXT } from './common/constants';
import { casesFeatureId, observabilityFeatureId, sloFeatureId } from '../common';
import { slo, SO_SLO_TYPE } from './saved_objects';
import { SLO_RULE_REGISTRATION_CONTEXT } from './common/constants';
import { registerRuleTypes } from './lib/rules/register_rule_types';
import { SLO_BURN_RATE_RULE_ID } from '../common/constants';
import { registerSloUsageCollector } from './lib/collectors/register';
export type ObservabilityPluginSetup = ReturnType<ObservabilityPlugin['setup']>;
@ -161,11 +162,61 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
const { ruleDataService } = plugins.ruleRegistry;
if (config.unsafe.slo.enabled) {
plugins.features.registerKibanaFeature({
id: sloFeatureId,
name: i18n.translate('xpack.observability.featureRegistry.linkSloTitle', {
defaultMessage: 'SLOs',
}),
order: 1200,
category: DEFAULT_APP_CATEGORIES.observability,
app: [sloFeatureId, 'kibana'],
catalogue: [sloFeatureId, 'observability'],
alerting: [SLO_BURN_RATE_RULE_ID],
privileges: {
all: {
app: [sloFeatureId, 'kibana'],
catalogue: [sloFeatureId, 'observability'],
api: ['slo_write', 'slo_read', 'rac'],
savedObject: {
all: [SO_SLO_TYPE],
read: [],
},
alerting: {
rule: {
all: [SLO_BURN_RATE_RULE_ID],
},
alert: {
all: [SLO_BURN_RATE_RULE_ID],
},
},
ui: ['read', 'write'],
},
read: {
app: [sloFeatureId, 'kibana'],
catalogue: [sloFeatureId, 'observability'],
api: ['slo_read', 'rac'],
savedObject: {
all: [],
read: [SO_SLO_TYPE],
},
alerting: {
rule: {
read: [SLO_BURN_RATE_RULE_ID],
},
alert: {
read: [SLO_BURN_RATE_RULE_ID],
},
},
ui: ['read'],
},
},
});
core.savedObjects.registerType(slo);
const ruleDataClient = ruleDataService.initializeIndex({
feature: OBSERVABILITY_FEATURE_ID,
registrationContext: RULE_REGISTRATION_CONTEXT,
feature: sloFeatureId,
registrationContext: SLO_RULE_REGISTRATION_CONTEXT,
dataset: Dataset.alerts,
componentTemplateRefs: [ECS_COMPONENT_TEMPLATE_NAME],
componentTemplates: [

View file

@ -52,7 +52,7 @@ const isLicenseAtLeastPlatinum = async (context: ObservabilityRequestHandlerCont
const createSLORoute = createObservabilityServerRoute({
endpoint: 'POST /api/observability/slos',
options: {
tags: [],
tags: ['access:slo_write'],
},
params: createSLOParamsSchema,
handler: async ({ context, params, logger }) => {
@ -77,7 +77,7 @@ const createSLORoute = createObservabilityServerRoute({
const updateSLORoute = createObservabilityServerRoute({
endpoint: 'PUT /api/observability/slos/{id}',
options: {
tags: [],
tags: ['access:slo_write'],
},
params: updateSLOParamsSchema,
handler: async ({ context, params, logger }) => {
@ -101,7 +101,7 @@ const updateSLORoute = createObservabilityServerRoute({
const deleteSLORoute = createObservabilityServerRoute({
endpoint: 'DELETE /api/observability/slos/{id}',
options: {
tags: [],
tags: ['access:slo_write'],
},
params: deleteSLOParamsSchema,
handler: async ({ context, params, logger }) => {
@ -124,7 +124,7 @@ const deleteSLORoute = createObservabilityServerRoute({
const getSLORoute = createObservabilityServerRoute({
endpoint: 'GET /api/observability/slos/{id}',
options: {
tags: [],
tags: ['access:slo_read'],
},
params: getSLOParamsSchema,
handler: async ({ context, params }) => {
@ -147,7 +147,7 @@ const getSLORoute = createObservabilityServerRoute({
const enableSLORoute = createObservabilityServerRoute({
endpoint: 'POST /api/observability/slos/{id}/enable',
options: {
tags: [],
tags: ['access:slo_write'],
},
params: manageSLOParamsSchema,
handler: async ({ context, params, logger }) => {
@ -171,7 +171,7 @@ const enableSLORoute = createObservabilityServerRoute({
const disableSLORoute = createObservabilityServerRoute({
endpoint: 'POST /api/observability/slos/{id}/disable',
options: {
tags: [],
tags: ['access:slo_write'],
},
params: manageSLOParamsSchema,
handler: async ({ context, params, logger }) => {
@ -195,7 +195,7 @@ const disableSLORoute = createObservabilityServerRoute({
const findSLORoute = createObservabilityServerRoute({
endpoint: 'GET /api/observability/slos',
options: {
tags: [],
tags: ['access:slo_read'],
},
params: findSLOParamsSchema,
handler: async ({ context, params }) => {
@ -218,7 +218,7 @@ const findSLORoute = createObservabilityServerRoute({
const fetchHistoricalSummary = createObservabilityServerRoute({
endpoint: 'POST /internal/observability/slos/_historical_summary',
options: {
tags: [],
tags: ['access:slo_read'],
},
params: fetchHistoricalSummaryParamsSchema,
handler: async ({ context, params }) => {