diff --git a/.eslintrc.js b/.eslintrc.js index f3a4fcf6ecc0d..ef667d73de3c0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -875,6 +875,7 @@ module.exports = { 'x-pack/plugins/observability/**/*.{js,mjs,ts,tsx}', 'x-pack/plugins/exploratory_view/**/*.{js,mjs,ts,tsx}', 'x-pack/plugins/ux/**/*.{js,mjs,ts,tsx}', + 'x-pack/plugins/observability_solution/slos/**/*.{js,mjs,ts,tsx}', ], rules: { 'no-console': ['warn', { allow: ['error'] }], @@ -897,6 +898,7 @@ module.exports = { 'x-pack/plugins/apm/**/*.stories.*', 'x-pack/plugins/observability/**/*.stories.*', 'x-pack/plugins/exploratory_view/**/*.stories.*', + 'x-pack/plugins/observability_solution/slos/**/*.stories', ], rules: { 'react/function-component-definition': [ @@ -918,6 +920,7 @@ module.exports = { 'x-pack/plugins/observability_ai_assistant/**/*.tsx', 'x-pack/plugins/observability_onboarding/**/*.tsx', 'x-pack/plugins/observability_shared/**/*.tsx', + 'x-pack/plugins/observability_solution/slos/**/*.tsx', 'x-pack/plugins/profiling/**/*.tsx', 'x-pack/plugins/synthetics/**/*.tsx', 'x-pack/plugins/ux/**/*.tsx', @@ -936,6 +939,7 @@ module.exports = { 'x-pack/plugins/observability_ai_assistant/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', 'x-pack/plugins/observability_onboarding/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', 'x-pack/plugins/observability_shared/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', + 'x-pack/plugins/observability_solution/slos/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', 'x-pack/plugins/profiling/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', 'x-pack/plugins/synthetics/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', 'x-pack/plugins/ux/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 526c14d18846c..8cf68a5b655b2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -577,6 +577,7 @@ x-pack/packages/observability/get_padded_alert_time_range_util @elastic/obs-ux-m x-pack/plugins/observability_solution/observability_logs_explorer @elastic/obs-ux-logs-team x-pack/plugins/observability_onboarding @elastic/obs-ux-logs-team x-pack/plugins/observability @elastic/obs-ux-management-team +x-pack/plugins/observability_solution/slos @elastic/obs-ux-management-team x-pack/plugins/observability_shared @elastic/observability-ui x-pack/test/security_api_integration/plugins/oidc_provider @elastic/kibana-security test/common/plugins/otel_metrics @elastic/obs-ux-infra_services-team diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 3eccd4beaedab..a7ad32e7bc30a 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -789,6 +789,9 @@ It leverages universal configuration and other APIs in the serverless plugin to |{kib-repo}blob/{branch}/x-pack/plugins/session_view/README.md[sessionView] |Session View is meant to provide a visualization into what is going on in a particular Linux environment where the agent is running. It looks likes a terminal emulator; however, it is a tool for introspecting process activity and understanding user and service behaviour in your Linux servers and infrastructure. It is a time-ordered series of process executions displayed in a tree over time. +{kib-repo}blob/{branch}/x-pack/plugins/observability_solution/slos/README.md[slos] +|This plugin contains the SLO components. + |{kib-repo}blob/{branch}/x-pack/plugins/snapshot_restore/README.md[snapshotRestore] |or diff --git a/package.json b/package.json index 5137b9b97de00..bfc4c1a1f0d0b 100644 --- a/package.json +++ b/package.json @@ -776,6 +776,7 @@ "@kbn/shared-ux-storybook-mock": "link:packages/shared-ux/storybook/mock", "@kbn/shared-ux-utility": "link:packages/kbn-shared-ux-utility", "@kbn/slo-schema": "link:x-pack/packages/kbn-slo-schema", + "@kbn/slos-plugin": "link:x-pack/plugins/observability_solution/slos", "@kbn/snapshot-restore-plugin": "link:x-pack/plugins/snapshot_restore", "@kbn/sort-predicates": "link:packages/kbn-sort-predicates", "@kbn/spaces-plugin": "link:x-pack/plugins/spaces", diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index c1e1a3e9f339a..92826fb695b41 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -56,6 +56,7 @@ export const storybookAliases = { security_solution_packages: 'x-pack/packages/security-solution/storybook/config', serverless: 'packages/serverless/storybook/config', shared_ux: 'packages/shared-ux/storybook/config', + slos: 'x-pack/plugins/observability_solution/slos/.storybook', threat_intelligence: 'x-pack/plugins/threat_intelligence/.storybook', triggers_actions_ui: 'x-pack/plugins/triggers_actions_ui/.storybook', ui_actions_enhanced: 'src/plugins/ui_actions_enhanced/.storybook', diff --git a/tsconfig.base.json b/tsconfig.base.json index 5a24c45675cf0..890826a09bb94 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1542,6 +1542,8 @@ "@kbn/shared-ux-utility/*": ["packages/kbn-shared-ux-utility/*"], "@kbn/slo-schema": ["x-pack/packages/kbn-slo-schema"], "@kbn/slo-schema/*": ["x-pack/packages/kbn-slo-schema/*"], + "@kbn/slos-plugin": ["x-pack/plugins/observability_solution/slos"], + "@kbn/slos-plugin/*": ["x-pack/plugins/observability_solution/slos/*"], "@kbn/snapshot-restore-plugin": ["x-pack/plugins/snapshot_restore"], "@kbn/snapshot-restore-plugin/*": ["x-pack/plugins/snapshot_restore/*"], "@kbn/some-dev-log": ["packages/kbn-some-dev-log"], diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 53068575a3623..c2abdbd389ad3 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -89,6 +89,7 @@ "xpack.securitySolutionEss": "plugins/security_solution_ess", "xpack.securitySolutionServerless": "plugins/security_solution_serverless", "xpack.sessionView": "plugins/session_view", + "xpack.slos": "plugins/observability_solution/slos", "xpack.snapshotRestore": "plugins/snapshot_restore", "xpack.spaces": "plugins/spaces", "xpack.savedObjectsTagging": ["plugins/saved_objects_tagging"], diff --git a/x-pack/plugins/observability/common/index.ts b/x-pack/plugins/observability/common/index.ts index 1ccebf7b00aac..02ebd72c15cd2 100644 --- a/x-pack/plugins/observability/common/index.ts +++ b/x-pack/plugins/observability/common/index.ts @@ -80,6 +80,7 @@ export const syntheticsSettingsLocatorID = 'SYNTHETICS_SETTINGS'; export const alertsLocatorID = 'ALERTS_LOCATOR'; export const ruleDetailsLocatorID = 'RULE_DETAILS_LOCATOR'; export const rulesLocatorID = 'RULES_LOCATOR'; +// TODO remove stuff from here export const sloDetailsLocatorID = 'SLO_DETAILS_LOCATOR'; export const sloEditLocatorID = 'SLO_EDIT_LOCATOR'; export const sloListLocatorID = 'SLO_LIST_LOCATOR'; diff --git a/x-pack/plugins/observability/common/locators/paths.ts b/x-pack/plugins/observability/common/locators/paths.ts index 5db834c867b9c..d95ea0324557d 100644 --- a/x-pack/plugins/observability/common/locators/paths.ts +++ b/x-pack/plugins/observability/common/locators/paths.ts @@ -15,6 +15,7 @@ export const EXPLORATORY_VIEW_PATH = '/exploratory-view' as const; // has been m export const RULES_PATH = '/alerts/rules' as const; export const RULES_LOGS_PATH = '/alerts/rules/logs' as const; export const RULE_DETAIL_PATH = '/alerts/rules/:ruleId' as const; +// TODO delete SLO stuff from here export const SLOS_PATH = '/slos' as const; export const SLOS_WELCOME_PATH = '/slos/welcome' as const; export const SLO_DETAIL_PATH = '/slos/:sloId' as const; @@ -30,6 +31,7 @@ export const paths = { `${OBSERVABILITY_BASE_PATH}${ALERTS_PATH}/${encodeURI(alertId)}`, rules: `${OBSERVABILITY_BASE_PATH}${RULES_PATH}`, ruleDetails: (ruleId: string) => `${OBSERVABILITY_BASE_PATH}${RULES_PATH}/${encodeURI(ruleId)}`, + // TODO: delete things from here slos: `${OBSERVABILITY_BASE_PATH}${SLOS_PATH}`, slosWelcome: `${OBSERVABILITY_BASE_PATH}${SLOS_WELCOME_PATH}`, slosOutdatedDefinitions: `${OBSERVABILITY_BASE_PATH}${SLOS_OUTDATED_DEFINITIONS_PATH}`, diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 1fd9d835b48ff..bd6de70504685 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -209,6 +209,7 @@ export class Plugin }, ], }, + // TODO remove SLOs from here { id: 'slos', title: i18n.translate('xpack.observability.slosLinkTitle', { @@ -408,6 +409,16 @@ export class Plugin ] : []; + const slosLink = [ + { + label: i18n.translate('xpack.observability.slosNewLinkTitle', { + defaultMessage: 'SLOs new', + }), + app: 'slos', + path: '/', + }, + ]; + const isAiAssistantEnabled = pluginsStart.observabilityAIAssistant.service.isEnabled(); @@ -448,7 +459,7 @@ export class Plugin { label: '', sortKey: 100, - entries: [...overviewLink, ...otherLinks, ...aiAssistantLink], + entries: [...overviewLink, ...slosLink, ...otherLinks, ...aiAssistantLink], }, ]; }) diff --git a/x-pack/plugins/observability_shared/public/services/update_global_navigation.tsx b/x-pack/plugins/observability_shared/public/services/update_global_navigation.tsx index 8908a90ff6545..18632cedb385b 100644 --- a/x-pack/plugins/observability_shared/public/services/update_global_navigation.tsx +++ b/x-pack/plugins/observability_shared/public/services/update_global_navigation.tsx @@ -62,7 +62,7 @@ export function updateGlobalNavigation({ updater$.next(() => ({ deepLinks: updatedDeepLinks, navLinkStatus: - someVisible || !!capabilities[sloFeatureId]?.read + someVisible || !!capabilities[sloFeatureId]?.read // TODO remove this, since now we have the new plugin ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden, })); diff --git a/x-pack/plugins/observability_solution/slos/README.md b/x-pack/plugins/observability_solution/slos/README.md new file mode 100755 index 0000000000000..f577b2da35ec9 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/README.md @@ -0,0 +1,22 @@ +# slos + +A Kibana plugin + +--- + +## Development + +See the [kibana contributing guide](https://github.com/elastic/kibana/blob/main/CONTRIBUTING.md) for instructions setting up your development environment. + +## Scripts + +
+
yarn kbn bootstrap
+
Execute this to install node_modules and setup the dependencies in your plugin and in Kibana
+ +
yarn plugin-helpers build
+
Execute this to create a distributable version of this plugin that can be installed in Kibana
+ +
yarn plugin-helpers dev --watch
+
Execute this to build your plugin ui browser side so Kibana could pick up when started in development
+
diff --git a/x-pack/plugins/observability_solution/slos/common/constants.ts b/x-pack/plugins/observability_solution/slos/common/constants.ts new file mode 100644 index 0000000000000..1e6160149ec6d --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/common/constants.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.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 SLO_MODEL_VERSION = 2; +export const SLO_RESOURCES_VERSION = 3; + +export const SLO_COMPONENT_TEMPLATE_MAPPINGS_NAME = '.slo-observability.sli-mappings'; +export const SLO_COMPONENT_TEMPLATE_SETTINGS_NAME = '.slo-observability.sli-settings'; + +export const SLO_INDEX_TEMPLATE_NAME = '.slo-observability.sli'; +export const SLO_INDEX_TEMPLATE_PATTERN = `.slo-observability.sli-*`; + +export const SLO_DESTINATION_INDEX_NAME = `.slo-observability.sli-v${SLO_RESOURCES_VERSION}`; +export const SLO_DESTINATION_INDEX_PATTERN = `.slo-observability.sli-v${SLO_RESOURCES_VERSION}*`; + +export const SLO_INGEST_PIPELINE_NAME = `.slo-observability.sli.pipeline-v${SLO_RESOURCES_VERSION}`; +export const SLO_INGEST_PIPELINE_INDEX_NAME_PREFIX = `.slo-observability.sli-v${SLO_RESOURCES_VERSION}.`; + +export const SLO_SUMMARY_COMPONENT_TEMPLATE_MAPPINGS_NAME = '.slo-observability.summary-mappings'; +export const SLO_SUMMARY_COMPONENT_TEMPLATE_SETTINGS_NAME = '.slo-observability.summary-settings'; +export const SLO_SUMMARY_INDEX_TEMPLATE_NAME = '.slo-observability.summary'; +export const SLO_SUMMARY_INDEX_TEMPLATE_PATTERN = `.slo-observability.summary-*`; + +export const SLO_SUMMARY_DESTINATION_INDEX_NAME = `.slo-observability.summary-v${SLO_RESOURCES_VERSION}`; // store the temporary summary document generated by transform +export const SLO_SUMMARY_TEMP_INDEX_NAME = `.slo-observability.summary-v${SLO_RESOURCES_VERSION}.temp`; // store the temporary summary document +export const SLO_SUMMARY_DESTINATION_INDEX_PATTERN = `.slo-observability.summary-v${SLO_RESOURCES_VERSION}*`; // include temp and non-temp summary indices + +export const getSLOTransformId = (sloId: string, sloRevision: number) => + `slo-${sloId}-${sloRevision}`; + +export const DEFAULT_SLO_PAGE_SIZE = 25; +export const DEFAULT_SLO_GROUPS_PAGE_SIZE = 25; + +export const getSLOSummaryTransformId = (sloId: string, sloRevision: number) => + `slo-summary-${sloId}-${sloRevision}`; + +export const getSLOSummaryPipelineId = (sloId: string, sloRevision: number) => + `.slo-observability.summary.pipeline-${sloId}-${sloRevision}`; diff --git a/x-pack/plugins/observability_solution/slos/common/i18n.ts b/x-pack/plugins/observability_solution/slos/common/i18n.ts new file mode 100644 index 0000000000000..8abb1bc15b671 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/common/i18n.ts @@ -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.slos.notAvailable', { + defaultMessage: 'N/A', +}); diff --git a/x-pack/plugins/observability_solution/slos/common/index.ts b/x-pack/plugins/observability_solution/slos/common/index.ts new file mode 100644 index 0000000000000..3325bc6ac7f10 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/common/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// TODO rename to slo +export const PLUGIN_ID = 'slos'; +export const PLUGIN_NAME = 'SLOs NEW'; +export const sloFeatureId = 'slo'; + +export const sloListLocatorID = 'SLO_LIST_LOCATOR'; +export const sloDetailsLocatorID = 'SLO_DETAILS_LOCATOR'; +export const sloEditLocatorID = 'SLO_EDIT_LOCATOR'; + +import { paths } from './locators/paths'; +export const sloPaths = paths; diff --git a/x-pack/plugins/observability_solution/slos/common/locators/paths.ts b/x-pack/plugins/observability_solution/slos/common/locators/paths.ts new file mode 100644 index 0000000000000..b2286dfbb1bad --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/common/locators/paths.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export const ROOT_PATH = '/' as const; +export const SLOS_PATH = '/slos' as const; +export const SLOS_WELCOME_PATH = '/slos/welcome' as const; +export const SLO_DETAIL_PATH = '/slos/:sloId' as const; +export const SLO_CREATE_PATH = '/slos/create' as const; +export const SLO_EDIT_PATH = '/slos/edit/:sloId' as const; +export const SLOS_OUTDATED_DEFINITIONS_PATH = '/slos/outdated-definitions' as const; + +// TODO export a paths const +export const paths = { + slos: SLOS_PATH, +}; diff --git a/x-pack/plugins/observability_solution/slos/kibana.jsonc b/x-pack/plugins/observability_solution/slos/kibana.jsonc new file mode 100644 index 0000000000000..9c6c2f3ad447f --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/kibana.jsonc @@ -0,0 +1,34 @@ +{ + "type": "plugin", + "id": "@kbn/slos-plugin", + "owner": "@elastic/obs-ux-management-team", + "plugin": { + "id": "slos", + "server": true, + "browser": true, + "configPath": [ + "xpack", + "slos" + ], + "requiredPlugins": [ + "observability", + "observabilityShared", + "ruleRegistry", + "triggersActionsUi", + "unifiedSearch", + "uiActions", + "lens", + "embeddable", + "data", + "dataViews", + "dataViewEditor", + "controls", + "presentationUtil", + "licensing" + ], + "requiredBundles": [ + "kibanaReact", + "kibanaUtils", + ] + } +} diff --git a/x-pack/plugins/observability_solution/slos/public/application/index.tsx b/x-pack/plugins/observability_solution/slos/public/application/index.tsx new file mode 100644 index 0000000000000..03e2eb983af72 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/application/index.tsx @@ -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 React from 'react'; +import ReactDOM from 'react-dom'; +import { EuiErrorBoundary } from '@elastic/eui'; +import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; +import { Router, Routes, Route } from '@kbn/shared-ux-router'; +import { AppMountParameters, APP_WRAPPER_CLASS, CoreStart } from '@kbn/core/public'; +// import { PluginContext } from '@kbn/exploratory-view-plugin/public/context/plugin_context'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { PluginContext } from '../context/plugin_context'; + +import { SlosPluginStartDeps } from './types'; +import { routes } from '../routes/routes'; + +// import { ConfigSchema, ObservabilityPublicPluginsStart } from '../plugin'; + +function App() { + return ( + <> + + {Object.keys(routes).map((key) => { + const path = key as keyof typeof routes; + const { handler, exact } = routes[path]; + const Wrapper = () => { + console.log('!!wrapper'); + return handler(); + }; + return ; + })} + + + ); +} + +export const renderApp = ({ core, plugins, appMountParameters, ObservabilityPageTemplate }) => { + const { element, history, theme$ } = appMountParameters; + const i18nCore = core.i18n; + // ensure all divs are .kbnAppWrappers + element.classList.add(APP_WRAPPER_CLASS); + + const CloudProvider = plugins.cloud?.CloudContextProvider ?? React.Fragment; + + const queryClient = new QueryClient(); + + console.log('!!renderApp'); + ReactDOM.render( + + + + + + + + + + + + + + + + + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/x-pack/plugins/observability_solution/slos/public/context/plugin_context.ts b/x-pack/plugins/observability_solution/slos/public/context/plugin_context.ts new file mode 100644 index 0000000000000..f8e711b980f4a --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/context/plugin_context.ts @@ -0,0 +1,21 @@ +/* + * 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 { createContext } from 'react'; +import type { AppMountParameters } from '@kbn/core/public'; +import type { LazyObservabilityPageTemplateProps } from '@kbn/observability-shared-plugin/public'; +// TODO +// import type { ConfigSchema } from '../../plugin'; + +export interface PluginContextValue { + // isDev?: boolean; + // config; + appMountParameters: AppMountParameters; + ObservabilityPageTemplate: React.ComponentType; +} + +export const PluginContext = createContext({} as PluginContextValue); diff --git a/x-pack/plugins/observability_solution/slos/public/hooks/query_key_factory.ts b/x-pack/plugins/observability_solution/slos/public/hooks/query_key_factory.ts new file mode 100644 index 0000000000000..a08edd6d49f8e --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/hooks/query_key_factory.ts @@ -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 type { Indicator } from '@kbn/slo-schema'; + +interface SloListFilter { + kqlQuery: string; + page: number; + perPage: number; + sortBy: string; + sortDirection: string; + filters: string; + lastRefresh?: number; +} + +interface SloGroupListFilter { + page: number; + perPage: number; + groupBy: string; + kqlQuery: string; + filters: string; + lastRefresh?: number; +} + +export const sloKeys = { + all: ['slo'] as const, + lists: () => [...sloKeys.all, 'list'] as const, + list: (filters: SloListFilter) => [...sloKeys.lists(), filters] as const, + group: (filters: SloGroupListFilter) => [...sloKeys.groups(), filters] as const, + groups: () => [...sloKeys.all, 'group'] as const, + details: () => [...sloKeys.all, 'details'] as const, + detail: (sloId?: string) => [...sloKeys.details(), sloId] as const, + rules: () => [...sloKeys.all, 'rules'] as const, + rule: (sloIds: string[]) => [...sloKeys.rules(), sloIds] as const, + activeAlerts: () => [...sloKeys.all, 'activeAlerts'] as const, + activeAlert: (sloIdsAndInstanceIds: Array<[string, string]>) => + [...sloKeys.activeAlerts(), ...sloIdsAndInstanceIds.flat()] as const, + historicalSummaries: () => [...sloKeys.all, 'historicalSummary'] as const, + historicalSummary: (list: Array<{ sloId: string; instanceId: string }>) => + [...sloKeys.historicalSummaries(), list] as const, + definitions: (search: string, page: number, perPage: number, includeOutdatedOnly: boolean) => + [...sloKeys.all, 'definitions', search, page, perPage, includeOutdatedOnly] as const, + globalDiagnosis: () => [...sloKeys.all, 'globalDiagnosis'] as const, + burnRates: (sloId: string, instanceId: string | undefined) => + [...sloKeys.all, 'burnRates', sloId, instanceId] as const, + preview: (indicator: Indicator, range: { start: number; end: number }) => + [...sloKeys.all, 'preview', indicator, range] as const, +}; + +export type SloKeys = typeof sloKeys; diff --git a/x-pack/plugins/observability_solution/slos/public/hooks/use_capabilities.ts b/x-pack/plugins/observability_solution/slos/public/hooks/use_capabilities.ts new file mode 100644 index 0000000000000..e1f68b26d27e4 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/hooks/use_capabilities.ts @@ -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 { useKibana } from '@kbn/kibana-react-plugin/public'; +import { sloFeatureId } from '../../common'; + +export function useCapabilities() { + const { + application: { capabilities }, + } = useKibana().services; + + return { + hasReadCapabilities: !!capabilities[sloFeatureId].read ?? false, + hasWriteCapabilities: !!capabilities[sloFeatureId].write ?? false, + }; +} diff --git a/x-pack/plugins/observability_solution/slos/public/hooks/use_create_data_view.ts b/x-pack/plugins/observability_solution/slos/public/hooks/use_create_data_view.ts new file mode 100644 index 0000000000000..04b50246e937f --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/hooks/use_create_data_view.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useState } from 'react'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +interface UseCreateDataViewProps { + indexPatternString: string | undefined; +} + +export function useCreateDataView({ indexPatternString }: UseCreateDataViewProps) { + const { dataViews } = useKibana().services; + + const [stateDataView, setStateDataView] = useState(); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const createDataView = () => + dataViews.create({ + id: `${indexPatternString}-id`, + title: indexPatternString, + allowNoIndex: true, + }); + + if (indexPatternString) { + setIsLoading(true); + createDataView() + .then((value) => { + setStateDataView(value); + }) + .finally(() => { + setIsLoading(false); + }); + } + }, [indexPatternString, dataViews]); + + return { dataView: stateDataView, loading: isLoading }; +} diff --git a/x-pack/plugins/observability_solution/slos/public/hooks/use_fetch_slo_groups.ts b/x-pack/plugins/observability_solution/slos/public/hooks/use_fetch_slo_groups.ts new file mode 100644 index 0000000000000..684c70503be45 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/hooks/use_fetch_slo_groups.ts @@ -0,0 +1,119 @@ +/* + * 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 { useQuery } from '@tanstack/react-query'; +import { i18n } from '@kbn/i18n'; +import { buildQueryFromFilters, Filter } from '@kbn/es-query'; +import { useMemo } from 'react'; +import { FindSLOGroupsResponse } from '@kbn/slo-schema'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useCreateDataView } from './use_create_data_view'; +import { sloKeys } from './query_key_factory'; +import { + DEFAULT_SLO_GROUPS_PAGE_SIZE, + SLO_SUMMARY_DESTINATION_INDEX_NAME, +} from '../../common/constants'; +import { SearchState } from './use_url_search_state'; + +interface SLOGroupsParams { + page?: number; + perPage?: number; + groupBy?: string; + kqlQuery?: string; + tagsFilter?: SearchState['tagsFilter']; + statusFilter?: SearchState['statusFilter']; + filters?: Filter[]; + lastRefresh?: number; +} + +interface UseFetchSloGroupsResponse { + isLoading: boolean; + isRefetching: boolean; + isSuccess: boolean; + isError: boolean; + data: FindSLOGroupsResponse | undefined; +} + +export function useFetchSloGroups({ + page = 1, + perPage = DEFAULT_SLO_GROUPS_PAGE_SIZE, + groupBy = 'ungrouped', + kqlQuery = '', + tagsFilter, + statusFilter, + filters: filterDSL = [], + lastRefresh, +}: SLOGroupsParams = {}): UseFetchSloGroupsResponse { + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const { dataView } = useCreateDataView({ + indexPatternString: SLO_SUMMARY_DESTINATION_INDEX_NAME, + }); + + const filters = useMemo(() => { + try { + return JSON.stringify( + buildQueryFromFilters( + [ + ...filterDSL, + ...(tagsFilter ? [tagsFilter] : []), + ...(statusFilter ? [statusFilter] : []), + ], + dataView, + { + ignoreFilterIfFieldNotInIndex: true, + } + ) + ); + } catch (e) { + return ''; + } + }, [filterDSL, tagsFilter, statusFilter, dataView]); + + const { data, isLoading, isSuccess, isError, isRefetching } = useQuery({ + queryKey: sloKeys.group({ page, perPage, groupBy, kqlQuery, filters, lastRefresh }), + queryFn: async ({ signal }) => { + const response = await http.get( + '/internal/api/observability/slos/_groups', + { + query: { + ...(page && { page }), + ...(perPage && { perPage }), + ...(groupBy && { groupBy }), + ...(kqlQuery && { kqlQuery }), + ...(filters && { filters }), + }, + signal, + } + ); + return response; + }, + cacheTime: 0, + refetchOnWindowFocus: false, + retry: (failureCount, error) => { + if (String(error) === 'Error: Forbidden') { + return false; + } + return failureCount < 4; + }, + onError: (error: Error) => { + toasts.addError(error, { + title: i18n.translate('xpack.slos.useFetchSloGroups.', { defaultMessage: '' }), + }); + }, + }); + + return { + data, + isLoading, + isSuccess, + isError, + isRefetching, + }; +} diff --git a/x-pack/plugins/observability_solution/slos/public/hooks/use_fetch_slo_list.ts b/x-pack/plugins/observability_solution/slos/public/hooks/use_fetch_slo_list.ts new file mode 100644 index 0000000000000..6849365c4256a --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/hooks/use_fetch_slo_list.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { FindSLOResponse } from '@kbn/slo-schema'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { buildQueryFromFilters, Filter } from '@kbn/es-query'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { SearchState } from './use_url_search_state'; +import { useCreateDataView } from './use_create_data_view'; +import { DEFAULT_SLO_PAGE_SIZE, SLO_SUMMARY_DESTINATION_INDEX_NAME } from '../../common/constants'; + +import { sloKeys } from './query_key_factory'; + +export interface SLOListParams { + kqlQuery?: string; + page?: number; + sortBy?: string; + sortDirection?: 'asc' | 'desc'; + perPage?: number; + filters?: Filter[]; + lastRefresh?: number; + tagsFilter?: SearchState['tagsFilter']; + statusFilter?: SearchState['statusFilter']; + disabled?: boolean; +} + +export interface UseFetchSloListResponse { + isInitialLoading: boolean; + isLoading: boolean; + isRefetching: boolean; + isSuccess: boolean; + isError: boolean; + data: FindSLOResponse | undefined; +} + +export function useFetchSloList({ + kqlQuery = '', + page = 1, + sortBy = 'status', + sortDirection = 'desc', + perPage = DEFAULT_SLO_PAGE_SIZE, + filters: filterDSL = [], + lastRefresh, + tagsFilter, + statusFilter, + disabled = false, +}: SLOListParams = {}): UseFetchSloListResponse { + const { + http, + notifications: { toasts }, + } = useKibana().services; + const queryClient = useQueryClient(); + + const { dataView } = useCreateDataView({ + indexPatternString: SLO_SUMMARY_DESTINATION_INDEX_NAME, + }); + + const filters = useMemo(() => { + try { + return JSON.stringify( + buildQueryFromFilters( + [ + ...filterDSL, + ...(statusFilter ? [statusFilter] : []), + ...(tagsFilter ? [tagsFilter] : []), + ], + dataView, + { + ignoreFilterIfFieldNotInIndex: true, + } + ) + ); + } catch (e) { + return ''; + } + }, [filterDSL, dataView, tagsFilter, statusFilter]); + + const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({ + queryKey: sloKeys.list({ + kqlQuery, + page, + perPage, + sortBy, + sortDirection, + filters, + lastRefresh, + }), + queryFn: async ({ signal }) => { + return await http.get(`/api/observability/slos`, { + query: { + ...(kqlQuery && { kqlQuery }), + ...(sortBy && { sortBy }), + ...(sortDirection && { sortDirection }), + ...(page !== undefined && { page }), + ...(perPage !== undefined && { perPage }), + ...(filters && { filters }), + }, + signal, + }); + }, + enabled: !disabled, + cacheTime: 0, + refetchOnWindowFocus: false, + retry: (failureCount, error) => { + if (String(error) === 'Error: Forbidden') { + return false; + } + return failureCount < 4; + }, + onSuccess: ({ results }: FindSLOResponse) => { + queryClient.invalidateQueries({ queryKey: sloKeys.historicalSummaries(), exact: false }); + queryClient.invalidateQueries({ queryKey: sloKeys.activeAlerts(), exact: false }); + queryClient.invalidateQueries({ queryKey: sloKeys.rules(), exact: false }); + }, + onError: (error: Error) => { + toasts.addError(error, { + title: i18n.translate('xpack.slos.useFetchSloList.', { defaultMessage: '' }), + }); + }, + }); + + return { + data, + isInitialLoading, + isLoading, + isRefetching, + isSuccess, + isError, + }; +} diff --git a/x-pack/plugins/observability_solution/slos/public/hooks/use_license.ts b/x-pack/plugins/observability_solution/slos/public/hooks/use_license.ts new file mode 100644 index 0000000000000..8eef3fa5d0001 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/hooks/use_license.ts @@ -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 { useCallback } from 'react'; +import { Observable } from 'rxjs'; +import useObservable from 'react-use/lib/useObservable'; +import type { ILicense, LicenseType } from '@kbn/licensing-plugin/public'; + +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +interface UseLicenseReturnValue { + getLicense: () => ILicense | null; + hasAtLeast: (level: LicenseType) => boolean | undefined; +} + +export const useLicense = (): UseLicenseReturnValue => { + const { licensing } = useKibana().services; + const license = useObservable(licensing?.license$ ?? new Observable(), null); + + return { + getLicense: () => license, + hasAtLeast: useCallback( + (level: LicenseType) => { + if (!license) return; + + return !!license && license.isAvailable && license.isActive && license.hasAtLeast(level); + }, + [license] + ), + }; +}; diff --git a/x-pack/plugins/observability_solution/slos/public/hooks/use_plugin_context.tsx b/x-pack/plugins/observability_solution/slos/public/hooks/use_plugin_context.tsx new file mode 100644 index 0000000000000..5ea1d46ac7af3 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/hooks/use_plugin_context.tsx @@ -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 { useContext } from 'react'; +import { PluginContext } from '../context/plugin_context'; + +export function usePluginContext() { + return useContext(PluginContext); +} diff --git a/x-pack/plugins/observability_solution/slos/public/hooks/use_slo_summary.ts b/x-pack/plugins/observability_solution/slos/public/hooks/use_slo_summary.ts new file mode 100644 index 0000000000000..3d2ab22eb8afa --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/hooks/use_slo_summary.ts @@ -0,0 +1,71 @@ +/* + * 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 { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema'; +import { IBasePath } from '@kbn/core-http-browser'; +import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { paths } from '../../common/locators/paths'; +import { NOT_AVAILABLE_LABEL } from '../../common/i18n'; + +export const useSloFormattedSummary = (slo: SLOWithSummaryResponse) => { + const { + http: { basePath }, + uiSettings, + } = useKibana().services; + + return getSloFormattedSummary(slo, uiSettings, basePath); +}; + +export const getSloFormattedSummary = ( + slo: SLOWithSummaryResponse, + uiSettings: IUiSettingsClient, + basePath: IBasePath +) => { + const percentFormat = uiSettings.get('format:percent:defaultPattern'); + + const sliValue = + slo.summary.status === 'NO_DATA' + ? NOT_AVAILABLE_LABEL + : numeral(slo.summary.sliValue).format(percentFormat); + + const sloTarget = numeral(slo.objective.target).format(percentFormat); + const errorBudgetRemaining = + slo.summary.errorBudget.remaining <= 0 + ? Math.trunc(slo.summary.errorBudget.remaining * 100) / 100 + : slo.summary.errorBudget.remaining; + + const errorBudgetRemainingTitle = + slo.summary.status === 'NO_DATA' + ? NOT_AVAILABLE_LABEL + : numeral(errorBudgetRemaining).format(percentFormat); + + const sloDetailsUrl = basePath.prepend( + paths.observability.sloDetails( + slo.id, + slo.groupBy !== ALL_VALUE && slo.instanceId ? slo.instanceId : undefined + ) + ); + + return { + sloDetailsUrl, + sliValue, + sloTarget, + errorBudgetRemaining: errorBudgetRemainingTitle, + }; +}; + +export const useSloFormattedSLIValue = (sliValue?: number): string | null => { + const { uiSettings } = useKibana().services; + const percentFormat = uiSettings.get('format:percent:defaultPattern'); + + const formattedSLIValue = + sliValue !== undefined && sliValue !== null ? numeral(sliValue).format(percentFormat) : null; + + return formattedSLIValue; +}; diff --git a/x-pack/plugins/observability_solution/slos/public/hooks/use_url_search_state.ts b/x-pack/plugins/observability_solution/slos/public/hooks/use_url_search_state.ts new file mode 100644 index 0000000000000..f965aa7ff01d6 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/hooks/use_url_search_state.ts @@ -0,0 +1,89 @@ +/* + * 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 { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; +import deepmerge from 'deepmerge'; +import { useHistory } from 'react-router-dom'; +import { Filter } from '@kbn/es-query'; +import { useEffect, useRef, useState } from 'react'; +import { DEFAULT_SLO_PAGE_SIZE } from '../../common/constants'; +import type { SortField, SortDirection } from '../pages/slos/components/slo_list_search_bar'; +import type { GroupByField } from '../pages/slos/components/slo_list_group_by'; +import type { SLOView } from '../pages/slos/components/toggle_slo_view'; + +export const SLO_LIST_SEARCH_URL_STORAGE_KEY = 'search'; + +export interface SearchState { + kqlQuery: string; + page: number; + perPage: number; + sort: { + by: SortField; + direction: SortDirection; + }; + view: SLOView; + groupBy: GroupByField; + filters: Filter[]; + lastRefresh?: number; + tagsFilter?: Filter; + statusFilter?: Filter; +} + +export const DEFAULT_STATE = { + kqlQuery: '', + page: 0, + perPage: DEFAULT_SLO_PAGE_SIZE, + sort: { by: 'status' as const, direction: 'desc' as const }, + view: 'cardView' as const, + groupBy: 'ungrouped' as const, + filters: [], + lastRefresh: 0, +}; + +export function useUrlSearchState(): { + state: SearchState; + onStateChange: (state: Partial) => Promise; +} { + const [state, setState] = useState(DEFAULT_STATE); + const history = useHistory(); + const urlStateStorage = useRef( + createKbnUrlStateStorage({ + history, + useHash: false, + useHashQuery: false, + }) + ); + + useEffect(() => { + const sub = urlStateStorage.current + ?.change$(SLO_LIST_SEARCH_URL_STORAGE_KEY) + .subscribe((newSearchState) => { + if (newSearchState) { + setState(newSearchState); + } + }); + + setState( + urlStateStorage.current?.get(SLO_LIST_SEARCH_URL_STORAGE_KEY) ?? DEFAULT_STATE + ); + + return () => { + sub?.unsubscribe(); + }; + }, [urlStateStorage]); + return { + state: deepmerge(DEFAULT_STATE, state), + onStateChange: (newState: Partial) => + urlStateStorage.current?.set( + SLO_LIST_SEARCH_URL_STORAGE_KEY, + { ...state, ...newState }, + { + replace: true, + } + ), + }; +} diff --git a/x-pack/plugins/observability_solution/slos/public/index.ts b/x-pack/plugins/observability_solution/slos/public/index.ts new file mode 100644 index 0000000000000..7e6e3bfadfeaa --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/index.ts @@ -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 { SlosPlugin } from './plugin'; + +export function plugin() { + return new SlosPlugin(); +} +export type { SlosPluginSetup, SlosPluginStart } from './types'; diff --git a/x-pack/plugins/observability_solution/slos/public/locators/slo_list.ts b/x-pack/plugins/observability_solution/slos/public/locators/slo_list.ts new file mode 100644 index 0000000000000..abd5cbe6e2cef --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/locators/slo_list.ts @@ -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. + */ + +/* + * 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 { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; +import type { LocatorDefinition } from '@kbn/share-plugin/public'; +import type { SerializableRecord } from '@kbn/utility-types'; +import deepmerge from 'deepmerge'; +import { sloListLocatorID } from '../../common'; +import { SLOS_PATH } from '../../common/locators/paths'; +import { + DEFAULT_STATE, + SearchState, + SLO_LIST_SEARCH_URL_STORAGE_KEY, +} from '../hooks/use_url_search_state'; + +export interface SloListLocatorParams extends SerializableRecord { + kqlQuery?: string; +} + +export class SloListLocatorDefinition implements LocatorDefinition { + public readonly id = sloListLocatorID; + + public readonly getLocation = async ({ kqlQuery = '' }: SloListLocatorParams) => { + const state: SearchState = deepmerge(DEFAULT_STATE, { kqlQuery }); + + return { + app: 'observability', + path: setStateToKbnUrl( + SLO_LIST_SEARCH_URL_STORAGE_KEY, + state, + { + useHash: false, + storeInHashQuery: false, + }, + SLOS_PATH + ), + state: {}, + }; + }; +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slo_edit/constants.ts b/x-pack/plugins/observability_solution/slos/public/pages/slo_edit/constants.ts new file mode 100644 index 0000000000000..db3d2c4c7e993 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slo_edit/constants.ts @@ -0,0 +1,44 @@ +/* + * 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 { IndicatorType } from '@kbn/slo-schema'; +import { + INDICATOR_APM_AVAILABILITY, + INDICATOR_APM_LATENCY, + INDICATOR_CUSTOM_KQL, + INDICATOR_CUSTOM_METRIC, + INDICATOR_HISTOGRAM, + INDICATOR_TIMESLICE_METRIC, +} from '../../utils/labels'; +export const SLI_OPTIONS: Array<{ + value: IndicatorType; + text: string; +}> = [ + { + value: 'sli.kql.custom', + text: INDICATOR_CUSTOM_KQL, + }, + { + value: 'sli.metric.custom', + text: INDICATOR_CUSTOM_METRIC, + }, + { + value: 'sli.metric.timeslice', + text: INDICATOR_TIMESLICE_METRIC, + }, + { + value: 'sli.histogram.custom', + text: INDICATOR_HISTOGRAM, + }, + { + value: 'sli.apm.transactionDuration', + text: INDICATOR_APM_LATENCY, + }, + { + value: 'sli.apm.transactionErrorRate', + text: INDICATOR_APM_AVAILABILITY, + }, +]; diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_badges.stories.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_badges.stories.tsx new file mode 100644 index 0000000000000..67869e7f0e76e --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_badges.stories.tsx @@ -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 React from 'react'; +import { ComponentStory } from '@storybook/react'; + +import { EuiFlexGroup } from '@elastic/eui'; +import { buildForecastedSlo } from '../../../../data/slo/slo'; +import { KibanaReactStorybookDecorator } from '../../../../utils/kibana_react.storybook_decorator'; +import { SloBadges as Component, SloBadgesProps } from './slo_badges'; + +export default { + component: Component, + title: 'app/SLO/Badges/SloBadges', + decorators: [KibanaReactStorybookDecorator], +}; + +const Template: ComponentStory = (props: SloBadgesProps) => ( + + + +); + +const defaultProps = { + slo: buildForecastedSlo(), + rules: [], +}; + +export const SloBadges = Template.bind({}); +SloBadges.args = defaultProps; diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_badges.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_badges.tsx new file mode 100644 index 0000000000000..d801bd848ce9b --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_badges.tsx @@ -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 React from 'react'; +import { EuiFlexGroup, EuiSkeletonRectangle } from '@elastic/eui'; +import { SLOWithSummaryResponse } from '@kbn/slo-schema'; +import { Rule } from '@kbn/triggers-actions-ui-plugin/public'; + +import { SloTagsList } from '../common/slo_tags_list'; +import { SloIndicatorTypeBadge } from './slo_indicator_type_badge'; +import { SloStatusBadge } from '../../../../components/slo/slo_status_badge'; +import { SloActiveAlertsBadge } from '../../../../components/slo/slo_status_badge/slo_active_alerts_badge'; +import { SloTimeWindowBadge } from './slo_time_window_badge'; +import { SloRulesBadge } from './slo_rules_badge'; +import type { SloRule } from '../../../../hooks/slo/use_fetch_rules_for_slo'; +import { SloGroupByBadge } from '../../../../components/slo/slo_status_badge/slo_group_by_badge'; +export type ViewMode = 'default' | 'compact'; + +export interface SloBadgesProps { + activeAlerts?: number; + isLoading: boolean; + rules: Array> | undefined; + slo: SLOWithSummaryResponse; + onClickRuleBadge: () => void; +} + +export function SloBadges({ + activeAlerts, + isLoading, + rules, + slo, + onClickRuleBadge, +}: SloBadgesProps) { + return ( + + {isLoading ? ( + + ) : ( + <> + + + + + + + + + )} + + ); +} + +export function LoadingBadges() { + return ( + <> + + + + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_indicator_type_badge.stories.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_indicator_type_badge.stories.tsx new file mode 100644 index 0000000000000..c8536bdc93ddd --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_indicator_type_badge.stories.tsx @@ -0,0 +1,40 @@ +/* + * 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 { ComponentStory } from '@storybook/react'; + +import { EuiFlexGroup } from '@elastic/eui'; +import { + buildCustomKqlIndicator, + buildApmAvailabilityIndicator, + buildApmLatencyIndicator, +} from '../../../../data/slo/indicator'; +import { KibanaReactStorybookDecorator } from '../../../../utils/kibana_react.storybook_decorator'; +import { SloIndicatorTypeBadge as Component, Props } from './slo_indicator_type_badge'; +import { buildSlo } from '../../../../data/slo/slo'; + +export default { + component: Component, + title: 'app/SLO/Badges/SloIndicatorTypeBadge', + decorators: [KibanaReactStorybookDecorator], +}; + +const Template: ComponentStory = (props: Props) => ( + + + +); + +export const WithCustomKql = Template.bind({}); +WithCustomKql.args = { slo: buildSlo({ indicator: buildCustomKqlIndicator() }) }; + +export const WithApmAvailability = Template.bind({}); +WithApmAvailability.args = { slo: buildSlo({ indicator: buildApmAvailabilityIndicator() }) }; + +export const WithApmLatency = Template.bind({}); +WithApmLatency.args = { slo: buildSlo({ indicator: buildApmLatencyIndicator() }) }; diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_indicator_type_badge.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_indicator_type_badge.tsx new file mode 100644 index 0000000000000..b7c5d07f919f8 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_indicator_type_badge.tsx @@ -0,0 +1,81 @@ +/* + * 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 { EuiBadge, EuiBadgeProps, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { + apmTransactionDurationIndicatorSchema, + apmTransactionErrorRateIndicatorSchema, + SLOResponse, + SLOWithSummaryResponse, +} from '@kbn/slo-schema'; +import { euiLightVars } from '@kbn/ui-theme'; +import React from 'react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useUrlSearchState } from '../../../../hooks/use_url_search_state'; +import { convertSliApmParamsToApmAppDeeplinkUrl } from '../../../../utils/slo/convert_sli_apm_params_to_apm_app_deeplink_url'; +import { toIndicatorTypeLabel } from '../../../../utils/slo/labels'; + +export interface Props { + color?: EuiBadgeProps['color']; + slo: SLOWithSummaryResponse | SLOResponse; +} + +export function SloIndicatorTypeBadge({ slo, color }: Props) { + const { + application: { navigateToUrl }, + http: { basePath }, + } = useKibana().services; + + const { onStateChange } = useUrlSearchState(); + + const handleNavigateToApm = () => { + const url = convertSliApmParamsToApmAppDeeplinkUrl(slo); + if (url) { + navigateToUrl(basePath.prepend(url)); + } + }; + + return ( + <> + + { + onStateChange({ + kqlQuery: `slo.indicator.type: ${slo.indicator.type}`, + }); + }} + onClickAriaLabel={i18n.translate('xpack.slos.sloIndicatorTypeBadge.', { + defaultMessage: '', + })} + > + {toIndicatorTypeLabel(slo.indicator.type)} + + + {(apmTransactionDurationIndicatorSchema.is(slo.indicator) || + apmTransactionErrorRateIndicatorSchema.is(slo.indicator)) && ( + + + + {slo.indicator.params.service} + + + + )} + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_rules_badge.stories.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_rules_badge.stories.tsx new file mode 100644 index 0000000000000..c97be672356f5 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_rules_badge.stories.tsx @@ -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 React from 'react'; +import { ComponentStory } from '@storybook/react'; + +import { EuiFlexGroup } from '@elastic/eui'; +import { Rule } from '@kbn/triggers-actions-ui-plugin/public'; +import { SloRule } from '../../../../hooks/slo/use_fetch_rules_for_slo'; +import { KibanaReactStorybookDecorator } from '../../../../utils/kibana_react.storybook_decorator'; +import { SloRulesBadge as Component, Props } from './slo_rules_badge'; + +export default { + component: Component, + title: 'app/SLO/Badges/SloRulesBadge', + decorators: [KibanaReactStorybookDecorator], +}; + +const Template: ComponentStory = (props: Props) => ( + + + +); + +export const WithNoRule = Template.bind({}); +WithNoRule.args = { rules: [] }; + +export const WithLoadingRule = Template.bind({}); +WithLoadingRule.args = { rules: undefined }; +export const WithRule = Template.bind({}); +WithRule.args = { rules: [{ name: 'rulename' }] as Array> }; diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_rules_badge.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_rules_badge.tsx new file mode 100644 index 0000000000000..bcb3a1a0c5111 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_rules_badge.tsx @@ -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 React from 'react'; +import { EuiBadge, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Rule } from '@kbn/triggers-actions-ui-plugin/public'; + +import { SloRule } from '../../../../hooks/slo/use_fetch_rules_for_slo'; + +export interface Props { + rules: Array> | undefined; + onClick?: () => void; +} + +export function SloRulesBadge({ rules, onClick }: Props) { + return rules === undefined || rules.length > 0 ? null : ( + + + + + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_time_window_badge.stories.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_time_window_badge.stories.tsx new file mode 100644 index 0000000000000..8998cb0de9492 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_time_window_badge.stories.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { ComponentStory } from '@storybook/react'; + +import { EuiFlexGroup } from '@elastic/eui'; +import { KibanaReactStorybookDecorator } from '../../../../utils/kibana_react.storybook_decorator'; +import { SloTimeWindowBadge as Component, Props } from './slo_time_window_badge'; +import { buildSlo } from '../../../../data/slo/slo'; + +export default { + component: Component, + title: 'app/SLO/Badges/SloTimeWindowBadge', + decorators: [KibanaReactStorybookDecorator], +}; + +const Template: ComponentStory = (props: Props) => ( + + + +); + +export const With7DaysRolling = Template.bind({}); +With7DaysRolling.args = { slo: buildSlo({ timeWindow: { duration: '7d', type: 'rolling' } }) }; + +export const With30DaysRolling = Template.bind({}); +With30DaysRolling.args = { slo: buildSlo({ timeWindow: { duration: '30d', type: 'rolling' } }) }; + +export const WithWeeklyCalendar = Template.bind({}); +WithWeeklyCalendar.args = { + slo: buildSlo({ + timeWindow: { duration: '1w', type: 'calendarAligned' }, + }), +}; + +export const WithMonthlyCalendar = Template.bind({}); +WithMonthlyCalendar.args = { + slo: buildSlo({ + timeWindow: { duration: '1M', type: 'calendarAligned' }, + }), +}; diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_time_window_badge.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_time_window_badge.tsx new file mode 100644 index 0000000000000..384c17f251b01 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/badges/slo_time_window_badge.tsx @@ -0,0 +1,60 @@ +/* + * 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 { EuiBadge, EuiBadgeProps, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { rollingTimeWindowTypeSchema, SLOResponse, SLOWithSummaryResponse } from '@kbn/slo-schema'; +import { euiLightVars } from '@kbn/ui-theme'; +import moment from 'moment'; +import React from 'react'; +import { toCalendarAlignedMomentUnitOfTime } from '../../../../utils/slo/duration'; +import { toDurationLabel } from '../../../../utils/slo/labels'; + +export interface Props { + color?: EuiBadgeProps['color']; + slo: SLOWithSummaryResponse | SLOResponse; +} + +export function SloTimeWindowBadge({ slo, color }: Props) { + const unit = slo.timeWindow.duration.slice(-1); + if (rollingTimeWindowTypeSchema.is(slo.timeWindow.type)) { + return ( + + + {toDurationLabel(slo.timeWindow.duration)} + + + ); + } + + const unitMoment = toCalendarAlignedMomentUnitOfTime(unit); + const now = moment.utc(); + + const periodStart = now.clone().startOf(unitMoment); + const periodEnd = now.clone().endOf(unitMoment); + + const totalDurationInDays = periodEnd.diff(periodStart, 'days') + 1; + const elapsedDurationInDays = now.diff(periodStart, 'days') + 1; + + return ( + + + {i18n.translate('xpack.observability.slo.slo.timeWindow.calendar', { + defaultMessage: '{elapsed}/{total} days', + values: { + elapsed: Math.min(elapsedDurationInDays, totalDurationInDays), + total: totalDurationInDays, + }, + })} + + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/card_view/badges_portal.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/card_view/badges_portal.tsx new file mode 100644 index 0000000000000..c876508301652 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/card_view/badges_portal.tsx @@ -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 React, { ReactNode, useEffect, useMemo } from 'react'; +import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; +import ReactDOM from 'react-dom'; +export interface Props { + children: ReactNode; + containerRef: React.RefObject; + index?: number; +} + +export function SloCardBadgesPortal({ children, containerRef, index }: Props) { + const portalNode = useMemo(() => createHtmlPortalNode(), []); + useEffect(() => { + if (containerRef?.current) { + setTimeout(() => { + const gapDivs = containerRef?.current?.querySelectorAll('.echMetricText__gap'); + if (!gapDivs?.[index ?? 0]) return; + ReactDOM.render(, gapDivs[index ?? 0]); + }, 100); + } + + return () => { + portalNode.unmount(); + }; + }, [portalNode, containerRef, index]); + + return {children}; +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/card_view/slo_card_item.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/card_view/slo_card_item.tsx new file mode 100644 index 0000000000000..2a72da17a7be7 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/card_view/slo_card_item.tsx @@ -0,0 +1,226 @@ +/* + * 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, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + Chart, + isMetricElementEvent, + LEGACY_DARK_THEME, + Metric, + MetricTrendShape, + Settings, +} from '@elastic/charts'; +import { EuiIcon, EuiPanel, useEuiBackgroundColor } from '@elastic/eui'; +import { ALL_VALUE, HistoricalSummaryResponse, SLOWithSummaryResponse } from '@kbn/slo-schema'; +import { Rule } from '@kbn/triggers-actions-ui-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import { + LazySavedObjectSaveModalDashboard, + withSuspense, +} from '@kbn/presentation-util-plugin/public'; +import { SloCardBadgesPortal } from './badges_portal'; +import { useSloListActions } from '../../hooks/use_slo_list_actions'; +import { BurnRateRuleFlyout } from '../common/burn_rate_rule_flyout'; +import { formatHistoricalData } from '../../../../utils/slo/chart_data_formatter'; +import { useKibana } from '../../../../utils/kibana_react'; +import { useSloFormattedSummary } from '../../hooks/use_slo_summary'; +import { SloCardItemActions } from './slo_card_item_actions'; +import { SloRule } from '../../../../hooks/slo/use_fetch_rules_for_slo'; +import { SloDeleteConfirmationModal } from '../../../../components/slo/delete_confirmation_modal/slo_delete_confirmation_modal'; +import { SloCardItemBadges } from './slo_card_item_badges'; +const SavedObjectSaveModalDashboard = withSuspense(LazySavedObjectSaveModalDashboard); +export interface Props { + slo: SLOWithSummaryResponse; + rules: Array> | undefined; + historicalSummary?: HistoricalSummaryResponse[]; + historicalSummaryLoading: boolean; + activeAlerts?: number; + loading: boolean; + error: boolean; + cardsPerRow: number; +} + +export const useSloCardColor = (status?: SLOWithSummaryResponse['summary']['status']) => { + const colors = { + DEGRADING: useEuiBackgroundColor('warning'), + VIOLATED: useEuiBackgroundColor('danger'), + HEALTHY: useEuiBackgroundColor('success'), + NO_DATA: useEuiBackgroundColor('subdued'), + }; + + return { cardColor: colors[status ?? 'NO_DATA'], colors }; +}; + +export const getSubTitle = (slo: SLOWithSummaryResponse) => { + return slo.groupBy && slo.groupBy !== ALL_VALUE ? `${slo.groupBy}: ${slo.instanceId}` : ''; +}; + +export function SloCardItem({ slo, rules, activeAlerts, historicalSummary, cardsPerRow }: Props) { + const containerRef = React.useRef(null); + + const [isMouseOver, setIsMouseOver] = useState(false); + const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false); + const [isAddRuleFlyoutOpen, setIsAddRuleFlyoutOpen] = useState(false); + const [isDeleteConfirmationModalOpen, setDeleteConfirmationModalOpen] = useState(false); + const [isDashboardAttachmentReady, setDashboardAttachmentReady] = useState(false); + const historicalSliData = formatHistoricalData(historicalSummary, 'sli_value'); + + const { handleCreateRule, handleDeleteCancel, handleDeleteConfirm, handleAttachToDashboardSave } = + useSloListActions({ + slo, + setDeleteConfirmationModalOpen, + setIsActionsPopoverOpen, + setIsAddRuleFlyoutOpen, + setDashboardAttachmentReady, + }); + + return ( + <> + } + onMouseOver={() => { + if (!isMouseOver) { + setIsMouseOver(true); + } + }} + onMouseLeave={() => { + if (isMouseOver) { + setIsMouseOver(false); + } + }} + paddingSize="none" + css={css` + height: 182px; + overflow: hidden; + position: relative; + `} + title={slo.summary.status} + > + + {(isMouseOver || isActionsPopoverOpen) && ( + + )} + + + + + + + + {isDeleteConfirmationModalOpen ? ( + + ) : null} + {isDashboardAttachmentReady ? ( + { + setDashboardAttachmentReady(false); + }} + onSave={handleAttachToDashboardSave} + /> + ) : null} + + ); +} + +export function SloCardChart({ + slo, + onClick, + historicalSliData, +}: { + slo: SLOWithSummaryResponse; + historicalSliData?: Array<{ key?: number; value?: number }>; + onClick?: () => void; +}) { + const { + application: { navigateToUrl }, + } = useKibana().services; + + const { cardColor } = useSloCardColor(slo.summary.status); + const subTitle = getSubTitle(slo); + const { sliValue, sloTarget, sloDetailsUrl } = useSloFormattedSummary(slo); + + return ( + + { + if (onClick) { + onClick(); + } else { + if (isMetricElementEvent(d)) { + navigateToUrl(sloDetailsUrl); + } + } + }} + locale={i18n.getLocale()} + /> + ({ + x: d.key as number, + y: d.value as number, + })), + extra: ( + + ), + icon: () => , + color: cardColor, + }, + ], + ]} + /> + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/card_view/slo_card_item_actions.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/card_view/slo_card_item_actions.tsx new file mode 100644 index 0000000000000..ff4d7f363caee --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/card_view/slo_card_item_actions.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { SLOWithSummaryResponse } from '@kbn/slo-schema'; +import styled from 'styled-components'; +import { useEuiShadow } from '@elastic/eui'; +import { SloItemActions } from '../slo_item_actions'; + +type PopoverPosition = 'relative' | 'default'; + +interface ActionContainerProps { + boxShadow: string; + position: PopoverPosition; +} + +const Container = styled.div` + ${({ position }) => + position === 'relative' + ? // custom styles used to overlay the popover button on `MetricItem` + ` + display: inline-block; + position: relative; + bottom: 42px; + left: 12px; + z-index: 1; +` + : // otherwise, no custom position needed + ''} + + border-radius: ${({ theme }) => theme.eui.euiBorderRadius}; + ${({ boxShadow, position }) => (position === 'relative' ? boxShadow : '')} +`; + +interface Props { + slo: SLOWithSummaryResponse; + isActionsPopoverOpen: boolean; + setIsActionsPopoverOpen: (value: boolean) => void; + setDeleteConfirmationModalOpen: (value: boolean) => void; + setIsAddRuleFlyoutOpen: (value: boolean) => void; + setDashboardAttachmentReady: (value: boolean) => void; +} + +export function SloCardItemActions(props: Props) { + const euiShadow = useEuiShadow('l'); + + return ( + + + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/card_view/slo_card_item_badges.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/card_view/slo_card_item_badges.tsx new file mode 100644 index 0000000000000..d9942530a96c0 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/card_view/slo_card_item_badges.tsx @@ -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 { SLOWithSummaryResponse } from '@kbn/slo-schema'; +import React, { useCallback } from 'react'; +import { Rule } from '@kbn/triggers-actions-ui-plugin/public'; +import styled from 'styled-components'; +import { EuiFlexGroup } from '@elastic/eui'; +import { SloTagsList } from '../common/slo_tags_list'; +import { useUrlSearchState } from '../../../../hooks/use_url_search_state'; +import { LoadingBadges } from '../badges/slo_badges'; +import { SloIndicatorTypeBadge } from '../badges/slo_indicator_type_badge'; +import { SloTimeWindowBadge } from '../badges/slo_time_window_badge'; +import { SloActiveAlertsBadge } from '../../../../components/slo/slo_status_badge/slo_active_alerts_badge'; +import { SloRulesBadge } from '../badges/slo_rules_badge'; +import { SloRule } from '../../../../hooks/slo/use_fetch_rules_for_slo'; + +interface Props { + hasGroupBy: boolean; + activeAlerts?: number; + slo: SLOWithSummaryResponse; + rules: Array> | undefined; + handleCreateRule?: () => void; +} + +const Container = styled.div` + display: inline-block; + margin-top: 5px; +`; + +export function SloCardItemBadges({ slo, activeAlerts, rules, handleCreateRule }: Props) { + const { onStateChange } = useUrlSearchState(); + + const onTagClick = useCallback( + (tag: string) => { + onStateChange({ + kqlQuery: `slo.tags: "${tag}"`, + }); + }, + [onStateChange] + ); + return ( + + + {!slo.summary ? ( + + ) : ( + <> + + + + + + + )} + + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/card_view/slos_card_view.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/card_view/slos_card_view.tsx new file mode 100644 index 0000000000000..8397a8ce99472 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/card_view/slos_card_view.tsx @@ -0,0 +1,110 @@ +/* + * 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 { + EuiFlexGrid, + EuiFlexItem, + EuiPanel, + EuiSkeletonText, + useIsWithinBreakpoints, +} from '@elastic/eui'; +import { EuiFlexGridProps } from '@elastic/eui/src/components/flex/flex_grid'; +import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema'; +import React from 'react'; +import { useFetchActiveAlerts } from '../../../../hooks/slo/use_fetch_active_alerts'; +import { useFetchHistoricalSummary } from '../../../../hooks/slo/use_fetch_historical_summary'; +import { useFetchRulesForSlo } from '../../../../hooks/slo/use_fetch_rules_for_slo'; +import { SloCardItem } from './slo_card_item'; + +export interface Props { + sloList: SLOWithSummaryResponse[]; + loading: boolean; + error: boolean; +} + +const useColumns = () => { + const isMobile = useIsWithinBreakpoints(['xs', 's']); + const isMedium = useIsWithinBreakpoints(['m']); + const isXLarge = useIsWithinBreakpoints(['xl']); + + switch (true) { + case isMobile: + return 1; + case isMedium: + return 3; + case isXLarge: + return 4; + default: + return 3; + } +}; + +export function SloListCardView({ sloList, loading, error }: Props) { + const sloIdsAndInstanceIds = sloList.map( + (slo) => [slo.id, slo.instanceId ?? ALL_VALUE] as [string, string] + ); + const { data: activeAlertsBySlo } = useFetchActiveAlerts({ sloIdsAndInstanceIds }); + const { data: rulesBySlo } = useFetchRulesForSlo({ + sloIds: sloIdsAndInstanceIds.map((item) => item[0]), + }); + const { isLoading: historicalSummaryLoading, data: historicalSummaries = [] } = + useFetchHistoricalSummary({ + list: sloList.map((slo) => ({ sloId: slo.id, instanceId: slo.instanceId ?? ALL_VALUE })), + }); + + const columns = useColumns(); + + if (loading && sloList.length === 0) { + return ; + } + + return ( + + {sloList + .filter((slo) => slo.summary) + .map((slo) => ( + + + historicalSummary.sloId === slo.id && + historicalSummary.instanceId === (slo.instanceId ?? ALL_VALUE) + )?.data + } + historicalSummaryLoading={historicalSummaryLoading} + cardsPerRow={Number(columns)} + /> + + ))} + + ); +} + +function LoadingSloGrid({ gridSize }: { gridSize: number }) { + const ROWS = 4; + const COLUMNS = gridSize; + const loaders = Array(ROWS * COLUMNS).fill(null); + return ( + <> + + {loaders.map((_, i) => ( + + + + {' '} + + ))} + + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/common/create_slo_btn.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/common/create_slo_btn.tsx new file mode 100644 index 0000000000000..358719843f606 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/common/create_slo_btn.tsx @@ -0,0 +1,37 @@ +/* + * 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 { EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { paths } from '../../../../../common/locators/paths'; +import { useCapabilities } from '../../../../hooks/use_capabilities'; + +export function CreateSloBtn() { + const { + application: { navigateToUrl }, + http: { basePath }, + } = useKibana().services; + + const { hasWriteCapabilities } = useCapabilities(); + + const handleClickCreateSlo = () => { + navigateToUrl(basePath.prepend(paths.observability.sloCreate)); + }; + return ( + + {i18n.translate('xpack.slos.sloList.pageHeader.create.', { defaultMessage: 'Create SLO' })} + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/common/feedback_button.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/common/feedback_button.tsx new file mode 100644 index 0000000000000..6efdbc6a2b3c5 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/common/feedback_button.tsx @@ -0,0 +1,33 @@ +/* + * 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 { EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const SLO_FEEDBACK_LINK = 'https://ela.st/slo-feedback'; + +interface Props { + disabled?: boolean; +} + +export function FeedbackButton({ disabled }: Props) { + return ( + + {i18n.translate('xpack.slos.feedbackButtonLabel', { + defaultMessage: 'Tell us what you think!', + })} + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/common/quick_filters.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/common/quick_filters.tsx new file mode 100644 index 0000000000000..8074e3c50eb7c --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/common/quick_filters.tsx @@ -0,0 +1,122 @@ +/* + * 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 React, { useEffect, useState } from 'react'; +import { AwaitingControlGroupAPI, ControlGroupRenderer } from '@kbn/controls-plugin/public'; +import { ViewMode } from '@kbn/embeddable-plugin/common'; +import styled from 'styled-components'; +import { Filter } from '@kbn/es-query'; +import { isEmpty } from 'lodash'; +import { useCreateDataView } from '../../../../hooks/use_create_data_view'; +import { SearchState } from '../../../../hooks/use_url_search_state'; +import { SLO_SUMMARY_DESTINATION_INDEX_NAME } from '../../../../../common/constants'; + +interface Props { + initialState: SearchState; + loading: boolean; + onStateChange: (newState: Partial) => void; +} + +export function QuickFilters({ initialState: { tagsFilter, statusFilter }, onStateChange }: Props) { + const { dataView, loading } = useCreateDataView({ + indexPatternString: SLO_SUMMARY_DESTINATION_INDEX_NAME, + }); + const [controlGroupAPI, setControlGroupAPI] = useState(); + + useEffect(() => { + if (!controlGroupAPI) { + return; + } + const subscription = controlGroupAPI.onFiltersPublished$.subscribe((newFilters) => { + if (newFilters.length === 0) { + onStateChange({ tagsFilter: undefined, statusFilter: undefined }); + } else { + onStateChange({ + tagsFilter: newFilters.filter((filter) => filter.meta.key === 'slo.tags')?.[0], + statusFilter: newFilters.filter((filter) => filter.meta.key === 'status')?.[0], + }); + } + }); + return () => { + subscription.unsubscribe(); + }; + }, [controlGroupAPI, onStateChange]); + + if (loading || !dataView) { + return null; + } + + return ( + + { + await builder.addOptionsListControl(initialInput, { + dataViewId: dataView.id!, + fieldName: 'status', + width: 'small', + grow: true, + title: STATUS_LABEL, + controlId: 'slo-status-filter', + exclude: statusFilter?.meta?.negate, + selectedOptions: getSelectedOptions(statusFilter), + existsSelected: Boolean(statusFilter?.query?.exists?.field === 'status'), + placeholder: ALL_LABEL, + }); + await builder.addOptionsListControl(initialInput, { + dataViewId: dataView.id!, + title: TAGS_LABEL, + fieldName: 'slo.tags', + width: 'small', + grow: false, + controlId: 'slo-tags-filter', + selectedOptions: getSelectedOptions(tagsFilter), + exclude: statusFilter?.meta?.negate, + existsSelected: Boolean(tagsFilter?.query?.exists?.field === 'slo.tags'), + placeholder: ALL_LABEL, + }); + return { + initialInput: { + ...initialInput, + viewMode: ViewMode.VIEW, + }, + }; + }} + ref={setControlGroupAPI} + timeRange={{ from: 'now-24h', to: 'now' }} + /> + + ); +} + +export const getSelectedOptions = (filter?: Filter) => { + if (isEmpty(filter)) { + return []; + } + if (filter?.meta?.params && Array.isArray(filter?.meta.params)) { + return filter?.meta.params; + } + if (filter?.query?.match_phrase?.status) { + return [filter.query.match_phrase.status]; + } + if (filter?.query?.match_phrase?.['slo.tags']) { + return [filter?.query.match_phrase?.['slo.tags']]; + } + return []; +}; + +const Container = styled.div` + .controlGroup { + min-height: initial; + } +`; + +const TAGS_LABEL = i18n.translate('xpack.slos.list.tags', { defaultMessage: 'Tags' }); + +const STATUS_LABEL = i18n.translate('xpack.slos.list.status', { defaultMessage: 'Status' }); + +const ALL_LABEL = i18n.translate('xpack.slos.list.all', { defaultMessage: 'All' }); diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/common/slo_tags_list.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/common/slo_tags_list.tsx new file mode 100644 index 0000000000000..9bdce86daed59 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/common/slo_tags_list.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { TagsList } from '@kbn/observability-shared-plugin/public'; +import type { TagsListProps } from '@kbn/observability-shared-plugin/public'; +import { useUrlSearchState } from '../../../../hooks/use_url_search_state'; + +export function SloTagsList(props: TagsListProps) { + const { onStateChange } = useUrlSearchState(); + + const onTagClick = useCallback( + (tag: string) => { + onStateChange({ + kqlQuery: `slo.tags: "${tag}"`, + }); + }, + [onStateChange] + ); + + return ; +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/common/sort_by_select.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/common/sort_by_select.tsx new file mode 100644 index 0000000000000..19072d0104178 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/common/sort_by_select.tsx @@ -0,0 +1,128 @@ +/* + * 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 { EuiPanel, EuiSelectableOption, EuiText } from '@elastic/eui'; +import { EuiSelectableOptionCheckedType } from '@elastic/eui/src/components/selectable/selectable_option'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import type { SearchState } from '../../../../hooks/use_url_search_state'; +import type { Option } from '../slo_context_menu'; +import { ContextMenuItem, SLOContextMenu } from '../slo_context_menu'; +import type { SortField } from '../slo_list_search_bar'; + +export interface Props { + onStateChange: (newState: Partial) => void; + state: SearchState; + loading: boolean; +} + +export type Item = EuiSelectableOption & { + label: string; + type: T; + checked?: EuiSelectableOptionCheckedType; +}; + +export function SLOSortBy({ state, onStateChange, loading }: Props) { + const [isSortByPopoverOpen, setIsSortByPopoverOpen] = useState(false); + const sortBy = state.sort.by; + + const handleChangeSortBy = ({ value, label }: { value: SortField; label: string }) => { + onStateChange({ + page: 0, + sort: { by: value, direction: state.sort.direction }, + }); + }; + + const sortByOptions: Option[] = [ + { + label: i18n.translate('xpack.slos.list.sortBy.sliValue', { defaultMessage: 'SLI value' }), + checked: sortBy === 'sli_value', + value: 'sli_value', + onClick: () => { + handleChangeSortBy({ + value: 'sli_value', + label: i18n.translate('xpack.slos.list.sortBy.sliValue', { defaultMessage: 'SLI value' }), + }); + }, + }, + { + label: i18n.translate('xpack.slos.list.sortBy.sloStatus', { defaultMessage: 'SLO status' }), + checked: sortBy === 'status', + value: 'status', + onClick: () => { + handleChangeSortBy({ + value: 'status', + label: i18n.translate('xpack.slos.sortByOptions.', { defaultMessage: '' }), + }); + }, + }, + { + label: i18n.translate('xpack.slos.list.sortBy.errorBudgetConsumed', { + defaultMessage: 'Error budget consumed', + }), + checked: sortBy === 'error_budget_consumed', + value: 'error_budget_consumed', + onClick: () => { + handleChangeSortBy({ + value: 'error_budget_consumed', + label: i18n.translate('xpack.slos.list.sortBy.errorBudgetConsumed', { + defaultMessage: 'Error budget consumed', + }), + }); + }, + }, + { + label: i18n.translate('xpack.slos.list.sortBy.errorBudgetRemaining', { + defaultMessage: 'Error budget remaining', + }), + checked: sortBy === 'error_budget_remaining', + value: 'error_budget_remaining', + onClick: () => { + handleChangeSortBy({ + value: 'error_budget_remaining', + label: i18n.translate('xpack.slos.list.sortBy.errorBudgetRemaining', { + defaultMessage: 'Error budget remaining', + }), + }); + }, + }, + ]; + + const groupLabel = sortByOptions.find((option) => option.value === sortBy)?.label || 'Default'; + + const items = [ + + +

{SORT_BY_LABEL}

+
+
, + + ...sortByOptions.map((option) => ( + setIsSortByPopoverOpen(false)} + key={option.value} + /> + )), + ]; + + return ( + + ); +} + +const SORT_BY_LABEL = i18n.translate('xpack.slos.list.sortByTypeLabel', { + defaultMessage: 'Sort by', +}); diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/compact_view/slo_list_compact_view.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/compact_view/slo_list_compact_view.tsx new file mode 100644 index 0000000000000..3a07bc3ac08b6 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/compact_view/slo_list_compact_view.tsx @@ -0,0 +1,424 @@ +/* + * 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 { + DefaultItemAction, + EuiBasicTable, + EuiBasicTableColumn, + EuiSkeletonRectangle, + EuiText, + EuiToolTip, + EuiFlexGroup, +} from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import { i18n } from '@kbn/i18n'; +import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema'; +import { useQueryClient } from '@tanstack/react-query'; +import React, { useState } from 'react'; +import { SloTagsList } from '../common/slo_tags_list'; +import { useCloneSlo } from '../../../../hooks/slo/use_clone_slo'; +import { rulesLocatorID, sloFeatureId } from '../../../../../common'; +import { SLO_BURN_RATE_RULE_TYPE_ID } from '../../../../../common/constants'; +import { paths } from '../../../../../common/locators/paths'; +import { SloDeleteConfirmationModal } from '../../../../components/slo/delete_confirmation_modal/slo_delete_confirmation_modal'; +import { SloStatusBadge } from '../../../../components/slo/slo_status_badge'; +import { SloActiveAlertsBadge } from '../../../../components/slo/slo_status_badge/slo_active_alerts_badge'; +import { sloKeys } from '../../../../hooks/slo/query_key_factory'; +import { useCapabilities } from '../../../../hooks/slo/use_capabilities'; +import { useDeleteSlo } from '../../../../hooks/slo/use_delete_slo'; +import { useFetchActiveAlerts } from '../../../../hooks/slo/use_fetch_active_alerts'; +import { useFetchHistoricalSummary } from '../../../../hooks/slo/use_fetch_historical_summary'; +import { useFetchRulesForSlo } from '../../../../hooks/slo/use_fetch_rules_for_slo'; +import { useGetFilteredRuleTypes } from '../../../../hooks/use_get_filtered_rule_types'; +import { RulesParams } from '../../../../locators/rules'; +import { useKibana } from '../../../../utils/kibana_react'; +import { formatHistoricalData } from '../../../../utils/slo/chart_data_formatter'; +import { SloRulesBadge } from '../badges/slo_rules_badge'; +import { SloListEmpty } from '../slo_list_empty'; +import { SloListError } from '../slo_list_error'; +import { SloSparkline } from '../slo_sparkline'; +import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; + +export interface Props { + sloList: SLOWithSummaryResponse[]; + loading: boolean; + error: boolean; +} + +export function SloListCompactView({ sloList, loading, error }: Props) { + const { + application: { navigateToUrl }, + http: { basePath }, + uiSettings, + share: { + url: { locators }, + }, + triggersActionsUi: { getAddRuleFlyout: AddRuleFlyout }, + } = useKibana().services; + + const percentFormat = uiSettings.get('format:percent:defaultPattern'); + const sloIdsAndInstanceIds = sloList.map( + (slo) => [slo.id, slo.instanceId ?? ALL_VALUE] as [string, string] + ); + + const { hasWriteCapabilities } = useCapabilities(); + const filteredRuleTypes = useGetFilteredRuleTypes(); + + const queryClient = useQueryClient(); + const { mutate: deleteSlo } = useDeleteSlo(); + + const [sloToAddRule, setSloToAddRule] = useState(undefined); + const [sloToDelete, setSloToDelete] = useState(undefined); + + const handleDeleteConfirm = () => { + if (sloToDelete) { + deleteSlo({ id: sloToDelete.id, name: sloToDelete.name }); + } + setSloToDelete(undefined); + }; + + const handleDeleteCancel = () => { + setSloToDelete(undefined); + }; + + const handleSavedRule = async () => { + queryClient.invalidateQueries({ queryKey: sloKeys.rules(), exact: false }); + }; + + const { data: activeAlertsBySlo } = useFetchActiveAlerts({ sloIdsAndInstanceIds }); + const { data: rulesBySlo } = useFetchRulesForSlo({ + sloIds: sloIdsAndInstanceIds.map((item) => item[0]), + }); + const { isLoading: historicalSummaryLoading, data: historicalSummaries = [] } = + useFetchHistoricalSummary({ + list: sloList.map((slo) => ({ sloId: slo.id, instanceId: slo.instanceId ?? ALL_VALUE })), + }); + + const navigateToClone = useCloneSlo(); + + const actions: Array> = [ + { + type: 'icon', + icon: 'inspect', + name: i18n.translate('xpack.observability.slo.item.actions.details', { + defaultMessage: 'Details', + }), + description: i18n.translate('xpack.observability.slo.item.actions.details', { + defaultMessage: 'Details', + }), + onClick: (slo: SLOWithSummaryResponse) => { + const sloDetailsUrl = basePath.prepend( + paths.observability.sloDetails( + slo.id, + slo.groupBy !== ALL_VALUE && slo.instanceId ? slo.instanceId : undefined + ) + ); + navigateToUrl(sloDetailsUrl); + }, + }, + { + type: 'icon', + icon: 'pencil', + name: i18n.translate('xpack.observability.slo.item.actions.edit', { + defaultMessage: 'Edit', + }), + description: i18n.translate('xpack.observability.slo.item.actions.edit', { + defaultMessage: 'Edit', + }), + 'data-test-subj': 'sloActionsEdit', + enabled: (_) => hasWriteCapabilities, + onClick: (slo: SLOWithSummaryResponse) => { + navigateToUrl(basePath.prepend(paths.observability.sloEdit(slo.id))); + }, + }, + { + type: 'icon', + icon: 'bell', + name: i18n.translate('xpack.observability.slo.item.actions.createRule', { + defaultMessage: 'Create new alert rule', + }), + description: i18n.translate('xpack.observability.slo.item.actions.createRule', { + defaultMessage: 'Create new alert rule', + }), + 'data-test-subj': 'sloActionsCreateRule', + enabled: (_) => hasWriteCapabilities, + onClick: (slo: SLOWithSummaryResponse) => { + setSloToAddRule(slo); + }, + }, + { + type: 'icon', + icon: 'gear', + name: i18n.translate('xpack.observability.slo.item.actions.manageRules', { + defaultMessage: 'Manage rules', + }), + description: i18n.translate('xpack.observability.slo.item.actions.manageRules', { + defaultMessage: 'Manage rules', + }), + 'data-test-subj': 'sloActionsManageRules', + enabled: (_) => hasWriteCapabilities, + onClick: (slo: SLOWithSummaryResponse) => { + const locator = locators.get(rulesLocatorID); + locator?.navigate({ params: { sloId: slo.id } }, { replace: false }); + }, + }, + { + type: 'icon', + icon: 'copy', + name: i18n.translate('xpack.observability.slo.item.actions.clone', { + defaultMessage: 'Clone', + }), + description: i18n.translate('xpack.observability.slo.item.actions.clone', { + defaultMessage: 'Clone', + }), + 'data-test-subj': 'sloActionsClone', + enabled: (_) => hasWriteCapabilities, + onClick: (slo: SLOWithSummaryResponse) => { + navigateToClone(slo); + }, + }, + { + type: 'icon', + icon: 'trash', + name: i18n.translate('xpack.observability.slo.item.actions.delete', { + defaultMessage: 'Delete', + }), + description: i18n.translate('xpack.observability.slo.item.actions.delete', { + defaultMessage: 'Delete', + }), + 'data-test-subj': 'sloActionsDelete', + enabled: (_) => hasWriteCapabilities, + onClick: (slo: SLOWithSummaryResponse) => setSloToDelete(slo), + }, + ]; + + const columns: Array> = [ + { + field: 'status', + name: 'Status', + render: (_, slo: SLOWithSummaryResponse) => + !slo.summary ? ( + + ) : ( + + + + ), + }, + { + field: 'alerts', + name: 'Alerts', + truncateText: true, + width: '5%', + render: (_, slo: SLOWithSummaryResponse) => ( + <> + setSloToAddRule(slo)} /> + + + ), + }, + { + field: 'name', + name: 'Name', + width: '15%', + truncateText: { lines: 2 }, + 'data-test-subj': 'sloItem', + render: (_, slo: SLOWithSummaryResponse) => { + const sloDetailsUrl = basePath.prepend( + paths.observability.sloDetails( + slo.id, + slo.groupBy !== ALL_VALUE && slo.instanceId ? slo.instanceId : undefined + ) + ); + return ( + + + {slo.summary ? ( + + {slo.name} + + ) : ( + {slo.name} + )} + + + ); + }, + }, + { + field: 'tags', + name: 'Tags', + render: (tags: string[]) => , + }, + { + field: 'instance', + name: 'Instance', + render: (_, slo: SLOWithSummaryResponse) => + slo.groupBy !== ALL_VALUE ? ( + + {slo.instanceId} + + ) : ( + {NOT_AVAILABLE_LABEL} + ), + }, + { + field: 'objective', + name: 'Objective', + render: (_, slo: SLOWithSummaryResponse) => numeral(slo.objective.target).format('0.00%'), + }, + { + field: 'sli', + name: 'SLI value', + truncateText: true, + render: (_, slo: SLOWithSummaryResponse) => + !slo.summary || slo.summary.status === 'NO_DATA' + ? NOT_AVAILABLE_LABEL + : numeral(slo.summary.sliValue).format(percentFormat), + }, + { + field: 'historicalSli', + name: 'Historical SLI', + render: (_, slo: SLOWithSummaryResponse) => { + const isSloFailed = + (slo.summary && slo.summary.status === 'VIOLATED') || + (slo.summary && slo.summary.status === 'DEGRADING'); + const historicalSliData = formatHistoricalData( + historicalSummaries.find( + (historicalSummary) => + historicalSummary.sloId === slo.id && + historicalSummary.instanceId === (slo.instanceId ?? ALL_VALUE) + )?.data, + 'sli_value' + ); + return ( + + ); + }, + }, + { + field: 'errorBudgetRemaining', + name: 'Budget remaining', + truncateText: true, + render: (_, slo: SLOWithSummaryResponse) => + !slo.summary || slo.summary.status === 'NO_DATA' + ? NOT_AVAILABLE_LABEL + : numeral(slo.summary.errorBudget.remaining).format(percentFormat), + }, + { + field: 'historicalErrorBudgetRemaining', + name: 'Historical budget remaining', + render: (_, slo: SLOWithSummaryResponse) => { + const isSloFailed = + (slo.summary && slo.summary.status === 'VIOLATED') || + (slo.summary && slo.summary.status === 'DEGRADING'); + const errorBudgetBurnDownData = formatHistoricalData( + historicalSummaries.find( + (historicalSummary) => + historicalSummary.sloId === slo.id && + historicalSummary.instanceId === (slo.instanceId ?? ALL_VALUE) + )?.data, + 'error_budget_remaining' + ); + return ( + + ); + }, + }, + + { + name: 'Actions', + actions, + width: '5%', + }, + ]; + + if (!loading && !error && sloList.length === 0) { + return ; + } + + if (!loading && error) { + return ; + } + + return ( + <> + + items={sloList} + columns={columns} + loading={loading} + noItemsMessage={loading ? LOADING_SLOS_LABEL : NO_SLOS_FOUND} + tableLayout="auto" + /> + {sloToAddRule ? ( + { + setSloToAddRule(undefined); + }} + useRuleProducer + /> + ) : null} + + {sloToDelete ? ( + + ) : null} + + ); +} + +const LOADING_SLOS_LABEL = i18n.translate('xpack.observability.slo.loadingSlosLabel', { + defaultMessage: 'Loading SLOs ...', +}); + +const NO_SLOS_FOUND = i18n.translate('xpack.observability.slo.noSlosFound', { + defaultMessage: 'No SLOs found', +}); diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/grouped_slos/group_list_empty.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/grouped_slos/group_list_empty.tsx new file mode 100644 index 0000000000000..f23ff645b8fbd --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/grouped_slos/group_list_empty.tsx @@ -0,0 +1,27 @@ +/* + * 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 { EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export function SloGroupListEmpty() { + return ( + + {i18n.translate('xpack.observability.slo.groupList.emptyMessage', { + defaultMessage: 'There are no results for your criteria.', + })} + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/grouped_slos/group_list_error.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/grouped_slos/group_list_error.tsx new file mode 100644 index 0000000000000..d67ef0548a949 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/grouped_slos/group_list_error.tsx @@ -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. + */ +/* + * 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 { EuiEmptyPrompt } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export function SloGroupListError() { + return ( + + {i18n.translate('xpack.observability.slo.groupList.errorTitle', { + defaultMessage: 'Unable to load SLO groups', + })} + + } + body={ +

+ {i18n.translate('xpack.observability.slo.groupList.errorMessage', { + defaultMessage: + 'There was an error loading the SLO groups. Contact your administrator for help.', + })} +

+ } + /> + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/grouped_slos/group_list_view.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/grouped_slos/group_list_view.tsx new file mode 100644 index 0000000000000..1651dcd5c90e8 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/grouped_slos/group_list_view.tsx @@ -0,0 +1,196 @@ +/* + * 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 { + EuiAccordion, + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiPanel, + EuiSpacer, + EuiTablePagination, + EuiText, + EuiTextColor, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +import { Filter } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; +import React, { memo, useState } from 'react'; +import { GroupSummary } from '@kbn/slo-schema'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { CoreStart } from '@kbn/core-lifecycle-browser'; +import { paths } from '../../../../../common/locators/paths'; +import { useFetchSloList } from '../../../../hooks/use_fetch_slo_list'; +import { SLI_OPTIONS } from '../../../slo_edit/constants'; +import { useSloFormattedSLIValue } from '../../../../hooks/use_slo_summary'; +import { SlosView } from '../slos_view'; +import type { SortDirection } from '../slo_list_search_bar'; +import { SLOView } from '../toggle_slo_view'; + +interface Props { + group: string; + kqlQuery: string; + sloView: SLOView; + sort: string; + direction: SortDirection; + groupBy: string; + summary: GroupSummary; + filters: Filter[]; +} + +export function GroupListView({ + group, + kqlQuery, + sloView, + sort, + direction, + groupBy, + summary, + filters, +}: Props) { + const query = kqlQuery ? `"${groupBy}": (${group}) and ${kqlQuery}` : `"${groupBy}": ${group}`; + let groupName = group.toLowerCase(); + if (groupBy === 'slo.indicator.type') { + groupName = SLI_OPTIONS.find((option) => option.value === group)?.text ?? group; + } + + const [page, setPage] = useState(0); + const [accordionState, setAccordionState] = useState<'open' | 'closed'>('closed'); + const onToggle = (isOpen: boolean) => { + const newState = isOpen ? 'open' : 'closed'; + setAccordionState(newState); + }; + const isAccordionOpen = accordionState === 'open'; + + const { + http: { basePath }, + } = useKibana().services; + + const [itemsPerPage, setItemsPerPage] = useState(10); + const { + isLoading, + isRefetching, + isError, + data: sloList, + } = useFetchSloList({ + kqlQuery: query, + sortBy: sort, + sortDirection: direction, + perPage: itemsPerPage, + page: page + 1, + filters, + disabled: !isAccordionOpen, + }); + const { results = [], total = 0 } = sloList ?? {}; + + const handlePageClick = (pageNumber: number) => { + setPage(pageNumber); + }; + + const worstSLI = useSloFormattedSLIValue(summary.worst.sliValue); + + return ( + <> + + + + + + +

{groupName}

+
+
+ + ({summary.total}) + +
+ } + extraAction={ + + {summary.violated > 0 && ( + + + {i18n.translate('xpack.slos.groupListView.', { defaultMessage: '' })} + + + )} + + + {i18n.translate('xpack.slos.groupListView.', { defaultMessage: '' })} + + + + + + {i18n.translate('xpack.slos.groupListView.', { defaultMessage: '' })} + + + {i18n.translate('xpack.slos.groupListView.', { defaultMessage: '' })} + + + } + > + + {i18n.translate('xpack.slos.groupListView.', { defaultMessage: '' })} + + {worstSLI} + + + + + + } + id={group} + initialIsOpen={false} + > + {isAccordionOpen && ( + <> + + + + setItemsPerPage(perPage)} + /> + + )} + + + +
+ + + ); +} + +const MemoEuiAccordion = memo(EuiAccordion); diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/grouped_slos/group_view.test.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/grouped_slos/group_view.test.tsx new file mode 100644 index 0000000000000..46f8e454c686c --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/grouped_slos/group_view.test.tsx @@ -0,0 +1,225 @@ +/* + * 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 { render } from '../../../../utils/test_helper'; +import { useFetchSloGroups } from '../../../../hooks/use_fetch_slo_groups'; +import { useFetchSloList } from '../../../../hooks/use_fetch_slo_list'; +import { DEFAULT_SLO_GROUPS_PAGE_SIZE } from '../../../../../common/constants'; + +import { useUrlSearchState } from '../../../../hooks/use_url_search_state'; +import { GroupView } from './group_view'; + +jest.mock('../../../../hooks/use_fetch_slo_groups'); +jest.mock('../../../../hooks/use_url_search_state'); +jest.mock('../../../../hooks/use_fetch_slo_list'); + +const useFetchSloGroupsMock = useFetchSloGroups as jest.Mock; +const useUrlSearchStateMock = useUrlSearchState as jest.Mock; +const useFetchSloListMock = useFetchSloList as jest.Mock; + +describe('Group View', () => { + beforeEach(() => { + useUrlSearchStateMock.mockImplementation(() => ({ + state: { + page: 0, + perPage: DEFAULT_SLO_GROUPS_PAGE_SIZE, + }, + })); + + useFetchSloListMock.mockReturnValue({ + isLoading: false, + isError: false, + isSuccess: true, + data: { + page: 0, + perPage: 10, + total: 0, + }, + }); + }); + + it('should show error', async () => { + useFetchSloGroupsMock.mockReturnValue({ + isError: true, + isLoading: false, + }); + const { queryByTestId, getByTestId } = render( + + ); + + expect(queryByTestId('sloGroupView')).toBeNull(); + expect(getByTestId('sloGroupListError')).toBeInTheDocument(); + }); + + it('should show no results', async () => { + useFetchSloGroupsMock.mockReturnValue({ + isError: false, + isLoading: false, + data: { + page: 0, + perPage: 10, + total: 0, + results: [], + }, + }); + + const { queryByTestId, getByTestId } = render( + + ); + + expect(queryByTestId('sloGroupView')).toBeNull(); + expect(getByTestId('sloGroupListEmpty')).toBeInTheDocument(); + }); + + it('should show loading indicator', async () => { + useFetchSloGroupsMock.mockReturnValue({ + isLoading: true, + }); + + const { queryByTestId, getByTestId } = render( + + ); + expect(queryByTestId('sloGroupView')).toBeNull(); + expect(getByTestId('sloGroupListLoading')).toBeInTheDocument(); + }); + + describe('group by tags', () => { + it('should render slo groups grouped by tags', async () => { + useFetchSloGroupsMock.mockReturnValue({ + isLoading: false, + isError: false, + isSuccess: true, + data: { + page: 0, + perPage: 10, + total: 3, + results: [ + { + group: 'production', + groupBy: 'slo.tags', + summary: { total: 3, worst: 0.95, healthy: 2, violated: 1, degrading: 0, noData: 0 }, + }, + { + group: 'something', + groupBy: 'slo.tags', + summary: { total: 1, worst: 0.9, healthy: 0, violated: 1, degrading: 0, noData: 0 }, + }, + { + group: 'anything', + groupBy: 'slo.tags', + summary: { total: 2, worst: 0.85, healthy: 1, violated: 0, degrading: 0, noData: 1 }, + }, + ], + }, + }); + const { queryAllByTestId, getByTestId } = render( + + ); + expect(getByTestId('sloGroupView')).toBeInTheDocument(); + expect(useFetchSloGroups).toHaveBeenCalled(); + expect(useFetchSloGroups).toHaveBeenCalledWith({ + groupBy: 'slo.tags', + kqlQuery: '', + page: 1, + perPage: DEFAULT_SLO_GROUPS_PAGE_SIZE, + }); + expect(queryAllByTestId('sloGroupViewPanel').length).toEqual(3); + }); + + it('should render slo groups filtered by selected tags', async () => { + useUrlSearchStateMock.mockImplementation(() => ({ + state: { + tags: { + included: ['production'], + excluded: [], + }, + page: 0, + perPage: DEFAULT_SLO_GROUPS_PAGE_SIZE, + }, + })); + useFetchSloGroupsMock.mockReturnValue({ + isLoading: false, + isError: false, + isSuccess: true, + data: { + page: 0, + perPage: 10, + total: 1, + results: [ + { + group: 'production', + groupBy: 'tags', + summary: { total: 3, worst: 0.95, healthy: 2, violated: 1, degrading: 0, noData: 0 }, + }, + ], + }, + }); + + const { queryAllByTestId } = render( + + ); + expect(useFetchSloGroups).toHaveBeenCalled(); + expect(useFetchSloGroups).toHaveBeenCalledWith({ + groupBy: 'slo.tags', + kqlQuery: '', + page: 1, + perPage: DEFAULT_SLO_GROUPS_PAGE_SIZE, + }); + expect(queryAllByTestId('sloGroupViewPanel').length).toEqual(1); + }); + }); + + describe('group by status', () => { + it('should render slo groups grouped by status', async () => { + const { getByTestId } = render( + + ); + expect(getByTestId('sloGroupView')).toBeInTheDocument(); + expect(useFetchSloGroups).toHaveBeenCalled(); + expect(useFetchSloGroups).toHaveBeenCalledWith({ + groupBy: 'status', + kqlQuery: '', + page: 1, + perPage: DEFAULT_SLO_GROUPS_PAGE_SIZE, + }); + }); + }); + + describe('group by SLI indicator type', () => { + it('should render slo groups grouped by indicator type', async () => { + const { getByTestId } = render( + + ); + expect(getByTestId('sloGroupView')).toBeInTheDocument(); + expect(useFetchSloGroups).toHaveBeenCalled(); + expect(useFetchSloGroups).toHaveBeenCalledWith({ + groupBy: 'slo.indicator.type', + kqlQuery: '', + page: 1, + perPage: DEFAULT_SLO_GROUPS_PAGE_SIZE, + }); + }); + }); +}); diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/grouped_slos/group_view.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/grouped_slos/group_view.tsx new file mode 100644 index 0000000000000..b4844a8748874 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/grouped_slos/group_view.tsx @@ -0,0 +1,94 @@ +/* + * 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 { EuiEmptyPrompt, EuiFlexItem, EuiLoadingSpinner, EuiTablePagination } from '@elastic/eui'; +import React from 'react'; +import { useFetchSloGroups } from '../../../../hooks/use_fetch_slo_groups'; +import { useUrlSearchState } from '../../../../hooks/use_url_search_state'; +import type { SortDirection } from '../slo_list_search_bar'; +import { SLOView } from '../toggle_slo_view'; +import { SloGroupListEmpty } from './group_list_empty'; +import { SloGroupListError } from './group_list_error'; +import { GroupListView } from './group_list_view'; + +interface Props { + groupBy: string; + kqlQuery: string; + sloView: SLOView; + sort: string; + direction: SortDirection; +} + +export function GroupView({ kqlQuery, sloView, sort, direction, groupBy }: Props) { + const { state, onStateChange } = useUrlSearchState(); + const { tagsFilter, statusFilter, filters, page, perPage, lastRefresh } = state; + + const { data, isLoading, isError } = useFetchSloGroups({ + perPage, + page: page + 1, + groupBy, + kqlQuery, + tagsFilter, + statusFilter, + filters, + lastRefresh, + }); + const { results = [], total = 0 } = data ?? {}; + const handlePageClick = (pageNumber: number) => { + onStateChange({ page: pageNumber }); + }; + + if (isLoading) { + return ( + } + /> + ); + } + + if (!isLoading && !isError && results.length === 0) { + return ; + } + + if (!isLoading && isError) { + return ; + } + return ( + + {results && + results.map((result) => ( + + ))} + + {total > 0 ? ( + + { + onStateChange({ perPage: newPerPage }); + }} + /> + + ) : null} + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/header_title.stories.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/header_title.stories.tsx new file mode 100644 index 0000000000000..05ecb3ab4bde3 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/header_title.stories.tsx @@ -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 React from 'react'; +import { ComponentStory } from '@storybook/react'; + +import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator'; +import { HeaderTitle as Component } from './header_title'; + +export default { + component: Component, + title: 'app/SLO/ListPage/HeaderTitle', + decorators: [KibanaReactStorybookDecorator], +}; + +const Template: ComponentStory = () => ; + +const defaultProps = {}; + +export const Default = Template.bind({}); +Default.args = defaultProps; diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/header_title.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/header_title.tsx new file mode 100644 index 0000000000000..31005db0f6440 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/header_title.tsx @@ -0,0 +1,20 @@ +/* + * 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 { EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export function HeaderTitle() { + return ( + + {i18n.translate('xpack.observability.slosPageTitle', { + defaultMessage: 'SLOs', + })} + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_context_menu.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_context_menu.tsx new file mode 100644 index 0000000000000..de03eab18fbb1 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_context_menu.tsx @@ -0,0 +1,127 @@ +/* + * 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 { + EuiButtonEmpty, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiSelectableOption, + useGeneratedHtmlId, + EuiTitle, +} from '@elastic/eui'; + +import { EuiSelectableOptionCheckedType } from '@elastic/eui/src/components/selectable/selectable_option'; + +export interface Option { + label: string; + value: string; + checked: boolean; + defaultSortOrder?: string; + onClick: () => void; +} + +export interface Props { + id: string; + isPopoverOpen: boolean; + setIsPopoverOpen: (isPopoverOpen: boolean) => void; + items: JSX.Element[]; + selected: string; + label: string; + loading: boolean; +} + +export type Item = EuiSelectableOption & { + label: string; + type: T; + checked?: EuiSelectableOptionCheckedType; +}; + +export function SLOContextMenu({ + id, + isPopoverOpen, + label, + items, + selected, + setIsPopoverOpen, + loading, +}: Props) { + const singleContextMenuPopoverId = useGeneratedHtmlId({ + prefix: 'singleContextMenuPopover', + }); + + const handleTogglePopover = () => { + setIsPopoverOpen(!isPopoverOpen); + }; + + const button = ( + + {selected} + + ); + + return ( + + + + + + {label} + + + + setIsPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + + + + + + ); +} + +export function ContextMenuItem({ + option, + onClosePopover, +}: { + option: Option; + onClosePopover: () => void; +}) { + const getIconType = (checked: boolean) => { + return checked ? 'check' : 'empty'; + }; + + return ( + { + onClosePopover(); + option.onClick(); + }} + > + {option.label} + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_item_actions.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_item_actions.tsx new file mode 100644 index 0000000000000..8bda3b98bd510 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_item_actions.tsx @@ -0,0 +1,228 @@ +/* + * 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 { + EuiButtonIcon, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiPopover, + EuiButtonIconProps, + useEuiShadow, + EuiPanel, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema'; +import styled from 'styled-components'; +import { useCloneSlo } from '../../../hooks/slo/use_clone_slo'; +import { useCapabilities } from '../../../hooks/slo/use_capabilities'; +import { useKibana } from '../../../utils/kibana_react'; +import { paths } from '../../../../common/locators/paths'; +import { RulesParams } from '../../../locators/rules'; +import { rulesLocatorID } from '../../../../common'; + +interface Props { + slo: SLOWithSummaryResponse; + isActionsPopoverOpen: boolean; + setIsActionsPopoverOpen: (value: boolean) => void; + setDeleteConfirmationModalOpen: (value: boolean) => void; + setIsAddRuleFlyoutOpen: (value: boolean) => void; + setDashboardAttachmentReady?: (value: boolean) => void; + btnProps?: Partial; +} +const CustomShadowPanel = styled(EuiPanel)<{ shadow: string }>` + ${(props) => props.shadow} +`; + +function IconPanel({ children, hasPanel }: { children: JSX.Element; hasPanel: boolean }) { + const shadow = useEuiShadow('s'); + if (!hasPanel) return children; + return ( + + {children} + + ); +} + +export function SloItemActions({ + slo, + isActionsPopoverOpen, + setIsActionsPopoverOpen, + setIsAddRuleFlyoutOpen, + setDeleteConfirmationModalOpen, + setDashboardAttachmentReady, + btnProps, +}: Props) { + const { + application: { navigateToUrl }, + http: { basePath }, + share: { + url: { locators }, + }, + } = useKibana().services; + const { hasWriteCapabilities } = useCapabilities(); + + const sloDetailsUrl = basePath.prepend( + paths.observability.sloDetails( + slo.id, + slo.groupBy !== ALL_VALUE && slo.instanceId ? slo.instanceId : undefined + ) + ); + + const handleClickActions = () => { + setIsActionsPopoverOpen(!isActionsPopoverOpen); + }; + + const handleViewDetails = () => { + navigateToUrl(sloDetailsUrl); + }; + + const handleEdit = () => { + navigateToUrl(basePath.prepend(paths.observability.sloEdit(slo.id))); + }; + + const navigateToClone = useCloneSlo(); + + const handleClone = () => { + navigateToClone(slo); + }; + + const handleNavigateToRules = async () => { + const locator = locators.get(rulesLocatorID); + locator?.navigate({ params: { sloId: slo.id } }, { replace: false }); + }; + + const handleDelete = () => { + setDeleteConfirmationModalOpen(true); + setIsActionsPopoverOpen(false); + }; + + const handleCreateRule = () => { + setIsActionsPopoverOpen(false); + setIsAddRuleFlyoutOpen(true); + }; + + const handleAttachToDashboard = () => { + setIsActionsPopoverOpen(false); + if (setDashboardAttachmentReady) { + setDashboardAttachmentReady(true); + } + }; + + const btn = ( + + ); + + return ( + {btn} : btn} + panelPaddingSize="m" + closePopover={handleClickActions} + isOpen={isActionsPopoverOpen} + > + + {i18n.translate('xpack.observability.slo.item.actions.details', { + defaultMessage: 'Details', + })} + , + + {i18n.translate('xpack.observability.slo.item.actions.edit', { + defaultMessage: 'Edit', + })} + , + + {i18n.translate('xpack.observability.slo.item.actions.createRule', { + defaultMessage: 'Create new alert rule', + })} + , + + {i18n.translate('xpack.observability.slo.item.actions.manageRules', { + defaultMessage: 'Manage rules', + })} + , + + {i18n.translate('xpack.observability.slo.item.actions.clone', { + defaultMessage: 'Clone', + })} + , + + {i18n.translate('xpack.observability.slo.item.actions.delete', { + defaultMessage: 'Delete', + })} + , + + {i18n.translate('xpack.observability.slo.item.actions.attachToDashboard', { + defaultMessage: 'Attach to Dashboard', + })} + , + ]} + /> + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list.stories.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list.stories.tsx new file mode 100644 index 0000000000000..3e6dd1c87d798 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list.stories.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { ComponentStory } from '@storybook/react'; + +import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator'; +import { SloList as Component } from './slo_list'; + +export default { + component: Component, + title: 'app/SLO/ListPage/SloList', + argTypes: {}, + decorators: [KibanaReactStorybookDecorator], +}; + +const Template: ComponentStory = () => ; + +const defaultProps = {}; + +export const SloList = Template.bind({}); +SloList.args = defaultProps; diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list.tsx new file mode 100644 index 0000000000000..b73429486a793 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list.tsx @@ -0,0 +1,139 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiTablePagination } from '@elastic/eui'; +import { useIsMutating } from '@tanstack/react-query'; +import React, { useEffect } from 'react'; +import dedent from 'dedent'; +import { groupBy as _groupBy, mapValues } from 'lodash'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useFetchSloList } from '../../../hooks/use_fetch_slo_list'; +import { SearchState, useUrlSearchState } from '../../../hooks/use_url_search_state'; +import { SlosView } from './slos_view'; +import { ToggleSLOView } from './toggle_slo_view'; +import { GroupView } from './grouped_slos/group_view'; + +export function SloList() { + const { state, onStateChange: storeState } = useUrlSearchState(); + const { view, page, perPage, kqlQuery, filters, tagsFilter, statusFilter, groupBy } = state; + + const { + isLoading, + isRefetching, + isError, + data: sloList, + } = useFetchSloList({ + tagsFilter, + statusFilter, + perPage, + filters, + page: page + 1, + kqlQuery, + sortBy: state.sort.by, + sortDirection: state.sort.direction, + lastRefresh: state.lastRefresh, + }); + + const { + observabilityAIAssistant: { + service: { setScreenContext }, + }, + } = useKibana().services; + const { results = [], total = 0 } = sloList ?? {}; + + const isDeletingSlo = Boolean(useIsMutating(['deleteSlo'])); + + const onStateChange = (newState: Partial) => { + storeState({ page: 0, ...newState }); + }; + + useEffect(() => { + if (!sloList) { + return; + } + + const slosByStatus = mapValues( + _groupBy(sloList.results, (result) => result.summary.status), + (groupResults) => groupResults.map((result) => `- ${result.name}`).join('\n') + ) as Record; + + return setScreenContext({ + screenDescription: dedent(`The user is looking at a list of SLOs. + + ${ + sloList.total >= 1 + ? `There are ${sloList.total} SLOs. Out of those, ${sloList.results.length} are visible. + + Violating SLOs: + ${slosByStatus.VIOLATED} + + Degrading SLOs: + ${slosByStatus.DEGRADING} + + Healthy SLOs: + ${slosByStatus.HEALTHY} + + SLOs without data: + ${slosByStatus.NO_DATA} + + ` + : '' + } + `), + }); + }, [sloList, setScreenContext]); + + return ( + + + onStateChange({ view: newView })} + onStateChange={onStateChange} + state={state} + loading={isLoading || isDeletingSlo} + /> + + {groupBy === 'ungrouped' && ( + <> + + {total > 0 ? ( + + { + onStateChange({ page: newPage }); + }} + itemsPerPage={perPage} + itemsPerPageOptions={[10, 25, 50, 100]} + onChangeItemsPerPage={(newPerPage) => { + onStateChange({ perPage: newPerPage }); + }} + /> + + ) : null} + + )} + {groupBy !== 'ungrouped' && ( + + )} + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_empty.stories.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_empty.stories.tsx new file mode 100644 index 0000000000000..b8c49050143fc --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_empty.stories.tsx @@ -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 React from 'react'; +import { ComponentStory } from '@storybook/react'; + +import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator'; +import { SloListEmpty as Component } from './slo_list_empty'; + +export default { + component: Component, + title: 'app/SLO/ListPage/SloListEmpty', + decorators: [KibanaReactStorybookDecorator], +}; + +const Template: ComponentStory = () => ; + +const defaultProps = {}; + +export const SloListEmpty = Template.bind({}); +SloListEmpty.args = defaultProps; diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_empty.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_empty.tsx new file mode 100644 index 0000000000000..22bbaf11bdf84 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_empty.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export function SloListEmpty() { + return ( + + {i18n.translate('xpack.observability.slo.list.emptyMessage', { + defaultMessage: 'There are no results for your criteria.', + })} + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_error.stories.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_error.stories.tsx new file mode 100644 index 0000000000000..a3e2503449da8 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_error.stories.tsx @@ -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 React from 'react'; +import { ComponentStory } from '@storybook/react'; + +import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator'; +import { SloListError as Component } from './slo_list_error'; + +export default { + component: Component, + title: 'app/SLO/ListPage/SloListError', + decorators: [KibanaReactStorybookDecorator], +}; + +const Template: ComponentStory = () => ; + +const defaultProps = {}; + +export const SloListError = Template.bind({}); +SloListError.args = defaultProps; diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_error.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_error.tsx new file mode 100644 index 0000000000000..171ceeb341b2d --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_error.tsx @@ -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 React from 'react'; +import { EuiEmptyPrompt } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export function SloListError() { + return ( + + {i18n.translate('xpack.observability.slo.list.errorTitle', { + defaultMessage: 'Unable to load SLOs', + })} + + } + body={ +

+ {i18n.translate('xpack.observability.slo.list.errorMessage', { + defaultMessage: + 'There was an error loading the SLOs. Contact your administrator for help.', + })} +

+ } + /> + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_group_by.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_group_by.tsx new file mode 100644 index 0000000000000..40b9f79acfe7f --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_group_by.tsx @@ -0,0 +1,116 @@ +/* + * 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. + */ + +/* + * 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 { EuiPanel, EuiSelectableOption, EuiText } from '@elastic/eui'; +import { EuiSelectableOptionCheckedType } from '@elastic/eui/src/components/selectable/selectable_option'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import type { SearchState } from '../../../hooks/use_url_search_state'; +import type { Option } from './slo_context_menu'; +import { ContextMenuItem, SLOContextMenu } from './slo_context_menu'; + +export type GroupByField = 'ungrouped' | 'slo.tags' | 'status' | 'slo.indicator.type'; +export interface Props { + onStateChange: (newState: Partial) => void; + state: SearchState; + loading: boolean; +} + +export type Item = EuiSelectableOption & { + label: string; + type: T; + checked?: EuiSelectableOptionCheckedType; +}; + +export function SloGroupBy({ onStateChange, state, loading }: Props) { + const [isGroupByPopoverOpen, setIsGroupByPopoverOpen] = useState(false); + const groupBy = state.groupBy; + + const handleChangeGroupBy = (value: GroupByField) => { + onStateChange({ + page: 0, + groupBy: value, + }); + }; + const groupByOptions: Option[] = [ + { + label: NONE_LABEL, + checked: groupBy === 'ungrouped', + value: 'ungrouped', + onClick: () => { + handleChangeGroupBy('ungrouped'); + }, + }, + { + label: i18n.translate('xpack.slos.sloGroupBy.', { defaultMessage: '' }), + checked: groupBy === 'slo.tags', + value: 'slo.tags', + onClick: () => { + handleChangeGroupBy('slo.tags'); + }, + }, + { + label: i18n.translate('xpack.slos.sloGroupBy.', { defaultMessage: '' }), + checked: groupBy === 'status', + value: 'status', + onClick: () => { + handleChangeGroupBy('status'); + }, + }, + { + label: i18n.translate('xpack.slos.sloGroupBy.', { defaultMessage: '' }), + checked: groupBy === 'slo.indicator.type', + value: 'slo.indicator.type', + onClick: () => { + handleChangeGroupBy('slo.indicator.type'); + }, + }, + ]; + + const items = [ + + +

{GROUP_TITLE}

+
+
, + + ...groupByOptions.map((option) => ( + setIsGroupByPopoverOpen(false)} + key={option.value} + /> + )), + ]; + + return ( + option.value === groupBy)?.label || NONE_LABEL} + isPopoverOpen={isGroupByPopoverOpen} + setIsPopoverOpen={setIsGroupByPopoverOpen} + label={GROUP_TITLE} + loading={loading} + /> + ); +} + +// TODO rename slos to slo +export const NONE_LABEL = i18n.translate('xpack.slos.list.groupBy.sliIndicator', { + defaultMessage: 'None', +}); + +export const GROUP_TITLE = i18n.translate('xpack.slos.groupPopover.group.title', { + defaultMessage: 'Group by', +}); diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_item.stories.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_item.stories.tsx new file mode 100644 index 0000000000000..2b9b289eda626 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_item.stories.tsx @@ -0,0 +1,36 @@ +/* + * 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 { ComponentStory } from '@storybook/react'; + +import { + HEALTHY_ROLLING_SLO, + historicalSummaryData, +} from '../../../data/slo/historical_summary_data'; +import { buildSlo } from '../../../data/slo/slo'; +import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator'; +import { SloListItem as Component, SloListItemProps } from './slo_list_item'; + +export default { + component: Component, + title: 'app/SLO/ListPage/SloListItem', + decorators: [KibanaReactStorybookDecorator], +}; + +const Template: ComponentStory = (props: SloListItemProps) => ( + +); + +const defaultProps = { + slo: buildSlo(), + historicalSummary: historicalSummaryData.find((datum) => datum.sloId === HEALTHY_ROLLING_SLO)! + .data, +}; + +export const SloListItem = Template.bind({}); +SloListItem.args = defaultProps; diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_item.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_item.tsx new file mode 100644 index 0000000000000..dc3865af00050 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_item.tsx @@ -0,0 +1,117 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; +import { HistoricalSummaryResponse, SLOWithSummaryResponse } from '@kbn/slo-schema'; +import type { Rule } from '@kbn/triggers-actions-ui-plugin/public'; +import React, { useState } from 'react'; + +import { SloDeleteConfirmationModal } from '../../../components/slo/delete_confirmation_modal/slo_delete_confirmation_modal'; +import { useSloFormattedSummary } from '../hooks/use_slo_summary'; +import { BurnRateRuleFlyout } from './common/burn_rate_rule_flyout'; +import { useSloListActions } from '../hooks/use_slo_list_actions'; +import { SloItemActions } from './slo_item_actions'; +import type { SloRule } from '../../../hooks/slo/use_fetch_rules_for_slo'; +import { SloBadges } from './badges/slo_badges'; +import { SloSummary } from './slo_summary'; + +export interface SloListItemProps { + slo: SLOWithSummaryResponse; + rules: Array> | undefined; + historicalSummary?: HistoricalSummaryResponse[]; + historicalSummaryLoading: boolean; + activeAlerts?: number; +} + +export function SloListItem({ + slo, + rules, + historicalSummary = [], + historicalSummaryLoading, + activeAlerts, +}: SloListItemProps) { + const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false); + const [isAddRuleFlyoutOpen, setIsAddRuleFlyoutOpen] = useState(false); + const [isDeleteConfirmationModalOpen, setDeleteConfirmationModalOpen] = useState(false); + + const { sloDetailsUrl } = useSloFormattedSummary(slo); + + const { handleCreateRule, handleDeleteCancel, handleDeleteConfirm } = useSloListActions({ + slo, + setDeleteConfirmationModalOpen, + setIsActionsPopoverOpen, + setIsAddRuleFlyoutOpen, + }); + + return ( + + + {/* CONTENT */} + + + + + + + {slo.summary ? ( + + {slo.name} + + ) : ( + {slo.name} + )} + + + + + + + + {slo.summary ? ( + + ) : null} + + + + + {/* ACTIONS */} + + + + + + + {isDeleteConfirmationModalOpen ? ( + + ) : null} + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_search_bar.stories.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_search_bar.stories.tsx new file mode 100644 index 0000000000000..0d36156cfeced --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_search_bar.stories.tsx @@ -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 React from 'react'; +import { ComponentStory } from '@storybook/react'; + +import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator'; +import { SloListSearchBar as Component } from './slo_list_search_bar'; + +export default { + component: Component, + title: 'app/SLO/ListPage/SloListSearchBar', + decorators: [KibanaReactStorybookDecorator], +}; + +const Template: ComponentStory = () => ; + +export const SloListSearchBar = Template.bind({}); diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_search_bar.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_search_bar.tsx new file mode 100644 index 0000000000000..aadfac7adc4ee --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_search_bar.tsx @@ -0,0 +1,94 @@ +/* + * 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. + */ + +/* + * 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 { EuiSelectableOption } from '@elastic/eui'; +import { EuiSelectableOptionCheckedType } from '@elastic/eui/src/components/selectable/selectable_option'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import styled from 'styled-components'; +import { useIsMutating } from '@tanstack/react-query'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { QuickFilters } from './common/quick_filters'; +import { SLO_SUMMARY_DESTINATION_INDEX_NAME } from '../../../../common/constants'; +import { useCreateDataView } from '../../../hooks/use_create_data_view'; +import { ObservabilityPublicPluginsStart } from '../../..'; +import { SearchState, useUrlSearchState } from '../../../hooks/use_url_search_state'; + +export type SortField = 'sli_value' | 'error_budget_consumed' | 'error_budget_remaining' | 'status'; +export type SortDirection = 'asc' | 'desc'; + +export type Item = EuiSelectableOption & { + label: string; + type: T; + checked?: EuiSelectableOptionCheckedType; +}; + +export type ViewMode = 'default' | 'compact'; + +export function SloListSearchBar() { + const { state, onStateChange: onChange } = useUrlSearchState(); + const { kqlQuery, filters } = state; + + const containerRef = React.useRef(null); + + const isDeletingSlo = Boolean(useIsMutating(['deleteSlo'])); + const loading = isDeletingSlo; + + const { dataView } = useCreateDataView({ + indexPatternString: SLO_SUMMARY_DESTINATION_INDEX_NAME, + }); + + const onStateChange = (newState: Partial) => { + onChange({ page: 0, ...newState }); + }; + + const { + unifiedSearch: { + ui: { SearchBar }, + }, + } = useKibana().services; + + return ( + + ( + + )} + filters={filters} + onFiltersUpdated={(newFilters) => { + onStateChange({ filters: newFilters }); + }} + onQuerySubmit={({ query: value }) => { + onStateChange({ kqlQuery: String(value?.query), lastRefresh: Date.now() }); + }} + query={{ query: String(kqlQuery), language: 'kuery' }} + showSubmitButton={true} + showDatePicker={false} + showQueryInput={true} + disableQueryLanguageSwitcher={true} + saveQueryMenuVisibility="globally_managed" + /> + + ); +} + +const Container = styled.div` + .uniSearchBar { + padding: 0; + } +`; diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_view/slo_list_view.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_view/slo_list_view.tsx new file mode 100644 index 0000000000000..f353f4743775a --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_list_view/slo_list_view.tsx @@ -0,0 +1,66 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema'; +import React from 'react'; +import { useFetchActiveAlerts } from '../../../../hooks/slo/use_fetch_active_alerts'; +import { useFetchHistoricalSummary } from '../../../../hooks/slo/use_fetch_historical_summary'; +import { useFetchRulesForSlo } from '../../../../hooks/slo/use_fetch_rules_for_slo'; +import { SloListEmpty } from '../slo_list_empty'; +import { SloListError } from '../slo_list_error'; +import { SloListItem } from '../slo_list_item'; + +export interface Props { + sloList: SLOWithSummaryResponse[]; + loading: boolean; + error: boolean; +} + +export function SloListView({ sloList, loading, error }: Props) { + const sloIdsAndInstanceIds = sloList.map( + (slo) => [slo.id, slo.instanceId ?? ALL_VALUE] as [string, string] + ); + const { data: activeAlertsBySlo } = useFetchActiveAlerts({ sloIdsAndInstanceIds }); + const { data: rulesBySlo } = useFetchRulesForSlo({ + sloIds: sloIdsAndInstanceIds.map((item) => item[0]), + }); + const { isLoading: historicalSummaryLoading, data: historicalSummaries = [] } = + useFetchHistoricalSummary({ + list: sloList.map((slo) => ({ sloId: slo.id, instanceId: slo.instanceId ?? ALL_VALUE })), + }); + + if (!loading && !error && sloList.length === 0) { + return ; + } + + if (!loading && error) { + return ; + } + + return ( + + {sloList.map((slo) => ( + + + historicalSummary.sloId === slo.id && + historicalSummary.instanceId === (slo.instanceId ?? ALL_VALUE) + )?.data + } + historicalSummaryLoading={historicalSummaryLoading} + slo={slo} + /> + + ))} + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_sparkline.stories.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_sparkline.stories.tsx new file mode 100644 index 0000000000000..0f2eeee498fba --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_sparkline.stories.tsx @@ -0,0 +1,142 @@ +/* + * 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 { HistoricalSummaryResponse } from '@kbn/slo-schema'; +import { ComponentStory } from '@storybook/react'; +import React from 'react'; +import { + DEGRADING_FAST_ROLLING_SLO, + HEALTHY_RANDOM_ROLLING_SLO, + HEALTHY_ROLLING_SLO, + HEALTHY_STEP_DOWN_ROLLING_SLO, + historicalSummaryData, + NO_DATA_TO_HEALTHY_ROLLING_SLO, +} from '../../../data/slo/historical_summary_data'; +import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator'; +import { Props, SloSparkline as Component } from './slo_sparkline'; + +export default { + component: Component, + title: 'app/SLO/ListPage/SloSparkline', + decorators: [KibanaReactStorybookDecorator], +}; + +const Template: ComponentStory = (props: Props) => ; + +export const AreaWithHealthyFlatData = Template.bind({}); +AreaWithHealthyFlatData.args = { + chart: 'area', + state: 'success', + id: 'history', + data: toBudgetBurnDown( + historicalSummaryData.find((datum) => datum.sloId === HEALTHY_ROLLING_SLO)!.data + ), +}; + +export const AreaWithHealthyRandomData = Template.bind({}); +AreaWithHealthyRandomData.args = { + chart: 'area', + state: 'success', + id: 'history', + data: toBudgetBurnDown( + historicalSummaryData.find((datum) => datum.sloId === HEALTHY_RANDOM_ROLLING_SLO)!.data + ), +}; + +export const AreaWithHealthyStepDownData = Template.bind({}); +AreaWithHealthyStepDownData.args = { + chart: 'area', + state: 'success', + id: 'history', + data: toBudgetBurnDown( + historicalSummaryData.find((datum) => datum.sloId === HEALTHY_STEP_DOWN_ROLLING_SLO)!.data + ), +}; + +export const AreaWithDegradingLinearData = Template.bind({}); +AreaWithDegradingLinearData.args = { + chart: 'area', + state: 'error', + id: 'history', + data: toBudgetBurnDown( + historicalSummaryData.find((datum) => datum.sloId === DEGRADING_FAST_ROLLING_SLO)!.data + ), +}; + +export const AreaWithNoDataToDegradingLinearData = Template.bind({}); +AreaWithNoDataToDegradingLinearData.args = { + chart: 'area', + state: 'error', + id: 'history', + data: toBudgetBurnDown( + historicalSummaryData.find((datum) => datum.sloId === NO_DATA_TO_HEALTHY_ROLLING_SLO)!.data + ), +}; + +export const LineWithHealthyFlatData = Template.bind({}); +LineWithHealthyFlatData.args = { + chart: 'line', + state: 'success', + id: 'history', + data: toSliHistory( + historicalSummaryData.find((datum) => datum.sloId === HEALTHY_ROLLING_SLO)!.data + ), +}; + +export const LineWithHealthyRandomData = Template.bind({}); +LineWithHealthyRandomData.args = { + chart: 'line', + state: 'success', + id: 'history', + data: toSliHistory( + historicalSummaryData.find((datum) => datum.sloId === HEALTHY_RANDOM_ROLLING_SLO)!.data + ), +}; + +export const LineWithHealthyStepDownData = Template.bind({}); +LineWithHealthyStepDownData.args = { + chart: 'line', + state: 'success', + id: 'history', + data: toSliHistory( + historicalSummaryData.find((datum) => datum.sloId === HEALTHY_STEP_DOWN_ROLLING_SLO)!.data + ), +}; + +export const LineWithDegradingLinearData = Template.bind({}); +LineWithDegradingLinearData.args = { + chart: 'line', + state: 'error', + id: 'history', + data: toSliHistory( + historicalSummaryData.find((datum) => datum.sloId === DEGRADING_FAST_ROLLING_SLO)!.data + ), +}; + +export const LineWithNoDataToDegradingLinearData = Template.bind({}); +LineWithNoDataToDegradingLinearData.args = { + chart: 'line', + state: 'error', + id: 'history', + data: toSliHistory( + historicalSummaryData.find((datum) => datum.sloId === NO_DATA_TO_HEALTHY_ROLLING_SLO)!.data + ), +}; + +function toBudgetBurnDown(data: HistoricalSummaryResponse[]) { + return data.map((datum) => ({ + key: new Date(datum.date).getTime(), + value: datum.status === 'NO_DATA' ? undefined : datum.errorBudget.remaining, + })); +} + +function toSliHistory(data: HistoricalSummaryResponse[]) { + return data.map((datum) => ({ + key: new Date(datum.date).getTime(), + value: datum.status === 'NO_DATA' ? undefined : datum.sliValue, + })); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_sparkline.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_sparkline.tsx new file mode 100644 index 0000000000000..0bb96b39d0649 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_sparkline.tsx @@ -0,0 +1,94 @@ +/* + * 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 { + AreaSeries, + Axis, + Chart, + Fit, + LineSeries, + ScaleType, + Settings, + Tooltip, + TooltipType, +} from '@elastic/charts'; +import React from 'react'; +import { EuiLoadingChart, useEuiTheme } from '@elastic/eui'; +import { EUI_SPARKLINE_THEME_PARTIAL } from '@elastic/eui/dist/eui_charts_theme'; + +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../utils/kibana_react'; + +interface Data { + key: number; + value: number | undefined; +} +type ChartType = 'area' | 'line'; +type State = 'success' | 'error'; + +export interface Props { + id: string; + data: Data[]; + chart: ChartType; + state: State; + size?: 'compact' | 'default'; + isLoading: boolean; +} + +export function SloSparkline({ chart, data, id, isLoading, size, state }: Props) { + const charts = useKibana().services.charts; + const baseTheme = charts.theme.useChartsBaseTheme(); + + const { euiTheme } = useEuiTheme(); + + const color = state === 'error' ? euiTheme.colors.danger : euiTheme.colors.success; + const ChartComponent = chart === 'area' ? AreaSeries : LineSeries; + + if (isLoading) { + return ; + } + + const height = size === 'compact' ? 14 : 28; + const width = size === 'compact' ? 40 : 60; + + return ( + + + + + + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_summary.stories.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_summary.stories.tsx new file mode 100644 index 0000000000000..13d6365232ac2 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_summary.stories.tsx @@ -0,0 +1,37 @@ +/* + * 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 { ComponentStory } from '@storybook/react'; +import React from 'react'; +import { + HEALTHY_ROLLING_SLO, + historicalSummaryData, +} from '../../../data/slo/historical_summary_data'; +import { buildSlo } from '../../../data/slo/slo'; +import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator'; +import { Props, SloSummary as Component } from './slo_summary'; + +export default { + component: Component, + title: 'app/SLO/ListPage/SloSummary', + decorators: [KibanaReactStorybookDecorator], +}; + +const Template: ComponentStory = (props: Props) => ; + +const defaultProps = { + slo: buildSlo(), + historicalSummary: historicalSummaryData.find((datum) => datum.sloId === HEALTHY_ROLLING_SLO)! + .data, + historicalSummaryLoading: false, +}; + +export const WithHistoricalData = Template.bind({}); +WithHistoricalData.args = { ...defaultProps }; + +export const WithLoadingData = Template.bind({}); +WithLoadingData.args = { ...defaultProps, historicalSummaryLoading: true }; diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_summary.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_summary.tsx new file mode 100644 index 0000000000000..77d23d6301aed --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slo_summary.tsx @@ -0,0 +1,97 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiStat } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { HistoricalSummaryResponse, SLOWithSummaryResponse } from '@kbn/slo-schema'; + +import { useSloFormattedSummary } from '../hooks/use_slo_summary'; +import { formatHistoricalData } from '../../../utils/slo/chart_data_formatter'; +import { SloSparkline } from './slo_sparkline'; + +export interface Props { + slo: SLOWithSummaryResponse; + historicalSummary?: HistoricalSummaryResponse[]; + historicalSummaryLoading: boolean; +} + +export function SloSummary({ slo, historicalSummary = [], historicalSummaryLoading }: Props) { + const { sliValue, sloTarget, errorBudgetRemaining } = useSloFormattedSummary(slo); + const isSloFailed = slo.summary.status === 'VIOLATED' || slo.summary.status === 'DEGRADING'; + const titleColor = isSloFailed ? 'danger' : ''; + const errorBudgetBurnDownData = formatHistoricalData(historicalSummary, 'error_budget_remaining'); + const historicalSliData = formatHistoricalData(historicalSummary, 'sli_value'); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slos_view.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slos_view.tsx new file mode 100644 index 0000000000000..1b5618a6b43b4 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/slos_view.tsx @@ -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 { i18n } from '@kbn/i18n'; + +import { EuiFlexItem } from '@elastic/eui'; +import { SLOWithSummaryResponse } from '@kbn/slo-schema'; +import React from 'react'; +// import { SloListCardView } from './card_view/slos_card_view'; +// import { SloListCompactView } from './compact_view/slo_list_compact_view'; +// import { SloListEmpty } from './slo_list_empty'; +// import { SloListError } from './slo_list_error'; +// import { SloListView } from './slo_list_view/slo_list_view'; +import { SLOView } from './toggle_slo_view'; + +export interface Props { + sloList: SLOWithSummaryResponse[]; + loading: boolean; + error: boolean; + sloView: SLOView; +} + +export function SlosView() { + return ( +
+ {i18n.translate('xpack.slos.slosView.div.slosViewLabel', { defaultMessage: 'Slos View' })} +
+ ); +} + +// export function SlosView({ sloList, loading, error, sloView }: Props) { +// if (!loading && !error && sloList.length === 0) { +// return ; +// } +// if (!loading && error) { +// return ; +// } + +// return sloView === 'cardView' ? ( +// +// +// +// ) : ( +// +// {sloView === 'compactView' && ( +// +// )} +// {sloView === 'listView' && } +// +// ); +// } diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/components/toggle_slo_view.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/toggle_slo_view.tsx new file mode 100644 index 0000000000000..7859bdc9f592d --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/components/toggle_slo_view.tsx @@ -0,0 +1,107 @@ +/* + * 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 { EuiButtonGroup, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { FindSLOResponse } from '@kbn/slo-schema'; +import React from 'react'; +import type { SearchState } from '../../../hooks/use_url_search_state'; +import { SLOSortBy } from './common/sort_by_select'; +import { SloGroupBy } from './slo_list_group_by'; +export type SLOView = 'cardView' | 'listView' | 'compactView'; + +interface Props { + onChangeView: (view: SLOView) => void; + onStateChange: (newState: Partial) => void; + sloView: SLOView; + state: SearchState; + sloList?: FindSLOResponse; + loading: boolean; +} + +const toggleButtonsIcons = [ + { + id: `cardView`, + label: 'Card View', + iconType: 'apps', + 'data-test-subj': 'sloCardViewButton', + }, + { + id: `listView`, + label: 'List View', + iconType: 'list', + 'data-test-subj': 'sloListViewButton', + }, + { + iconType: 'tableDensityCompact', + id: 'compactView', + label: i18n.translate('xpack.slos.listView.compactViewLabel', { + defaultMessage: 'Compact view', + }), + }, +]; + +export function ToggleSLOView({ + sloView, + onChangeView, + onStateChange, + sloList, + state, + loading, +}: Props) { + const total = sloList?.total ?? 0; + const pageSize = sloList?.perPage ?? 0; + const pageIndex = sloList?.page ?? 1; + + const rangeStart = total === 0 ? 0 : pageSize * (pageIndex - 1) + 1; + const rangeEnd = Math.min(total, pageSize * (pageIndex - 1) + pageSize); + + return ( + + + {!state.groupBy && ( + + {`${rangeStart}-${rangeEnd}`}, + total, + slos: ( + + + + ), + }} + /> + + )} + + + + + + + + + onChangeView(id as SLOView)} + isIconOnly + isDisabled={loading} + /> + + + ); +} diff --git a/x-pack/plugins/observability_solution/slos/public/pages/slos/slos.tsx b/x-pack/plugins/observability_solution/slos/public/pages/slos/slos.tsx new file mode 100644 index 0000000000000..9d8f545054c51 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/pages/slos/slos.tsx @@ -0,0 +1,89 @@ +/* + * 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. + */ + +/* + * 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, { useEffect } from 'react'; +import { useBreadcrumbs } from '@kbn/observability-shared-plugin/public'; + +import { i18n } from '@kbn/i18n'; +// apm does it like this +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { FeedbackButton } from './components/common/feedback_button'; +import { CreateSloBtn } from './components/common/create_slo_btn'; +import { SloListSearchBar } from './components/slo_list_search_bar'; +// For now copy from observability +// import { useKibana } from '../../utils/kibana_react'; + +import { usePluginContext } from '../../hooks/use_plugin_context'; +import { useLicense } from '../../hooks/use_license'; +import { useFetchSloList } from '../../hooks/use_fetch_slo_list'; +import { SloList } from './components/slo_list'; +import { paths } from '../../../common/locators/paths'; +// import { HeaderMenu } from '../overview/components/header_menu/header_menu'; +// import { SloOutdatedCallout } from '../../components/slo/slo_outdated_callout'; + +export const SLO_PAGE_ID = 'slo-page-container'; + +export function SlosPage() { + console.log('!!slos page'); + const { + application: { navigateToUrl }, + http: { basePath }, + } = useKibana().services; + const { ObservabilityPageTemplate } = usePluginContext(); + const { hasAtLeast } = useLicense(); + + const { + isLoading, + isError, + data: sloList, + } = useFetchSloList({ + perPage: 0, + }); + const { total } = sloList ?? { total: 0 }; + + useBreadcrumbs([ + { + href: basePath.prepend(paths.slos), + text: i18n.translate('xpack.slos.breadcrumbs.slosLinkText', { defaultMessage: 'SLOs' }), + deepLinkId: 'observability-overview:slos', + }, + ]); + + useEffect(() => { + if ((!isLoading && total === 0) || hasAtLeast('platinum') === false || isError) { + navigateToUrl(basePath.prepend(paths.slosWelcome)); + } + }, [basePath, hasAtLeast, isError, isLoading, navigateToUrl, total]); + + return ( + , ], + }} + topSearchBar={} + > + {/* */} + {/* */} + + + ); +} + +// export function SlosPage() { +// return ( +//
{i18n.translate('xpack.slos.slosPage.div.helloLabel', { defaultMessage: 'Hello' })}
+// ); +// } diff --git a/x-pack/plugins/observability_solution/slos/public/plugin.ts b/x-pack/plugins/observability_solution/slos/public/plugin.ts new file mode 100644 index 0000000000000..e62d29151c181 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/plugin.ts @@ -0,0 +1,57 @@ +/* + * 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 { + AppMountParameters, + CoreSetup, + CoreStart, + DEFAULT_APP_CATEGORIES, + Plugin, +} from '@kbn/core/public'; +import { from } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { SlosPluginSetupDeps, SlosPluginStartDeps } from './types'; // TODO move later to type +import { PLUGIN_NAME } from '../common'; +import type { SlosPluginSetup, SlosPluginStart } from './types'; + +export class SlosPlugin implements Plugin { + public setup(core: CoreSetup, plugins: SlosPluginSetupDeps) /* : SlosPluginSetup*/ { + // plugins.observabilityShared.navigation.registerSections(from(core.getStartServices()).pipe( + // map(([]) => { + // return [ + // ...(capabilities.slos.show ? [ { label: 'SLOs new'}] : []) + // ] + // }) + // )); + // Register an application into the side navigation menu + core.application.register({ + id: 'slos', + title: PLUGIN_NAME, + order: 8001, // 8100 adds it after Cases, 8000 adds it before alerts, 8001 adds it after Alerts + euiIconType: 'logoObservability', + appRoute: '/app/slos', + category: DEFAULT_APP_CATEGORIES.observability, + // Do I need deep links + async mount(params: AppMountParameters) { + const { renderApp } = await import('./application'); + const [coreStart, depsStart] = await core.getStartServices(); + return renderApp({ + core: coreStart, + plugins: depsStart as SlosPluginStartDeps, + appMountParameters: params, + ObservabilityPageTemplate: depsStart.observabilityShared.navigation.PageTemplate, + }); + }, + }); + } + + public start(core: CoreStart, plugins: SlosPluginStartDeps) /* : SlosPluginStart*/ { + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/observability_solution/slos/public/routes/routes.tsx b/x-pack/plugins/observability_solution/slos/public/routes/routes.tsx new file mode 100644 index 0000000000000..45d705ce96348 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/routes/routes.tsx @@ -0,0 +1,95 @@ +/* + * 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 { useHistory, useLocation } from 'react-router-dom'; +// import { useKibana } from '../utils/kibana_react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { SlosPage } from '../pages/slos/slos'; +// import { SlosWelcomePage } from '../pages/slos_welcome/slos_welcome'; +// import { SloDetailsPage } from '../pages/slo_details/slo_details'; +// import { SloEditPage } from '../pages/slo_edit/slo_edit'; +// import { SlosOutdatedDefinitions } from '../pages/slo_outdated_definitions'; + +import { + SLOS_OUTDATED_DEFINITIONS_PATH, + SLOS_PATH, + SLOS_WELCOME_PATH, + SLO_CREATE_PATH, + SLO_DETAIL_PATH, + SLO_EDIT_PATH, +} from '../../common/locators/paths'; + +// Note: React Router DOM component was not working here +// so I've recreated this simple version for this purpose. +// function SimpleRedirect({ to, redirectToApp }: { to: string; redirectToApp?: string }) { +// const { +// application: { navigateToApp }, +// } = useKibana().services; +// const history = useHistory(); +// const { search, hash } = useLocation(); + +// if (redirectToApp) { +// navigateToApp(redirectToApp, { path: `/${search}${hash}`, replace: true }); +// } else if (to) { +// history.replace(to); +// } +// return null; +// } + +export const routes = { + // [ROOT_PATH]: { + // handler: () => { + // return ; + // }, + // params: {}, + // exact: true, + // }, + [SLOS_PATH]: { + handler: () => { + console.log('SLos page'); + return ; + }, + params: {}, + exact: true, + }, + // [SLO_CREATE_PATH]: { + // handler: () => { + // return ; + // }, + // params: {}, + // exact: true, + // }, + // [SLOS_WELCOME_PATH]: { + // handler: () => { + // return ; + // }, + // params: {}, + // exact: true, + // }, + // [SLOS_OUTDATED_DEFINITIONS_PATH]: { + // handler: () => { + // return ; + // }, + // params: {}, + // exact: true, + // }, + // [SLO_EDIT_PATH]: { + // handler: () => { + // return ; + // }, + // params: {}, + // exact: true, + // }, + // [SLO_DETAIL_PATH]: { + // handler: () => { + // return ; + // }, + // params: {}, + // exact: true, + // }, +}; diff --git a/x-pack/plugins/observability_solution/slos/public/types.ts b/x-pack/plugins/observability_solution/slos/public/types.ts new file mode 100644 index 0000000000000..3126608768699 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/types.ts @@ -0,0 +1,37 @@ +/* + * 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 { + ObservabilityPublicSetup, + ObservabilityPublicStart, +} from '@kbn/observability-plugin/public'; +import type { + ObservabilitySharedPluginSetup, + ObservabilitySharedPluginStart, +} from '@kbn/observability-shared-plugin/public'; +import type { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, +} from '@kbn/triggers-actions-ui-plugin/public'; +import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public'; +import { SlosPlugin } from './plugin'; + +export interface SlosPluginSetupDeps { + observability: ObservabilityPublicSetup; + observabilityShared: ObservabilitySharedPluginSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; +} + +export interface SlosPluginStartDeps { + observability: ObservabilityPublicStart; + observabilityShared: ObservabilitySharedPluginStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; + navigation: NavigationPublicPluginStart; +} + +export type SlosPluginSetup = ReturnType; +export type SlosPluginStart = void; diff --git a/x-pack/plugins/observability_solution/slos/public/utils/labels.ts b/x-pack/plugins/observability_solution/slos/public/utils/labels.ts new file mode 100644 index 0000000000000..b220ac6337a1d --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/public/utils/labels.ts @@ -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 { i18n } from '@kbn/i18n'; +export const INDICATOR_CUSTOM_KQL = i18n.translate('xpack.slos.indicators.customKql', { + defaultMessage: 'Custom KQL', +}); + +export const INDICATOR_CUSTOM_METRIC = i18n.translate('xpack.slos.indicators.customMetric', { + defaultMessage: 'Custom Metric', +}); + +export const INDICATOR_TIMESLICE_METRIC = i18n.translate('xpack.slos.indicators.timesliceMetric', { + defaultMessage: 'Timeslice Metric', +}); + +export const INDICATOR_HISTOGRAM = i18n.translate('xpack.slos.indicators.histogram', { + defaultMessage: 'Histogram Metric', +}); + +export const INDICATOR_APM_LATENCY = i18n.translate('xpack.slos.indicators.apmLatency', { + defaultMessage: 'APM latency', +}); + +export const INDICATOR_APM_AVAILABILITY = i18n.translate('xpack.slos.indicators.apmAvailability', { + defaultMessage: 'APM availability', +}); diff --git a/x-pack/plugins/observability_solution/slos/server/index.ts b/x-pack/plugins/observability_solution/slos/server/index.ts new file mode 100644 index 0000000000000..5d79cec7fa115 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/server/index.ts @@ -0,0 +1,11 @@ +import { PluginInitializerContext } from '../../../src/core/server'; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. + +export async function plugin(initializerContext: PluginInitializerContext) { + const { SlosPlugin } = await import('./plugin'); + return new SlosPlugin(initializerContext); +} + +export type { SlosPluginSetup, SlosPluginStart } from './types'; diff --git a/x-pack/plugins/observability_solution/slos/server/plugin.ts b/x-pack/plugins/observability_solution/slos/server/plugin.ts new file mode 100644 index 0000000000000..1d1df3919504f --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/server/plugin.ts @@ -0,0 +1,35 @@ +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, +} from '../../../src/core/server'; + +import { SlosPluginSetup, SlosPluginStart } from './types'; +import { defineRoutes } from './routes'; + +export class SlosPlugin implements Plugin { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup(core: CoreSetup) { + this.logger.debug('slos: Setup'); + const router = core.http.createRouter(); + + // Register server side APIs + defineRoutes(router); + + return {}; + } + + public start(core: CoreStart) { + this.logger.debug('slos: Started'); + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/observability_solution/slos/server/routes/index.ts b/x-pack/plugins/observability_solution/slos/server/routes/index.ts new file mode 100644 index 0000000000000..b13370fee031a --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/server/routes/index.ts @@ -0,0 +1,17 @@ +import { IRouter } from '../../../../src/core/server'; + +export function defineRoutes(router: IRouter) { + router.get( + { + path: '/api/slos/example', + validate: false, + }, + async (context, request, response) => { + return response.ok({ + body: { + time: new Date().toISOString(), + }, + }); + } + ); +} diff --git a/x-pack/plugins/observability_solution/slos/server/types.ts b/x-pack/plugins/observability_solution/slos/server/types.ts new file mode 100644 index 0000000000000..d2ba45cddc097 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/server/types.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SlosPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SlosPluginStart {} diff --git a/x-pack/plugins/observability_solution/slos/tsconfig.json b/x-pack/plugins/observability_solution/slos/tsconfig.json new file mode 100644 index 0000000000000..7034c98b7da48 --- /dev/null +++ b/x-pack/plugins/observability_solution/slos/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "../../../typings/**/*" + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/i18n", + "@kbn/i18n-react", + "@kbn/shared-ux-router", + "@kbn/core", + "@kbn/navigation-plugin", + ] +} diff --git a/yarn.lock b/yarn.lock index 0b461a53619e4..3055f742b6735 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6149,6 +6149,10 @@ version "0.0.0" uid "" +"@kbn/slos-plugin@link:x-pack/plugins/observability_solution/slos": + version "0.0.0" + uid "" + "@kbn/slo-schema@link:x-pack/packages/kbn-slo-schema": version "0.0.0" uid ""