AG filters (#4718)

# What this PR does

- Refactored filters to allow for easier customization part of
`extraInformation`
- Refactored filters to render with portals
- Always initialize mandatory fields with default values
- Added `TimeRangePickerWrapper` that mimics logic of the same selector
used in Grafana from `@grafana/scenes`, specifically `ScenesTimePicker`

~~- Bumped all `@grafana/*` dependencies to latest version~~ (postponed
to be done in a separate PR)

## Which issue(s) this PR closes

Related to https://github.com/grafana/oncall/issues/4464
This commit is contained in:
Rares Mardare 2024-07-23 16:51:00 +03:00 committed by GitHub
parent 443844348e
commit a5587031b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 323 additions and 73 deletions

View file

@ -5,7 +5,6 @@ import { GrafanaTheme2, KeyValue, SelectableValue, TimeRange } from '@grafana/da
import {
InlineSwitch,
MultiSelect,
TimeRangeInput,
Select,
LoadingPlaceholder,
Input,
@ -19,33 +18,53 @@ import { capitalCase } from 'change-case';
import { debounce, isUndefined, omitBy, pickBy } from 'lodash-es';
import { observer } from 'mobx-react';
import moment from 'moment-timezone';
import ReactDOM from 'react-dom';
import Emoji from 'react-emoji-render';
import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally';
import { Text } from 'components/Text/Text';
import { LabelsFilter } from 'containers/Labels/LabelsFilter';
import { RemoteSelect } from 'containers/RemoteSelect/RemoteSelect';
import { TeamName } from 'containers/TeamName/TeamName';
import { FiltersValues } from 'models/filters/filters.types';
import { FilterExtraInformation, FilterExtraInformationValues } from 'models/filters/filters.types';
import { GrafanaTeamStore } from 'models/grafana_team/grafana_team';
import { SelectOption, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { LocationHelper } from 'utils/LocationHelper';
import { PAGE } from 'utils/consts';
import { convertTimerangeToFilterValue, getValueForDateRangeFilterType } from 'utils/datetime';
import { allFieldsEmpty } from 'utils/utils';
import { parseFilters } from './RemoteFilters.helpers';
import { FilterOption } from './RemoteFilters.types';
import { TimeRangePickerWrapper } from './TimeRangePickerWrapper';
interface RemoteFiltersProps extends WithStoreProps, Themeable2 {
onChange: (filters: Record<string, any>, isOnMount: boolean, invalidateFn: () => boolean) => void;
query: KeyValue;
page: PAGE;
defaultFilters?: FiltersValues;
extraFilters?: (state, setState, onFiltersValueChange) => React.ReactNode;
grafanaTeamStore: GrafanaTeamStore;
extraInformation?: FilterExtraInformation;
extraFilters?: (state, setState, onFiltersValueChange) => React.ReactNode;
skipFilterOptionFn?: (filterOption: FilterOption) => boolean;
}
export function filterExtraInformation(object: FilterExtraInformationValues): FilterExtraInformationValues {
const defaultValues: Partial<FilterExtraInformationValues> = {
isClearable: true,
showInputLabel: true,
};
const result = { ...object };
Object.keys(defaultValues).forEach((key) => {
if (!result.hasOwnProperty(key)) {
result[key] = defaultValues[key];
}
});
return result;
}
export interface RemoteFiltersState {
filterOptions?: FilterOption[];
filters: FilterOption[];
@ -86,12 +105,12 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
query,
page,
store: { filtersStore },
defaultFilters,
skipFilterOptionFn,
} = this.props;
let filterOptions = await filtersStore.updateOptionsForPage(page);
const currentTablePageNum = parseInt(filtersStore.currentTablePageNum[page] || query.p || 1, 10);
const defaultFilters = this.extractDefaultValuesFromExtraInformation();
if (skipFilterOptionFn) {
filterOptions = filterOptions.filter((option: FilterOption) => !skipFilterOptionFn(option));
@ -100,11 +119,11 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
// set the current page from filters/query or default it to 1
filtersStore.setCurrentTablePageNum(page, currentTablePageNum);
let { filters, values } = parseFilters({ ...query, ...filtersStore.globalValues }, filterOptions, query);
if (allFieldsEmpty(values)) {
({ filters, values } = parseFilters(defaultFilters, filterOptions, query));
}
let { filters, values } = parseFilters(
{ ...defaultFilters, ...query, ...filtersStore.globalValues },
filterOptions,
query
);
this.setState({ filterOptions, filters, values }, () => this.onChange(true));
}
@ -147,28 +166,8 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
return (
<div className={styles.filters}>
{filters.map((filterOption: FilterOption) => (
<div key={filterOption.name} className={styles.filter}>
<Text withBackground wrap={false} type="primary">
{filterOption.display_name || capitalCase(filterOption.name)}
{filterOption.description && (
<span className={styles.infoIcon}>
<Tooltip content={filterOption.description}>
<Icon name="info-circle" />
</Tooltip>
</span>
)}
</Text>
{this.renderFilterOption(filterOption)}
<Button
size="md"
icon="times"
tooltip="Remove filter"
variant="secondary"
onClick={this.getDeleteFilterClickHandler(filterOption.name)}
/>
</div>
))}
{filters.map(this.renderFilterBlock)}
<div className={styles.filterOptions}>
<Select
menuShouldPortal
@ -187,6 +186,67 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
);
};
renderFilterBlock = (filterOption: FilterOption) => {
const { theme, extraInformation } = this.props;
const showInputLabel = this.getExtraInformationField(filterOption, 'showInputLabel');
const isInputClearable = this.getExtraInformationField(filterOption, 'isClearable');
const styles = getStyles(theme);
const filterElement = (
<div key={filterOption.name} className={styles.filter}>
<RenderConditionally shouldRender={showInputLabel}>
<Text withBackground wrap={false} type="primary">
{filterOption.display_name || capitalCase(filterOption.name)}
{filterOption.description && (
<span className={styles.infoIcon}>
<Tooltip content={filterOption.description}>
<Icon name="info-circle" />
</Tooltip>
</span>
)}
</Text>
</RenderConditionally>
{this.renderFilterOption(filterOption)}
<RenderConditionally shouldRender={isInputClearable}>
<Button
size="md"
icon="times"
tooltip="Remove filter"
variant="secondary"
onClick={this.getDeleteFilterClickHandler(filterOption.name)}
/>
</RenderConditionally>
</div>
);
if (extraInformation?.[filterOption.name]?.portal?.current) {
return ReactDOM.createPortal(filterElement, extraInformation[filterOption.name].portal.current);
}
return filterElement;
};
getExtraInformationField = (filterOption: FilterOption, key: keyof FilterExtraInformationValues) => {
return filterExtraInformation(this.props.extraInformation?.[filterOption.name])?.[key];
};
extractDefaultValuesFromExtraInformation = (): { [key: string]: FilterExtraInformationValues } => {
const { extraInformation } = this.props;
return extraInformation
? Object.keys(extraInformation).reduce((acc, key) => {
if (extraInformation[key].value) {
acc[key] = extraInformation[key].value;
}
return acc;
}, {})
: {};
};
handleSearch = (query: string) => {
const { filters } = this.state;
@ -324,12 +384,10 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
const value = getValueForDateRangeFilterType(values[filter.name]);
return (
<TimeRangeInput
<TimeRangePickerWrapper
timeZone={moment.tz.guess()}
value={value}
onChange={this.getDateRangeFilterChangeHandler(filter.name)}
hideTimeZone
clearable={false}
/>
);
@ -465,7 +523,6 @@ const getStyles = (theme: GrafanaTheme2) => {
filters: css`
display: flex;
gap: 10px;
padding: 10px;
border: 1px solid ${theme.colors.border.weak}
border-radius: 2px;
flex-wrap: wrap;

View file

@ -0,0 +1,122 @@
import React, { useState } from 'react';
import { dateMath, DateTime, TimeRange, toUtc } from '@grafana/data';
import { TimeZone } from '@grafana/schema';
import { TimeRangePicker } from '@grafana/ui';
import { noop } from 'lodash';
interface TimeRangePickerProps {
value: TimeRange;
timeZone: string;
onChange: (timeRange: TimeRange) => void;
}
export function TimeRangePickerWrapper(props: TimeRangePickerProps) {
const { onChange } = props;
const [value, setValue] = useState<TimeRange>(props.value);
const [timezone, setTimezone] = useState<string>(props.timeZone);
return (
<TimeRangePicker
isOnCanvas
timeZone={timezone}
value={value}
onChange={onPickerChange}
onZoom={onZoom}
onMoveBackward={onMoveBackward}
onMoveForward={onMoveForward}
onChangeTimeZone={onTimezoneChange}
onChangeFiscalYearStartMonth={noop}
/>
);
function onPickerChange(timeRange: TimeRange) {
setValue(timeRange);
onChange(timeRange);
}
function onZoom() {
const zoomedTimeRange = getZoomedTimeRange(value, 2);
onPickerChange(zoomedTimeRange);
}
function onMoveBackward() {
onPickerChange(getShiftedTimeRange(TimeRangeDirection.Backward, value, Date.now()));
}
function onMoveForward() {
onPickerChange(getShiftedTimeRange(TimeRangeDirection.Forward, value, Date.now()));
}
function onTimezoneChange(timeZone: TimeZone) {
setTimezone(timeZone);
onPickerChange(evaluateTimeRange(value.from, value.to, timeZone));
}
}
function evaluateTimeRange(
from: string | DateTime,
to: string | DateTime,
timeZone: TimeZone,
fiscalYearStartMonth?: number,
delay?: string
): TimeRange {
const hasDelay = delay && to === 'now';
return {
from: dateMath.parse(from, false, timeZone, fiscalYearStartMonth)!,
to: dateMath.parse(hasDelay ? 'now-' + delay : to, true, timeZone, fiscalYearStartMonth)!,
raw: {
from: from,
to: to,
},
};
}
function getZoomedTimeRange(timeRange: TimeRange, factor: number): TimeRange {
const timespan = timeRange.to.valueOf() - timeRange.from.valueOf();
const center = timeRange.to.valueOf() - timespan / 2;
// If the timepsan is 0, zooming out would do nothing, so we force a zoom out to 30s
const newTimespan = timespan === 0 ? 30000 : timespan * factor;
const to = center + newTimespan / 2;
const from = center - newTimespan / 2;
return { from: toUtc(from), to: toUtc(to), raw: { from: toUtc(from), to: toUtc(to) } };
}
enum TimeRangeDirection {
Backward,
Forward,
}
function getShiftedTimeRange(dir: TimeRangeDirection, timeRange: TimeRange, upperLimit: number): TimeRange {
const oldTo = timeRange.to.valueOf();
const oldFrom = timeRange.from.valueOf();
const halfSpan = (oldTo - oldFrom) / 2;
let fromRaw: number;
let toRaw: number;
if (dir === TimeRangeDirection.Backward) {
fromRaw = oldFrom - halfSpan;
toRaw = oldTo - halfSpan;
} else {
fromRaw = oldFrom + halfSpan;
toRaw = oldTo + halfSpan;
if (toRaw > upperLimit && oldTo < upperLimit) {
toRaw = upperLimit;
fromRaw = oldFrom;
}
}
const from = toUtc(fromRaw);
const to = toUtc(toRaw);
return {
from,
to,
raw: { from, to },
};
}

View file

@ -172,6 +172,7 @@ export class AlertGroupStore {
this.liveUpdatesPaused = value;
}
@WithGlobalNotification({ failure: PROCESSING_REQUEST_ERROR })
@AutoLoadingState(ActionKey.UPDATE_FILTERS_AND_FETCH_INCIDENTS)
async updateIncidentFiltersAndRefetchIncidentsAndStats(params: any, keepCursor = false) {
if (!keepCursor) {

View file

@ -1,9 +1,22 @@
import React from 'react';
import { SelectOption } from 'state/types';
export interface FiltersValues {
[key: string]: any;
}
export interface FilterExtraInformationValues {
isClearable?: boolean;
value?: any;
portal?: React.RefObject<any>;
showInputLabel?: boolean;
}
export interface FilterExtraInformation {
[key: string]: FilterExtraInformationValues;
}
export interface FilterOption {
name: string;
type: 'search' | 'options' | 'boolean' | 'daterange' | 'team_select';

View file

@ -1,14 +1,14 @@
import React, { SyntheticEvent } from 'react';
import { css, cx } from '@emotion/css';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { GrafanaTheme2, durationToMilliseconds, parseDuration, SelectableValue } from '@grafana/data';
import { LabelTag } from '@grafana/labels';
import {
Button,
HorizontalGroup,
Icon,
LoadingPlaceholder,
RadioButtonGroup,
RefreshPicker,
Tooltip,
VerticalGroup,
withTheme2,
@ -79,16 +79,18 @@ interface IncidentsPageState {
isSelectorColumnMenuOpen: boolean;
isHorizontalScrolling: boolean;
isFirstIncidentsFetchDone: boolean;
refreshInterval: string;
}
const POLLING_NUM_SECONDS = 10;
const PAGINATION_OPTIONS = [
{ label: '25', value: 25 },
{ label: '50', value: 50 },
{ label: '100', value: 100 },
];
const REFRESH_OPTIONS = ['5s', '10s', '15s', '30s', '1m', '5m'];
const REFRESH_DEFAULT_VALUE = '10s';
const TABLE_SCROLL_OPTIONS: SelectableValue[] = [
{
value: false,
@ -124,10 +126,12 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
store.alertGroupStore.incidentsCursor = cursorQuery || undefined;
this.rootElRef = React.createRef<HTMLDivElement>();
this.filtersPortalRef = React.createRef<HTMLDivElement>();
this.state = {
selectedIncidentIds: [],
showAddAlertGroupForm: false,
refreshInterval: REFRESH_DEFAULT_VALUE,
pagination: {
start,
end: start + pageSize,
@ -138,6 +142,7 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
};
}
private filtersPortalRef: React.RefObject<HTMLDivElement>;
private rootElRef: React.RefObject<HTMLDivElement>;
private pollingIntervalId: ReturnType<typeof setInterval> = undefined;
@ -160,25 +165,46 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
render() {
const { history } = this.props;
const { showAddAlertGroupForm } = this.state;
const { refreshInterval, showAddAlertGroupForm } = this.state;
const {
theme,
store,
store: { alertReceiveChannelStore },
} = this.props;
const styles = getStyles(theme);
const isLoading = LoaderHelper.isLoading(store.loaderStore, [
ActionKey.FETCH_INCIDENTS,
ActionKey.FETCH_INCIDENTS_POLLING,
ActionKey.FETCH_INCIDENTS_AND_STATS,
ActionKey.INCIDENTS_BULK_UPDATE,
]);
return (
<>
<div>
<div className={styles.title}>
<HorizontalGroup justify="space-between">
<Text.Title level={3}>Alert Groups</Text.Title>
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsDirectPaging}>
<Button icon="plus" onClick={this.handleOnClickEscalateTo}>
Escalation
</Button>
</WithPermissionControlTooltip>
<div className={styles.rightSideFilters}>
<div ref={this.filtersPortalRef} />
<RefreshPicker
onIntervalChanged={this.onIntervalRefreshChange}
onRefresh={this.onRefresh}
intervals={REFRESH_OPTIONS}
value={refreshInterval}
isLoading={isLoading}
isOnCanvas
showAutoInterval={false}
/>
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsDirectPaging}>
<Button icon="plus" onClick={this.handleOnClickEscalateTo}>
Escalation
</Button>
</WithPermissionControlTooltip>
</div>
</HorizontalGroup>
</div>
{this.renderIncidentFilters()}
@ -322,32 +348,54 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
query={query}
page={PAGE.Incidents}
onChange={this.handleFiltersChange}
extraInformation={{
started_at: {
isClearable: false,
value: 'now-30d_now',
portal: this.filtersPortalRef,
showInputLabel: false,
},
team: {
value: [],
},
status: {
value: [IncidentStatus.Firing, IncidentStatus.Acknowledged],
},
mine: {
value: false,
},
}}
extraFilters={(...args) => {
return this.renderCards(...args, store, theme);
}}
grafanaTeamStore={store.grafanaTeamStore}
defaultFilters={{
team: [],
status: [IncidentStatus.Firing, IncidentStatus.Acknowledged],
mine: false,
started_at: 'now-30d_now',
}}
/>
</div>
);
}
onRefresh = async () => {
this.clearPollingInterval();
await this.props.store.alertGroupStore.fetchIncidentsAndStats(true);
this.setPollingInterval();
};
onIntervalRefreshChange = (value: string) => {
this.clearPollingInterval();
this.setState({ refreshInterval: value }, () => value && this.setPollingInterval());
};
handleOnClickEscalateTo = () => {
this.setState({ showAddAlertGroupForm: true });
};
handleFiltersChange = async (filters: IncidentsFiltersType, isOnMount: boolean) => {
const {
store: { alertGroupStore },
} = this.props;
const { alertGroupStore } = this.props.store;
const { start } = this.state.pagination;
// Clear polling whenever filters change
this.clearPollingInterval();
this.setState({
filters,
selectedIncidentIds: [],
@ -357,7 +405,12 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
this.setPagination(1, alertGroupStore.alertsSearchResult.page_size);
}
await this.fetchIncidentData(filters);
try {
await this.fetchIncidentData(filters);
} finally {
// Re-enable polling after query is done
this.setPollingInterval();
}
if (isOnMount) {
this.setPagination(start, start + alertGroupStore.alertsSearchResult.page_size - 1);
@ -437,15 +490,8 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
return null;
}
const hasSelected = selectedIncidentIds.length > 0;
const isLoading = LoaderHelper.isLoading(store.loaderStore, [
ActionKey.FETCH_INCIDENTS,
ActionKey.FETCH_INCIDENTS_POLLING,
ActionKey.FETCH_INCIDENTS_AND_STATS,
ActionKey.INCIDENTS_BULK_UPDATE,
]);
const styles = getStyles(theme);
const hasSelected = selectedIncidentIds.length > 0;
const isBulkUpdate = LoaderHelper.isLoading(store.loaderStore, ActionKey.INCIDENTS_BULK_UPDATE);
return (
@ -493,7 +539,7 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
/>
</WithPermissionControlTooltip>
)}
<Text type="secondary">
<Text type="secondary" className={styles.alertsSelected}>
{hasSelected
? `${selectedIncidentIds.length} Alert Group${selectedIncidentIds.length > 1 ? 's' : ''} selected`
: 'No Alert Groups selected'}
@ -501,10 +547,6 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
</div>
<div className={styles.fieldsDropdown}>
<RenderConditionally shouldRender={isLoading}>
<LoadingPlaceholder text="Loading..." className={styles.loadingPlaceholder} />
</RenderConditionally>
<RenderConditionally shouldRender={store.hasFeature(AppFeature.Labels)}>
<RadioButtonGroup
options={TABLE_SCROLL_OPTIONS}
@ -964,6 +1006,12 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
setPollingInterval() {
const startPolling = () => {
if (!this.state.refreshInterval) {
return;
}
const pollingNum = durationToMilliseconds(parseDuration(this.state.refreshInterval));
this.pollingIntervalId = setTimeout(async () => {
const isBrowserWindowInactive = document.hidden;
const { liveUpdatesPaused } = this.props.store.alertGroupStore;
@ -984,7 +1032,7 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
}
startPolling();
}, POLLING_NUM_SECONDS * 1000);
}, pollingNum);
};
startPolling();
@ -997,6 +1045,15 @@ const getStyles = (theme: GrafanaTheme2) => {
width: 400px;
`,
alertsSelected: css`
white-space: nowrap;
`,
rightSideFilters: css`
display: flex;
gap: 8px;
`,
bau: css`
${[1, 2, 3].map(
(num) => `

View file

@ -1,4 +1,4 @@
import { AppRootProps as BaseAppRootProps, AppPluginMeta, CurrentUserDTO, PluginConfigPageProps } from '@grafana/data';
import { AppRootProps as BaseAppRootProps, AppPluginMeta, PluginConfigPageProps, CurrentUserDTO } from '@grafana/data';
export type OnCallPluginMetaJSONData = {
stackId: number;