Merge branch 'main' into ui-actions-refactor

This commit is contained in:
James Gowdy 2023-11-23 18:51:50 +00:00 committed by GitHub
commit dd649dca3e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
213 changed files with 2621 additions and 2512 deletions

1
.github/CODEOWNERS vendored
View file

@ -753,7 +753,6 @@ test/server_integration/plugins/status_plugin_b @elastic/kibana-core
packages/kbn-std @elastic/kibana-core
packages/kbn-stdio-dev-helpers @elastic/kibana-operations
packages/kbn-storybook @elastic/kibana-operations
packages/kbn-subscription-tracking @elastic/security-threat-hunting-investigations
x-pack/plugins/synthetics @elastic/obs-ux-infra_services-team
x-pack/test/alerting_api_integration/common/plugins/task_manager_fixture @elastic/response-ops
x-pack/test/plugin_api_perf/plugins/task_manager_performance @elastic/response-ops

View file

@ -54,19 +54,9 @@ spec:
provider_settings:
trigger_mode: none
teams:
kibana-release-operators:
access_level: MANAGE_BUILD_AND_READ
kibana-operations:
access_level: MANAGE_BUILD_AND_READ
appex-qa:
access_level: BUILD_AND_READ
security-engineering-productivity:
access_level: BUILD_AND_READ
fleet:
access_level: BUILD_AND_READ
kibana-tech-leads:
access_level: BUILD_AND_READ
kibana-core:
kibana-release-operators:
access_level: BUILD_AND_READ
cloud-tooling:
access_level: BUILD_AND_READ

View file

@ -750,7 +750,6 @@
"@kbn/status-plugin-a-plugin": "link:test/server_integration/plugins/status_plugin_a",
"@kbn/status-plugin-b-plugin": "link:test/server_integration/plugins/status_plugin_b",
"@kbn/std": "link:packages/kbn-std",
"@kbn/subscription-tracking": "link:packages/kbn-subscription-tracking",
"@kbn/synthetics-plugin": "link:x-pack/plugins/synthetics",
"@kbn/task-manager-fixture-plugin": "link:x-pack/test/alerting_api_integration/common/plugins/task_manager_fixture",
"@kbn/task-manager-performance-plugin": "link:x-pack/test/plugin_api_perf/plugins/task_manager_performance",
@ -866,7 +865,6 @@
"ansi-regex": "^5.0.1",
"antlr4ts": "^0.5.0-alpha.3",
"archiver": "^5.3.1",
"argon2": "0.31.1",
"async": "^3.2.3",
"aws4": "^1.12.0",
"axios": "^1.6.0",

View file

@ -62,6 +62,17 @@ const getHeaderCss = ({ size }: EuiThemeComputed) => ({
top: 2px;
`,
},
leftHeaderSection: css`
// needed to enable breadcrumbs truncation
min-width: 0;
flex-shrink: 1;
`,
breadcrumbsSectionItem: css`
min-width: 0; // needed to enable breadcrumbs truncation
`,
redirectAppLinksContainer: css`
min-width: 0; // needed to enable breadcrumbs truncation
`,
});
type HeaderCss = ReturnType<typeof getHeaderCss>;
@ -181,7 +192,7 @@ export const ProjectHeader = ({
<header data-test-subj="kibanaProjectHeader">
<div id="globalHeaderBars" data-test-subj="headerGlobalNav" className="header__bars">
<EuiHeader position="fixed" className="header__firstBar">
<EuiHeaderSection grow={false}>
<EuiHeaderSection grow={false} css={headerCss.leftHeaderSection}>
<Router history={application.history}>
<ProjectNavigation toggleSideNav={toggleSideNav}>{children}</ProjectNavigation>
</Router>
@ -196,8 +207,11 @@ export const ProjectHeader = ({
/>
</EuiHeaderSectionItem>
<EuiHeaderSectionItem>
<RedirectAppLinks coreStart={{ application }}>
<EuiHeaderSectionItem css={headerCss.breadcrumbsSectionItem}>
<RedirectAppLinks
coreStart={{ application }}
css={headerCss.redirectAppLinksContainer}
>
<Breadcrumbs breadcrumbs$={observables.breadcrumbs$} />
</RedirectAppLinks>
</EuiHeaderSectionItem>

View file

@ -770,6 +770,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => {
installAndUninstallIntegrationAssets: `${FLEET_DOCS}install-uninstall-integration-assets.html`,
elasticAgentInputConfiguration: `${FLEET_DOCS}elastic-agent-input-configuration.html`,
policySecrets: `${FLEET_DOCS}agent-policy.html#agent-policy-secret-values`,
remoteESOoutput: `${FLEET_DOCS}monitor-elastic-agent.html#external-elasticsearch-monitoring`,
},
ecs: {
guide: `${ELASTIC_WEBSITE_URL}guide/en/ecs/current/index.html`,

View file

@ -528,6 +528,7 @@ export interface DocLinks {
installAndUninstallIntegrationAssets: string;
elasticAgentInputConfiguration: string;
policySecrets: string;
remoteESOoutput: string;
}>;
readonly ecs: {
readonly guide: string;

View file

@ -1,35 +0,0 @@
# @kbn/subscription-tracking
This package leverages the `@kbn/analytics-client` package to send dedicated subscription tracking events.
It provides a set of React components that automatically track `impression` and `click` events. Consumers of those components need to specify a `subscription context` that gives more details on the type of feature that is advertised and the location of the upsell.
```typescript
import { SubscriptionLink } from '@kbn/subscription-tracking';
import type { SubscriptionContext } from '@kbn/subscription-tracking';
const subscriptionContext: SubscriptionContext = {
feature: 'threat-intelligence',
source: 'security__threat-intelligence',
};
export const Paywall = () => {
return (
<div>
<SubscriptionLink subscriptionContext={subscriptionContext}>
Upgrade to Platinum to get this feature
</SubscriptionLink>
</div>
);
};
```
The example above uses a `SubscriptionLink` which is a wrapper of `EuiLink` . So it behaves just like a normal link. Alternatively, upsells can also use a `SubscriptionButton` or `SubscriptionButtonEmpty` which wrap `EuiButton` and `EuiButtonEmpty` respectively.
When the link is mounted, it will send off an `impression` event with the given `subscriptionContext`. That piece of metadata consists of an identifier of the advertised feature (in this case `threat-intelligence`) and the `source` (aka location) of the impression (in this case the `threat-intelligence` page in the `security` solution). `source` follows the following format: `{solution-identifier}__location-identifier`.
There are no special rules for how to name these identifiers but it's good practise to make sure that `feature` has the same value for all upsells advertising the same feature (e.g. use enums for features to prevent spelling mistakes).
Upon interaction with the upsell link/button, a special `click` event is sent, which, again, contains the same subscription context.
If you want to use the `subscription-tracking` elements in your app, you have to set up a `SubscriptionTrackingProvider` in your plugin setup and register the tracking events on startup. Have a look at https://github.com/elastic/kibana/pull/143910 for an example of an integration.

View file

@ -1,17 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export * from './src/subscription_elements';
export {
SubscriptionTrackingContext,
SubscriptionTrackingProvider,
registerEvents,
} from './src/services';
export * from './types';

View file

@ -1,13 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-subscription-tracking'],
};

View file

@ -1,5 +0,0 @@
{
"type": "shared-common",
"id": "@kbn/subscription-tracking",
"owner": "@elastic/security-threat-hunting-investigations"
}

View file

@ -1,28 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { FC } from 'react';
import { analyticsClientMock } from '@kbn/analytics-client/src/mocks';
import { SubscriptionTrackingProvider } from './src/services';
const analyticsClientMockInst = analyticsClientMock.create();
/**
* Mock for the external services provider. Only use in tests!
*/
export const MockSubscriptionTrackingProvider: FC = ({ children }) => {
return (
<SubscriptionTrackingProvider
navigateToApp={jest.fn()}
analyticsClient={analyticsClientMockInst}
>
{children}
</SubscriptionTrackingProvider>
);
};

View file

@ -1,6 +0,0 @@
{
"name": "@kbn/subscription-tracking",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -1,45 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { isValidContext } from './helpers';
describe('tracking', () => {
describe('isValidLocation', () => {
it('identifies correct contexts', () => {
expect(
isValidContext({
feature: 'test',
source: 'security__test',
})
).toBeTruthy();
});
it('identifies incorrect contexts', () => {
expect(
isValidContext({
feature: '',
source: 'security__',
})
).toBeFalsy();
expect(
isValidContext({
feature: 'test',
source: 'security__',
})
).toBeFalsy();
expect(
isValidContext({
feature: '',
source: 'security__test',
})
).toBeFalsy();
});
});
});

View file

@ -1,14 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { SubscriptionContextData } from '../types';
const sourceStringRegEx = /^(\w[\w\-_]*)__(\w[\w\-_]*)$/;
export function isValidContext(context: SubscriptionContextData): boolean {
return context.feature.length > 0 && sourceStringRegEx.test(context.source);
}

View file

@ -1,70 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { FC, useContext } from 'react';
import type { AnalyticsClient, EventTypeOpts } from '@kbn/analytics-client';
import { EVENT_NAMES, Services, SubscriptionContextData } from '../types';
export const SubscriptionTrackingContext = React.createContext<Services | null>(null);
/**
* External services provider
*/
export const SubscriptionTrackingProvider: FC<Services> = ({ children, ...services }) => {
return (
<SubscriptionTrackingContext.Provider value={services}>
{children}
</SubscriptionTrackingContext.Provider>
);
};
/**
* React hook for accessing pre-wired services.
*/
export function useServices() {
const context = useContext(SubscriptionTrackingContext);
if (!context) {
throw new Error(
'SubscriptionTrackingContext is missing. Ensure your component or React root is wrapped with SubscriptionTrackingProvider.'
);
}
return context;
}
const subscriptionContextSchema: EventTypeOpts<SubscriptionContextData>['schema'] = {
source: {
type: 'keyword',
_meta: {
description:
'A human-readable identifier describing the location of the beginning of the subscription flow',
},
},
feature: {
type: 'keyword',
_meta: {
description: 'A human-readable identifier describing the feature that is being promoted',
},
},
};
/**
* Registers the subscription-specific event types
*/
export function registerEvents(analyticsClient: Pick<AnalyticsClient, 'registerEventType'>) {
analyticsClient.registerEventType<SubscriptionContextData>({
eventType: EVENT_NAMES.IMPRESSION,
schema: subscriptionContextSchema,
});
analyticsClient.registerEventType<SubscriptionContextData>({
eventType: EVENT_NAMES.CLICK,
schema: subscriptionContextSchema,
});
}

View file

@ -1,108 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import {
SubscriptionLink,
SubscriptionButton,
SubscriptionButtonEmpty,
} from './subscription_elements';
import { SubscriptionTrackingProvider } from './services';
import { EVENT_NAMES, Services, SubscriptionContextData } from '../types';
import { coolDownTimeMs, resetCoolDown } from './use_impression';
const testServices: Services = {
navigateToApp: jest.fn(),
analyticsClient: {
reportEvent: jest.fn(),
registerEventType: jest.fn(),
} as any,
};
const testContext: SubscriptionContextData = { feature: 'test', source: 'security__test' };
const WithProviders: React.FC = ({ children }) => (
<SubscriptionTrackingProvider
analyticsClient={testServices.analyticsClient}
navigateToApp={testServices.navigateToApp}
>
{children}
</SubscriptionTrackingProvider>
);
const renderWithProviders = (children: React.ReactElement) =>
render(children, { wrapper: WithProviders });
const reset = () => {
jest.resetAllMocks();
resetCoolDown();
};
describe('SubscriptionElements', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
[SubscriptionButton, SubscriptionLink, SubscriptionButtonEmpty].forEach((SubscriptionElement) => {
describe(SubscriptionElement.name, () => {
beforeEach(reset);
it('renders the children correctly', () => {
renderWithProviders(
<SubscriptionElement subscriptionContext={testContext}>Hello</SubscriptionElement>
);
expect(screen.getByText('Hello')).toBeTruthy();
});
it('fires an impression event when rendered', () => {
renderWithProviders(<SubscriptionElement subscriptionContext={testContext} />);
expect(testServices.analyticsClient.reportEvent).toHaveBeenCalledWith(
EVENT_NAMES.IMPRESSION,
testContext
);
});
it('fires an impression event when rendered (but only once)', () => {
const { unmount } = renderWithProviders(
<SubscriptionElement subscriptionContext={testContext} />
);
expect(testServices.analyticsClient.reportEvent).toHaveBeenCalledTimes(1);
unmount();
// does not create an impression again when remounted
const { unmount: unmountAgain } = renderWithProviders(
<SubscriptionElement subscriptionContext={testContext} />
);
unmountAgain();
expect(testServices.analyticsClient.reportEvent).toHaveBeenCalledTimes(1);
// only creates anew impression when the cooldown time has passed
jest.setSystemTime(Date.now() + coolDownTimeMs);
renderWithProviders(<SubscriptionElement subscriptionContext={testContext} />);
expect(testServices.analyticsClient.reportEvent).toHaveBeenCalledTimes(2);
});
it('tracks a click when clicked and navigates to page', () => {
renderWithProviders(
<SubscriptionElement subscriptionContext={testContext}>hello</SubscriptionElement>
);
screen.getByText('hello').click();
expect(testServices.analyticsClient.reportEvent).toHaveBeenCalledWith(
EVENT_NAMES.CLICK,
testContext
);
expect(testServices.navigateToApp).toHaveBeenCalled();
});
});
});
});

View file

@ -1,79 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiLink, EuiButton, EuiButtonEmpty } from '@elastic/eui';
import type { EuiLinkProps, EuiButtonEmptyProps, EuiButtonProps } from '@elastic/eui';
import { useGoToSubscription } from './use_go_to_subscription';
import { useImpression } from './use_impression';
import type { SubscriptionContextData } from '../types';
interface CommonProps {
/** The context information for this subscription element */
subscriptionContext: SubscriptionContextData;
}
export type SubscriptionLinkProps = EuiLinkProps & CommonProps;
/**
* Wrapper around `EuiLink` that provides subscription events
*/
export function SubscriptionLink({
subscriptionContext,
children,
...restProps
}: SubscriptionLinkProps) {
const goToSubscription = useGoToSubscription({ subscriptionContext });
useImpression(subscriptionContext);
return (
<EuiLink {...restProps} onClick={goToSubscription}>
{children}
</EuiLink>
);
}
export type SubscriptionButtonProps = EuiButtonProps & CommonProps;
/**
* Wrapper around `EuiButton` that provides subscription events
*/
export function SubscriptionButton({
subscriptionContext,
children,
...restProps
}: SubscriptionButtonProps) {
const goToSubscription = useGoToSubscription({ subscriptionContext });
useImpression(subscriptionContext);
return (
<EuiButton {...restProps} onClick={goToSubscription}>
{children}
</EuiButton>
);
}
export type SubscriptionButtonEmptyProps = EuiButtonEmptyProps & CommonProps;
/**
* Wrapper around `EuiButtonEmpty` that provides subscription events
*/
export function SubscriptionButtonEmpty({
subscriptionContext,
children,
...restProps
}: SubscriptionButtonEmptyProps) {
const goToSubscription = useGoToSubscription({ subscriptionContext });
useImpression(subscriptionContext);
return (
<EuiButtonEmpty {...restProps} onClick={goToSubscription}>
{children}
</EuiButtonEmpty>
);
}

View file

@ -1,35 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useCallback } from 'react';
import { isValidContext } from './helpers';
import { useServices } from './services';
import { EVENT_NAMES, SubscriptionContextData } from '../types';
interface Options {
subscriptionContext: SubscriptionContextData;
}
/**
* Provides a navigation function that navigates to the subscription
* management page. When the function executes, a click event with the
* given context is emitted.
*/
export const useGoToSubscription = ({ subscriptionContext }: Options) => {
const { navigateToApp, analyticsClient } = useServices();
const goToSubscription = useCallback(() => {
if (isValidContext(subscriptionContext)) {
analyticsClient.reportEvent(EVENT_NAMES.CLICK, subscriptionContext);
} else {
// eslint-disable-next-line no-console
console.error('The provided subscription context is invalid', subscriptionContext);
}
navigateToApp('management', { path: 'stack/license_management' });
}, [analyticsClient, navigateToApp, subscriptionContext]);
return goToSubscription;
};

View file

@ -1,57 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useEffect } from 'react';
import { isValidContext } from './helpers';
import { useServices } from './services';
import { EVENT_NAMES, SubscriptionContextData } from '../types';
/**
* Sends an impression event with the given context.
*
* Note: impression events are throttled and will not fire more
* often than once every 30 seconds.
*/
export const useImpression = (context: SubscriptionContextData) => {
const { analyticsClient } = useServices();
useEffect(() => {
if (!isValidContext(context)) {
// eslint-disable-next-line no-console
console.error('The provided subscription context is invalid', context);
return;
}
if (!isCoolingDown(context)) {
analyticsClient.reportEvent(EVENT_NAMES.IMPRESSION, context);
coolDown(context);
}
}, [analyticsClient, context]);
};
/**
* Impressions from the same context should not fire more than once every 30 seconds.
* This prevents logging too many impressions in case a page is reloaded often or
* if the user is navigating back and forth rapidly.
*/
export const coolDownTimeMs = 30 * 1000;
let impressionCooldown = new WeakMap<SubscriptionContextData, number>();
function isCoolingDown(context: SubscriptionContextData) {
const previousLog = impressionCooldown.get(context);
// we logged before and we are in the cooldown period
return previousLog && Date.now() - previousLog < coolDownTimeMs;
}
function coolDown(context: SubscriptionContextData) {
impressionCooldown.set(context, Date.now());
}
export function resetCoolDown() {
impressionCooldown = new WeakMap<SubscriptionContextData, number>();
}

View file

@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": ["jest", "node", "react"]
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["target/**/*"],
"kbn_references": ["@kbn/analytics-client"]
}

View file

@ -1,47 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { AnalyticsClient } from '@kbn/analytics-client';
enum SolutionIdentifier {
observability = 'observability',
security = 'security',
}
type LocationString = string;
type SourceIdentifier = `${SolutionIdentifier}__${LocationString}`;
/**
* A piece of metadata which consists of an identifier of the advertised feature and
* the `source` (e.g. location) of the subscription element.
*/
export interface SubscriptionContextData {
/**
* A human-readable identifier describing the location of the beginning of the
* subscription flow.
* Location identifiers are prefixed with a solution identifier, e.g. `security__`
*
* @example "security__host-overview" - the user is looking at an upsell button
* on the host overview page in the security solution
*/
source: SourceIdentifier;
/**
* A human-readable identifier describing the feature that is being promoted.
*
* @example "alerts-by-process-ancestry"
*/
feature: string;
}
export interface Services {
navigateToApp: (app: string, options: { path: string }) => void;
analyticsClient: Pick<AnalyticsClient, 'reportEvent'>;
}
export enum EVENT_NAMES {
CLICK = 'subscription__upsell__click',
IMPRESSION = 'subscription__upsell__impression',
}

View file

@ -71,7 +71,10 @@ export interface CreateTestEsClusterOptions {
*/
esArgs?: string[];
esFrom?: string;
esServerlessOptions?: Pick<ServerlessOptions, 'image' | 'tag' | 'resources' | 'host'>;
esServerlessOptions?: Pick<
ServerlessOptions,
'image' | 'tag' | 'resources' | 'host' | 'kibanaUrl'
>;
esJavaOpts?: string;
/**
* License to run your cluster under. Keep in mind that a `trial` license
@ -242,10 +245,7 @@ export function createTestEsCluster<
await firstNode.runServerless({
basePath,
esArgs: customEsArgs,
image: esServerlessOptions?.image,
tag: esServerlessOptions?.tag,
host: esServerlessOptions?.host,
resources: esServerlessOptions?.resources,
...esServerlessOptions,
port,
clean: true,
background: true,

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import Url from 'url';
import { resolve } from 'path';
import type { ToolingLog } from '@kbn/tooling-log';
import getPort from 'get-port';
@ -160,7 +161,18 @@ async function startEsNode({
return cluster;
}
function getESServerlessOptions(esServerlessImageFromArg: string | undefined, config: Config) {
interface EsServerlessOptions {
host?: string;
resources: string[];
kibanaUrl: string;
tag?: string;
image?: string;
}
function getESServerlessOptions(
esServerlessImageFromArg: string | undefined,
config: Config
): EsServerlessOptions {
const esServerlessImageUrlOrTag =
esServerlessImageFromArg ||
esTestConfig.getESServerlessImage() ||
@ -172,24 +184,24 @@ function getESServerlessOptions(esServerlessImageFromArg: string | undefined, co
const serverlessHost: string | undefined =
config.has('esServerlessOptions.host') && config.get('esServerlessOptions.host');
const commonOptions = {
host: serverlessHost,
resources: serverlessResources,
kibanaUrl: Url.format({
protocol: config.get('servers.kibana.protocol'),
hostname: config.get('servers.kibana.hostname'),
port: config.get('servers.kibana.port'),
}),
};
if (esServerlessImageUrlOrTag) {
if (esServerlessImageUrlOrTag.includes(':')) {
return {
resources: serverlessResources,
image: esServerlessImageUrlOrTag,
host: serverlessHost,
};
} else {
return {
resources: serverlessResources,
tag: esServerlessImageUrlOrTag,
host: serverlessHost,
};
}
return {
...commonOptions,
...(esServerlessImageUrlOrTag.includes(':')
? { image: esServerlessImageUrlOrTag }
: { tag: esServerlessImageUrlOrTag }),
};
}
return {
resources: serverlessResources,
host: serverlessHost,
};
return commonOptions;
}

View file

@ -29,6 +29,7 @@ export const RedirectAppLinks: FC<RedirectAppLinksComponentProps> = ({
children,
navigateToUrl,
currentAppId,
...containerProps
}) => {
const containerRef = useRef<HTMLDivElement>(null);
@ -50,6 +51,7 @@ export const RedirectAppLinks: FC<RedirectAppLinksComponentProps> = ({
ref={containerRef}
css={redirectAppLinksStyles}
data-test-subj="kbnRedirectAppLink"
{...containerProps}
>
{children}
</div>

View file

@ -7,6 +7,7 @@
*/
import React, { FC } from 'react';
import type { RedirectAppLinksComponentProps } from '@kbn/shared-ux-link-redirect-app-types';
import { useServices } from './services';
import { RedirectAppLinks as Component } from './redirect_app_links.component';
@ -22,6 +23,11 @@ import { RedirectAppLinks as Component } from './redirect_app_links.component';
* </RedirectAppLinks>
* ```
*/
export const RedirectAppLinks: FC<{}> = ({ children }) => (
<Component {...useServices()}>{children}</Component>
export const RedirectAppLinks: FC<Omit<RedirectAppLinksComponentProps, 'navigateToUrl'>> = ({
children,
...props
}) => (
<Component {...useServices()} {...props}>
{children}
</Component>
);

View file

@ -25,10 +25,11 @@ const isKibanaContract = (services: any): services is RedirectAppLinksKibanaDepe
* with which consumers can wrap their components or solutions.
*/
export const RedirectAppLinks: FC<RedirectAppLinksProps> = ({ children, ...props }) => {
const container = <RedirectAppLinksContainer>{children}</RedirectAppLinksContainer>;
if (isKibanaContract(props)) {
const { coreStart } = props;
const { coreStart, ...containerProps } = props;
const container = (
<RedirectAppLinksContainer {...containerProps}>{children}</RedirectAppLinksContainer>
);
return (
<RedirectAppLinksKibanaProvider {...{ coreStart }}>
{container}
@ -36,7 +37,10 @@ export const RedirectAppLinks: FC<RedirectAppLinksProps> = ({ children, ...props
);
}
const { navigateToUrl, currentAppId } = props;
const { navigateToUrl, currentAppId, ...containerProps } = props;
const container = (
<RedirectAppLinksContainer {...containerProps}>{children}</RedirectAppLinksContainer>
);
return (
<RedirectAppLinksProvider {...{ currentAppId, navigateToUrl }}>
{container}

View file

@ -32,7 +32,11 @@ export interface RedirectAppLinksKibanaDependencies {
}
/** Props for the `RedirectAppLinks` component. */
export type RedirectAppLinksProps = RedirectAppLinksServices | RedirectAppLinksKibanaDependencies;
export type RedirectAppLinksProps = (
| RedirectAppLinksServices
| RedirectAppLinksKibanaDependencies
) &
DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
/** Props for the `RedirectAppLinksComponent`. */
export interface RedirectAppLinksComponentProps

View file

@ -215,7 +215,7 @@ export function ChangeDataView({
})}
</EuiContextMenuItem>
) : (
<React.Fragment />
<React.Fragment key="empty" />
),
<EuiHorizontalRule margin="none" key="dataviewActions-divider" />
);
@ -336,7 +336,7 @@ export function ChangeDataView({
if (textBasedLanguages?.length) {
panelItems.push(
<EuiHorizontalRule margin="none" key="textbasedLanguages-divider" />,
<EuiPanel color="transparent" paddingSize="none">
<EuiPanel color="transparent" paddingSize="none" key="try-esql">
<EuiButton
color="success"
size="s"

View file

@ -1500,8 +1500,6 @@
"@kbn/stdio-dev-helpers/*": ["packages/kbn-stdio-dev-helpers/*"],
"@kbn/storybook": ["packages/kbn-storybook"],
"@kbn/storybook/*": ["packages/kbn-storybook/*"],
"@kbn/subscription-tracking": ["packages/kbn-subscription-tracking"],
"@kbn/subscription-tracking/*": ["packages/kbn-subscription-tracking/*"],
"@kbn/synthetics-plugin": ["x-pack/plugins/synthetics"],
"@kbn/synthetics-plugin/*": ["x-pack/plugins/synthetics/*"],
"@kbn/task-manager-fixture-plugin": ["x-pack/test/alerting_api_integration/common/plugins/task_manager_fixture"],
@ -1680,9 +1678,7 @@
"@kbn/zod-helpers/*": ["packages/kbn-zod-helpers/*"],
// END AUTOMATED PACKAGE LISTING
// Allows for importing from `kibana` package for the exported types.
"@emotion/core": [
"typings/@emotion"
],
"@emotion/core": ["typings/@emotion"]
},
// Support .tsx files and transform JSX into calls to React.createElement
"jsx": "react",
@ -1753,6 +1749,6 @@
"@kbn/ambient-ui-types",
"@kbn/ambient-common-types",
"@kbn/ambient-storybook-types"
],
]
}
}

View file

@ -42,6 +42,7 @@ export const getGenAiTokenTracking = async ({
try {
const { total, prompt, completion } = await getTokenCountFromInvokeStream({
responseStream: result.data.pipe(new PassThrough()),
actionTypeId,
body: (validatedParams as { subActionParams: InvokeBody }).subActionParams,
logger,
});

View file

@ -7,20 +7,15 @@
import { Transform } from 'stream';
import { getTokenCountFromInvokeStream } from './get_token_count_from_invoke_stream';
import { loggerMock } from '@kbn/logging-mocks';
import { EventStreamCodec } from '@smithy/eventstream-codec';
import { fromUtf8, toUtf8 } from '@smithy/util-utf8';
interface StreamMock {
write: (data: string) => void;
fail: () => void;
complete: () => void;
transform: Transform;
}
function createStreamMock(): StreamMock {
function createStreamMock() {
const transform: Transform = new Transform({});
return {
write: (data: string) => {
transform.push(`${data}\n`);
write: (data: unknown) => {
transform.push(data);
},
fail: () => {
transform.emit('error', new Error('Stream failed'));
@ -34,7 +29,10 @@ function createStreamMock(): StreamMock {
}
const logger = loggerMock.create();
describe('getTokenCountFromInvokeStream', () => {
let stream: StreamMock;
beforeEach(() => {
jest.resetAllMocks();
});
let stream: ReturnType<typeof createStreamMock>;
const body = {
messages: [
{
@ -48,36 +46,79 @@ describe('getTokenCountFromInvokeStream', () => {
],
};
const chunk = {
object: 'chat.completion.chunk',
choices: [
{
delta: {
content: 'Single.',
},
},
],
};
const PROMPT_TOKEN_COUNT = 34;
const COMPLETION_TOKEN_COUNT = 2;
beforeEach(() => {
stream = createStreamMock();
stream.write('Single');
});
describe('when a stream completes', () => {
beforeEach(async () => {
stream.complete();
describe('OpenAI stream', () => {
beforeEach(() => {
stream = createStreamMock();
stream.write(`data: ${JSON.stringify(chunk)}`);
});
it('counts the prompt tokens', async () => {
it('counts the prompt + completion tokens for OpenAI response', async () => {
stream.complete();
const tokens = await getTokenCountFromInvokeStream({
responseStream: stream.transform,
body,
logger,
actionTypeId: '.gen-ai',
});
expect(tokens.prompt).toBe(PROMPT_TOKEN_COUNT);
expect(tokens.completion).toBe(COMPLETION_TOKEN_COUNT);
expect(tokens.total).toBe(PROMPT_TOKEN_COUNT + COMPLETION_TOKEN_COUNT);
});
});
describe('when a stream fails', () => {
it('resolves the promise with the correct prompt tokens', async () => {
const tokenPromise = getTokenCountFromInvokeStream({
responseStream: stream.transform,
body,
logger,
actionTypeId: '.gen-ai',
});
stream.fail();
await expect(tokenPromise).resolves.toEqual({
prompt: PROMPT_TOKEN_COUNT,
total: PROMPT_TOKEN_COUNT + COMPLETION_TOKEN_COUNT,
completion: COMPLETION_TOKEN_COUNT,
});
expect(logger.error).toHaveBeenCalled();
});
});
describe('Bedrock stream', () => {
beforeEach(() => {
stream = createStreamMock();
stream.write(encodeBedrockResponse('Simple.'));
});
it('counts the prompt + completion tokens for OpenAI response', async () => {
stream.complete();
const tokens = await getTokenCountFromInvokeStream({
responseStream: stream.transform,
body,
logger,
actionTypeId: '.bedrock',
});
expect(tokens.prompt).toBe(PROMPT_TOKEN_COUNT);
expect(tokens.completion).toBe(COMPLETION_TOKEN_COUNT);
expect(tokens.total).toBe(PROMPT_TOKEN_COUNT + COMPLETION_TOKEN_COUNT);
});
it('resolves the promise with the correct prompt tokens', async () => {
const tokenPromise = getTokenCountFromInvokeStream({
responseStream: stream.transform,
body,
logger,
actionTypeId: '.bedrock',
});
stream.fail();
@ -91,3 +132,16 @@ describe('getTokenCountFromInvokeStream', () => {
});
});
});
function encodeBedrockResponse(completion: string) {
return new EventStreamCodec(toUtf8, fromUtf8).encode({
headers: {},
body: Uint8Array.from(
Buffer.from(
JSON.stringify({
bytes: Buffer.from(JSON.stringify({ completion })).toString('base64'),
})
)
),
});
}

View file

@ -9,6 +9,8 @@ import { Logger } from '@kbn/logging';
import { encode } from 'gpt-tokenizer';
import { Readable } from 'stream';
import { finished } from 'stream/promises';
import { EventStreamCodec } from '@smithy/eventstream-codec';
import { fromUtf8, toUtf8 } from '@smithy/util-utf8';
export interface InvokeBody {
messages: Array<{
@ -26,10 +28,12 @@ export interface InvokeBody {
* @param logger the logger
*/
export async function getTokenCountFromInvokeStream({
actionTypeId,
responseStream,
body,
logger,
}: {
actionTypeId: string;
responseStream: Readable;
body: InvokeBody;
logger: Logger;
@ -47,9 +51,37 @@ export async function getTokenCountFromInvokeStream({
.join('\n')
).length;
let responseBody: string = '';
const parser = actionTypeId === '.bedrock' ? parseBedrockStream : parseOpenAIStream;
const parsedResponse = await parser(responseStream, logger);
responseStream.on('data', (chunk: string) => {
const completionTokens = encode(parsedResponse).length;
return {
prompt: promptTokens,
completion: completionTokens,
total: promptTokens + completionTokens,
};
}
type StreamParser = (responseStream: Readable, logger: Logger) => Promise<string>;
const parseBedrockStream: StreamParser = async (responseStream, logger) => {
const responseBuffer: Uint8Array[] = [];
responseStream.on('data', (chunk) => {
// special encoding for bedrock, do not attempt to convert to string
responseBuffer.push(chunk);
});
try {
await finished(responseStream);
} catch (e) {
logger.error('An error occurred while calculating streaming response tokens');
}
return parseBedrockBuffer(responseBuffer);
};
const parseOpenAIStream: StreamParser = async (responseStream, logger) => {
let responseBody: string = '';
responseStream.on('data', (chunk) => {
// no special encoding, can safely use toString and append to responseBody
responseBody += chunk.toString();
});
try {
@ -57,12 +89,109 @@ export async function getTokenCountFromInvokeStream({
} catch (e) {
logger.error('An error occurred while calculating streaming response tokens');
}
return parseOpenAIResponse(responseBody);
};
const completionTokens = encode(responseBody).length;
/**
* Parses a Bedrock buffer from an array of chunks.
*
* @param {Uint8Array[]} chunks - Array of Uint8Array chunks to be parsed.
* @returns {string} - Parsed string from the Bedrock buffer.
*/
const parseBedrockBuffer = (chunks: Uint8Array[]): string => {
// Initialize an empty Uint8Array to store the concatenated buffer.
let bedrockBuffer: Uint8Array = new Uint8Array(0);
return {
prompt: promptTokens,
completion: completionTokens,
total: promptTokens + completionTokens,
};
// Map through each chunk to process the Bedrock buffer.
return chunks
.map((chunk) => {
// Concatenate the current chunk to the existing buffer.
bedrockBuffer = concatChunks(bedrockBuffer, chunk);
// Get the length of the next message in the buffer.
let messageLength = getMessageLength(bedrockBuffer);
// Initialize an array to store fully formed message chunks.
const buildChunks = [];
// Process the buffer until no complete messages are left.
while (bedrockBuffer.byteLength > 0 && bedrockBuffer.byteLength >= messageLength) {
// Extract a chunk of the specified length from the buffer.
const extractedChunk = bedrockBuffer.slice(0, messageLength);
// Add the extracted chunk to the array of fully formed message chunks.
buildChunks.push(extractedChunk);
// Remove the processed chunk from the buffer.
bedrockBuffer = bedrockBuffer.slice(messageLength);
// Get the length of the next message in the updated buffer.
messageLength = getMessageLength(bedrockBuffer);
}
const awsDecoder = new EventStreamCodec(toUtf8, fromUtf8);
// Decode and parse each message chunk, extracting the 'completion' property.
return buildChunks
.map((bChunk) => {
const event = awsDecoder.decode(bChunk);
const body = JSON.parse(
Buffer.from(JSON.parse(new TextDecoder().decode(event.body)).bytes, 'base64').toString()
);
return body.completion;
})
.join('');
})
.join('');
};
/**
* Concatenates two Uint8Array buffers.
*
* @param {Uint8Array} a - First buffer.
* @param {Uint8Array} b - Second buffer.
* @returns {Uint8Array} - Concatenated buffer.
*/
function concatChunks(a: Uint8Array, b: Uint8Array): Uint8Array {
const newBuffer = new Uint8Array(a.length + b.length);
// Copy the contents of the first buffer to the new buffer.
newBuffer.set(a);
// Copy the contents of the second buffer to the new buffer starting from the end of the first buffer.
newBuffer.set(b, a.length);
return newBuffer;
}
/**
* Gets the length of the next message from the buffer.
*
* @param {Uint8Array} buffer - Buffer containing the message.
* @returns {number} - Length of the next message.
*/
function getMessageLength(buffer: Uint8Array): number {
// If the buffer is empty, return 0.
if (buffer.byteLength === 0) return 0;
// Create a DataView to read the Uint32 value at the beginning of the buffer.
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
// Read and return the Uint32 value (message length).
return view.getUint32(0, false);
}
const parseOpenAIResponse = (responseBody: string) =>
responseBody
.split('\n')
.filter((line) => {
return line.startsWith('data: ') && !line.endsWith('[DONE]');
})
.map((line) => {
return JSON.parse(line.replace('data: ', ''));
})
.filter(
(
line
): line is {
choices: Array<{
delta: { content?: string; function_call?: { name?: string; arguments: string } };
}>;
} => {
return 'object' in line && line.object === 'chat.completion.chunk';
}
)
.reduce((prev, line) => {
const msg = line.choices[0].delta!;
prev += msg.content || '';
return prev;
}, '');

View file

@ -6,15 +6,8 @@
*/
import React from 'react';
import { EuiEmptyPrompt, EuiPageSection } from '@elastic/eui';
import { EuiEmptyPrompt, EuiLink, EuiPageSection } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { SubscriptionLink } from '@kbn/subscription-tracking';
import type { SubscriptionContextData } from '@kbn/subscription-tracking';
const subscriptionContext: SubscriptionContextData = {
feature: 'cloud-security-posture',
source: 'security__cloud-security-posture',
};
export const SubscriptionNotAllowed = ({
licenseManagementLocator,
@ -41,12 +34,12 @@ export const SubscriptionNotAllowed = ({
defaultMessage="To use these cloud security features, you must {link}."
values={{
link: (
<SubscriptionLink subscriptionContext={subscriptionContext}>
<EuiLink href={licenseManagementLocator}>
<FormattedMessage
id="xpack.csp.subscriptionNotAllowed.promptLinkText"
defaultMessage="start a trial or upgrade your subscription"
/>
</SubscriptionLink>
</EuiLink>
),
}}
/>

View file

@ -11,7 +11,6 @@ import { I18nProvider } from '@kbn/i18n-react';
// eslint-disable-next-line no-restricted-imports
import { Router } from 'react-router-dom';
import { Route, Routes } from '@kbn/shared-ux-router';
import { MockSubscriptionTrackingProvider } from '@kbn/subscription-tracking/mocks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { coreMock } from '@kbn/core/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
@ -52,11 +51,9 @@ export const TestProvider: React.FC<Partial<CspAppDeps>> = ({
<QueryClientProvider client={queryClient}>
<Router history={params.history}>
<I18nProvider>
<MockSubscriptionTrackingProvider>
<Routes>
<Route path="*" render={() => <>{children}</>} />
</Routes>
</MockSubscriptionTrackingProvider>
<Routes>
<Route path="*" render={() => <>{children}</>} />
</Routes>
</I18nProvider>
</Router>
</QueryClientProvider>

View file

@ -50,7 +50,6 @@
"@kbn/share-plugin",
"@kbn/core-http-server",
"@kbn/core-http-browser",
"@kbn/subscription-tracking",
"@kbn/discover-utils",
"@kbn/unified-data-table",
"@kbn/cell-actions",

View file

@ -309,8 +309,7 @@ describe('<FollowerIndicesList />', () => {
});
});
// FLAKY: https://github.com/elastic/kibana/issues/100951
describe.skip('detail panel', () => {
describe('detail panel', () => {
test('should open a detail panel when clicking on a follower index', async () => {
expect(exists('followerIndexDetail')).toBe(false);
@ -372,7 +371,8 @@ describe('<FollowerIndicesList />', () => {
);
});
test('should have a section to render the follower index shards stats', async () => {
// FLAKY: https://github.com/elastic/kibana/issues/100951
test.skip('should have a section to render the follower index shards stats', async () => {
await actions.clickFollowerIndexAt(0);
expect(exists('followerIndexDetail.shardsStatsSection')).toBe(true);

View file

@ -23,12 +23,7 @@ export type KafkaPartitionType = typeof kafkaPartitionType;
export type KafkaTopicWhenType = typeof kafkaTopicWhenType;
export type KafkaAcknowledgeReliabilityLevel = typeof kafkaAcknowledgeReliabilityLevel;
export type KafkaVerificationMode = typeof kafkaVerificationModes;
export type OutputSecret =
| string
| {
id: string;
hash?: string;
};
interface NewBaseOutput {
is_default: boolean;
is_default_monitoring: boolean;
@ -50,7 +45,11 @@ interface NewBaseOutput {
allow_edit?: string[];
secrets?: {
ssl?: {
key?: OutputSecret;
key?:
| string
| {
id: string;
};
};
};
}
@ -132,9 +131,17 @@ export interface KafkaOutput extends NewBaseOutput {
broker_timeout?: number;
required_acks?: ValueOf<KafkaAcknowledgeReliabilityLevel>;
secrets?: {
password?: OutputSecret;
password?:
| string
| {
id: string;
};
ssl?: {
key?: OutputSecret;
key?:
| string
| {
id: string;
};
};
};
}

View file

@ -322,7 +322,7 @@ export const EditOutputFlyout: React.FunctionComponent<EditOutputFlyoutProps> =
case outputType.Elasticsearch:
return i18n.translate('xpack.fleet.settings.editOutputFlyout.esOutputTypeCallout', {
defaultMessage:
'This output type does not support connectivity to a remote Elasticsearch cluster, please the Remote Elasticsearch type for that.',
'This output type does not support connectivity to a remote Elasticsearch cluster, please use the Remote Elasticsearch type for that.',
});
}
};
@ -335,7 +335,7 @@ export const EditOutputFlyout: React.FunctionComponent<EditOutputFlyoutProps> =
defaultMessage="Enter your output hosts, service token for your remote cluster, and any advanced YAML configuration. Learn more about how to use these parameters in {doc}."
values={{
doc: (
<EuiLink href={docLinks.links.fleet.guide} target="_blank">
<EuiLink href={docLinks.links.fleet.remoteESOoutput} target="_blank">
{i18n.translate('xpack.fleet.settings.editOutputFlyout.docLabel', {
defaultMessage: 'our documentation',
})}

View file

@ -419,12 +419,7 @@ class OutputService {
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient,
output: NewOutput,
options?: {
id?: string;
fromPreconfiguration?: boolean;
overwrite?: boolean;
secretHashes?: Record<string, any>;
}
options?: { id?: string; fromPreconfiguration?: boolean; overwrite?: boolean }
): Promise<Output> {
const data: OutputSOAttributes = { ...omit(output, ['ssl', 'secrets']) };
if (output.type === outputType.RemoteElasticsearch) {
@ -560,7 +555,6 @@ class OutputService {
const { output: outputWithSecrets } = await extractAndWriteOutputSecrets({
output,
esClient,
secretHashes: output.is_preconfigured ? options?.secretHashes : undefined,
});
if (outputWithSecrets.secrets) data.secrets = outputWithSecrets.secrets;
@ -722,10 +716,7 @@ class OutputService {
esClient: ElasticsearchClient,
id: string,
data: Partial<Output>,
{
fromPreconfiguration = false,
secretHashes,
}: { fromPreconfiguration: boolean; secretHashes?: Record<string, any> } = {
{ fromPreconfiguration = false }: { fromPreconfiguration: boolean } = {
fromPreconfiguration: false,
}
) {
@ -756,7 +747,6 @@ class OutputService {
oldOutput: originalOutput,
outputUpdate: data,
esClient,
secretHashes: data.is_preconfigured ? secretHashes : undefined,
});
updateData.secrets = secretsRes.outputUpdate.secrets;

View file

@ -17,7 +17,6 @@ import {
createOrUpdatePreconfiguredOutputs,
cleanPreconfiguredOutputs,
getPreconfiguredOutputFromConfig,
hash,
} from './outputs';
jest.mock('../agent_policy_update');
@ -47,18 +46,16 @@ const spyAgentPolicyServicBumpAllAgentPoliciesForOutput = jest.spyOn(
);
describe('output preconfiguration', () => {
beforeEach(async () => {
beforeEach(() => {
mockedOutputService.create.mockReset();
mockedOutputService.update.mockReset();
mockedOutputService.delete.mockReset();
mockedOutputService.getDefaultDataOutputId.mockReset();
mockedOutputService.getDefaultESHosts.mockReturnValue(['http://default-es:9200']);
const keyHash = await hash('secretKey');
const passwordHash = await hash('secretPassword');
mockedOutputService.bulkGet.mockImplementation(async (soClient, id): Promise<Output[]> => {
return [
{
id: 'existing-es-output-1',
id: 'existing-output-1',
is_default: false,
is_default_monitoring: false,
name: 'Output 1',
@ -77,76 +74,8 @@ describe('output preconfiguration', () => {
hosts: ['kafka.co:80'],
is_preconfigured: true,
},
{
id: 'existing-logstash-output-with-secrets-1',
is_default: false,
is_default_monitoring: false,
name: 'Logstash Output With Secrets 1',
type: 'logstash',
hosts: ['test:4343'],
is_preconfigured: true,
secrets: {
ssl: {
key: {
id: '123',
hash: keyHash,
},
},
},
},
{
id: 'existing-logstash-output-with-secrets-2',
is_default: false,
is_default_monitoring: false,
name: 'Logstash Output With Secrets 2',
type: 'logstash',
hosts: ['test:4343'],
is_preconfigured: true,
secrets: {
ssl: {
key: 'secretKey',
},
},
},
{
id: 'existing-kafka-output-with-secrets-1',
is_default: false,
is_default_monitoring: false,
name: 'Kafka Output With Secrets 1',
type: 'kafka',
hosts: ['kafka.co:80'],
is_preconfigured: true,
secrets: {
password: {
id: '456',
hash: passwordHash,
},
ssl: {
key: {
id: '789',
hash: keyHash,
},
},
},
},
{
id: 'existing-kafka-output-with-secrets-2',
is_default: false,
is_default_monitoring: false,
name: 'Kafka Output With Secrets 2',
type: 'kafka',
hosts: ['kafka.co:80'],
is_preconfigured: true,
secrets: {
password: 'secretPassword',
ssl: {
key: 'secretKey',
},
},
},
];
});
spyAgentPolicyServicBumpAllAgentPoliciesForOutput.mockClear();
});
it('should generate a preconfigured output if elasticsearch.hosts is set in the config', async () => {
@ -175,7 +104,7 @@ describe('output preconfiguration', () => {
`);
});
it('should create preconfigured output that does not exist', async () => {
it('should create preconfigured output that does not exists', async () => {
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
await createOrUpdatePreconfiguredOutputs(soClient, esClient, [
@ -194,7 +123,7 @@ describe('output preconfiguration', () => {
expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled();
});
it('should create preconfigured kafka output that does not exist', async () => {
it('should create preconfigured kafka output that does not exists', async () => {
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
await createOrUpdatePreconfiguredOutputs(soClient, esClient, [
@ -213,7 +142,7 @@ describe('output preconfiguration', () => {
expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled();
});
it('should create a preconfigured output with ca_trusted_fingerprint that does not exist', async () => {
it('should create a preconfigured output with ca_trusted_fingerprint that does not exists', async () => {
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
await createOrUpdatePreconfiguredOutputs(soClient, esClient, [
@ -241,7 +170,7 @@ describe('output preconfiguration', () => {
expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled();
});
it('should create a preconfigured logstash output that does not exist', async () => {
it('should create preconfigured logstash output that does not exist', async () => {
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
await createOrUpdatePreconfiguredOutputs(soClient, esClient, [
@ -261,66 +190,7 @@ describe('output preconfiguration', () => {
expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled();
});
it('should create a preconfigured logstash output with secrets that does not exist', async () => {
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
await createOrUpdatePreconfiguredOutputs(soClient, esClient, [
{
id: 'non-existing-logstash-output-with-secrets-1',
name: 'Logstash Output With Secrets 2',
type: 'logstash',
is_default: false,
is_default_monitoring: false,
secrets: {
ssl: {
key: 'secretKey',
},
},
},
]);
expect(mockedOutputService.create).toBeCalled();
expect(mockedOutputService.create).toBeCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
secrets: {
ssl: {
key: 'secretKey',
},
},
}),
expect.anything()
);
expect(mockedOutputService.update).not.toBeCalled();
expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled();
});
it('should create a preconfigured kafka output with secrets that does not exist', async () => {
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
await createOrUpdatePreconfiguredOutputs(soClient, esClient, [
{
id: 'non-existing-kafka-output-with-secrets-1',
name: 'Kafka Output With Secrets 2',
type: 'kafka',
is_default: false,
is_default_monitoring: false,
secrets: {
password: 'secretPassword',
ssl: {
key: 'secretKey',
},
},
},
]);
expect(mockedOutputService.create).toBeCalled();
expect(mockedOutputService.update).not.toBeCalled();
expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled();
});
it('should set default hosts if hosts is not set output that does not exist', async () => {
it('should set default hosts if hosts is not set output that does not exists', async () => {
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
await createOrUpdatePreconfiguredOutputs(soClient, esClient, [
@ -343,7 +213,7 @@ describe('output preconfiguration', () => {
soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 });
mockedOutputService.bulkGet.mockResolvedValue([
{
id: 'existing-es-output-1',
id: 'existing-output-1',
is_default: false,
is_default_monitoring: false,
name: 'Output 1',
@ -355,7 +225,7 @@ describe('output preconfiguration', () => {
]);
await createOrUpdatePreconfiguredOutputs(soClient, esClient, [
{
id: 'existing-es-output-1',
id: 'existing-output-1',
is_default: false,
is_default_monitoring: false,
name: 'Output 1',
@ -369,7 +239,7 @@ describe('output preconfiguration', () => {
expect(mockedOutputService.update).toBeCalledWith(
expect.anything(),
expect.anything(),
'existing-es-output-1',
'existing-output-1',
expect.objectContaining({
is_preconfigured: true,
}),
@ -384,7 +254,7 @@ describe('output preconfiguration', () => {
soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 });
await createOrUpdatePreconfiguredOutputs(soClient, esClient, [
{
id: 'existing-es-output-1',
id: 'existing-output-1',
is_default: false,
is_default_monitoring: false,
name: 'Output 1',
@ -398,30 +268,6 @@ describe('output preconfiguration', () => {
expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled();
});
it('should update output if a preconfigured logstash ouput with secrets exists and has changed', async () => {
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 });
await createOrUpdatePreconfiguredOutputs(soClient, esClient, [
{
id: 'existing-logstash-output-with-secrets-1',
is_default: false,
is_default_monitoring: false,
name: 'Logstash Output With Secrets 1',
type: 'logstash',
secrets: {
ssl: {
key: 'secretKey2', // field that changed
},
},
},
]);
expect(mockedOutputService.create).not.toBeCalled();
expect(mockedOutputService.update).toBeCalled();
expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled();
});
it('should update output if preconfigured kafka output exists and changed', async () => {
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
@ -433,7 +279,7 @@ describe('output preconfiguration', () => {
is_default_monitoring: false,
name: 'Kafka Output 1',
type: 'kafka',
hosts: ['kafka.co:8080'], // field that changed
hosts: ['kafka.co:8080'],
},
]);
@ -442,49 +288,24 @@ describe('output preconfiguration', () => {
expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled();
});
it('should update ouput if a preconfigured kafka with secrets exists and has changed', async () => {
it('should not update output if preconfigured output exists and did not changed', async () => {
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 });
await createOrUpdatePreconfiguredOutputs(soClient, esClient, [
{
id: 'existing-kafka-output-with-secrets-1',
is_default: false,
is_default_monitoring: false,
name: 'Kafka Output With Secrets 1',
type: 'kafka',
secrets: {
password: 'secretPassword2', // field that changed
ssl: {
key: 'secretKey2',
},
},
},
]);
expect(mockedOutputService.create).not.toBeCalled();
expect(mockedOutputService.update).toBeCalled();
expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled();
});
it('should not update output if preconfigured output exists and did not change', async () => {
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 });
await createOrUpdatePreconfiguredOutputs(soClient, esClient, [
{
id: 'existing-es-output-1',
id: 'existing-output-1',
is_default: false,
is_default_monitoring: false,
name: 'Output 1',
type: 'elasticsearch',
hosts: ['http://es.co:80'],
hosts: ['http://newhostichanged.co:9201'], // field that changed
},
]);
expect(mockedOutputService.create).not.toBeCalled();
expect(mockedOutputService.update).not.toBeCalled();
expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled();
expect(mockedOutputService.update).toBeCalled();
expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled();
});
it('should not update output if preconfigured kafka output exists and did not change', async () => {
@ -498,109 +319,7 @@ describe('output preconfiguration', () => {
is_default_monitoring: false,
name: 'Kafka Output 1',
type: 'kafka',
hosts: ['kafka.co:80'],
},
]);
expect(mockedOutputService.create).not.toBeCalled();
expect(mockedOutputService.update).not.toBeCalled();
expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled();
});
it('should not update output if a preconfigured logstash output with secrets exists and did not change', async () => {
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 });
await createOrUpdatePreconfiguredOutputs(soClient, esClient, [
{
id: 'existing-logstash-output-with-secrets-1',
is_default: false,
is_default_monitoring: false,
name: 'Logstash Output With Secrets 1',
type: 'logstash',
hosts: ['test:4343'],
secrets: {
ssl: {
key: 'secretKey',
},
},
},
]);
expect(mockedOutputService.create).not.toBeCalled();
expect(mockedOutputService.update).not.toBeCalled();
expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled();
});
it('should not update output if a preconfigured kafka output with secrets exists and did not change', async () => {
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 });
await createOrUpdatePreconfiguredOutputs(soClient, esClient, [
{
id: 'existing-kafka-output-with-secrets-1',
is_default: false,
is_default_monitoring: false,
name: 'Kafka Output With Secrets 1',
type: 'kafka',
hosts: ['kafka.co:80'],
secrets: {
password: 'secretPassword',
ssl: {
key: 'secretKey',
},
},
},
]);
expect(mockedOutputService.create).not.toBeCalled();
expect(mockedOutputService.update).not.toBeCalled();
expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled();
});
it('should update output if a preconfigured logstash output with plain value secrets exists and did not change', async () => {
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 });
await createOrUpdatePreconfiguredOutputs(soClient, esClient, [
{
id: 'existing-logstash-output-with-secrets-2',
is_default: false,
is_default_monitoring: false,
name: 'Logstash Output With Secrets 2',
type: 'logstash',
hosts: ['test:4343'],
secrets: {
ssl: {
key: 'secretKey', // no change
},
},
},
]);
expect(mockedOutputService.create).not.toBeCalled();
expect(mockedOutputService.update).toBeCalled();
expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled();
});
it('should update output if a preconfigured kafka output with plain value secrets exists and did not change', async () => {
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 });
await createOrUpdatePreconfiguredOutputs(soClient, esClient, [
{
id: 'existing-kafka-output-with-secrets-2',
is_default: false,
is_default_monitoring: false,
name: 'Kafka Output With Secrets 2',
type: 'kafka',
hosts: ['kafka.co:80'],
secrets: {
password: 'secretPassword', // no change
ssl: {
key: 'secretKey', // no change
},
},
hosts: ['kafka.co:8080'],
},
]);
@ -613,7 +332,7 @@ describe('output preconfiguration', () => {
{
name: 'no changes',
data: {
id: 'existing-es-output-1',
id: 'existing-output-1',
is_default: false,
is_default_monitoring: false,
name: 'Output 1',
@ -624,7 +343,7 @@ describe('output preconfiguration', () => {
{
name: 'hosts without port',
data: {
id: 'existing-es-output-1',
id: 'existing-output-1',
is_default: false,
is_default_monitoring: false,
name: 'Output 1',

View file

@ -8,16 +8,8 @@
import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server';
import { isEqual } from 'lodash';
import { safeDump } from 'js-yaml';
import argon2 from 'argon2';
import type {
PreconfiguredOutput,
Output,
NewOutput,
OutputSecret,
KafkaOutput,
NewLogstashOutput,
} from '../../../common/types';
import type { PreconfiguredOutput, Output, NewOutput } from '../../../common/types';
import { normalizeHostsForAgents } from '../../../common/services';
import type { FleetConfigType } from '../../config';
import { DEFAULT_OUTPUT_ID, DEFAULT_OUTPUT } from '../../constants';
@ -107,79 +99,25 @@ export async function createOrUpdatePreconfiguredOutputs(
}
const isUpdateWithNewData =
existingOutput && (await isPreconfiguredOutputDifferentFromCurrent(existingOutput, data));
existingOutput && isPreconfiguredOutputDifferentFromCurrent(existingOutput, data);
if (isCreate || isUpdateWithNewData) {
const secretHashes = await hashSecrets(output);
if (isCreate) {
logger.debug(`Creating preconfigured output ${output.id}`);
await outputService.create(soClient, esClient, data, {
id,
fromPreconfiguration: true,
secretHashes,
});
} else if (isUpdateWithNewData) {
logger.debug(`Updating preconfigured output ${output.id}`);
await outputService.update(soClient, esClient, id, data, {
fromPreconfiguration: true,
secretHashes,
});
// Bump revision of all policies using that output
if (outputData.is_default || outputData.is_default_monitoring) {
await agentPolicyService.bumpAllAgentPolicies(soClient, esClient);
} else {
await agentPolicyService.bumpAllAgentPoliciesForOutput(soClient, esClient, id);
}
if (isCreate) {
logger.debug(`Creating output ${output.id}`);
await outputService.create(soClient, esClient, data, { id, fromPreconfiguration: true });
} else if (isUpdateWithNewData) {
logger.debug(`Updating output ${output.id}`);
await outputService.update(soClient, esClient, id, data, { fromPreconfiguration: true });
// Bump revision of all policies using that output
if (outputData.is_default || outputData.is_default_monitoring) {
await agentPolicyService.bumpAllAgentPolicies(soClient, esClient);
} else {
await agentPolicyService.bumpAllAgentPoliciesForOutput(soClient, esClient, id);
}
}
})
);
}
export async function hash(str: string) {
return argon2.hash(str, {
type: argon2.argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
});
}
async function hashSecrets(output: PreconfiguredOutput) {
if (output.type === 'kafka') {
const kafkaOutput = output as KafkaOutput;
if (typeof kafkaOutput.secrets?.password === 'string') {
const password = await hash(kafkaOutput.secrets?.password);
return {
password,
};
}
if (typeof kafkaOutput.secrets?.ssl?.key === 'string') {
const key = await hash(kafkaOutput.secrets?.ssl?.key);
return {
ssl: {
key,
},
};
}
}
if (output.type === 'logstash') {
const logstashOutput = output as NewLogstashOutput;
if (typeof logstashOutput.secrets?.ssl?.key === 'string') {
const key = await hash(logstashOutput.secrets?.ssl?.key);
return {
ssl: {
key,
},
};
}
}
return undefined;
}
export async function cleanPreconfiguredOutputs(
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient,
@ -227,56 +165,15 @@ export async function cleanPreconfiguredOutputs(
}
}
const hasHash = (secret?: OutputSecret): secret is { id: string; hash: string } => {
return !!secret && typeof secret !== 'string' && !!secret.hash;
};
async function isSecretDifferent(
preconfiguredValue: OutputSecret | undefined,
existingSecret: OutputSecret | undefined
): Promise<boolean> {
if (!existingSecret && preconfiguredValue) {
return true;
}
if (!preconfiguredValue && existingSecret) {
return true;
}
if (!preconfiguredValue && !existingSecret) {
return false;
}
if (hasHash(existingSecret) && typeof preconfiguredValue === 'string') {
// verifying the has tells us if the value has changed
const hashIsVerified = await argon2.verify(existingSecret.hash, preconfiguredValue!);
return !hashIsVerified;
} else {
// if there is no hash then the safest thing to do is assume the value has changed
return true;
}
}
async function isPreconfiguredOutputDifferentFromCurrent(
function isPreconfiguredOutputDifferentFromCurrent(
existingOutput: Output,
preconfiguredOutput: Partial<Output>
): Promise<boolean> {
const kafkaFieldsAreDifferent = async (): Promise<boolean> => {
): boolean {
const kafkaFieldsAreDifferent = (): boolean => {
if (existingOutput.type !== 'kafka' || preconfiguredOutput.type !== 'kafka') {
return false;
}
const passwordHashIsDifferent = await isSecretDifferent(
preconfiguredOutput.secrets?.password,
existingOutput.secrets?.password
);
const sslKeyHashIsDifferent = await isSecretDifferent(
preconfiguredOutput.secrets?.ssl?.key,
existingOutput.secrets?.ssl?.key
);
return (
isDifferent(existingOutput.client_id, preconfiguredOutput.client_id) ||
isDifferent(existingOutput.version, preconfiguredOutput.version) ||
@ -296,24 +193,10 @@ async function isPreconfiguredOutputDifferentFromCurrent(
isDifferent(existingOutput.headers, preconfiguredOutput.headers) ||
isDifferent(existingOutput.timeout, preconfiguredOutput.timeout) ||
isDifferent(existingOutput.broker_timeout, preconfiguredOutput.broker_timeout) ||
isDifferent(existingOutput.required_acks, preconfiguredOutput.required_acks) ||
passwordHashIsDifferent ||
sslKeyHashIsDifferent
isDifferent(existingOutput.required_acks, preconfiguredOutput.required_acks)
);
};
const logstashFieldsAreDifferent = async (): Promise<boolean> => {
if (existingOutput.type !== 'logstash' || preconfiguredOutput.type !== 'logstash') {
return false;
}
const sslKeyHashIsDifferent = await isSecretDifferent(
preconfiguredOutput.secrets?.ssl?.key,
existingOutput.secrets?.ssl?.key
);
return sslKeyHashIsDifferent;
};
return (
!existingOutput.is_preconfigured ||
isDifferent(existingOutput.is_default, preconfiguredOutput.is_default) ||
@ -338,7 +221,6 @@ async function isPreconfiguredOutputDifferentFromCurrent(
isDifferent(existingOutput.config_yaml, preconfiguredOutput.config_yaml) ||
isDifferent(existingOutput.proxy_id, preconfiguredOutput.proxy_id) ||
isDifferent(existingOutput.allow_edit ?? [], preconfiguredOutput.allow_edit ?? []) ||
(await kafkaFieldsAreDifferent()) ||
(await logstashFieldsAreDifferent())
kafkaFieldsAreDifferent()
);
}

View file

@ -7,7 +7,7 @@
import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server';
import { get, keyBy } from 'lodash';
import { keyBy } from 'lodash';
import { set } from '@kbn/safer-lodash-set';
import type { KafkaOutput, Output, OutputSecretPath } from '../../common/types';
@ -247,9 +247,8 @@ export async function extractAndWriteSecrets(opts: {
export async function extractAndWriteOutputSecrets(opts: {
output: NewOutput;
esClient: ElasticsearchClient;
secretHashes?: Record<string, any>;
}): Promise<{ output: NewOutput; secretReferences: PolicySecretReference[] }> {
const { output, esClient, secretHashes = {} } = opts;
const { output, esClient } = opts;
const secretPaths = getOutputSecretPaths(output.type, output).filter(
(path) => typeof path.value === 'string'
@ -266,12 +265,7 @@ export async function extractAndWriteOutputSecrets(opts: {
const outputWithSecretRefs = JSON.parse(JSON.stringify(output));
secretPaths.forEach((secretPath, i) => {
const pathWithoutPrefix = secretPath.path.replace('secrets.', '');
const maybeHash = get(secretHashes, pathWithoutPrefix);
set(outputWithSecretRefs, secretPath.path, {
id: secrets[i].id,
...(typeof maybeHash === 'string' && { hash: maybeHash }),
});
set(outputWithSecretRefs, secretPath.path, { id: secrets[i].id });
});
return {
@ -405,13 +399,12 @@ export async function extractAndUpdateOutputSecrets(opts: {
oldOutput: Output;
outputUpdate: Partial<Output>;
esClient: ElasticsearchClient;
secretHashes?: Record<string, any>;
}): Promise<{
outputUpdate: Partial<Output>;
secretReferences: PolicySecretReference[];
secretsToDelete: PolicySecretReference[];
}> {
const { oldOutput, outputUpdate, esClient, secretHashes } = opts;
const { oldOutput, outputUpdate, esClient } = opts;
const outputType = outputUpdate.type || oldOutput.type;
const oldSecretPaths = getOutputSecretPaths(outputType, oldOutput);
const updatedSecretPaths = getOutputSecretPaths(outputType, outputUpdate);
@ -432,13 +425,7 @@ export async function extractAndUpdateOutputSecrets(opts: {
const outputWithSecretRefs = JSON.parse(JSON.stringify(outputUpdate));
toCreate.forEach((secretPath, i) => {
const pathWithoutPrefix = secretPath.path.replace('secrets.', '');
const maybeHash = get(secretHashes, pathWithoutPrefix);
set(outputWithSecretRefs, secretPath.path, {
id: createdSecrets[i].id,
...(typeof maybeHash === 'string' && { hash: maybeHash }),
});
set(outputWithSecretRefs, secretPath.path, { id: createdSecrets[i].id });
});
const secretReferences = [

View file

@ -10,6 +10,10 @@ export { API_BASE_PATH, INTERNAL_API_BASE_PATH } from './api_base_path';
export { INVALID_INDEX_PATTERN_CHARS, INVALID_TEMPLATE_NAME_CHARS } from './invalid_characters';
export * from './index_statuses';
// Since each index can have a max length or 255 characters and the max length of
// the request is 4096 bytes we can fit a max of 16 indices in a single request.
export const MAX_INDICES_PER_REQUEST = 16;
export {
UIM_APP_NAME,
UIM_APP_LOAD,

View file

@ -27,8 +27,9 @@ export const clearCacheIndices =
dispatch(reloadIndices(indexNames));
notificationService.showSuccessToast(
i18n.translate('xpack.idxMgmt.clearCacheIndicesAction.successMessage', {
defaultMessage: 'Successfully cleared cache: [{indexNames}]',
values: { indexNames: indexNames.join(', ') },
defaultMessage:
'Successfully cleared cache for {count, plural, one {# index} other {# indices} }',
values: { count: indexNames.length },
})
);
};

View file

@ -25,8 +25,8 @@ export const closeIndices =
dispatch(reloadIndices(indexNames));
notificationService.showSuccessToast(
i18n.translate('xpack.idxMgmt.closeIndicesAction.successfullyClosedIndicesMessage', {
defaultMessage: 'Successfully closed: [{indexNames}]',
values: { indexNames: indexNames.join(', ') },
defaultMessage: 'Successfully closed {count, plural, one {# index} other {# indices} }',
values: { count: indexNames.length },
})
);
};

View file

@ -23,8 +23,8 @@ export const deleteIndices =
}
notificationService.showSuccessToast(
i18n.translate('xpack.idxMgmt.deleteIndicesAction.successfullyDeletedIndicesMessage', {
defaultMessage: 'Successfully deleted: [{indexNames}]',
values: { indexNames: indexNames.join(', ') },
defaultMessage: 'Successfully deleted {count, plural, one {# index} other {# indices} }',
values: { count: indexNames.length },
})
);
dispatch(deleteIndicesSuccess({ indexNames }));

View file

@ -26,8 +26,8 @@ export const flushIndices =
dispatch(reloadIndices(indexNames));
notificationService.showSuccessToast(
i18n.translate('xpack.idxMgmt.flushIndicesAction.successfullyFlushedIndicesMessage', {
defaultMessage: 'Successfully flushed: [{indexNames}]',
values: { indexNames: indexNames.join(', ') },
defaultMessage: 'Successfully flushed {count, plural, one {# index} other {# indices} }',
values: { count: indexNames.length },
})
);
};

View file

@ -28,8 +28,9 @@ export const forcemergeIndices =
i18n.translate(
'xpack.idxMgmt.forceMergeIndicesAction.successfullyForceMergedIndicesMessage',
{
defaultMessage: 'Successfully force merged: [{indexNames}]',
values: { indexNames: indexNames.join(', ') },
defaultMessage:
'Successfully force merged {count, plural, one {# index} other {# indices} }',
values: { count: indexNames.length },
}
)
);

View file

@ -26,8 +26,8 @@ export const openIndices =
dispatch(reloadIndices(indexNames));
notificationService.showSuccessToast(
i18n.translate('xpack.idxMgmt.openIndicesAction.successfullyOpenedIndicesMessage', {
defaultMessage: 'Successfully opened: [{indexNames}]',
values: { indexNames: indexNames.join(', ') },
defaultMessage: 'Successfully opened {count, plural, one {# index} other {# indices} }',
values: { count: indexNames.length },
})
);
};

View file

@ -26,8 +26,8 @@ export const refreshIndices =
dispatch(reloadIndices(indexNames));
notificationService.showSuccessToast(
i18n.translate('xpack.idxMgmt.refreshIndicesAction.successfullyRefreshedIndicesMessage', {
defaultMessage: 'Successfully refreshed: [{indexNames}]',
values: { indexNames: indexNames.join(', ') },
defaultMessage: 'Successfully refreshed {count, plural, one {# index} other {# indices} }',
values: { count: indexNames.length },
})
);
};

View file

@ -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 { IScopedClusterClient } from '@kbn/core/server';
import { executeAsyncByChunks } from './helpers';
const generateIndices = (count: number) => {
const indices = [];
for (let i = 0; i < count; i++) {
indices.push(`index-${i}`);
}
return indices;
};
const mockClient = {
asCurrentUser: {
indices: {
delete: jest.fn(),
},
},
} as unknown as IScopedClusterClient;
describe('executeAsyncByChunks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should make just one request for one index', async () => {
const params = {
index: generateIndices(1),
};
await executeAsyncByChunks(params, mockClient, 'delete');
expect(mockClient.asCurrentUser.indices.delete).toHaveBeenCalledTimes(1);
});
it('should make 2 requests for 32 indices', async () => {
const params = {
index: generateIndices(32),
};
await executeAsyncByChunks(params, mockClient, 'delete');
expect(mockClient.asCurrentUser.indices.delete).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,53 @@
/*
* 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 { chunk } from 'lodash';
import type { IScopedClusterClient } from '@kbn/core/server';
import { MAX_INDICES_PER_REQUEST } from '../../../../common/constants';
// To avoid having to to match method signatures with the client
// type, we use a generic CallableFn type.
type CallableFn = (args: Record<any, any>) => Promise<any>;
export async function executeAsyncByChunks<T>(
// Since we are using a key to access the index method, we need
// to use a generic type.
params: {
index: T[];
format?: string;
expand_wildcards?: string;
max_num_segments?: number;
},
dataClient: IScopedClusterClient,
methodName: keyof IScopedClusterClient['asCurrentUser']['indices']
) {
const { index: indices, ...commonParams } = params;
// When the number of indices is small, we can execute in a single request
//
// Otherwise we need to split the indices into chunks and execute them in multiple requests because
// if we try to execute an action with too many indices that account for a long string in the request
// ES will throw an error saying that the HTTP line is too large.
if (indices.length <= MAX_INDICES_PER_REQUEST) {
await (dataClient.asCurrentUser.indices[methodName] as CallableFn)({
...commonParams,
index: indices,
});
} else {
const chunks = chunk(indices, MAX_INDICES_PER_REQUEST);
await Promise.all(
chunks.map((chunkOfIndices) =>
(dataClient.asCurrentUser.indices[methodName] as CallableFn)({
...commonParams,
index: chunkOfIndices,
})
)
);
}
}

View file

@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema';
import { RouteDependencies } from '../../../types';
import { addBasePath } from '..';
import { executeAsyncByChunks } from './helpers';
const bodySchema = schema.object({
indices: schema.arrayOf(schema.string()),
@ -28,7 +29,8 @@ export function registerClearCacheRoute({ router, lib: { handleEsError } }: Rout
};
try {
await client.asCurrentUser.indices.clearCache(params);
await executeAsyncByChunks(params, client, 'clearCache');
return response.ok();
} catch (error) {
return handleEsError({ error, response });

View file

@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema';
import { RouteDependencies } from '../../../types';
import { addBasePath } from '..';
import { executeAsyncByChunks } from './helpers';
const bodySchema = schema.object({
indices: schema.arrayOf(schema.string()),
@ -28,7 +29,7 @@ export function registerCloseRoute({ router, lib: { handleEsError } }: RouteDepe
};
try {
await client.asCurrentUser.indices.close(params);
await executeAsyncByChunks(params, client, 'close');
return response.ok();
} catch (error) {
return handleEsError({ error, response });

View file

@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema';
import { RouteDependencies } from '../../../types';
import { addBasePath } from '..';
import { executeAsyncByChunks } from './helpers';
const bodySchema = schema.object({
indices: schema.arrayOf(schema.string()),
@ -22,13 +23,14 @@ export function registerDeleteRoute({ router, lib: { handleEsError } }: RouteDep
const { indices = [] } = request.body as typeof bodySchema.type;
const params = {
expand_wildcards: 'none' as const,
format: 'json',
expand_wildcards: 'none' as const,
index: indices,
};
try {
await client.asCurrentUser.indices.delete(params);
await executeAsyncByChunks(params, client, 'delete');
return response.ok();
} catch (error) {
return handleEsError({ error, response });

View file

@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema';
import { RouteDependencies } from '../../../types';
import { addBasePath } from '..';
import { executeAsyncByChunks } from './helpers';
const bodySchema = schema.object({
indices: schema.arrayOf(schema.string()),
@ -28,7 +29,8 @@ export function registerFlushRoute({ router, lib: { handleEsError } }: RouteDepe
};
try {
await client.asCurrentUser.indices.flush(params);
await executeAsyncByChunks(params, client, 'flush');
return response.ok();
} catch (error) {
return handleEsError({ error, response });

View file

@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema';
import { RouteDependencies } from '../../../types';
import { addBasePath } from '..';
import { executeAsyncByChunks } from './helpers';
const bodySchema = schema.object({
indices: schema.arrayOf(schema.string()),
@ -36,7 +37,8 @@ export function registerForcemergeRoute({ router, lib: { handleEsError } }: Rout
}
try {
await client.asCurrentUser.indices.forcemerge(params);
await executeAsyncByChunks(params, client, 'forcemerge');
return response.ok();
} catch (error) {
return handleEsError({ error, response });

View file

@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema';
import { RouteDependencies } from '../../../types';
import { addBasePath } from '..';
import { executeAsyncByChunks } from './helpers';
const bodySchema = schema.object({
indices: schema.arrayOf(schema.string()),
@ -28,7 +29,8 @@ export function registerOpenRoute({ router, lib: { handleEsError } }: RouteDepen
};
try {
await client.asCurrentUser.indices.open(params);
await executeAsyncByChunks(params, client, 'open');
return response.ok();
} catch (error) {
return handleEsError({ error, response });

View file

@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema';
import { RouteDependencies } from '../../../types';
import { addBasePath } from '..';
import { executeAsyncByChunks } from './helpers';
const bodySchema = schema.object({
indices: schema.arrayOf(schema.string()),
@ -28,7 +29,8 @@ export function registerRefreshRoute({ router, lib: { handleEsError } }: RouteDe
};
try {
await client.asCurrentUser.indices.refresh(params);
await executeAsyncByChunks(params, client, 'refresh');
return response.ok();
} catch (error) {
return handleEsError({ error, response });

View file

@ -5,8 +5,10 @@
* 2.0.
*/
import { chunk } from 'lodash';
import { schema } from '@kbn/config-schema';
import { MAX_INDICES_PER_REQUEST } from '../../../../common/constants';
import { RouteDependencies } from '../../../types';
import { fetchIndices } from '../../../lib/fetch_indices';
import { addBasePath } from '..';
@ -30,7 +32,27 @@ export function registerReloadRoute({
const { indexNames = [] } = (request.body as typeof bodySchema.type) ?? {};
try {
const indices = await fetchIndices({ client, indexDataEnricher, config, indexNames });
let indices;
// When the number of indices is small, we can execute in a single request
//
// Otherwise we need to split the indices into chunks and execute them in multiple requests because
// if we try to execute an action with too many indices that account for a long string in the request
// ES will throw an error saying that the HTTP line is too large.
if (indexNames.length <= MAX_INDICES_PER_REQUEST) {
indices = await fetchIndices({ client, indexDataEnricher, config, indexNames });
} else {
const chunks = chunk(indexNames, MAX_INDICES_PER_REQUEST);
indices = (
await Promise.all(
chunks.map((indexNamesChunk) =>
fetchIndices({ client, indexDataEnricher, config, indexNames: indexNamesChunk })
)
)
).flat();
}
return response.ok({ body: indices });
} catch (error) {
return handleEsError({ error, response });

View file

@ -16,7 +16,6 @@ import { License } from '../common/license';
import { licenseMock } from '../common/licensing.mock';
import { coreMock } from '@kbn/core/public/mocks';
import { HttpInterceptor } from '@kbn/core/public';
import type { AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
const coreStart = coreMock.createStart();
describe('licensing plugin', () => {
@ -443,69 +442,5 @@ describe('licensing plugin', () => {
expect(removeInterceptorMock).toHaveBeenCalledTimes(1);
});
it('registers the subscription upsell events', async () => {
const sessionStorage = coreMock.createStorage();
plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage);
const coreSetup = coreMock.createSetup();
await plugin.setup(coreSetup);
await plugin.stop();
expect(findRegisteredEventTypeByName('subscription__upsell__click', coreSetup.analytics))
.toMatchInlineSnapshot(`
Array [
Object {
"eventType": "subscription__upsell__click",
"schema": Object {
"feature": Object {
"_meta": Object {
"description": "A human-readable identifier describing the feature that is being promoted",
},
"type": "keyword",
},
"source": Object {
"_meta": Object {
"description": "A human-readable identifier describing the location of the beginning of the subscription flow",
},
"type": "keyword",
},
},
},
]
`);
expect(findRegisteredEventTypeByName('subscription__upsell__impression', coreSetup.analytics))
.toMatchInlineSnapshot(`
Array [
Object {
"eventType": "subscription__upsell__impression",
"schema": Object {
"feature": Object {
"_meta": Object {
"description": "A human-readable identifier describing the feature that is being promoted",
},
"type": "keyword",
},
"source": Object {
"_meta": Object {
"description": "A human-readable identifier describing the location of the beginning of the subscription flow",
},
"type": "keyword",
},
},
},
]
`);
});
});
});
function findRegisteredEventTypeByName(
eventTypeName: string,
analyticsClientMock: jest.Mocked<AnalyticsServiceSetup>
) {
return analyticsClientMock.registerEventType.mock.calls.find(
([{ eventType }]) => eventType === eventTypeName
)!;
}

View file

@ -8,7 +8,6 @@
import { Observable, Subject, Subscription } from 'rxjs';
import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import { registerEvents as registerSubscriptionTrackingEvents } from '@kbn/subscription-tracking';
import { ILicense } from '../common/types';
import { LicensingPluginSetup, LicensingPluginStart } from './types';
import { createLicenseUpdate } from '../common/license_update';
@ -85,7 +84,6 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPl
);
registerAnalyticsContextProvider(core.analytics, license$);
registerSubscriptionTrackingEvents(core.analytics);
this.internalSubscription = license$.subscribe((license) => {
if (license.isAvailable) {

View file

@ -14,8 +14,6 @@
"@kbn/std",
"@kbn/i18n",
"@kbn/analytics-client",
"@kbn/subscription-tracking",
"@kbn/core-analytics-browser",
"@kbn/logging-mocks"
],
"exclude": ["target/**/*"]

View file

@ -350,7 +350,10 @@ export class JobsListView extends Component {
});
this.isDoneRefreshing();
if (jobsSummaryList.some((j) => j.blocked !== undefined)) {
if (
blockingJobsRefreshTimeout === null &&
jobsSummaryList.some((j) => j.blocked !== undefined)
) {
// if there are some jobs in a deleting state, start polling for
// deleting jobs so we can update the jobs list once the
// deleting tasks are over

View file

@ -481,7 +481,7 @@ export function useModelActions({
);
},
enabled: (item) => {
return item.state !== MODEL_STATE.STARTED;
return canStartStopTrainedModels && item.state !== MODEL_STATE.STARTED;
},
},
{

View file

@ -14,11 +14,12 @@ import { paths } from '../../../../common/locators/paths';
import { useKibana } from '../../../utils/kibana_react';
export interface Props {
viewMode?: 'compact' | 'default';
activeAlerts?: number;
slo: SLOWithSummaryResponse;
}
export function SloActiveAlertsBadge({ slo, activeAlerts }: Props) {
export function SloActiveAlertsBadge({ slo, activeAlerts, viewMode = 'default' }: Props) {
const {
application: { navigateToUrl },
http: { basePath },
@ -50,10 +51,12 @@ export function SloActiveAlertsBadge({ slo, activeAlerts }: Props) {
)}
data-test-subj="o11ySloActiveAlertsBadge"
>
{i18n.translate('xpack.observability.slo.slo.activeAlertsBadge.label', {
defaultMessage: '{count, plural, one {# alert} other {# alerts}}',
values: { count: activeAlerts },
})}
{viewMode !== 'default'
? activeAlerts
: i18n.translate('xpack.observability.slo.slo.activeAlertsBadge.label', {
defaultMessage: '{count, plural, one {# alert} other {# alerts}}',
values: { count: activeAlerts },
})}
</EuiBadge>
</EuiFlexItem>
);

View file

@ -5,24 +5,25 @@
* 2.0.
*/
import { EuiBadge, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { EuiBadge, EuiBadgeProps, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { euiLightVars } from '@kbn/ui-theme';
import React from 'react';
import { euiLightVars } from '@kbn/ui-theme';
export interface Props {
color?: EuiBadgeProps['color'];
slo: SLOWithSummaryResponse;
}
export function SloGroupByBadge({ slo }: Props) {
export function SloGroupByBadge({ slo, color }: Props) {
if (!slo.groupBy || slo.groupBy === ALL_VALUE) {
return null;
}
return (
<EuiFlexItem grow={false}>
<EuiBadge color={euiLightVars.euiColorDisabled}>
<EuiBadge color={color ?? euiLightVars.euiColorDisabled}>
<EuiToolTip
position="top"
content={i18n.translate('xpack.observability.slo.partitionByBadge', {

View file

@ -14,7 +14,7 @@ describe('SloListLocator', () => {
const location = await locator.getLocation({});
expect(location.app).toEqual('observability');
expect(location.path).toEqual(
"/slos?search=(kqlQuery:'',page:0,sort:(by:status,direction:desc))"
"/slos?search=(kqlQuery:'',page:0,sort:(by:status,direction:desc),viewMode:compact)"
);
});
@ -24,7 +24,7 @@ describe('SloListLocator', () => {
});
expect(location.app).toEqual('observability');
expect(location.path).toEqual(
"/slos?search=(kqlQuery:'slo.name:%20%22Service%20Availability%22%20and%20slo.indicator.type%20:%20%22sli.kql.custom%22',page:0,sort:(by:status,direction:desc))"
"/slos?search=(kqlQuery:'slo.name:%20%22Service%20Availability%22%20and%20slo.indicator.type%20:%20%22sli.kql.custom%22',page:0,sort:(by:status,direction:desc),viewMode:compact)"
);
});
});

View file

@ -11,7 +11,7 @@ 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, Props } from './slo_badges';
import { SloBadges as Component, SloBadgesProps } from './slo_badges';
export default {
component: Component,
@ -19,7 +19,7 @@ export default {
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = (props: Props) => (
const Template: ComponentStory<typeof Component> = (props: SloBadgesProps) => (
<EuiFlexGroup>
<Component {...props} />
</EuiFlexGroup>

View file

@ -17,8 +17,9 @@ 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 Props {
export interface SloBadgesProps {
activeAlerts?: number;
isLoading: boolean;
rules: Array<Rule<SloRule>> | undefined;
@ -26,33 +27,17 @@ export interface Props {
onClickRuleBadge: () => void;
}
export function SloBadges({ activeAlerts, isLoading, rules, slo, onClickRuleBadge }: Props) {
export function SloBadges({
activeAlerts,
isLoading,
rules,
slo,
onClickRuleBadge,
}: SloBadgesProps) {
return (
<EuiFlexGroup direction="row" responsive={false} gutterSize="s" alignItems="center" wrap>
{isLoading ? (
<>
<EuiSkeletonRectangle
isLoading
contentAriaLabel="Loading"
width="54.16px"
height="20px"
borderRadius="s"
/>
<EuiSkeletonRectangle
isLoading
contentAriaLabel="Loading"
width="54.16px"
height="20px"
borderRadius="s"
/>
<EuiSkeletonRectangle
isLoading
contentAriaLabel="Loading"
width="54.16px"
height="20px"
borderRadius="s"
/>
</>
<LoadingBadges />
) : (
<>
<SloStatusBadge slo={slo} />
@ -66,3 +51,31 @@ export function SloBadges({ activeAlerts, isLoading, rules, slo, onClickRuleBadg
</EuiFlexGroup>
);
}
export function LoadingBadges() {
return (
<>
<EuiSkeletonRectangle
isLoading
contentAriaLabel="Loading"
width="54.16px"
height="20px"
borderRadius="s"
/>
<EuiSkeletonRectangle
isLoading
contentAriaLabel="Loading"
width="54.16px"
height="20px"
borderRadius="s"
/>
<EuiSkeletonRectangle
isLoading
contentAriaLabel="Loading"
width="54.16px"
height="20px"
borderRadius="s"
/>
</>
);
}

View file

@ -6,21 +6,22 @@
*/
import React from 'react';
import { EuiBadge, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { EuiBadge, EuiFlexItem, EuiToolTip, EuiBadgeProps } from '@elastic/eui';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { euiLightVars } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n';
import { euiLightVars } from '@kbn/ui-theme';
import { useKibana } from '../../../../utils/kibana_react';
import { convertSliApmParamsToApmAppDeeplinkUrl } from '../../../../utils/slo/convert_sli_apm_params_to_apm_app_deeplink_url';
import { isApmIndicatorType } from '../../../../utils/slo/indicator';
import { toIndicatorTypeLabel } from '../../../../utils/slo/labels';
export interface Props {
color?: EuiBadgeProps['color'];
slo: SLOWithSummaryResponse;
}
export function SloIndicatorTypeBadge({ slo }: Props) {
export function SloIndicatorTypeBadge({ slo, color }: Props) {
const {
application: { navigateToUrl },
http: { basePath },
@ -54,7 +55,7 @@ export function SloIndicatorTypeBadge({ slo }: Props) {
return (
<>
<EuiFlexItem grow={false}>
<EuiBadge color={euiLightVars.euiColorDisabled}>
<EuiBadge color={color ?? euiLightVars.euiColorDisabled}>
{toIndicatorTypeLabel(slo.indicator.type)}
</EuiBadge>
</EuiFlexItem>
@ -68,7 +69,7 @@ export function SloIndicatorTypeBadge({ slo }: Props) {
})}
>
<EuiBadge
color={euiLightVars.euiColorDisabled}
color={color ?? euiLightVars.euiColorDisabled}
onClick={handleNavigateToApm}
onClickAriaLabel={i18n.translate(
'xpack.observability.slo.indicatorTypeBadge.exploreInApm',

View file

@ -28,7 +28,7 @@ export function SloRulesBadge({ rules, onClick }: Props) {
display="block"
>
<span onClick={onClick} onKeyDown={onClick}>
<EuiBadge isDisabled color="default" iconType="alert" css={{ cursor: 'pointer' }} />
<EuiBadge color="text" iconType="alert" css={{ cursor: 'pointer' }} />
</span>
</EuiToolTip>
);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiBadge, EuiFlexItem } from '@elastic/eui';
import { EuiBadge, EuiBadgeProps, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { rollingTimeWindowTypeSchema, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { euiLightVars } from '@kbn/ui-theme';
@ -15,16 +15,17 @@ import { toCalendarAlignedMomentUnitOfTime } from '../../../../utils/slo/duratio
import { toDurationLabel } from '../../../../utils/slo/labels';
export interface Props {
color?: EuiBadgeProps['color'];
slo: SLOWithSummaryResponse;
}
export function SloTimeWindowBadge({ slo }: Props) {
export function SloTimeWindowBadge({ slo, color }: Props) {
const unit = slo.timeWindow.duration.slice(-1);
if (rollingTimeWindowTypeSchema.is(slo.timeWindow.type)) {
return (
<EuiFlexItem grow={false}>
<EuiBadge
color={euiLightVars.euiColorDisabled}
color={color ?? euiLightVars.euiColorDisabled}
iconType="editorItemAlignRight"
iconSide="left"
>
@ -45,7 +46,7 @@ export function SloTimeWindowBadge({ slo }: Props) {
return (
<EuiFlexItem grow={false}>
<EuiBadge color={euiLightVars.euiColorDisabled} iconType="calendar" iconSide="left">
<EuiBadge color={color ?? euiLightVars.euiColorDisabled} iconType="calendar" iconSide="left">
{i18n.translate('xpack.observability.slo.slo.timeWindow.calendar', {
defaultMessage: '{elapsed}/{total} days',
values: {

View file

@ -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.
*/
import React, { useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiFormRow, EuiSelect } from '@elastic/eui';
import useLocalStorage from 'react-use/lib/useLocalStorage';
export const SLO_CARD_VIEW_PER_ROW_SIZE = 'slo-card-view-per-row-size';
export function CardsPerRow({
setCardsPerRow,
}: {
setCardsPerRow: (cardsPerRow?: string) => void;
}) {
const [value, setValue] = useLocalStorage(SLO_CARD_VIEW_PER_ROW_SIZE, '3');
useEffect(() => {
setCardsPerRow(value);
}, [setCardsPerRow, value]);
const options = [
{ value: '3', text: '3' },
{ value: '4', text: '4' },
];
return (
<EuiFormRow
label={
<FormattedMessage
id="xpack.observability.gridSize.euiFormRow.itemsPerRowLabel"
defaultMessage="Cards per row"
/>
}
>
<EuiSelect
data-test-subj="o11yGridSizeSelect"
id={'grid-size-select'}
options={options}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</EuiFormRow>
);
}

View file

@ -0,0 +1,181 @@
/*
* 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,
Metric,
Settings,
DARK_THEME,
MetricTrendShape,
} from '@elastic/charts';
import { EuiIcon, EuiPanel, useEuiBackgroundColor } from '@elastic/eui';
import { SLOWithSummaryResponse, HistoricalSummaryResponse, ALL_VALUE } from '@kbn/slo-schema';
import { Rule } from '@kbn/triggers-actions-ui-plugin/public';
import { i18n } from '@kbn/i18n';
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';
export interface Props {
slo: SLOWithSummaryResponse;
rules: Array<Rule<SloRule>> | undefined;
historicalSummary?: HistoricalSummaryResponse[];
historicalSummaryLoading: boolean;
activeAlerts?: number;
loading: boolean;
error: boolean;
cardsPerRow: number;
}
const useCardColor = (status?: SLOWithSummaryResponse['summary']['status']) => {
const colors = {
DEGRADING: useEuiBackgroundColor('warning'),
VIOLATED: useEuiBackgroundColor('danger'),
HEALTHY: useEuiBackgroundColor('success'),
NO_DATA: useEuiBackgroundColor('subdued'),
};
return colors[status ?? 'NO_DATA'];
};
const getSubTitle = (slo: SLOWithSummaryResponse, cardsPerRow: number) => {
const subTitle =
slo.groupBy && slo.groupBy !== ALL_VALUE ? `${slo.groupBy}: ${slo.instanceId}` : '';
if (cardsPerRow === 4) {
return subTitle.substring(0, 30) + (subTitle.length > 30 ? '..' : '');
}
return subTitle.substring(0, 40) + (subTitle.length > 40 ? '..' : '');
};
export function SloCardItem({ slo, rules, activeAlerts, historicalSummary, cardsPerRow }: Props) {
const {
application: { navigateToUrl },
} = useKibana().services;
const [isMouseOver, setIsMouseOver] = useState(false);
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false);
const [isAddRuleFlyoutOpen, setIsAddRuleFlyoutOpen] = useState(false);
const [isDeleteConfirmationModalOpen, setDeleteConfirmationModalOpen] = useState(false);
const { sliValue, sloTarget, sloDetailsUrl } = useSloFormattedSummary(slo);
const cardColor = useCardColor(slo.summary.status);
const subTitle = getSubTitle(slo, cardsPerRow);
const historicalSliData = formatHistoricalData(historicalSummary, 'sli_value');
const { handleCreateRule, handleDeleteCancel, handleDeleteConfirm } = useSloListActions({
slo,
setDeleteConfirmationModalOpen,
setIsActionsPopoverOpen,
setIsAddRuleFlyoutOpen,
});
return (
<>
<EuiPanel
onMouseOver={() => {
if (!isMouseOver) {
setIsMouseOver(true);
}
}}
onMouseLeave={() => {
if (isMouseOver) {
setIsMouseOver(false);
}
}}
paddingSize="none"
style={{
height: '182px',
overflow: 'hidden',
position: 'relative',
}}
title={slo.summary.status}
>
<Chart>
<Settings
baseTheme={DARK_THEME}
onElementClick={([d]) => {
if (isMetricElementEvent(d)) {
navigateToUrl(sloDetailsUrl);
}
}}
locale={i18n.getLocale()}
/>
<Metric
id={`${slo.id}-${slo.instanceId}`}
data={[
[
{
title: slo.name,
subtitle: subTitle,
value: sliValue,
trendShape: MetricTrendShape.Area,
trend: historicalSliData?.map((d) => ({
x: d.key as number,
y: d.value as number,
})),
extra: (
<FormattedMessage
id="xpack.observability.sLOGridItem.targetFlexItemLabel"
defaultMessage="Target {target}"
values={{
target: sloTarget,
}}
/>
),
icon: () => <EuiIcon type="visGauge" size="l" />,
color: cardColor,
},
],
]}
/>
</Chart>
<SloCardItemBadges
slo={slo}
rules={rules}
activeAlerts={activeAlerts}
handleCreateRule={handleCreateRule}
hasGroupBy={Boolean(slo.groupBy && slo.groupBy !== ALL_VALUE)}
/>
{(isMouseOver || isActionsPopoverOpen) && (
<SloCardItemActions
slo={slo}
isActionsPopoverOpen={isActionsPopoverOpen}
setIsActionsPopoverOpen={setIsActionsPopoverOpen}
setIsAddRuleFlyoutOpen={setIsAddRuleFlyoutOpen}
setDeleteConfirmationModalOpen={setDeleteConfirmationModalOpen}
/>
)}
</EuiPanel>
<BurnRateRuleFlyout
slo={slo}
isAddRuleFlyoutOpen={isAddRuleFlyoutOpen}
setIsAddRuleFlyoutOpen={setIsAddRuleFlyoutOpen}
/>
{isDeleteConfirmationModalOpen ? (
<SloDeleteConfirmationModal
slo={slo}
onCancel={handleDeleteCancel}
onConfirm={handleDeleteConfirm}
/>
) : null}
</>
);
}

View file

@ -0,0 +1,61 @@
/*
* 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<ActionContainerProps>`
${({ 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;
}
export function SloCardItemActions(props: Props) {
const euiShadow = useEuiShadow('l');
return (
<Container boxShadow={euiShadow} position={'relative'}>
<SloItemActions
{...props}
btnProps={{
iconType: 'boxesHorizontal',
color: 'primary',
display: 'empty',
}}
/>
</Container>
);
}

View file

@ -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 { SLOWithSummaryResponse } from '@kbn/slo-schema';
import React from 'react';
import { Rule } from '@kbn/triggers-actions-ui-plugin/public';
import styled from 'styled-components';
import { EuiFlexGroup } from '@elastic/eui';
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<Rule<SloRule>> | undefined;
handleCreateRule: () => void;
}
const Container = styled.div<{ hasGroupBy: boolean }>`
position: absolute;
display: inline-block;
top: ${({ hasGroupBy }) => (hasGroupBy ? '55px' : '35px')};
left: 7px;
z-index: 1;
border-radius: ${({ theme }) => theme.eui.euiBorderRadius};
`;
export function SloCardItemBadges({
slo,
activeAlerts,
rules,
handleCreateRule,
hasGroupBy,
}: Props) {
return (
<Container hasGroupBy={hasGroupBy}>
<EuiFlexGroup direction="row" responsive={false} gutterSize="s" alignItems="center" wrap>
{!slo.summary ? (
<LoadingBadges />
) : (
<>
<SloActiveAlertsBadge slo={slo} activeAlerts={activeAlerts} viewMode="compact" />
<SloIndicatorTypeBadge slo={slo} color="default" />
<SloTimeWindowBadge slo={slo} color="default" />
<SloRulesBadge rules={rules} onClick={handleCreateRule} />
</>
)}
</EuiFlexGroup>
</Container>
);
}

View file

@ -0,0 +1,86 @@
/*
* 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 { EuiFlexGrid, EuiFlexItem, EuiPanel, EuiSkeletonText } from '@elastic/eui';
import { SLOWithSummaryResponse, ALL_VALUE } from '@kbn/slo-schema';
import { EuiFlexGridProps } from '@elastic/eui/src/components/flex/flex_grid';
import { ActiveAlerts } from '../../../../hooks/slo/use_fetch_active_alerts';
import type { UseFetchRulesForSloResponse } from '../../../../hooks/slo/use_fetch_rules_for_slo';
import { useFetchHistoricalSummary } from '../../../../hooks/slo/use_fetch_historical_summary';
import { SloCardItem } from './slo_card_item';
export interface Props {
sloList: SLOWithSummaryResponse[];
loading: boolean;
error: boolean;
cardsPerRow?: string;
activeAlertsBySlo: ActiveAlerts;
rulesBySlo?: UseFetchRulesForSloResponse['data'];
}
export function SloListCardView({
sloList,
loading,
error,
cardsPerRow,
rulesBySlo,
activeAlertsBySlo,
}: Props) {
const { isLoading: historicalSummaryLoading, data: historicalSummaries = [] } =
useFetchHistoricalSummary({
list: sloList.map((slo) => ({ sloId: slo.id, instanceId: slo.instanceId ?? ALL_VALUE })),
});
if (loading && sloList.length === 0) {
return <LoadingSloGrid gridSize={Number(cardsPerRow)} />;
}
return (
<EuiFlexGrid columns={Number(cardsPerRow) as EuiFlexGridProps['columns']}>
{sloList.map((slo) => (
<EuiFlexItem key={`${slo.id}-${slo.instanceId ?? 'ALL_VALUE'}`}>
<SloCardItem
slo={slo}
loading={loading}
error={error}
activeAlerts={activeAlertsBySlo.get(slo)}
rules={rulesBySlo?.[slo.id]}
historicalSummary={
historicalSummaries.find(
(historicalSummary) =>
historicalSummary.sloId === slo.id &&
historicalSummary.instanceId === (slo.instanceId ?? ALL_VALUE)
)?.data
}
historicalSummaryLoading={historicalSummaryLoading}
cardsPerRow={Number(cardsPerRow)}
/>
</EuiFlexItem>
))}
</EuiFlexGrid>
);
}
function LoadingSloGrid({ gridSize }: { gridSize: number }) {
const ROWS = 4;
const COLUMNS = gridSize;
const loaders = Array(ROWS * COLUMNS).fill(null);
return (
<>
<EuiFlexGrid gutterSize="m" columns={COLUMNS as EuiFlexGridProps['columns']}>
{loaders.map((_, i) => (
<EuiFlexItem key={i}>
<EuiPanel style={{ height: '200px' }} hasBorder={true}>
<EuiSkeletonText lines={2} />
</EuiPanel>{' '}
</EuiFlexItem>
))}
</EuiFlexGrid>
</>
);
}

View file

@ -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.
*/
import React from 'react';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useQueryClient } from '@tanstack/react-query';
import { useGetFilteredRuleTypes } from '../../../../hooks/use_get_filtered_rule_types';
import { sloKeys } from '../../../../hooks/slo/query_key_factory';
import { useKibana } from '../../../../utils/kibana_react';
import { SLO_BURN_RATE_RULE_TYPE_ID } from '../../../../../common/constants';
import { sloFeatureId } from '../../../../../common';
export function BurnRateRuleFlyout({
slo,
isAddRuleFlyoutOpen,
setIsAddRuleFlyoutOpen,
}: {
slo: SLOWithSummaryResponse;
isAddRuleFlyoutOpen: boolean;
setIsAddRuleFlyoutOpen: (value: boolean) => void;
}) {
const {
triggersActionsUi: { getAddRuleFlyout: AddRuleFlyout },
} = useKibana().services;
const filteredRuleTypes = useGetFilteredRuleTypes();
const queryClient = useQueryClient();
const handleSavedRule = async () => {
queryClient.invalidateQueries({ queryKey: sloKeys.rules(), exact: false });
};
return isAddRuleFlyoutOpen ? (
<AddRuleFlyout
consumer={sloFeatureId}
filteredRuleTypes={filteredRuleTypes}
ruleTypeId={SLO_BURN_RATE_RULE_TYPE_ID}
initialValues={{ name: `${slo.name} Burn Rate rule`, params: { sloId: slo.id } }}
onSave={handleSavedRule}
onClose={() => {
setIsAddRuleFlyoutOpen(false);
}}
useRuleProducer
/>
) : null;
}

View file

@ -0,0 +1,217 @@
/*
* 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 { useCapabilities } from '../../../hooks/slo/use_capabilities';
import { useCloneSlo } from '../../../hooks/slo/use_clone_slo';
import { useKibana } from '../../../utils/kibana_react';
import { paths } from '../../../../common/locators/paths';
import { RulesParams } from '../../../locators/rules';
import { rulesLocatorID } from '../../../../common';
import {
transformCreateSLOFormToCreateSLOInput,
transformSloResponseToCreateSloForm,
} from '../../slo_edit/helpers/process_slo_form_values';
interface Props {
slo: SLOWithSummaryResponse;
isActionsPopoverOpen: boolean;
setIsActionsPopoverOpen: (value: boolean) => void;
setDeleteConfirmationModalOpen: (value: boolean) => void;
setIsAddRuleFlyoutOpen: (value: boolean) => void;
btnProps?: Partial<EuiButtonIconProps>;
}
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 (
<CustomShadowPanel
color="plain"
element="button"
grow={false}
paddingSize="none"
hasShadow={false}
shadow={shadow}
>
{children}
</CustomShadowPanel>
);
}
export function SloItemActions({
slo,
isActionsPopoverOpen,
setIsActionsPopoverOpen,
setIsAddRuleFlyoutOpen,
setDeleteConfirmationModalOpen,
btnProps,
}: Props) {
const {
application: { navigateToUrl },
http: { basePath },
share: {
url: { locators },
},
} = useKibana().services;
const { hasWriteCapabilities } = useCapabilities();
const { mutate: cloneSlo } = useCloneSlo();
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 handleNavigateToRules = async () => {
const locator = locators.get<RulesParams>(rulesLocatorID);
locator?.navigate({ params: { sloId: slo.id } }, { replace: false });
};
const handleClone = () => {
const newSlo = transformCreateSLOFormToCreateSLOInput(
transformSloResponseToCreateSloForm({ ...slo, name: `[Copy] ${slo.name}` })!
);
cloneSlo({ slo: newSlo, originalSloId: slo.id });
setIsActionsPopoverOpen(false);
};
const handleDelete = () => {
setDeleteConfirmationModalOpen(true);
setIsActionsPopoverOpen(false);
};
const handleCreateRule = () => {
setIsActionsPopoverOpen(false);
setIsAddRuleFlyoutOpen(true);
};
const btn = (
<EuiButtonIcon
data-test-subj="o11ySloListItemButton"
aria-label={i18n.translate('xpack.observability.slo.item.actions.button', {
defaultMessage: 'Actions',
})}
color="text"
disabled={!slo.summary}
display="empty"
iconType="boxesVertical"
size="s"
onClick={handleClickActions}
{...btnProps}
/>
);
return (
<EuiPopover
anchorPosition="downLeft"
button={btnProps ? <IconPanel hasPanel={true}>{btn}</IconPanel> : btn}
panelPaddingSize="m"
closePopover={handleClickActions}
isOpen={isActionsPopoverOpen}
>
<EuiContextMenuPanel
size="m"
items={[
<EuiContextMenuItem
key="view"
icon="inspect"
onClick={handleViewDetails}
data-test-subj="sloActionsView"
>
{i18n.translate('xpack.observability.slo.item.actions.details', {
defaultMessage: 'Details',
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="edit"
icon="pencil"
disabled={!hasWriteCapabilities}
onClick={handleEdit}
data-test-subj="sloActionsEdit"
>
{i18n.translate('xpack.observability.slo.item.actions.edit', {
defaultMessage: 'Edit',
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="createRule"
icon="bell"
disabled={!hasWriteCapabilities}
onClick={handleCreateRule}
data-test-subj="sloActionsCreateRule"
>
{i18n.translate('xpack.observability.slo.item.actions.createRule', {
defaultMessage: 'Create new alert rule',
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="manageRules"
icon="gear"
disabled={!hasWriteCapabilities}
onClick={handleNavigateToRules}
data-test-subj="sloActionsManageRules"
>
{i18n.translate('xpack.observability.slo.item.actions.manageRules', {
defaultMessage: 'Manage rules',
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="clone"
disabled={!hasWriteCapabilities}
icon="copy"
onClick={handleClone}
data-test-subj="sloActionsClone"
>
{i18n.translate('xpack.observability.slo.item.actions.clone', {
defaultMessage: 'Clone',
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="delete"
icon="trash"
disabled={!hasWriteCapabilities}
onClick={handleDelete}
data-test-subj="sloActionsDelete"
>
{i18n.translate('xpack.observability.slo.item.actions.delete', {
defaultMessage: 'Delete',
})}
</EuiContextMenuItem>,
]}
/>
</EuiPopover>
);
}

View file

@ -8,9 +8,12 @@
import { EuiFlexGroup, EuiFlexItem, EuiPagination } from '@elastic/eui';
import { useIsMutating } from '@tanstack/react-query';
import React, { useState } from 'react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { SlosView } from './slos_view';
import { SLO_CARD_VIEW_PER_ROW_SIZE } from './card_view/cards_per_row';
import { SLOViewType, ToggleSLOView } from './toggle_slo_view';
import { useFetchSloList } from '../../../hooks/slo/use_fetch_slo_list';
import { useUrlSearchState } from '../hooks/use_url_search_state';
import { SloListItems } from './slo_list_items';
import { SloListSearchBar, SortField } from './slo_list_search_bar';
export interface Props {
@ -24,6 +27,8 @@ export function SloList({ autoRefresh }: Props) {
const [sort, setSort] = useState<SortField>(state.sort.by);
const [direction] = useState<'asc' | 'desc'>(state.sort.direction);
const [sloView, setSLOView] = useState<SLOViewType>('cardView');
const {
isLoading,
isRefetching,
@ -43,6 +48,7 @@ export function SloList({ autoRefresh }: Props) {
const isCloningSlo = Boolean(useIsMutating(['cloningSlo']));
const isUpdatingSlo = Boolean(useIsMutating(['updatingSlo']));
const isDeletingSlo = Boolean(useIsMutating(['deleteSlo']));
const [cardsPerRow, setCardsPerRow] = useLocalStorage(SLO_CARD_VIEW_PER_ROW_SIZE, '4');
const handlePageClick = (pageNumber: number) => {
setPage(pageNumber);
@ -71,9 +77,16 @@ export function SloList({ autoRefresh }: Props) {
initialState={state}
/>
</EuiFlexItem>
<EuiFlexItem>
<SloListItems sloList={results} loading={isLoading || isRefetching} error={isError} />
<EuiFlexItem grow={false}>
<ToggleSLOView sloView={sloView} setSLOView={setSLOView} setCardsPerRow={setCardsPerRow} />
</EuiFlexItem>
<SlosView
sloList={results}
loading={isLoading || isRefetching}
error={isError}
cardsPerRow={cardsPerRow}
sloView={sloView}
/>
{total > 0 ? (
<EuiFlexItem>

View file

@ -5,37 +5,16 @@
* 2.0.
*/
import {
EuiButtonIcon,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiPopover,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ALL_VALUE, HistoricalSummaryResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
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 { useQueryClient } from '@tanstack/react-query';
import React, { useState } from 'react';
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 { SloDeleteConfirmationModal } from '../../../components/slo/delete_confirmation_modal/slo_delete_confirmation_modal';
import { rulesLocatorID, sloFeatureId } from '../../../../common';
import { SLO_BURN_RATE_RULE_TYPE_ID } from '../../../../common/constants';
import { paths } from '../../../../common/locators/paths';
import { sloKeys } from '../../../hooks/slo/query_key_factory';
import { useCapabilities } from '../../../hooks/slo/use_capabilities';
import { useCloneSlo } from '../../../hooks/slo/use_clone_slo';
import { useDeleteSlo } from '../../../hooks/slo/use_delete_slo';
import type { SloRule } from '../../../hooks/slo/use_fetch_rules_for_slo';
import { useGetFilteredRuleTypes } from '../../../hooks/use_get_filtered_rule_types';
import type { RulesParams } from '../../../locators/rules';
import { useKibana } from '../../../utils/kibana_react';
import {
transformCreateSLOFormToCreateSLOInput,
transformSloResponseToCreateSloForm,
} from '../../slo_edit/helpers/process_slo_form_values';
import { SloBadges } from './badges/slo_badges';
import { SloSummary } from './slo_summary';
@ -54,80 +33,18 @@ export function SloListItem({
historicalSummaryLoading,
activeAlerts,
}: SloListItemProps) {
const {
application: { navigateToUrl },
http: { basePath },
share: {
url: { locators },
},
triggersActionsUi: { getAddRuleFlyout: AddRuleFlyout },
} = useKibana().services;
const { hasWriteCapabilities } = useCapabilities();
const queryClient = useQueryClient();
const filteredRuleTypes = useGetFilteredRuleTypes();
const { mutate: cloneSlo } = useCloneSlo();
const { mutate: deleteSlo } = useDeleteSlo();
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false);
const [isAddRuleFlyoutOpen, setIsAddRuleFlyoutOpen] = useState(false);
const [isDeleteConfirmationModalOpen, setDeleteConfirmationModalOpen] = useState(false);
const handleClickActions = () => {
setIsActionsPopoverOpen(!isActionsPopoverOpen);
};
const { sloDetailsUrl } = useSloFormattedSummary(slo);
const sloDetailsUrl = basePath.prepend(
paths.observability.sloDetails(
slo.id,
slo.groupBy !== ALL_VALUE && slo.instanceId ? slo.instanceId : undefined
)
);
const handleViewDetails = () => {
navigateToUrl(sloDetailsUrl);
};
const handleEdit = () => {
navigateToUrl(basePath.prepend(paths.observability.sloEdit(slo.id)));
};
const handleCreateRule = () => {
setIsActionsPopoverOpen(false);
setIsAddRuleFlyoutOpen(true);
};
const handleSavedRule = async () => {
queryClient.invalidateQueries({ queryKey: sloKeys.rules(), exact: false });
};
const handleNavigateToRules = async () => {
const locator = locators.get<RulesParams>(rulesLocatorID);
locator?.navigate({ params: { sloId: slo.id } }, { replace: false });
};
const handleClone = () => {
const newSlo = transformCreateSLOFormToCreateSLOInput(
transformSloResponseToCreateSloForm({ ...slo, name: `[Copy] ${slo.name}` })!
);
cloneSlo({ slo: newSlo, originalSloId: slo.id });
setIsActionsPopoverOpen(false);
};
const handleDelete = () => {
setDeleteConfirmationModalOpen(true);
setIsActionsPopoverOpen(false);
};
const handleDeleteConfirm = () => {
setDeleteConfirmationModalOpen(false);
deleteSlo({ id: slo.id, name: slo.name });
};
const handleDeleteCancel = () => {
setDeleteConfirmationModalOpen(false);
};
const { handleCreateRule, handleDeleteCancel, handleDeleteConfirm } = useSloListActions({
slo,
setDeleteConfirmationModalOpen,
setIsActionsPopoverOpen,
setIsAddRuleFlyoutOpen,
});
return (
<EuiPanel data-test-subj="sloItem" hasBorder hasShadow={false}>
@ -172,113 +89,21 @@ export function SloListItem({
{/* ACTIONS */}
<EuiFlexItem grow={false}>
<EuiPopover
anchorPosition="downLeft"
button={
<EuiButtonIcon
data-test-subj="o11ySloListItemButton"
aria-label={i18n.translate('xpack.observability.slo.item.actions.button', {
defaultMessage: 'Actions',
})}
color="text"
disabled={!slo.summary}
display="empty"
iconType="boxesVertical"
size="s"
onClick={handleClickActions}
/>
}
panelPaddingSize="m"
closePopover={handleClickActions}
isOpen={isActionsPopoverOpen}
>
<EuiContextMenuPanel
size="m"
items={[
<EuiContextMenuItem
key="view"
icon="inspect"
onClick={handleViewDetails}
data-test-subj="sloActionsView"
>
{i18n.translate('xpack.observability.slo.item.actions.details', {
defaultMessage: 'Details',
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="edit"
icon="pencil"
disabled={!hasWriteCapabilities}
onClick={handleEdit}
data-test-subj="sloActionsEdit"
>
{i18n.translate('xpack.observability.slo.item.actions.edit', {
defaultMessage: 'Edit',
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="createRule"
icon="bell"
disabled={!hasWriteCapabilities}
onClick={handleCreateRule}
data-test-subj="sloActionsCreateRule"
>
{i18n.translate('xpack.observability.slo.item.actions.createRule', {
defaultMessage: 'Create new alert rule',
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="manageRules"
icon="gear"
disabled={!hasWriteCapabilities}
onClick={handleNavigateToRules}
data-test-subj="sloActionsManageRules"
>
{i18n.translate('xpack.observability.slo.item.actions.manageRules', {
defaultMessage: 'Manage rules',
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="clone"
disabled={!hasWriteCapabilities}
icon="copy"
onClick={handleClone}
data-test-subj="sloActionsClone"
>
{i18n.translate('xpack.observability.slo.item.actions.clone', {
defaultMessage: 'Clone',
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="delete"
icon="trash"
disabled={!hasWriteCapabilities}
onClick={handleDelete}
data-test-subj="sloActionsDelete"
>
{i18n.translate('xpack.observability.slo.item.actions.delete', {
defaultMessage: 'Delete',
})}
</EuiContextMenuItem>,
]}
/>
</EuiPopover>
<SloItemActions
slo={slo}
isActionsPopoverOpen={isActionsPopoverOpen}
setIsAddRuleFlyoutOpen={setIsAddRuleFlyoutOpen}
setIsActionsPopoverOpen={setIsActionsPopoverOpen}
setDeleteConfirmationModalOpen={setDeleteConfirmationModalOpen}
/>
</EuiFlexItem>
</EuiFlexGroup>
{isAddRuleFlyoutOpen ? (
<AddRuleFlyout
consumer={sloFeatureId}
filteredRuleTypes={filteredRuleTypes}
ruleTypeId={SLO_BURN_RATE_RULE_TYPE_ID}
initialValues={{ name: `${slo.name} Burn Rate rule`, params: { sloId: slo.id } }}
onSave={handleSavedRule}
onClose={() => {
setIsAddRuleFlyoutOpen(false);
}}
useRuleProducer
/>
) : null}
<BurnRateRuleFlyout
slo={slo}
isAddRuleFlyoutOpen={isAddRuleFlyoutOpen}
setIsAddRuleFlyoutOpen={setIsAddRuleFlyoutOpen}
/>
{isDeleteConfirmationModalOpen ? (
<SloDeleteConfirmationModal

View file

@ -8,6 +8,7 @@
import React from 'react';
import { ComponentStory } from '@storybook/react';
import { ActiveAlerts } from '../../../hooks/slo/use_fetch_active_alerts';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import { SloListItems as Component, Props } from './slo_list_items';
import { sloList } from '../../../data/slo/slo';
@ -22,8 +23,7 @@ const Template: ComponentStory<typeof Component> = (props: Props) => <Component
const defaultProps: Props = {
sloList: sloList.results,
loading: false,
error: false,
activeAlertsBySlo: new ActiveAlerts(),
};
export const SloListItems = Template.bind({});

View file

@ -7,40 +7,23 @@
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 { ActiveAlerts } 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 { UseFetchRulesForSloResponse } from '../../../hooks/slo/use_fetch_rules_for_slo';
import { SloListItem } from './slo_list_item';
export interface Props {
sloList: SLOWithSummaryResponse[];
loading: boolean;
error: boolean;
activeAlertsBySlo: ActiveAlerts;
rulesBySlo?: UseFetchRulesForSloResponse['data'];
}
export function SloListItems({ 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]),
});
export function SloListItems({ sloList, activeAlertsBySlo, rulesBySlo }: Props) {
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 <SloListEmpty />;
}
if (!loading && error) {
return <SloListError />;
}
return (
<EuiFlexGroup direction="column" gutterSize="s">
{sloList.map((slo) => (

View file

@ -6,13 +6,11 @@
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiStat } from '@elastic/eui';
import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
import { HistoricalSummaryResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useKibana } from '../../../utils/kibana_react';
import { useSloFormattedSummary } from '../hooks/use_slo_summary';
import { formatHistoricalData } from '../../../utils/slo/chart_data_formatter';
import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n';
import { SloSparkline } from './slo_sparkline';
export interface Props {
@ -22,18 +20,12 @@ export interface Props {
}
export function SloSummary({ slo, historicalSummary = [], historicalSummaryLoading }: Props) {
const { uiSettings } = useKibana().services;
const percentFormat = uiSettings.get('format:percent:defaultPattern');
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');
const errorBudgetRemaining =
slo.summary.errorBudget.remaining <= 0
? Math.trunc(slo.summary.errorBudget.remaining * 100) / 100
: slo.summary.errorBudget.remaining;
return (
<EuiFlexGroup direction="row" justifyContent="spaceBetween" gutterSize="l" responsive={false}>
<EuiFlexItem grow={false} style={{ maxWidth: 200 }}>
@ -48,13 +40,9 @@ export function SloSummary({ slo, historicalSummary = [], historicalSummaryLoadi
<EuiStat
description={i18n.translate('xpack.observability.slo.slo.stats.objective', {
defaultMessage: '{objective} target',
values: { objective: numeral(slo.objective.target).format(percentFormat) },
values: { objective: sloTarget },
})}
title={
slo.summary.status === 'NO_DATA'
? NOT_AVAILABLE_LABEL
: numeral(slo.summary.sliValue).format(percentFormat)
}
title={sliValue}
textAlign="right"
titleColor={titleColor}
titleSize="m"
@ -87,11 +75,7 @@ export function SloSummary({ slo, historicalSummary = [], historicalSummaryLoadi
defaultMessage: 'Budget remaining',
})}
textAlign="right"
title={
slo.summary.status === 'NO_DATA'
? NOT_AVAILABLE_LABEL
: numeral(errorBudgetRemaining).format(percentFormat)
}
title={errorBudgetRemaining}
titleColor={titleColor}
titleSize="m"
reverse

View file

@ -0,0 +1,63 @@
/*
* 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 { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema';
import React from 'react';
import { EuiFlexItem } from '@elastic/eui';
import { useFetchRulesForSlo } from '../../../hooks/slo/use_fetch_rules_for_slo';
import { useFetchActiveAlerts } from '../../../hooks/slo/use_fetch_active_alerts';
import { SloListCardView } from './card_view/slos_card_view';
import { SloListEmpty } from './slo_list_empty';
import { SloListError } from './slo_list_error';
import { SloListItems } from './slo_list_items';
export interface Props {
sloList: SLOWithSummaryResponse[];
loading: boolean;
error: boolean;
cardsPerRow?: string;
sloView: string;
}
export function SlosView({ cardsPerRow, sloList, loading, error, sloView }: 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]),
});
if (!loading && !error && sloList.length === 0) {
return <SloListEmpty />;
}
if (!loading && error) {
return <SloListError />;
}
return sloView === 'cardView' ? (
<EuiFlexItem>
<SloListCardView
sloList={sloList}
loading={loading}
error={error}
cardsPerRow={cardsPerRow}
activeAlertsBySlo={activeAlertsBySlo}
rulesBySlo={rulesBySlo}
/>
</EuiFlexItem>
) : (
<EuiFlexItem>
<SloListItems
sloList={sloList}
activeAlertsBySlo={activeAlertsBySlo}
rulesBySlo={rulesBySlo}
/>
</EuiFlexItem>
);
}

View file

@ -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 { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonGroup,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiPopoverTitle,
} from '@elastic/eui';
import { CardsPerRow } from './card_view/cards_per_row';
export type SLOViewType = 'cardView' | 'listView';
interface Props {
setCardsPerRow: (gridSize?: string) => void;
setSLOView: (view: SLOViewType) => void;
sloView: SLOViewType;
}
const toggleButtonsIcons = [
{
id: `cardView`,
label: 'Card View',
iconType: 'visGauge',
'data-test-subj': 'sloCardViewButton',
},
{
id: `listView`,
label: 'List View',
iconType: 'list',
'data-test-subj': 'sloListViewButton',
},
];
export function ToggleSLOView({ sloView, setSLOView, setCardsPerRow }: Props) {
return (
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<EuiButtonGroup
legend={i18n.translate('xpack.observability.toggleSLOView.euiButtonGroup.sloView', {
defaultMessage: 'SLO View',
})}
options={toggleButtonsIcons}
idSelected={sloView}
onChange={(id) => setSLOView(id as SLOViewType)}
isIconOnly
/>
</EuiFlexItem>
{sloView === 'cardView' && (
<EuiFlexItem grow={false}>
<ViewSettings setCardsPerRow={setCardsPerRow} />
</EuiFlexItem>
)}
</EuiFlexGroup>
);
}
function ViewSettings({ setCardsPerRow }: { setCardsPerRow: (cardsPerRow?: string) => void }) {
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
return (
<EuiPopover
button={
<EuiButtonIcon
data-test-subj="o11yToggleSLOViewButton"
iconType={'gear'}
aria-label={i18n.translate(
'xpack.observability.toggleSLOView.euiButtonIcon.settingsLabel',
{ defaultMessage: 'Settings' }
)}
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
/>
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
anchorPosition="downCenter"
>
<EuiPopoverTitle>
<FormattedMessage
id="xpack.observability.viewSettings.viewSettingsPopoverTitleLabel"
defaultMessage="View settings"
/>
</EuiPopoverTitle>
<div style={{ width: '300px' }}>
<CardsPerRow setCardsPerRow={setCardsPerRow} />
</div>
</EuiPopover>
);
}

View file

@ -0,0 +1,42 @@
/*
* 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 { useDeleteSlo } from '../../../hooks/slo/use_delete_slo';
export function useSloListActions({
slo,
setIsAddRuleFlyoutOpen,
setIsActionsPopoverOpen,
setDeleteConfirmationModalOpen,
}: {
slo: SLOWithSummaryResponse;
setIsActionsPopoverOpen: (val: boolean) => void;
setIsAddRuleFlyoutOpen: (val: boolean) => void;
setDeleteConfirmationModalOpen: (val: boolean) => void;
}) {
const { mutate: deleteSlo } = useDeleteSlo();
const handleDeleteConfirm = () => {
setDeleteConfirmationModalOpen(false);
deleteSlo({ id: slo.id, name: slo.name });
};
const handleDeleteCancel = () => {
setDeleteConfirmationModalOpen(false);
};
const handleCreateRule = () => {
setIsActionsPopoverOpen(false);
setIsAddRuleFlyoutOpen(true);
};
return {
handleDeleteConfirm,
handleDeleteCancel,
handleCreateRule,
};
}

View file

@ -0,0 +1,50 @@
/*
* 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 { paths } from '../../../../common/locators/paths';
import { useKibana } from '../../../utils/kibana_react';
import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n';
export const useSloFormattedSummary = (slo: SLOWithSummaryResponse) => {
const {
http: { basePath },
} = useKibana().services;
const { uiSettings } = useKibana().services;
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,
};
};

View file

@ -8,6 +8,7 @@
import { useHistory } from 'react-router-dom';
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import deepmerge from 'deepmerge';
import { ViewMode } from '../components/badges/slo_badges';
import type { SortField } from '../components/slo_list_search_bar';
export const SLO_LIST_SEARCH_URL_STORAGE_KEY = 'search';
@ -19,12 +20,14 @@ export interface SearchState {
by: SortField;
direction: 'asc' | 'desc';
};
viewMode: ViewMode;
}
export const DEFAULT_STATE = {
kqlQuery: '',
page: 0,
sort: { by: 'status' as const, direction: 'desc' as const },
viewMode: 'compact' as const,
};
export function useUrlSearchState(): {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { act, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl';
@ -211,6 +211,9 @@ describe('SLOs Page', () => {
await act(async () => {
render(<SlosPage />);
});
expect(await screen.findByTestId('sloListViewButton')).toBeTruthy();
fireEvent.click(screen.getByTestId('sloListViewButton'));
expect(screen.queryByTestId('slosPage')).toBeTruthy();
expect(screen.queryByTestId('sloList')).toBeTruthy();
@ -229,6 +232,8 @@ describe('SLOs Page', () => {
await act(async () => {
render(<SlosPage />);
});
expect(await screen.findByTestId('sloListViewButton')).toBeTruthy();
fireEvent.click(screen.getByTestId('sloListViewButton'));
screen.getAllByLabelText('Actions').at(0)?.click();
@ -256,7 +261,8 @@ describe('SLOs Page', () => {
await act(async () => {
render(<SlosPage />);
});
expect(await screen.findByTestId('sloListViewButton')).toBeTruthy();
fireEvent.click(screen.getByTestId('sloListViewButton'));
screen.getAllByLabelText('Actions').at(0)?.click();
await waitForEuiPopoverOpen();
@ -281,7 +287,8 @@ describe('SLOs Page', () => {
await act(async () => {
render(<SlosPage />);
});
expect(await screen.findByTestId('sloListViewButton')).toBeTruthy();
fireEvent.click(screen.getByTestId('sloListViewButton'));
screen.getAllByLabelText('Actions').at(0)?.click();
await waitForEuiPopoverOpen();
@ -307,6 +314,8 @@ describe('SLOs Page', () => {
render(<SlosPage />);
});
expect(await screen.findByTestId('sloListViewButton')).toBeTruthy();
fireEvent.click(screen.getByTestId('sloListViewButton'));
screen.getAllByLabelText('Actions').at(0)?.click();
await waitForEuiPopoverOpen();
@ -337,6 +346,8 @@ describe('SLOs Page', () => {
render(<SlosPage />);
});
expect(await screen.findByTestId('sloListViewButton')).toBeTruthy();
fireEvent.click(screen.getByTestId('sloListViewButton'));
screen.getAllByLabelText('Actions').at(0)?.click();
await waitForEuiPopoverOpen();

View file

@ -70,6 +70,9 @@ export const ConfirmTrustSetupModal = ({ closeModal, onSubmit }: ModalProps) =>
label={i18n.translate('xpack.remoteClusters.clusterWizard.trustStep.modal.checkbox', {
defaultMessage: 'Yes, I have setup trust',
})}
labelProps={{
'data-test-subj': 'remoteClusterTrustCheckboxLabel',
}}
checked={hasSetupTrust}
onChange={() => setHasSetupTrust(!hasSetupTrust)}
data-test-subj="remoteClusterTrustCheckbox"

View file

@ -7,7 +7,6 @@
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { SubscriptionTrackingProvider } from '@kbn/subscription-tracking';
import { SecurityApp } from './app';
import type { RenderAppProps } from './types';
import { AppRoutes } from './app_routes';
@ -21,7 +20,6 @@ export const renderApp = ({
usageCollection,
subPluginRoutes,
theme$,
subscriptionTrackingServices,
}: RenderAppProps): (() => void) => {
const ApplicationUsageTrackingProvider =
usageCollection?.components.ApplicationUsageTrackingProvider ?? React.Fragment;
@ -34,12 +32,7 @@ export const renderApp = ({
theme$={theme$}
>
<ApplicationUsageTrackingProvider>
<SubscriptionTrackingProvider
analyticsClient={subscriptionTrackingServices.analyticsClient}
navigateToApp={subscriptionTrackingServices.navigateToApp}
>
<AppRoutes subPluginRoutes={subPluginRoutes} services={services} />
</SubscriptionTrackingProvider>
<AppRoutes subPluginRoutes={subPluginRoutes} services={services} />
</ApplicationUsageTrackingProvider>
</SecurityApp>,
element

View file

@ -19,7 +19,6 @@ import type { RouteProps } from 'react-router-dom';
import type { AppMountParameters } from '@kbn/core/public';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import type { TableState } from '@kbn/securitysolution-data-table';
import type { Services as SubscriptionTrackingServices } from '@kbn/subscription-tracking';
import type { ExploreReducer, ExploreState } from '../explore';
import type { StartServices } from '../types';
@ -30,7 +29,6 @@ export interface RenderAppProps extends AppMountParameters {
services: StartServices;
store: Store<State, Action>;
subPluginRoutes: RouteProps[];
subscriptionTrackingServices: SubscriptionTrackingServices;
usageCollection?: UsageCollectionSetup;
}

View file

@ -66,6 +66,8 @@ export const getComments = ({
regenerateMessage(currentConversation.id);
};
const connectorTypeTitle = currentConversation.apiConfig.connectorTypeTitle ?? '';
const extraLoadingComment = isFetchingResponse
? [
{
@ -75,6 +77,7 @@ export const getComments = ({
children: (
<StreamComment
amendMessage={amendMessageOfConversation}
connectorTypeTitle={connectorTypeTitle}
content=""
regenerateMessage={regenerateMessageOfConversation}
isLastComment
@ -122,6 +125,7 @@ export const getComments = ({
children: (
<StreamComment
amendMessage={amendMessageOfConversation}
connectorTypeTitle={connectorTypeTitle}
index={index}
isLastComment={isLastComment}
isError={message.isError}
@ -142,6 +146,7 @@ export const getComments = ({
children: (
<StreamComment
amendMessage={amendMessageOfConversation}
connectorTypeTitle={connectorTypeTitle}
content={transformedMessage.content}
index={index}
isLastComment={isLastComment}

View file

@ -19,6 +19,7 @@ const testProps = {
content,
index: 1,
isLastComment: true,
connectorTypeTitle: 'OpenAI',
regenerateMessage: jest.fn(),
transformMessage: jest.fn(),
};

View file

@ -21,6 +21,7 @@ interface Props {
isFetching?: boolean;
isLastComment: boolean;
index: number;
connectorTypeTitle: string;
reader?: ReadableStreamDefaultReader<Uint8Array>;
regenerateMessage: () => void;
transformMessage: (message: string) => ContentMessage;
@ -29,6 +30,7 @@ interface Props {
export const StreamComment = ({
amendMessage,
content,
connectorTypeTitle,
index,
isError = false,
isFetching = false,
@ -40,6 +42,7 @@ export const StreamComment = ({
const { error, isLoading, isStreaming, pendingMessage, setComplete } = useStream({
amendMessage,
content,
connectorTypeTitle,
reader,
isError,
});

View file

@ -9,6 +9,8 @@ import { API_ERROR } from '../translations';
import type { PromptObservableState } from './types';
import { Subject } from 'rxjs';
import { EventStreamCodec } from '@smithy/eventstream-codec';
import { fromUtf8, toUtf8 } from '@smithy/util-utf8';
describe('getStreamObservable', () => {
const mockReader = {
read: jest.fn(),
@ -22,29 +24,29 @@ describe('getStreamObservable', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should emit loading state and chunks', (done) => {
it('should emit loading state and chunks for Bedrock', (done) => {
const completeSubject = new Subject<void>();
const expectedStates: PromptObservableState[] = [
{ chunks: [], loading: true },
{
chunks: ['one chunk ', 'another chunk', ''],
message: 'one chunk ',
// when i log the actual emit, chunks equal to message.split(''); test is wrong
chunks: ['My', ' new', ' message'],
message: 'My',
loading: true,
},
{
chunks: ['one chunk ', 'another chunk', ''],
message: 'one chunk another chunk',
chunks: ['My', ' new', ' message'],
message: 'My new',
loading: true,
},
{
chunks: ['one chunk ', 'another chunk', ''],
message: 'one chunk another chunk',
chunks: ['My', ' new', ' message'],
message: 'My new message',
loading: true,
},
{
chunks: ['one chunk ', 'another chunk', ''],
message: 'one chunk another chunk',
chunks: ['My', ' new', ' message'],
message: 'My new message',
loading: false,
},
];
@ -52,11 +54,84 @@ describe('getStreamObservable', () => {
mockReader.read
.mockResolvedValueOnce({
done: false,
value: new Uint8Array(new TextEncoder().encode(`one chunk `)),
value: encodeBedrockResponse('My'),
})
.mockResolvedValueOnce({
done: false,
value: new Uint8Array(new TextEncoder().encode(`another chunk`)),
value: encodeBedrockResponse(' new'),
})
.mockResolvedValueOnce({
done: false,
value: encodeBedrockResponse(' message'),
})
.mockResolvedValue({
done: true,
});
const source = getStreamObservable({
connectorTypeTitle: 'Amazon Bedrock',
isError: false,
reader: typedReader,
setLoading,
});
const emittedStates: PromptObservableState[] = [];
source.subscribe({
next: (state) => {
return emittedStates.push(state);
},
complete: () => {
expect(emittedStates).toEqual(expectedStates);
done();
completeSubject.subscribe({
next: () => {
expect(setLoading).toHaveBeenCalledWith(false);
expect(typedReader.cancel).toHaveBeenCalled();
done();
},
});
},
error: (err) => done(err),
});
});
it('should emit loading state and chunks for OpenAI', (done) => {
const chunk1 = `data: {"object":"chat.completion.chunk","choices":[{"delta":{"content":"My"}}]}\ndata: {"object":"chat.completion.chunk","choices":[{"delta":{"content":" new"}}]}`;
const chunk2 = `\ndata: {"object":"chat.completion.chunk","choices":[{"delta":{"content":" message"}}]}\ndata: [DONE]`;
const completeSubject = new Subject<void>();
const expectedStates: PromptObservableState[] = [
{ chunks: [], loading: true },
{
// when i log the actual emit, chunks equal to message.split(''); test is wrong
chunks: ['My', ' new', ' message'],
message: 'My',
loading: true,
},
{
chunks: ['My', ' new', ' message'],
message: 'My new',
loading: true,
},
{
chunks: ['My', ' new', ' message'],
message: 'My new message',
loading: true,
},
{
chunks: ['My', ' new', ' message'],
message: 'My new message',
loading: false,
},
];
mockReader.read
.mockResolvedValueOnce({
done: false,
value: new Uint8Array(new TextEncoder().encode(chunk1)),
})
.mockResolvedValueOnce({
done: false,
value: new Uint8Array(new TextEncoder().encode(chunk2)),
})
.mockResolvedValueOnce({
done: false,
@ -66,11 +141,91 @@ describe('getStreamObservable', () => {
done: true,
});
const source = getStreamObservable(typedReader, setLoading, false);
const source = getStreamObservable({
connectorTypeTitle: 'OpenAI',
isError: false,
reader: typedReader,
setLoading,
});
const emittedStates: PromptObservableState[] = [];
source.subscribe({
next: (state) => emittedStates.push(state),
next: (state) => {
return emittedStates.push(state);
},
complete: () => {
expect(emittedStates).toEqual(expectedStates);
done();
completeSubject.subscribe({
next: () => {
expect(setLoading).toHaveBeenCalledWith(false);
expect(typedReader.cancel).toHaveBeenCalled();
done();
},
});
},
error: (err) => done(err),
});
});
it('should emit loading state and chunks for partial response OpenAI', (done) => {
const chunk1 = `data: {"object":"chat.completion.chunk","choices":[{"delta":{"content":"My"}}]}\ndata: {"object":"chat.completion.chunk","choices":[{"delta":{"content":" new"`;
const chunk2 = `}}]}\ndata: {"object":"chat.completion.chunk","choices":[{"delta":{"content":" message"}}]}\ndata: [DONE]`;
const completeSubject = new Subject<void>();
const expectedStates: PromptObservableState[] = [
{ chunks: [], loading: true },
{
// when i log the actual emit, chunks equal to message.split(''); test is wrong
chunks: ['My', ' new', ' message'],
message: 'My',
loading: true,
},
{
chunks: ['My', ' new', ' message'],
message: 'My new',
loading: true,
},
{
chunks: ['My', ' new', ' message'],
message: 'My new message',
loading: true,
},
{
chunks: ['My', ' new', ' message'],
message: 'My new message',
loading: false,
},
];
mockReader.read
.mockResolvedValueOnce({
done: false,
value: new Uint8Array(new TextEncoder().encode(chunk1)),
})
.mockResolvedValueOnce({
done: false,
value: new Uint8Array(new TextEncoder().encode(chunk2)),
})
.mockResolvedValueOnce({
done: false,
value: new Uint8Array(new TextEncoder().encode('')),
})
.mockResolvedValue({
done: true,
});
const source = getStreamObservable({
connectorTypeTitle: 'OpenAI',
isError: false,
reader: typedReader,
setLoading,
});
const emittedStates: PromptObservableState[] = [];
source.subscribe({
next: (state) => {
return emittedStates.push(state);
},
complete: () => {
expect(emittedStates).toEqual(expectedStates);
done();
@ -112,7 +267,12 @@ describe('getStreamObservable', () => {
done: true,
});
const source = getStreamObservable(typedReader, setLoading, true);
const source = getStreamObservable({
connectorTypeTitle: 'OpenAI',
isError: true,
reader: typedReader,
setLoading,
});
const emittedStates: PromptObservableState[] = [];
source.subscribe({
@ -138,7 +298,12 @@ describe('getStreamObservable', () => {
const error = new Error('Test Error');
// Simulate an error
mockReader.read.mockRejectedValue(error);
const source = getStreamObservable(typedReader, setLoading, false);
const source = getStreamObservable({
connectorTypeTitle: 'OpenAI',
isError: false,
reader: typedReader,
setLoading,
});
source.subscribe({
next: (state) => {},
@ -157,3 +322,16 @@ describe('getStreamObservable', () => {
});
});
});
function encodeBedrockResponse(completion: string) {
return new EventStreamCodec(toUtf8, fromUtf8).encode({
headers: {},
body: Uint8Array.from(
Buffer.from(
JSON.stringify({
bytes: Buffer.from(JSON.stringify({ completion })).toString('base64'),
})
)
),
});
}

View file

@ -7,10 +7,18 @@
import { concatMap, delay, finalize, Observable, of, scan, timestamp } from 'rxjs';
import type { Dispatch, SetStateAction } from 'react';
import { API_ERROR } from '../translations';
import { EventStreamCodec } from '@smithy/eventstream-codec';
import { fromUtf8, toUtf8 } from '@smithy/util-utf8';
import type { PromptObservableState } from './types';
import { API_ERROR } from '../translations';
const MIN_DELAY = 35;
interface StreamObservable {
connectorTypeTitle: string;
reader: ReadableStreamDefaultReader<Uint8Array>;
setLoading: Dispatch<SetStateAction<boolean>>;
isError: boolean;
}
/**
* Returns an Observable that reads data from a ReadableStream and emits values representing the state of the data processing.
*
@ -19,16 +27,68 @@ const MIN_DELAY = 35;
* @param isError - indicates whether the reader response is an error message or not
* @returns {Observable<PromptObservableState>} An Observable that emits PromptObservableState
*/
export const getStreamObservable = (
reader: ReadableStreamDefaultReader<Uint8Array>,
setLoading: Dispatch<SetStateAction<boolean>>,
isError: boolean
): Observable<PromptObservableState> =>
export const getStreamObservable = ({
connectorTypeTitle,
isError,
reader,
setLoading,
}: StreamObservable): Observable<PromptObservableState> =>
new Observable<PromptObservableState>((observer) => {
observer.next({ chunks: [], loading: true });
const decoder = new TextDecoder();
const chunks: string[] = [];
function read() {
// Initialize an empty string to store the OpenAI buffer.
let openAIBuffer: string = '';
// Initialize an empty Uint8Array to store the Bedrock concatenated buffer.
let bedrockBuffer: Uint8Array = new Uint8Array(0);
function readOpenAI() {
reader
.read()
.then(({ done, value }: { done: boolean; value?: Uint8Array }) => {
try {
if (done) {
if (openAIBuffer) {
chunks.push(getOpenAIChunks([openAIBuffer])[0]);
}
observer.next({
chunks,
message: chunks.join(''),
loading: false,
});
observer.complete();
return;
}
const decoded = decoder.decode(value);
let nextChunks;
if (isError) {
nextChunks = [`${API_ERROR}\n\n${JSON.parse(decoded).message}`];
} else {
const lines = decoded.split('\n');
lines[0] = openAIBuffer + lines[0];
openAIBuffer = lines.pop() || '';
nextChunks = getOpenAIChunks(lines);
}
nextChunks.forEach((chunk: string) => {
chunks.push(chunk);
observer.next({
chunks,
message: chunks.join(''),
loading: true,
});
});
} catch (err) {
observer.error(err);
return;
}
readOpenAI();
})
.catch((err) => {
observer.error(err);
});
}
function readBedrock() {
reader
.read()
.then(({ done, value }: { done: boolean; value?: Uint8Array }) => {
@ -36,35 +96,86 @@ export const getStreamObservable = (
if (done) {
observer.next({
chunks,
message: getMessageFromChunks(chunks),
message: chunks.join(''),
loading: false,
});
observer.complete();
return;
}
const decoded = decoder.decode(value);
const content = isError
? // we format errors as {message: string; status_code: number}
`${API_ERROR}\n\n${JSON.parse(decoded).message}`
: // all other responses are just strings (handled by subaction invokeStream)
decoded;
chunks.push(content);
observer.next({
chunks,
message: getMessageFromChunks(chunks),
loading: true,
});
let content;
if (isError) {
content = `${API_ERROR}\n\n${JSON.parse(decoder.decode(value)).message}`;
chunks.push(content);
observer.next({
chunks,
message: chunks.join(''),
loading: true,
});
} else if (value != null) {
const chunk: Uint8Array = value;
// Concatenate the current chunk to the existing buffer.
bedrockBuffer = concatChunks(bedrockBuffer, chunk);
// Get the length of the next message in the buffer.
let messageLength = getMessageLength(bedrockBuffer);
// Initialize an array to store fully formed message chunks.
const buildChunks = [];
// Process the buffer until no complete messages are left.
while (bedrockBuffer.byteLength > 0 && bedrockBuffer.byteLength >= messageLength) {
// Extract a chunk of the specified length from the buffer.
const extractedChunk = bedrockBuffer.slice(0, messageLength);
// Add the extracted chunk to the array of fully formed message chunks.
buildChunks.push(extractedChunk);
// Remove the processed chunk from the buffer.
bedrockBuffer = bedrockBuffer.slice(messageLength);
// Get the length of the next message in the updated buffer.
messageLength = getMessageLength(bedrockBuffer);
}
const awsDecoder = new EventStreamCodec(toUtf8, fromUtf8);
// Decode and parse each message chunk, extracting the 'completion' property.
buildChunks.forEach((bChunk) => {
const event = awsDecoder.decode(bChunk);
const body = JSON.parse(
Buffer.from(JSON.parse(decoder.decode(event.body)).bytes, 'base64').toString()
);
content = body.completion;
chunks.push(content);
observer.next({
chunks,
message: chunks.join(''),
loading: true,
});
});
}
} catch (err) {
observer.error(err);
return;
}
read();
readBedrock();
})
.catch((err) => {
observer.error(err);
});
}
read();
// this should never actually happen
function badConnector() {
observer.next({
chunks: [
`Invalid connector type - ${connectorTypeTitle} is not a supported GenAI connector.`,
],
message: `Invalid connector type - ${connectorTypeTitle} is not a supported GenAI connector.`,
loading: false,
});
observer.complete();
}
if (connectorTypeTitle === 'Amazon Bedrock') readBedrock();
else if (connectorTypeTitle === 'OpenAI') readOpenAI();
else badConnector();
return () => {
reader.cancel();
};
@ -99,8 +210,55 @@ export const getStreamObservable = (
finalize(() => setLoading(false))
);
function getMessageFromChunks(chunks: string[]) {
return chunks.join('');
/**
* Parses an OpenAI response from a string.
* @param lines
* @returns {string[]} - Parsed string array from the OpenAI response.
*/
const getOpenAIChunks = (lines: string[]): string[] => {
const nextChunk = lines
.map((str) => str.substring(6))
.filter((str) => !!str && str !== '[DONE]')
.map((line) => {
try {
const openaiResponse = JSON.parse(line);
return openaiResponse.choices[0]?.delta.content ?? '';
} catch (err) {
return '';
}
});
return nextChunk;
};
/**
* Concatenates two Uint8Array buffers.
*
* @param {Uint8Array} a - First buffer.
* @param {Uint8Array} b - Second buffer.
* @returns {Uint8Array} - Concatenated buffer.
*/
function concatChunks(a: Uint8Array, b: Uint8Array): Uint8Array {
const newBuffer = new Uint8Array(a.length + b.length);
// Copy the contents of the first buffer to the new buffer.
newBuffer.set(a);
// Copy the contents of the second buffer to the new buffer starting from the end of the first buffer.
newBuffer.set(b, a.length);
return newBuffer;
}
/**
* Gets the length of the next message from the buffer.
*
* @param {Uint8Array} buffer - Buffer containing the message.
* @returns {number} - Length of the next message.
*/
function getMessageLength(buffer: Uint8Array): number {
// If the buffer is empty, return 0.
if (buffer.byteLength === 0) return 0;
// Create a DataView to read the Uint32 value at the beginning of the buffer.
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
// Read and return the Uint32 value (message length).
return view.getUint32(0, false);
}
export const getPlaceholderObservable = () => new Observable<PromptObservableState>();

View file

@ -11,20 +11,22 @@ import { useStream } from './use_stream';
const amendMessage = jest.fn();
const reader = jest.fn();
const cancel = jest.fn();
const chunk1 = `data: {"object":"chat.completion.chunk","choices":[{"delta":{"content":"My"}}]}\ndata: {"object":"chat.completion.chunk","choices":[{"delta":{"content":" new"}}]}`;
const chunk2 = `\ndata: {"object":"chat.completion.chunk","choices":[{"delta":{"content":" message"}}]}\ndata: [DONE]`;
const readerComplete = {
read: reader
.mockResolvedValueOnce({
done: false,
value: new Uint8Array(new TextEncoder().encode('one chunk ')),
value: new Uint8Array(new TextEncoder().encode(chunk1)),
})
.mockResolvedValueOnce({
done: false,
value: new Uint8Array(new TextEncoder().encode(`another chunk`)),
value: new Uint8Array(new TextEncoder().encode(chunk2)),
})
.mockResolvedValueOnce({
done: false,
value: new Uint8Array(new TextEncoder().encode(``)),
value: new Uint8Array(new TextEncoder().encode('')),
})
.mockResolvedValue({
done: true,
@ -34,7 +36,12 @@ const readerComplete = {
closed: jest.fn().mockResolvedValue(true),
} as unknown as ReadableStreamDefaultReader<Uint8Array>;
const defaultProps = { amendMessage, reader: readerComplete, isError: false };
const defaultProps = {
amendMessage,
reader: readerComplete,
isError: false,
connectorTypeTitle: 'OpenAI',
};
describe('useStream', () => {
beforeEach(() => {
jest.clearAllMocks();
@ -57,7 +64,7 @@ describe('useStream', () => {
error: undefined,
isLoading: true,
isStreaming: true,
pendingMessage: 'one chunk ',
pendingMessage: 'My',
setComplete: expect.any(Function),
});
});
@ -67,7 +74,7 @@ describe('useStream', () => {
error: undefined,
isLoading: false,
isStreaming: false,
pendingMessage: 'one chunk another chunk',
pendingMessage: 'My new message',
setComplete: expect.any(Function),
});
});

Some files were not shown because too many files have changed in this diff Show more