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}
+
+
+
+
+
+
+
+
+ );
+}
+
+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 && (
+
+
+ )}
+
+
+
+
+
+
+
+
+ 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 ""