mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Merge branch 'main' into ui-actions-refactor
This commit is contained in:
commit
dd649dca3e
213 changed files with 2621 additions and 2512 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -528,6 +528,7 @@ export interface DocLinks {
|
|||
installAndUninstallIntegrationAssets: string;
|
||||
elasticAgentInputConfiguration: string;
|
||||
policySecrets: string;
|
||||
remoteESOoutput: string;
|
||||
}>;
|
||||
readonly ecs: {
|
||||
readonly guide: string;
|
||||
|
|
|
@ -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.
|
|
@ -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';
|
|
@ -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'],
|
||||
};
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/subscription-tracking",
|
||||
"owner": "@elastic/security-threat-hunting-investigations"
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "@kbn/subscription-tracking",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "SSPL-1.0 OR Elastic License 2.0"
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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>();
|
||||
}
|
|
@ -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"]
|
||||
}
|
|
@ -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',
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
],
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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'),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}, '');
|
||||
|
|
|
@ -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>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
})}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 }));
|
||||
|
|
|
@ -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 },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 },
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
|
@ -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 },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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
|
||||
)!;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -14,8 +14,6 @@
|
|||
"@kbn/std",
|
||||
"@kbn/i18n",
|
||||
"@kbn/analytics-client",
|
||||
"@kbn/subscription-tracking",
|
||||
"@kbn/core-analytics-browser",
|
||||
"@kbn/logging-mocks"
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -481,7 +481,7 @@ export function useModelActions({
|
|||
);
|
||||
},
|
||||
enabled: (item) => {
|
||||
return item.state !== MODEL_STATE.STARTED;
|
||||
return canStartStopTrainedModels && item.state !== MODEL_STATE.STARTED;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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)"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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({});
|
||||
|
|
|
@ -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) => (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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(): {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -19,6 +19,7 @@ const testProps = {
|
|||
content,
|
||||
index: 1,
|
||||
isLastComment: true,
|
||||
connectorTypeTitle: 'OpenAI',
|
||||
regenerateMessage: jest.fn(),
|
||||
transformMessage: jest.fn(),
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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'),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue