mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Use runtime data source for getting events from alert state history in the bar chart (#89307)
* Use runtime data source for getting events from alert state history in the bar chart * extract translations * refactor * More refactor * Update events limit * Add info icon with tooltip info for label querying filter * Add translations * Create new useRuleHistoryRecords hook skipping extraction of common labels as they are not used * Fix test * update limit value for the events in the api to 5000 * Use state for rows key * remove React import * Address review comments * Address review comments * run prettier * Remove duplicated handlers
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import {
|
||||
EmbeddedScene,
|
||||
PanelBuilders,
|
||||
@@ -9,35 +10,64 @@ import {
|
||||
SceneReactObject,
|
||||
SceneRefreshPicker,
|
||||
SceneTimePicker,
|
||||
SceneTimeRange,
|
||||
SceneVariableSet,
|
||||
TextBoxVariable,
|
||||
VariableValueSelectors,
|
||||
useUrlSync,
|
||||
} from '@grafana/scenes';
|
||||
import { GraphDrawStyle, VisibilityMode } from '@grafana/schema/dist/esm/index';
|
||||
import {
|
||||
GraphDrawStyle,
|
||||
GraphGradientMode,
|
||||
Icon,
|
||||
LegendDisplayMode,
|
||||
LineInterpolation,
|
||||
ScaleDistribution,
|
||||
StackingMode,
|
||||
Tooltip,
|
||||
TooltipDisplayMode,
|
||||
VisibilityMode,
|
||||
} from '@grafana/schema/dist/esm/index';
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
|
||||
import { DataSourceInformation, PANEL_STYLES } from '../../../home/Insights';
|
||||
import { SectionSubheader } from '../../../insights/SectionSubheader';
|
||||
import { DataSourceInformation } from '../../../home/Insights';
|
||||
|
||||
import { HistoryEventsListObjectRenderer } from './CentralAlertHistory';
|
||||
import { alertStateHistoryDatasource, useRegisterHistoryRuntimeDataSource } from './CentralHistoryRuntimeDataSource';
|
||||
import { HistoryEventsListObject } from './EventListSceneObject';
|
||||
|
||||
export const LABELS_FILTER = 'filter';
|
||||
/**
|
||||
*
|
||||
* This scene shows the history of the alert state changes.
|
||||
* It shows a timeseries panel with the alert state changes and a list of the events.
|
||||
* The events in the panel are fetched from the history api, through a runtime datasource.
|
||||
* The events in the list are fetched direclty from the history api.
|
||||
* Main scene renders two children scene objects, one for the timeseries panel and one for the list of events.
|
||||
* Both share time range and filter variable from the parent scene.
|
||||
*/
|
||||
|
||||
export const CentralAlertHistoryScene = () => {
|
||||
const dataSourceSrv = getDataSourceSrv();
|
||||
const alertStateHistoryDatasource: DataSourceInformation = {
|
||||
type: 'loki',
|
||||
uid: 'grafanacloud-alert-state-history',
|
||||
settings: undefined,
|
||||
};
|
||||
const filterVariable = new TextBoxVariable({
|
||||
name: LABELS_FILTER,
|
||||
label: 'Filter by labels: ',
|
||||
});
|
||||
|
||||
alertStateHistoryDatasource.settings = dataSourceSrv.getInstanceSettings(alertStateHistoryDatasource.uid);
|
||||
useRegisterHistoryRuntimeDataSource(); // register the runtime datasource for the history api.
|
||||
|
||||
const scene = new EmbeddedScene({
|
||||
controls: [new SceneControlsSpacer(), new SceneTimePicker({}), new SceneRefreshPicker({})],
|
||||
controls: [
|
||||
new SceneReactObject({
|
||||
component: FilterInfo,
|
||||
}),
|
||||
new VariableValueSelectors({}),
|
||||
new SceneControlsSpacer(),
|
||||
new SceneTimePicker({}),
|
||||
new SceneRefreshPicker({}),
|
||||
],
|
||||
$timeRange: new SceneTimeRange({}), //needed for using the time range sync in the url
|
||||
$variables: new SceneVariableSet({
|
||||
variables: [filterVariable],
|
||||
}),
|
||||
body: new SceneFlexLayout({
|
||||
direction: 'column',
|
||||
children: [
|
||||
@@ -46,31 +76,35 @@ export const CentralAlertHistoryScene = () => {
|
||||
body: getEventsSceneObject(alertStateHistoryDatasource),
|
||||
}),
|
||||
new SceneFlexItem({
|
||||
body: new SceneReactObject({
|
||||
component: HistoryEventsListObjectRenderer,
|
||||
}),
|
||||
body: new HistoryEventsListObject(),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
// we need to call this to sync the url with the scene state
|
||||
const isUrlSyncInitialized = useUrlSync(scene);
|
||||
|
||||
if (!isUrlSyncInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <scene.Component model={scene} />;
|
||||
};
|
||||
|
||||
function getEventsSceneObject(ashDs: DataSourceInformation) {
|
||||
/**
|
||||
* Creates a SceneFlexItem with a timeseries panel that shows the events.
|
||||
* The query uses a runtime datasource that fetches the events from the history api.
|
||||
* @param alertStateHistoryDataSource the datasource information for the runtime datasource
|
||||
*/
|
||||
function getEventsSceneObject(alertStateHistoryDataSource: DataSourceInformation) {
|
||||
return new EmbeddedScene({
|
||||
controls: [
|
||||
new SceneReactObject({
|
||||
component: SectionSubheader,
|
||||
}),
|
||||
],
|
||||
controls: [],
|
||||
body: new SceneFlexLayout({
|
||||
direction: 'column',
|
||||
children: [
|
||||
new SceneFlexItem({
|
||||
ySizing: 'content',
|
||||
body: new SceneFlexLayout({
|
||||
children: [getEventsScenesFlexItem(ashDs)],
|
||||
children: [getEventsScenesFlexItem(alertStateHistoryDataSource)],
|
||||
}),
|
||||
}),
|
||||
],
|
||||
@@ -78,13 +112,18 @@ function getEventsSceneObject(ashDs: DataSourceInformation) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SceneQueryRunner with the datasource information for the runtime datasource.
|
||||
* @param datasource the datasource information for the runtime datasource
|
||||
* @returns the SceneQueryRunner
|
||||
*/
|
||||
function getSceneQuery(datasource: DataSourceInformation) {
|
||||
const query = new SceneQueryRunner({
|
||||
datasource,
|
||||
datasource: datasource,
|
||||
queries: [
|
||||
{
|
||||
refId: 'A',
|
||||
expr: 'count_over_time({from="state-history"} |= `` [$__auto])',
|
||||
expr: '',
|
||||
queryType: 'range',
|
||||
step: '10s',
|
||||
},
|
||||
@@ -92,10 +131,13 @@ function getSceneQuery(datasource: DataSourceInformation) {
|
||||
});
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function creates a SceneFlexItem with a timeseries panel that shows the events.
|
||||
* The query uses a runtime datasource that fetches the events from the history api.
|
||||
*/
|
||||
export function getEventsScenesFlexItem(datasource: DataSourceInformation) {
|
||||
return new SceneFlexItem({
|
||||
...PANEL_STYLES,
|
||||
minHeight: 300,
|
||||
body: PanelBuilders.timeseries()
|
||||
.setTitle('Events')
|
||||
.setDescription('Alert events during the period of time.')
|
||||
@@ -120,3 +162,39 @@ export function getEventsScenesFlexItem(datasource: DataSourceInformation) {
|
||||
.build(),
|
||||
});
|
||||
}
|
||||
|
||||
export const FilterInfo = () => {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Tooltip
|
||||
content={
|
||||
<div>
|
||||
<Trans i18nKey="central-alert-history.filter.info.label1">
|
||||
Filter events using label querying without spaces, ex:
|
||||
</Trans>
|
||||
<pre>{`{severity="critical", instance=~"cluster-us-.+"}`}</pre>
|
||||
<Trans i18nKey="central-alert-history.filter.info.label2">Invalid use of spaces:</Trans>
|
||||
<pre>{`{severity= "critical"}`}</pre>
|
||||
<pre>{`{severity ="critical"}`}</pre>
|
||||
<Trans i18nKey="central-alert-history.filter.info.label3">Valid use of spaces:</Trans>
|
||||
<pre>{`{severity=" critical"}`}</pre>
|
||||
<Trans i18nKey="central-alert-history.filter.info.label4">
|
||||
Filter alerts using label querying without braces, ex:
|
||||
</Trans>
|
||||
<pre>{`severity="critical", instance=~"cluster-us-.+"`}</pre>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Icon name="info-circle" size="sm" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = () => ({
|
||||
container: css({
|
||||
padding: '0',
|
||||
alignSelf: 'center',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { DataQuery, DataQueryRequest, DataQueryResponse, TestDataSourceResponse } from '@grafana/data';
|
||||
import { RuntimeDataSource, sceneUtils } from '@grafana/scenes';
|
||||
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
import { dispatch } from 'app/store/store';
|
||||
|
||||
import { stateHistoryApi } from '../../../api/stateHistoryApi';
|
||||
import { DataSourceInformation } from '../../../home/Insights';
|
||||
|
||||
import { LIMIT_EVENTS } from './EventListSceneObject';
|
||||
import { historyResultToDataFrame } from './utils';
|
||||
|
||||
const historyDataSourceUid = '__history_api_ds_uid__';
|
||||
const historyDataSourcePluginId = '__history_api_ds_pluginId__';
|
||||
|
||||
export const alertStateHistoryDatasource: DataSourceInformation = {
|
||||
type: historyDataSourcePluginId,
|
||||
uid: historyDataSourceUid,
|
||||
settings: undefined,
|
||||
};
|
||||
|
||||
export function useRegisterHistoryRuntimeDataSource() {
|
||||
// we need to memoize the datasource so it is not registered multiple times for each render
|
||||
const ds = useMemo(() => new HistoryAPIDatasource(historyDataSourceUid, historyDataSourcePluginId), []);
|
||||
useEffect(() => {
|
||||
try {
|
||||
// avoid showing error when the datasource is already registered
|
||||
sceneUtils.registerRuntimeDataSource({ dataSource: ds });
|
||||
} catch (e) {}
|
||||
}, [ds]);
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is a runtime datasource that fetches the events from the history api.
|
||||
* The events are grouped by alert instance and then converted to a DataFrame list.
|
||||
* The DataFrame list is then grouped by time.
|
||||
* This allows us to filter the events by labels.
|
||||
* The result is a timeseries panel that shows the events for the selected time range and filtered by labels.
|
||||
*/
|
||||
class HistoryAPIDatasource extends RuntimeDataSource {
|
||||
constructor(pluginId: string, uid: string) {
|
||||
super(uid, pluginId);
|
||||
}
|
||||
|
||||
async query(request: DataQueryRequest<DataQuery>): Promise<DataQueryResponse> {
|
||||
const from = request.range.from.unix();
|
||||
const to = request.range.to.unix();
|
||||
|
||||
return {
|
||||
data: historyResultToDataFrame(await getHistory(from, to)),
|
||||
};
|
||||
}
|
||||
|
||||
testDatasource(): Promise<TestDataSourceResponse> {
|
||||
return Promise.resolve({ status: 'success', message: 'Data source is working', title: 'Success' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the history events from the history api.
|
||||
* @param from the start time
|
||||
* @param to the end time
|
||||
* @returns the history events only filtered by time
|
||||
*/
|
||||
export const getHistory = (from: number, to: number) => {
|
||||
return dispatch(
|
||||
stateHistoryApi.endpoints.getRuleHistory.initiate(
|
||||
{
|
||||
from: from,
|
||||
to: to,
|
||||
limit: LIMIT_EVENTS,
|
||||
},
|
||||
{
|
||||
forceRefetch: Boolean(getTimeSrv().getAutoRefreshInteval().interval), // force refetch in case we are using the refresh option
|
||||
}
|
||||
)
|
||||
).unwrap();
|
||||
};
|
||||
@@ -1,27 +1,13 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { forwardRef, useCallback, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, TimeRange } from '@grafana/data';
|
||||
import { DataFrameJSON, GrafanaTheme2, TimeRange } from '@grafana/data';
|
||||
import { isFetchError } from '@grafana/runtime';
|
||||
import { SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Field,
|
||||
Icon,
|
||||
Input,
|
||||
Label,
|
||||
LoadingBar,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
useStyles2,
|
||||
withErrorBoundary,
|
||||
} from '@grafana/ui';
|
||||
import { SceneComponentProps, SceneObjectBase, TextBoxVariable, VariableValue, sceneGraph } from '@grafana/scenes';
|
||||
import { Alert, Icon, LoadingBar, Stack, Text, Tooltip, useStyles2, withErrorBoundary } from '@grafana/ui';
|
||||
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import {
|
||||
GrafanaAlertStateWithReason,
|
||||
isAlertStateWithReason,
|
||||
@@ -31,86 +17,78 @@ import {
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { stateHistoryApi } from '../../../api/stateHistoryApi';
|
||||
import { labelsMatchMatchers, parseMatchers } from '../../../utils/alertmanager';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
|
||||
import { stringifyErrorLike } from '../../../utils/misc';
|
||||
import { hashLabelsOrAnnotations } from '../../../utils/rule-id';
|
||||
import { AlertLabels } from '../../AlertLabels';
|
||||
import { CollapseToggle } from '../../CollapseToggle';
|
||||
import { LogRecord } from '../state-history/common';
|
||||
import { useRuleHistoryRecords } from '../state-history/useRuleHistoryRecords';
|
||||
import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords';
|
||||
|
||||
const LIMIT_EVENTS = 250;
|
||||
import { LABELS_FILTER } from './CentralAlertHistoryScene';
|
||||
|
||||
const HistoryEventsList = ({ timeRange }: { timeRange?: TimeRange }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
export const LIMIT_EVENTS = 5000; // limit is hard-capped at 5000 at the BE level.
|
||||
|
||||
// Filter state
|
||||
const [eventsFilter, setEventsFilter] = useState('');
|
||||
// form for filter fields
|
||||
const { register, handleSubmit, reset } = useForm({ defaultValues: { query: '' } }); // form for search field
|
||||
/**
|
||||
*
|
||||
* This component displays a list of history events.
|
||||
* It fetches the events from the history api and displays them in a list.
|
||||
* The list is filtered by the labels in the filter variable and by the time range variable in the scene graph.
|
||||
*/
|
||||
export const HistoryEventsList = ({
|
||||
timeRange,
|
||||
valueInfilterTextBox,
|
||||
}: {
|
||||
timeRange?: TimeRange;
|
||||
valueInfilterTextBox: VariableValue;
|
||||
}) => {
|
||||
const from = timeRange?.from.unix();
|
||||
const to = timeRange?.to.unix();
|
||||
const onFilterCleared = useCallback(() => {
|
||||
setEventsFilter('');
|
||||
reset();
|
||||
}, [setEventsFilter, reset]);
|
||||
|
||||
const {
|
||||
data: stateHistory,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = stateHistoryApi.endpoints.getRuleHistory.useQuery(
|
||||
{
|
||||
from: from,
|
||||
to: to,
|
||||
limit: LIMIT_EVENTS,
|
||||
},
|
||||
{
|
||||
refetchOnFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
}
|
||||
} = stateHistoryApi.endpoints.getRuleHistory.useQuery({
|
||||
from: from,
|
||||
to: to,
|
||||
limit: LIMIT_EVENTS,
|
||||
});
|
||||
|
||||
const { historyRecords: historyRecordsNotSorted } = useRuleHistoryRecords(
|
||||
stateHistory,
|
||||
valueInfilterTextBox.toString()
|
||||
);
|
||||
|
||||
const { historyRecords } = useRuleHistoryRecords(stateHistory, eventsFilter);
|
||||
const historyRecords = historyRecordsNotSorted.sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
if (isError) {
|
||||
return <HistoryErrorMessage error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={1}>
|
||||
<div className={styles.labelsFilter}>
|
||||
<form onSubmit={handleSubmit((data) => setEventsFilter(data.query))}>
|
||||
<SearchFieldInput
|
||||
{...register('query')}
|
||||
showClearFilterSuffix={!!eventsFilter}
|
||||
onClearFilterClick={onFilterCleared}
|
||||
/>
|
||||
<input type="submit" hidden />
|
||||
</form>
|
||||
</div>
|
||||
<>
|
||||
<LoadingIndicator visible={isLoading} />
|
||||
<HistoryLogEvents logRecords={historyRecords} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// todo: this function has been copied from RuleList.v2.tsx, should be moved to a shared location
|
||||
const LoadingIndicator = ({ visible = false }) => {
|
||||
const [measureRef, { width }] = useMeasure<HTMLDivElement>();
|
||||
return <div ref={measureRef}>{visible && <LoadingBar width={width} />}</div>;
|
||||
return <div ref={measureRef}>{visible && <LoadingBar width={width} data-testid="loading-bar" />}</div>;
|
||||
};
|
||||
|
||||
interface HistoryLogEventsProps {
|
||||
logRecords: LogRecord[];
|
||||
}
|
||||
function HistoryLogEvents({ logRecords }: HistoryLogEventsProps) {
|
||||
// display log records
|
||||
return (
|
||||
<ul>
|
||||
{logRecords.map((record) => {
|
||||
return <EventRow key={record.timestamp + hashLabelsOrAnnotations(record.line.labels ?? {})} record={record} />;
|
||||
return <EventRow key={record.timestamp + (record.line.fingerprint ?? '')} record={record} />;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
@@ -129,52 +107,12 @@ function HistoryErrorMessage({ error }: HistoryErrorMessageProps) {
|
||||
return <Alert title={title}>{stringifyErrorLike(error)}</Alert>;
|
||||
}
|
||||
|
||||
interface SearchFieldInputProps {
|
||||
showClearFilterSuffix: boolean;
|
||||
onClearFilterClick: () => void;
|
||||
}
|
||||
const SearchFieldInput = forwardRef<HTMLInputElement, SearchFieldInputProps>(
|
||||
({ showClearFilterSuffix, onClearFilterClick, ...rest }: SearchFieldInputProps, ref) => {
|
||||
const placeholder = t('central-alert-history.filter.placeholder', 'Filter events in the list with labels');
|
||||
return (
|
||||
<Field
|
||||
label={
|
||||
<Label htmlFor="eventsSearchInput">
|
||||
<Stack gap={0.5}>
|
||||
<span>
|
||||
<Trans i18nKey="central-alert-history.filter.label">Filter events</Trans>
|
||||
</span>
|
||||
</Stack>
|
||||
</Label>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
id="eventsSearchInput"
|
||||
prefix={<Icon name="search" />}
|
||||
suffix={
|
||||
showClearFilterSuffix && (
|
||||
<Button fill="text" icon="times" size="sm" onClick={onClearFilterClick}>
|
||||
<Trans i18nKey="central-alert-history.filter.button.clear">Clear</Trans>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
placeholder={placeholder}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SearchFieldInput.displayName = 'SearchFieldInput';
|
||||
|
||||
function EventRow({ record }: { record: LogRecord }) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.header} data-testid="rule-group-header">
|
||||
<div className={styles.header} data-testid="event-row-header">
|
||||
<CollapseToggle
|
||||
size="sm"
|
||||
className={styles.collapseToggle}
|
||||
@@ -365,19 +303,60 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
display: 'block',
|
||||
color: theme.colors.text.link,
|
||||
}),
|
||||
labelsFilter: css({
|
||||
width: '100%',
|
||||
paddingTop: theme.spacing(4),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* This is a scene object that displays a list of history events.
|
||||
*/
|
||||
|
||||
export class HistoryEventsListObject extends SceneObjectBase {
|
||||
public static Component = HistoryEventsListObjectRenderer;
|
||||
public constructor() {
|
||||
super({});
|
||||
}
|
||||
}
|
||||
|
||||
export function HistoryEventsListObjectRenderer({ model }: SceneComponentProps<HistoryEventsListObject>) {
|
||||
const { value: timeRange } = sceneGraph.getTimeRange(model).useState(); // get time range from scene graph
|
||||
const filtersVariable = sceneGraph.lookupVariable(LABELS_FILTER, model)!;
|
||||
|
||||
return <HistoryEventsList timeRange={timeRange} />;
|
||||
const valueInfilterTextBox: VariableValue = !(filtersVariable instanceof TextBoxVariable)
|
||||
? ''
|
||||
: filtersVariable.getValue();
|
||||
|
||||
return <HistoryEventsList timeRange={timeRange} valueInfilterTextBox={valueInfilterTextBox} />;
|
||||
}
|
||||
|
||||
function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filter?: string) {
|
||||
return useMemo(() => {
|
||||
if (!stateHistory?.data) {
|
||||
return { historyRecords: [] };
|
||||
}
|
||||
|
||||
const filterMatchers = filter ? parseMatchers(filter) : [];
|
||||
|
||||
const [tsValues, lines] = stateHistory.data.values;
|
||||
const timestamps = isNumbers(tsValues) ? tsValues : [];
|
||||
|
||||
// merge timestamp with "line"
|
||||
const logRecords = timestamps.reduce((acc: LogRecord[], timestamp: number, index: number) => {
|
||||
const line = lines[index];
|
||||
if (!isLine(line)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// values property can be undefined for some instance states (e.g. NoData)
|
||||
const filterMatch = line.labels && labelsMatchMatchers(line.labels, filterMatchers);
|
||||
if (filterMatch) {
|
||||
acc.push({ timestamp, line });
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
historyRecords: logRecords,
|
||||
};
|
||||
}, [stateHistory, filter]);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, waitFor } from 'test/test-utils';
|
||||
import { byLabelText, byTestId } from 'testing-library-selector';
|
||||
|
||||
import { setupMswServer } from '../../../mockApi';
|
||||
|
||||
import { HistoryEventsList } from './EventListSceneObject';
|
||||
|
||||
setupMswServer();
|
||||
// msw server is setup to intercept the history api call and return the mocked data by default
|
||||
// that consists in 4 rows.
|
||||
// 2 rows for alert1 and 2 rows for alert2
|
||||
|
||||
const ui = {
|
||||
rowHeader: byTestId('event-row-header'),
|
||||
};
|
||||
describe('HistoryEventsList', () => {
|
||||
it('should render the list correctly filtered by label in filter variable', async () => {
|
||||
render(<HistoryEventsList valueInfilterTextBox={'alertname=alert1'} />);
|
||||
await waitFor(() => {
|
||||
expect(byLabelText('Loading bar').query()).not.toBeInTheDocument();
|
||||
});
|
||||
expect(ui.rowHeader.getAll()).toHaveLength(2); // 2 events for alert1
|
||||
expect(ui.rowHeader.getAll()[0]).toHaveTextContent(
|
||||
'June 14 at 06:39:00alert1alertnamealert1grafana_folderFOLDER Ahandler/alerting/*'
|
||||
);
|
||||
expect(ui.rowHeader.getAll()[1]).toHaveTextContent(
|
||||
'June 14 at 06:38:30alert1alertnamealert1grafana_folderFOLDER Ahandler/alerting/*'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
getHistoryResponse,
|
||||
time_0,
|
||||
time_plus_10,
|
||||
time_plus_15,
|
||||
time_plus_30,
|
||||
time_plus_5,
|
||||
} from '../../../mocks/alertRuleApi';
|
||||
|
||||
import { historyResultToDataFrame } from './utils';
|
||||
|
||||
describe('historyResultToDataFrame', () => {
|
||||
it('should return correct result grouping by 10 seconds', async () => {
|
||||
const result = historyResultToDataFrame(getHistoryResponse([time_0, time_0, time_plus_30, time_plus_30]));
|
||||
expect(result[0].length).toBe(2);
|
||||
expect(result[0].fields[0].name).toBe('time');
|
||||
expect(result[0].fields[1].name).toBe('value');
|
||||
expect(result[0].fields[0].values).toStrictEqual([time_0, time_plus_30]);
|
||||
expect(result[0].fields[1].values).toStrictEqual([2, 2]);
|
||||
|
||||
const result2 = historyResultToDataFrame(getHistoryResponse([time_0, time_plus_5, time_plus_30, time_plus_30]));
|
||||
expect(result2[0].length).toBe(2);
|
||||
expect(result2[0].fields[0].name).toBe('time');
|
||||
expect(result2[0].fields[1].name).toBe('value');
|
||||
expect(result2[0].fields[0].values).toStrictEqual([time_0, time_plus_30]);
|
||||
expect(result2[0].fields[1].values).toStrictEqual([2, 2]);
|
||||
|
||||
const result3 = historyResultToDataFrame(getHistoryResponse([time_0, time_plus_15, time_plus_10, time_plus_30]));
|
||||
expect(result3[0].length).toBe(3);
|
||||
expect(result3[0].fields[0].name).toBe('time');
|
||||
expect(result3[0].fields[1].name).toBe('value');
|
||||
expect(result3[0].fields[0].values).toStrictEqual([time_0, time_plus_10, time_plus_30]);
|
||||
expect(result3[0].fields[1].values).toStrictEqual([1, 2, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
import { DataFrame, Field as DataFrameField, DataFrameJSON, Field, FieldType } from '@grafana/data';
|
||||
import { fieldIndexComparer } from '@grafana/data/src/field/fieldComparers';
|
||||
|
||||
import { labelsMatchMatchers, parseMatchers } from '../../../utils/alertmanager';
|
||||
import { LogRecord } from '../state-history/common';
|
||||
import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords';
|
||||
|
||||
import { LABELS_FILTER } from './CentralAlertHistoryScene';
|
||||
|
||||
const GROUPING_INTERVAL = 10 * 1000; // 10 seconds
|
||||
const QUERY_PARAM_PREFIX = 'var-'; // Prefix used by Grafana to sync variables in the URL
|
||||
/*
|
||||
* This function is used to convert the history response to a DataFrame list and filter the data by labels.
|
||||
* The response is a list of log records, each log record has a timestamp and a line.
|
||||
* We group all records by alert instance (unique set of labels) and create a DataFrame for each group (instance).
|
||||
* This allows us to be able to filter by labels in the groupDataFramesByTime function.
|
||||
*/
|
||||
export function historyResultToDataFrame(data: DataFrameJSON): DataFrame[] {
|
||||
const tsValues = data?.data?.values[0] ?? [];
|
||||
const timestamps: number[] = isNumbers(tsValues) ? tsValues : [];
|
||||
const lines = data?.data?.values[1] ?? [];
|
||||
|
||||
const logRecords = timestamps.reduce((acc: LogRecord[], timestamp: number, index: number) => {
|
||||
const line = lines[index];
|
||||
// values property can be undefined for some instance states (e.g. NoData)
|
||||
if (isLine(line)) {
|
||||
acc.push({ timestamp, line });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// Group log records by alert instance
|
||||
const logRecordsByInstance = groupBy(logRecords, (record: LogRecord) => {
|
||||
return JSON.stringify(record.line.labels);
|
||||
});
|
||||
|
||||
// Convert each group of log records to a DataFrame
|
||||
const dataFrames: DataFrame[] = Object.entries(logRecordsByInstance).map<DataFrame>(([key, records]) => {
|
||||
// key is the stringified labels
|
||||
return logRecordsToDataFrame(key, records);
|
||||
});
|
||||
|
||||
// Group DataFrames by time and filter by labels
|
||||
return groupDataFramesByTimeAndFilterByLabels(dataFrames);
|
||||
}
|
||||
|
||||
// Scenes sync variables in the URL adding a prefix to the variable name.
|
||||
function getFilterInQueryParams() {
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
return queryParams.get(`${QUERY_PARAM_PREFIX}${LABELS_FILTER}`) ?? '';
|
||||
}
|
||||
|
||||
/*
|
||||
* This function groups the data frames by time and filters them by labels.
|
||||
* The interval is set to 10 seconds.
|
||||
* */
|
||||
function groupDataFramesByTimeAndFilterByLabels(dataFrames: DataFrame[]): DataFrame[] {
|
||||
// Filter data frames by labels. This is used to filter out the data frames that do not match the query.
|
||||
const filterValue = getFilterInQueryParams();
|
||||
const dataframesFiltered = dataFrames.filter((frame) => {
|
||||
const labels = JSON.parse(frame.name ?? ''); // in name we store the labels stringified
|
||||
const matchers = Boolean(filterValue) ? parseMatchers(filterValue) : [];
|
||||
return labelsMatchMatchers(labels, matchers);
|
||||
});
|
||||
// Extract time fields from filtered data frames
|
||||
const timeFieldList = dataframesFiltered.flatMap((frame) => frame.fields.find((field) => field.name === 'time'));
|
||||
|
||||
// Group time fields by interval
|
||||
const groupedTimeFields = groupBy(
|
||||
timeFieldList?.flatMap((tf) => tf?.values),
|
||||
(time: number) => Math.floor(time / GROUPING_INTERVAL) * GROUPING_INTERVAL
|
||||
);
|
||||
|
||||
// Create new time field with grouped time values
|
||||
const newTimeField: Field = {
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: Object.keys(groupedTimeFields).map(Number),
|
||||
config: { displayName: 'Time', custom: { fillOpacity: 100 } },
|
||||
};
|
||||
|
||||
// Create count field with count of records in each group
|
||||
const countField: Field = {
|
||||
name: 'value',
|
||||
type: FieldType.number,
|
||||
values: Object.values(groupedTimeFields).map((group) => group.length),
|
||||
config: {},
|
||||
};
|
||||
|
||||
// Return new DataFrame with time and count fields
|
||||
return [
|
||||
{
|
||||
fields: [newTimeField, countField],
|
||||
length: newTimeField.values.length,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
* This function is used to convert the log records to a DataFrame.
|
||||
* The DataFrame has two fields: time and value.
|
||||
* The time field is the timestamp of the log record.
|
||||
* The value field is always 1.
|
||||
* */
|
||||
function logRecordsToDataFrame(instanceLabels: string, records: LogRecord[]): DataFrame {
|
||||
const timeField: DataFrameField = {
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: [...records.map((record) => record.timestamp)],
|
||||
config: { displayName: 'Time', custom: { fillOpacity: 100 } },
|
||||
};
|
||||
|
||||
// Sort time field values
|
||||
const timeIndex = timeField.values.map((_, index) => index);
|
||||
timeIndex.sort(fieldIndexComparer(timeField));
|
||||
|
||||
// Create DataFrame with time and value fields
|
||||
const frame: DataFrame = {
|
||||
fields: [
|
||||
{
|
||||
...timeField,
|
||||
values: timeField.values.map((_, i) => timeField.values[timeIndex[i]]),
|
||||
},
|
||||
{
|
||||
name: instanceLabels,
|
||||
type: FieldType.number,
|
||||
values: timeField.values.map((record) => 1),
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
length: timeField.values.length,
|
||||
name: instanceLabels,
|
||||
};
|
||||
|
||||
return frame;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export interface Line {
|
||||
current: GrafanaAlertStateWithReason;
|
||||
values?: Record<string, number>;
|
||||
labels?: Record<string, string>;
|
||||
fingerprint?: string;
|
||||
ruleUID?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { SetupServer } from 'msw/node';
|
||||
|
||||
import { FieldType } from '@grafana/data';
|
||||
import {
|
||||
GrafanaAlertStateDecision,
|
||||
PromRulesResponse,
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
RulerRuleGroupDTO,
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { PreviewResponse, PREVIEW_URL, PROM_RULES_URL } from '../api/alertRuleApi';
|
||||
import { PREVIEW_URL, PreviewResponse, PROM_RULES_URL } from '../api/alertRuleApi';
|
||||
import { Annotation } from '../utils/constants';
|
||||
|
||||
export function mockPreviewApiResponse(server: SetupServer, result: PreviewResponse) {
|
||||
@@ -80,3 +81,167 @@ export const namespaces: Record<string, RulerRuleGroupDTO[]> = {
|
||||
[grafanaRulerNamespace.uid]: [grafanaRulerGroup],
|
||||
[grafanaRulerNamespace2.uid]: [grafanaRulerEmptyGroup],
|
||||
};
|
||||
|
||||
//-------------------- for alert history tests we reuse these constants --------------------
|
||||
export const time_0 = 1718368710000;
|
||||
// time1 + 30 seg
|
||||
export const time_plus_30 = 1718368740000;
|
||||
// time1 + 5 seg
|
||||
export const time_plus_5 = 1718368715000;
|
||||
// time1 + 15 seg
|
||||
export const time_plus_15 = 1718368725000;
|
||||
// time1 + 10 seg
|
||||
export const time_plus_10 = 1718368720000;
|
||||
|
||||
// returns 4 transitions. times is an array of 4 timestamps.
|
||||
export const getHistoryResponse = (times: number[]) => ({
|
||||
schema: {
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
labels: {},
|
||||
},
|
||||
{
|
||||
name: 'line',
|
||||
type: FieldType.other,
|
||||
labels: {},
|
||||
},
|
||||
{
|
||||
name: 'labels',
|
||||
type: FieldType.other,
|
||||
labels: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
data: {
|
||||
values: [
|
||||
[...times],
|
||||
[
|
||||
{
|
||||
schemaVersion: 1,
|
||||
previous: 'Pending',
|
||||
current: 'Alerting',
|
||||
value: {
|
||||
A: 1,
|
||||
B: 1,
|
||||
C: 1,
|
||||
},
|
||||
condition: 'C',
|
||||
dashboardUID: '',
|
||||
panelID: 0,
|
||||
fingerprint: '141da2d491f61029',
|
||||
ruleTitle: 'alert1',
|
||||
ruleID: 7,
|
||||
ruleUID: 'adnpo0g62bg1sb',
|
||||
labels: {
|
||||
alertname: 'alert1',
|
||||
grafana_folder: 'FOLDER A',
|
||||
handler: '/alerting/*',
|
||||
},
|
||||
},
|
||||
{
|
||||
schemaVersion: 1,
|
||||
previous: 'Pending',
|
||||
current: 'Alerting',
|
||||
value: {
|
||||
A: 1,
|
||||
B: 1,
|
||||
C: 1,
|
||||
},
|
||||
condition: 'C',
|
||||
dashboardUID: '',
|
||||
panelID: 0,
|
||||
fingerprint: '141da2d491f61029',
|
||||
ruleTitle: 'alert2',
|
||||
ruleID: 3,
|
||||
ruleUID: 'adna1xso80hdsd',
|
||||
labels: {
|
||||
alertname: 'alert2',
|
||||
grafana_folder: 'FOLDER A',
|
||||
handler: '/alerting/*',
|
||||
},
|
||||
},
|
||||
{
|
||||
schemaVersion: 1,
|
||||
previous: 'Pending',
|
||||
current: 'Alerting',
|
||||
value: {
|
||||
A: 1,
|
||||
B: 1,
|
||||
C: 1,
|
||||
},
|
||||
condition: 'C',
|
||||
dashboardUID: '',
|
||||
panelID: 0,
|
||||
|
||||
fingerprint: '141da2d491f61029',
|
||||
ruleTitle: 'alert1',
|
||||
ruleID: 7,
|
||||
ruleUID: 'adnpo0g62bg1sb',
|
||||
labels: {
|
||||
alertname: 'alert1',
|
||||
grafana_folder: 'FOLDER A',
|
||||
handler: '/alerting/*',
|
||||
},
|
||||
},
|
||||
{
|
||||
schemaVersion: 1,
|
||||
previous: 'Pending',
|
||||
current: 'Alerting',
|
||||
value: {
|
||||
A: 1,
|
||||
B: 1,
|
||||
C: 1,
|
||||
},
|
||||
condition: 'C',
|
||||
dashboardUID: '',
|
||||
panelID: 0,
|
||||
fingerprint: '5d438530c73fc657',
|
||||
ruleTitle: 'alert2',
|
||||
ruleID: 3,
|
||||
ruleUID: 'adna1xso80hdsd',
|
||||
labels: {
|
||||
alertname: 'alert2',
|
||||
grafana_folder: 'FOLDER A',
|
||||
handler: '/alerting/*',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
folderUID: 'edlvwh5881z40e',
|
||||
from: 'state-history',
|
||||
group: 'GROUP111',
|
||||
level: 'info',
|
||||
orgID: '1',
|
||||
service_name: 'unknown_service',
|
||||
},
|
||||
{
|
||||
folderUID: 'edlvwh5881z40e',
|
||||
from: 'state-history',
|
||||
group: 'GROUP111',
|
||||
level: 'info',
|
||||
orgID: '1',
|
||||
service_name: 'unknown_service',
|
||||
},
|
||||
{
|
||||
folderUID: 'edlvwh5881z40e',
|
||||
from: 'state-history',
|
||||
group: 'GROUP111',
|
||||
level: 'info',
|
||||
orgID: '1',
|
||||
service_name: 'unknown_service',
|
||||
},
|
||||
{
|
||||
folderUID: 'edlvwh5881z40e',
|
||||
from: 'state-history',
|
||||
group: 'GROUP111',
|
||||
level: 'info',
|
||||
orgID: '1',
|
||||
service_name: 'unknown_service',
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,9 +8,15 @@ import {
|
||||
RulerRulesConfigDTO,
|
||||
} from '../../../../../../types/unified-alerting-dto';
|
||||
import { AlertGroupUpdated } from '../../../api/alertRuleApi';
|
||||
import { grafanaRulerRule, namespaceByUid, namespaces } from '../../alertRuleApi';
|
||||
import {
|
||||
getHistoryResponse,
|
||||
grafanaRulerRule,
|
||||
namespaceByUid,
|
||||
namespaces,
|
||||
time_0,
|
||||
time_plus_30,
|
||||
} from '../../alertRuleApi';
|
||||
import { HandlerOptions } from '../configure';
|
||||
|
||||
export const rulerRulesHandler = () => {
|
||||
return http.get(`/api/ruler/grafana/api/v1/rules`, () => {
|
||||
const response = Object.entries(namespaces).reduce<RulerRulesConfigDTO>((acc, [namespaceUid, groups]) => {
|
||||
@@ -118,12 +124,19 @@ export const rulerRuleHandler = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const historyHandler = () => {
|
||||
return http.get('/api/v1/rules/history', () => {
|
||||
return HttpResponse.json(getHistoryResponse([time_0, time_0, time_plus_30, time_plus_30]));
|
||||
});
|
||||
};
|
||||
|
||||
const handlers = [
|
||||
rulerRulesHandler(),
|
||||
getRulerRuleNamespaceHandler(),
|
||||
updateRulerRuleNamespaceHandler(),
|
||||
rulerRuleGroupHandler(),
|
||||
deleteRulerRuleGroupHandler(),
|
||||
rulerRuleHandler(),
|
||||
historyHandler(),
|
||||
updateRulerRuleNamespaceHandler(),
|
||||
deleteRulerRuleGroupHandler(),
|
||||
];
|
||||
export default handlers;
|
||||
|
||||
@@ -158,11 +158,12 @@
|
||||
"central-alert-history": {
|
||||
"error": "Something went wrong loading the alert state history",
|
||||
"filter": {
|
||||
"button": {
|
||||
"clear": "Clear"
|
||||
},
|
||||
"label": "Filter events",
|
||||
"placeholder": "Filter events in the list with labels"
|
||||
"info": {
|
||||
"label1": "Filter events using label querying without spaces, ex:",
|
||||
"label2": "Invalid use of spaces:",
|
||||
"label3": "Valid use of spaces:",
|
||||
"label4": "Filter alerts using label querying without braces, ex:"
|
||||
}
|
||||
}
|
||||
},
|
||||
"clipboard-button": {
|
||||
|
||||
@@ -158,11 +158,12 @@
|
||||
"central-alert-history": {
|
||||
"error": "Ŝőmęŧĥįʼnģ ŵęʼnŧ ŵřőʼnģ ľőäđįʼnģ ŧĥę äľęřŧ şŧäŧę ĥįşŧőřy",
|
||||
"filter": {
|
||||
"button": {
|
||||
"clear": "Cľęäř"
|
||||
},
|
||||
"label": "Fįľŧęř ęvęʼnŧş",
|
||||
"placeholder": "Fįľŧęř ęvęʼnŧş įʼn ŧĥę ľįşŧ ŵįŧĥ ľäþęľş"
|
||||
"info": {
|
||||
"label1": "Fįľŧęř ęvęʼnŧş ūşįʼnģ ľäþęľ qūęřyįʼnģ ŵįŧĥőūŧ şpäčęş, ęχ:",
|
||||
"label2": "Ĩʼnväľįđ ūşę őƒ şpäčęş:",
|
||||
"label3": "Väľįđ ūşę őƒ şpäčęş:",
|
||||
"label4": "Fįľŧęř äľęřŧş ūşįʼnģ ľäþęľ qūęřyįʼnģ ŵįŧĥőūŧ þřäčęş, ęχ:"
|
||||
}
|
||||
}
|
||||
},
|
||||
"clipboard-button": {
|
||||
|
||||
Reference in New Issue
Block a user