Tweaks for integrations (#2869)

# What this PR does

This PR adds a few tweaks to the integrations page

- prevent calling `/counters` on the integration page, instead call it
with `counters/[id]`
- call to `/counters` will be done only once when we load all the
integrations and prevent calling it again once we switch pages. Prior to
this change every page resulted in a call to `/counters`, and due to the
fact the request took on average `3-5s` it was being stalled by the
browser on subsequent calls.
- Call to `/connected_contact_points` is now conditioned by the
integration type
- No more page flicking when going back from integration page to the
list of integrations
- No more page flickering when switching pages in table view if there's
more requests ongoing
- Prevent discarding filters when going back from Integration view to
the table view
- Fixed routes not moving up/down
- Fixed routes flickering when adding/removing a route

These changes should result in slightly loader times because `/counters`
was time-consuming request, and less UX issues.

## Which issue(s) this PR fixes

- described at https://github.com/grafana/oncall/issues/2843 but
incompletely

## Checklist

- [ ] Unit, integration, and e2e (if applicable) tests updated
- [ ] Documentation added (or `pr:no public docs` PR label added if not
required)
- [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
Rares Mardare 2023-08-30 14:06:22 +03:00 committed by GitHub
parent 4cff4f2fa9
commit 26dee30cfa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 258 additions and 214 deletions

View file

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Performance and UX tweaks to integrations page ([#2869](https://github.com/grafana/oncall/pull/2869))
## v1.3.29 (2023-08-29)
### Fixed

View file

@ -24,7 +24,7 @@ const CursorPagination: FC<CursorPaginationProps> = (props) => {
setDisabled(false);
}, [prev, next]);
const onChangeItemsPerPageCallback = useCallback((option) => {
const onChangeItemsPerPageCallback = useCallback((option: SelectableValue) => {
setDisabled(true);
onChangeItemsPerPage(option.value);
}, []);

View file

@ -27,6 +27,10 @@
.integrationTree__group {
position: relative;
margin-bottom: 12px;
&--hidden {
display: none;
}
}
.integrationTree__icon {

View file

@ -3,12 +3,14 @@ import React, { useEffect, useState } from 'react';
import { Icon, IconButton, IconName } from '@grafana/ui';
import cn from 'classnames/bind';
import { isArray, isUndefined } from 'lodash-es';
import { observer } from 'mobx-react';
import styles from './IntegrationCollapsibleTreeView.module.scss';
const cx = cn.bind(styles);
export interface IntegrationCollapsibleItem {
isHidden?: boolean;
customIcon?: IconName;
canHoverIcon: boolean;
collapsedView: (toggle?: () => void) => React.ReactNode; // needs toggle param for toggling on click
@ -22,7 +24,7 @@ interface IntegrationCollapsibleTreeViewProps {
configElements: Array<IntegrationCollapsibleItem | IntegrationCollapsibleItem[]>;
}
const IntegrationCollapsibleTreeView: React.FC<IntegrationCollapsibleTreeViewProps> = (props) => {
const IntegrationCollapsibleTreeView: React.FC<IntegrationCollapsibleTreeViewProps> = observer((props) => {
const { configElements } = props;
const [expandedList, setExpandedList] = useState(getStartingExpandedState());
@ -97,7 +99,7 @@ const IntegrationCollapsibleTreeView: React.FC<IntegrationCollapsibleTreeViewPro
})
);
}
};
});
const IntegrationCollapsibleTreeItem: React.FC<{
item: IntegrationCollapsibleItem;
@ -107,7 +109,7 @@ const IntegrationCollapsibleTreeItem: React.FC<{
const iconOnClickFn = !item.isCollapsible ? undefined : onClick;
return (
<div className={cx('integrationTree__group')}>
<div className={cx('integrationTree__group', { 'integrationTree__group--hidden': item.isHidden })}>
<div className={cx('integrationTree__icon')}>
{item.canHoverIcon ? (
<IconButton name={getIconName()} onClick={iconOnClickFn} size="lg" />

View file

@ -38,8 +38,7 @@ const IntegrationContactPoint: React.FC<{
}> = observer(({ id }) => {
const { alertReceiveChannelStore } = useStore();
const contactPoints = alertReceiveChannelStore.connectedContactPoints[id];
const warnings = contactPoints.filter((cp) => !cp.notificationConnected);
const warnings = contactPoints?.filter((cp) => !cp.notificationConnected);
const [
{
isLoading,
@ -88,6 +87,7 @@ const IntegrationContactPoint: React.FC<{
<Drawer scrollableContent title="Connected Contact Points" onClose={closeDrawer} closeOnMaskClick={false}>
<div className={cx('contactpoints__drawer')}>
<GTable
emptyText={'No contact points'}
className={cx('contactpoints__table')}
rowKey="id"
data={contactPoints}

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import { ConfirmModal, HorizontalGroup, Icon, IconName } from '@grafana/ui';
import cn from 'classnames/bind';
@ -15,7 +15,6 @@ import { ChannelFilter } from 'models/channel_filter';
import CommonIntegrationHelper from 'pages/integration/CommonIntegration.helper';
import IntegrationHelper from 'pages/integration/Integration.helper';
import { useStore } from 'state/useStore';
import { openNotification } from 'utils';
const cx = cn.bind(styles);
@ -26,24 +25,39 @@ interface CollapsedIntegrationRouteDisplayProps {
toggle: () => void;
openEditTemplateModal: (templateName: string | string[], channelFilterId?: ChannelFilter['id']) => void;
onEditRegexpTemplate: (channelFilterId: ChannelFilter['id']) => void;
onRouteDelete: (routeId: string) => void;
onItemMove: () => void;
}
const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDisplayProps> = observer(
({ channelFilterId, alertReceiveChannelId, routeIndex, toggle, openEditTemplateModal, onEditRegexpTemplate }) => {
({
channelFilterId,
alertReceiveChannelId,
routeIndex,
toggle,
openEditTemplateModal,
onEditRegexpTemplate,
onRouteDelete,
onItemMove,
}) => {
const store = useStore();
const { escalationChainStore, alertReceiveChannelStore } = store;
const [routeIdForDeletion, setRouteIdForDeletion] = useState<ChannelFilter['id']>(undefined);
const channelFilter = alertReceiveChannelStore.channelFilters[channelFilterId];
const routeWording = useMemo(() => {
return CommonIntegrationHelper.getRouteConditionWording(
alertReceiveChannelStore.channelFilterIds[alertReceiveChannelId],
routeIndex
);
}, [routeIndex, alertReceiveChannelStore.channelFilterIds[alertReceiveChannelId]]);
if (!channelFilter) {
return null;
}
const escalationChain = escalationChainStore.items[channelFilter.escalation_chain];
const routeWording = CommonIntegrationHelper.getRouteConditionWording(
alertReceiveChannelStore.channelFilterIds[alertReceiveChannelId],
routeIndex
);
const chatOpsAvailableChannels = IntegrationHelper.getChatOpsChannels(channelFilter, store).filter(
(channel) => channel
);
@ -59,10 +73,7 @@ const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDispla
<div className={cx('heading-container__item', 'heading-container__item--large')}>
<TooltipBadge
borderType="success"
text={CommonIntegrationHelper.getRouteConditionWording(
alertReceiveChannelStore.channelFilterIds[alertReceiveChannelId],
routeIndex
)}
text={routeWording}
tooltipTitle={CommonIntegrationHelper.getRouteConditionTooltipWording(
alertReceiveChannelStore.channelFilterIds[alertReceiveChannelId],
routeIndex
@ -93,6 +104,7 @@ const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDispla
alertReceiveChannelId={alertReceiveChannelId}
channelFilterId={channelFilterId}
routeIndex={routeIndex}
onItemMove={onItemMove}
setRouteIdForDeletion={() => setRouteIdForDeletion(channelFilterId)}
openRouteTemplateEditor={() => handleEditRoutingTemplate(channelFilter, channelFilterId)}
/>
@ -179,8 +191,7 @@ const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDispla
async function onRouteDeleteConfirm() {
setRouteIdForDeletion(undefined);
await alertReceiveChannelStore.deleteChannelFilter(routeIdForDeletion);
openNotification('Route has been deleted');
onRouteDelete(routeIdForDeletion);
}
}
);

View file

@ -50,6 +50,8 @@ interface ExpandedIntegrationRouteDisplayProps {
templates: AlertTemplatesDTO[];
openEditTemplateModal: (templateName: string | string[], channelFilterId?: ChannelFilter['id']) => void;
onEditRegexpTemplate: (channelFilterId: ChannelFilter['id']) => void;
onRouteDelete: (routeId: string) => void;
onItemMove: () => void;
}
interface ExpandedIntegrationRouteDisplayState {
@ -59,7 +61,16 @@ interface ExpandedIntegrationRouteDisplayState {
}
const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayProps> = observer(
({ alertReceiveChannelId, channelFilterId, templates, routeIndex, openEditTemplateModal, onEditRegexpTemplate }) => {
({
alertReceiveChannelId,
channelFilterId,
templates,
routeIndex,
openEditTemplateModal,
onEditRegexpTemplate,
onRouteDelete,
onItemMove,
}) => {
const store = useStore();
const {
telegramChannelStore,
@ -130,6 +141,7 @@ const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayP
alertReceiveChannelId={alertReceiveChannelId}
channelFilterId={channelFilterId}
routeIndex={routeIndex}
onItemMove={onItemMove}
setRouteIdForDeletion={() => setState({ routeIdForDeletion: channelFilterId })}
openRouteTemplateEditor={() => handleEditRoutingTemplate(channelFilter, channelFilterId)}
/>
@ -278,8 +290,7 @@ const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayP
async function onRouteDeleteConfirm() {
setState({ routeIdForDeletion: undefined });
await alertReceiveChannelStore.deleteChannelFilter(routeIdForDeletion);
openNotification('Route has been deleted');
onRouteDelete(routeIdForDeletion);
}
function onEscalationChainChange({ id }) {
@ -319,6 +330,7 @@ interface RouteButtonsDisplayProps {
routeIndex: number;
setRouteIdForDeletion(): void;
openRouteTemplateEditor(): void;
onItemMove();
}
export const RouteButtonsDisplay: React.FC<RouteButtonsDisplayProps> = ({
@ -327,6 +339,7 @@ export const RouteButtonsDisplay: React.FC<RouteButtonsDisplayProps> = ({
routeIndex,
setRouteIdForDeletion,
openRouteTemplateEditor,
onItemMove,
}) => {
const { alertReceiveChannelStore } = useStore();
const channelFilter = alertReceiveChannelStore.channelFilters[channelFilterId];
@ -404,11 +417,13 @@ export const RouteButtonsDisplay: React.FC<RouteButtonsDisplayProps> = ({
function onRouteMoveDown(e: React.SyntheticEvent) {
e.stopPropagation();
alertReceiveChannelStore.moveChannelFilterToPosition(alertReceiveChannelId, routeIndex, routeIndex + 1);
onItemMove();
}
function onRouteMoveUp(e: React.SyntheticEvent) {
e.stopPropagation();
alertReceiveChannelStore.moveChannelFilterToPosition(alertReceiveChannelId, routeIndex, routeIndex - 1);
onItemMove();
}
};

View file

@ -27,6 +27,7 @@ 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 { parseFilters } from './RemoteFilters.helpers';
import { FilterOption, RemoteFiltersType } from './RemoteFilters.types';
@ -39,7 +40,7 @@ interface RemoteFiltersProps extends WithStoreProps {
value: RemoteFiltersType;
onChange: (filters: { [key: string]: any }, isOnMount: boolean) => void;
query: { [key: string]: any };
page: string;
page: PAGE;
defaultFilters?: FiltersValues;
extraFilters?: (state, setState, onFiltersValueChange) => React.ReactNode;
grafanaTeamStore: GrafanaTeamStore;
@ -378,6 +379,7 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
}
LocationHelper.update({ ...values }, 'partial');
onChange(values, isOnMount);
};

View file

@ -131,10 +131,14 @@ export class AlertReceiveChannelStore extends BaseStore {
return results;
}
async updatePaginatedItems(query: any = '', page = 1) {
async updatePaginatedItems(query: any = '', page = 1, updateCounters = false, invalidateFn = undefined) {
const filters = typeof query === 'string' ? { search: query } : query;
const { count, results } = await makeRequest(this.path, { params: { ...filters, page } });
if (invalidateFn?.()) {
return undefined;
}
this.items = {
...this.items,
...results.reduce(
@ -153,7 +157,9 @@ export class AlertReceiveChannelStore extends BaseStore {
results: results.map((item: AlertReceiveChannel) => item.id),
};
this.updateCounters();
if (updateCounters) {
this.updateCounters();
}
return results;
}
@ -297,7 +303,7 @@ export class AlertReceiveChannelStore extends BaseStore {
method: 'DELETE',
});
this.updateChannelFilters(channelFilter.alert_receive_channel, true);
return this.updateChannelFilters(channelFilter.alert_receive_channel, true);
}
@action
@ -499,6 +505,21 @@ export class AlertReceiveChannelStore extends BaseStore {
this.counters = counters;
}
async updateCountersForIntegration(id: AlertReceiveChannel['id']): Promise<any> {
const counters = await makeRequest(`${this.path}${id}/counters`, {
method: 'GET',
});
this.counters = {
...this.counters,
[id]: {
...counters[id],
},
};
return counters;
}
startMaintenanceMode = (id: AlertReceiveChannel['id'], mode: MaintenanceMode, duration: number): Promise<void> =>
makeRequest<null>(`${this.path}${id}/start_maintenance/`, {
method: 'POST',

View file

@ -111,35 +111,35 @@ export class UserStore extends BaseStore {
}
@action
async updateItems(f: any = { searchTerm: '' }, page = 1) {
return new Promise<void>(async (resolve) => {
const filters = typeof f === 'string' ? { searchTerm: f } : f; // for GSelect compatibility
const { searchTerm: search } = filters;
const { count, results } = await makeRequest(this.path, {
params: { search, page },
});
this.items = {
...this.items,
...results.reduce(
(acc: { [key: number]: User }, item: User) => ({
...acc,
[item.pk]: {
...item,
timezone: getTimezone(item),
},
}),
{}
),
};
this.searchResult = {
count,
results: results.map((item: User) => item.pk),
};
resolve();
async updateItems(f: any = { searchTerm: '' }, page = 1): Promise<any> {
const filters = typeof f === 'string' ? { searchTerm: f } : f; // for GSelect compatibility
const { searchTerm: search } = filters;
const response = await makeRequest(this.path, {
params: { search, page },
});
const { count, results } = response;
this.items = {
...this.items,
...results.reduce(
(acc: { [key: number]: User }, item: User) => ({
...acc,
[item.pk]: {
...item,
timezone: getTimezone(item),
},
}),
{}
),
};
this.searchResult = {
count,
results: results.map((item: User) => item.pk),
};
return response;
}
getSearchResult() {

View file

@ -28,7 +28,7 @@ import { FiltersValues } from 'models/filters/filters.types';
import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import { PAGE, PLUGIN_ROOT } from 'utils/consts';
import styles from './EscalationChains.module.css';
@ -231,7 +231,7 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
<div className={cx('filters')}>
<RemoteFilters
query={query}
page="escalation_chains"
page={PAGE.Escalations}
grafanaTeamStore={store.grafanaTeamStore}
onChange={this.handleFiltersChange}
/>

View file

@ -27,7 +27,7 @@ import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import LocationHelper from 'utils/LocationHelper';
import { UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import { PAGE, PLUGIN_ROOT } from 'utils/consts';
import styles from './Incidents.module.scss';
import { IncidentDropdown } from './parts/IncidentDropdown';
@ -264,7 +264,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
<div className={cx('filters')}>
<RemoteFilters
query={query}
page="incidents"
page={PAGE.Incidents}
onChange={this.handleFiltersChange}
extraFilters={this.renderCards.bind(this)}
grafanaTeamStore={store.grafanaTeamStore}
@ -434,12 +434,15 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
renderTable() {
const { selectedIncidentIds, pagination } = this.state;
const { store } = this.props;
const { alertGroupsLoading } = store.alertGroupStore;
const {
store,
store: { alertGroupStore, filtersStore },
} = this.props;
const results = store.alertGroupStore.getAlertSearchResult('default');
const prev = get(store.alertGroupStore.alertsSearchResult, `default.prev`);
const next = get(store.alertGroupStore.alertsSearchResult, `default.next`);
const results = alertGroupStore.getAlertSearchResult('default');
const prev = get(alertGroupStore.alertsSearchResult, `default.prev`);
const next = get(alertGroupStore.alertsSearchResult, `default.next`);
const isLoading = alertGroupStore.alertGroupsLoading || filtersStore.options['incidents'] === undefined;
if (results && !results.length) {
return (
@ -517,8 +520,8 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
<div className={cx('root')}>
{this.renderBulkActions()}
<GTable
emptyText={alertGroupsLoading ? 'Loading...' : 'No alert groups found'}
loading={alertGroupsLoading}
emptyText={isLoading ? 'Loading...' : 'No alert groups found'}
loading={isLoading}
className={cx('incidents-table')}
rowSelection={{
selectedRowKeys: selectedIncidentIds,
@ -531,7 +534,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
<div className={cx('pagination')}>
<CursorPagination
current={`${pagination.start}-${pagination.end}`}
itemsPerPage={store.alertGroupStore.incidentsItemsPerPage}
itemsPerPage={alertGroupStore.incidentsItemsPerPage}
itemsPerPageOptions={[
{ label: '25', value: 25 },
{ label: '50', value: 50 },

View file

@ -97,14 +97,6 @@ export const pages: { [id: string]: PageDefinition } = [
hideFromTabs: isTopNavbar(),
action: UserActions.ChatOpsRead,
},
{
icon: 'wrench',
id: 'maintenance',
text: 'Maintenance',
hideFromBreadcrumbs: true,
path: getPath('maintenance'),
action: UserActions.MaintenanceRead,
},
{
icon: 'cog',
id: 'settings',
@ -169,7 +161,6 @@ export const ROUTES = {
schedules: ['schedules'],
schedule: ['schedules/:id'],
outgoing_webhooks: ['outgoing_webhooks', 'outgoing_webhooks/:id', 'outgoing_webhooks/:action/:id'],
maintenance: ['maintenance'],
settings: ['settings'],
'chat-ops': ['chat-ops'],
'live-settings': ['live-settings'],

View file

@ -15,6 +15,10 @@ import { MAX_CHARACTERS_COUNT, TEXTAREA_ROWS_COUNT } from './IntegrationCommon.c
const IntegrationHelper = {
isGrafanaAlerting: (alertReceiveChannel: AlertReceiveChannel | string) => {
if (!alertReceiveChannel) {
return false;
}
if (typeof alertReceiveChannel === 'string') {
return alertReceiveChannel === 'grafana_alerting';
}

View file

@ -13,7 +13,7 @@ import {
Alert,
} from '@grafana/ui';
import cn from 'classnames/bind';
import { get, noop } from 'lodash-es';
import { get } from 'lodash-es';
import { observer } from 'mobx-react';
import CopyToClipboard from 'react-copy-to-clipboard';
import Emoji from 'react-emoji-render';
@ -112,7 +112,7 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
this.openEditTemplateModal(query.template, query.routeId && query.routeId);
}
await this.loadIntegration();
await this.loadData();
}
render() {
@ -127,7 +127,7 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
} = this.state;
const {
store: { alertReceiveChannelStore },
query: { p },
query,
match: {
params: { id },
},
@ -181,7 +181,7 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
)}
<div className={cx('integration__heading-container')}>
<PluginLink query={{ page: 'integrations', p }} className={cx('back-arrow')}>
<PluginLink query={{ page: 'integrations', ...query }} className={cx('back-arrow')}>
<IconButton name="arrow-left" size="xl" />
</PluginLink>
<h2 className={cx('integration__name')}>
@ -200,7 +200,7 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
{this.renderDescriptionMaybe(alertReceiveChannel)}
{/* MobX seems to have issues updating contact points if we don't reference it here */}
{!contactPoints?.length && this.renderContactPointsWarningMaybe(alertReceiveChannel)}
{contactPoints && contactPoints.length === 0 && this.renderContactPointsWarningMaybe(alertReceiveChannel)}
<div className={cx('no-wrap')}>
<IntegrationHeader
@ -286,7 +286,7 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
To ensure a smooth transition you can migrate now using "Migrate" button in the menu on the right.
</Text>
<Text type="secondary">
Please, check{' '}
Please check out the{' '}
<a
href={`https://grafana.com/docs/oncall/latest/integrations/${getIntegrationName()}`}
target="_blank"
@ -356,9 +356,11 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
} = this.props;
const alertReceiveChannel = alertReceiveChannelStore.items[id];
const contactPoints = alertReceiveChannelStore.connectedContactPoints[id];
return [
IntegrationHelper.isGrafanaAlerting(alertReceiveChannel) && {
isHidden: contactPoints === null || contactPoints === undefined,
isCollapsible: false,
customIcon: 'grafana',
canHoverIcon: false,
@ -482,7 +484,7 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
};
handleAddNewRoute = () => {
const { alertReceiveChannelStore, escalationPolicyStore } = this.props.store;
const { alertReceiveChannelStore } = this.props.store;
const {
params: { id },
} = this.props.match;
@ -499,12 +501,16 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
filtering_term_type: 1, // non-regex
})
.then(async (channelFilter: ChannelFilter) => {
this.setState((prevState) => ({
isAddingRoute: false,
openRoutes: prevState.openRoutes.concat(channelFilter.id),
}));
await alertReceiveChannelStore.updateChannelFilters(id, true);
await escalationPolicyStore.updateEscalationPolicies(channelFilter.escalation_chain);
await alertReceiveChannelStore.updateChannelFilters(id);
this.setState(
(prevState) => ({
isAddingRoute: false,
openRoutes: prevState.openRoutes.concat(channelFilter.id),
}),
() => this.forceUpdate()
);
openNotification('A new route has been added');
})
.catch((err) => {
@ -530,7 +536,12 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
const templates = alertReceiveChannelStore.templates[id];
const channelFilterIds = alertReceiveChannelStore.channelFilterIds[id];
return channelFilterIds.map(
const onRouteDelete = async (routeId: string) => {
await alertReceiveChannelStore.deleteChannelFilter(routeId).then(() => this.forceUpdate());
openNotification('Route has been deleted');
};
return (channelFilterIds || []).map(
(channelFilterId: ChannelFilter['id'], routeIndex: number) =>
({
canHoverIcon: true,
@ -550,8 +561,10 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
channelFilterId={channelFilterId}
routeIndex={routeIndex}
toggle={toggle}
onItemMove={() => this.forceUpdate()}
openEditTemplateModal={this.openEditTemplateModal}
onEditRegexpTemplate={this.handleEditRegexpRouteTemplate}
onRouteDelete={onRouteDelete}
/>
),
expandedView: () => (
@ -562,6 +575,8 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
templates={templates}
openEditTemplateModal={this.openEditTemplateModal}
onEditRegexpTemplate={this.handleEditRegexpRouteTemplate}
onItemMove={() => this.forceUpdate()}
onRouteDelete={onRouteDelete}
/>
),
} as IntegrationCollapsibleItem)
@ -655,7 +670,7 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
alertReceiveChannelStore.deleteAlertReceiveChannel(id).then(() => history.push(`${PLUGIN_ROOT}/integrations/`));
};
async loadIntegration() {
async loadData() {
const {
store,
store: { alertReceiveChannelStore },
@ -668,12 +683,9 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
const promises = [];
if (!alertReceiveChannelStore.items[id]) {
// See what happens if the request fails
promises.push(alertReceiveChannelStore.loadItem(id));
}
if (!alertReceiveChannelStore.counters[id]) {
promises.push(alertReceiveChannelStore.updateCounters());
promises.push(alertReceiveChannelStore.loadItem(id).then(() => this.loadExtraData(id)));
} else {
promises.push(this.loadExtraData(id));
}
if (!alertReceiveChannelStore.channelFilterIds[id]) {
@ -681,12 +693,8 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
}
promises.push(alertReceiveChannelStore.updateTemplates(id));
promises.push(IntegrationHelper.fetchChatOps(store));
// skip checking for grafana alerting so that we don't wait for the first request to complete
// at the cost of getting a failed network request for all other types other than alerting
promises.push(alertReceiveChannelStore.updateConnectedContactPoints(id).catch(noop));
promises.push(alertReceiveChannelStore.updateCountersForIntegration(id));
await Promise.all(promises)
.catch(() => {
@ -697,6 +705,17 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
})
.finally(() => this.setState({ isLoading: false }));
}
async loadExtraData(id: AlertReceiveChannel['id']) {
const { alertReceiveChannelStore } = this.props.store;
if (IntegrationHelper.isGrafanaAlerting(alertReceiveChannelStore.items[id])) {
// this will be delayed and not awaitable so that we don't delay the whole page load
return await alertReceiveChannelStore.updateConnectedContactPoints(id);
}
return Promise.resolve();
}
}
interface IntegrationActionsProps {

View file

@ -34,6 +34,7 @@ import { withMobXProviderContext } from 'state/withStore';
import { openNotification } from 'utils';
import LocationHelper from 'utils/LocationHelper';
import { UserActions } from 'utils/authorization';
import { PAGE } from 'utils/consts';
import styles from './Integrations.module.scss';
@ -45,7 +46,6 @@ const MAX_LINE_LENGTH = 40;
interface IntegrationsState extends PageBaseState {
integrationsFilters: Filters;
alertReceiveChannelId?: AlertReceiveChannel['id'] | 'new';
page: number;
confirmationModal: {
isOpen: boolean;
title: any;
@ -62,20 +62,21 @@ interface IntegrationsProps extends WithStoreProps, PageProps, RouteComponentPro
@observer
class Integrations extends React.Component<IntegrationsProps, IntegrationsState> {
state: IntegrationsState = {
integrationsFilters: { searchTerm: '' },
errorData: initErrorDataState(),
page: 1,
confirmationModal: undefined,
};
constructor(props: IntegrationsProps) {
super(props);
const { query, store } = props;
this.state = {
integrationsFilters: { searchTerm: '' },
errorData: initErrorDataState(),
confirmationModal: undefined,
};
store.currentPage['integrations'] = Number(store.currentPage['integrations'] || query.p || 1);
}
async componentDidMount() {
const {
query: { p },
} = this.props;
this.setState({ page: p ? Number(p) : 1 }, this.update);
this.parseQueryParams();
}
@ -118,15 +119,19 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
update = () => {
const { store } = this.props;
const { page, integrationsFilters } = this.state;
const { integrationsFilters } = this.state;
const page = store.currentPage['integrations'];
LocationHelper.update({ p: page }, 'partial');
return store.alertReceiveChannelStore.updatePaginatedItems(integrationsFilters, page);
return store.alertReceiveChannelStore.updatePaginatedItems(integrationsFilters, page, false, () =>
this.invalidateRequestFn(page)
);
};
render() {
const { store, query } = this.props;
const { alertReceiveChannelId, page, confirmationModal } = this.state;
const { alertReceiveChannelId, confirmationModal } = this.state;
const { alertReceiveChannelStore } = store;
const { count, results } = alertReceiveChannelStore.getPaginatedSearchResult();
@ -158,12 +163,13 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
<div>
<RemoteFilters
query={query}
page="integrations"
page={PAGE.Integrations}
grafanaTeamStore={store.grafanaTeamStore}
onChange={this.handleIntegrationsFiltersChange}
/>
<GTable
emptyText={this.renderNotFound()}
emptyText={count === undefined ? 'Loading...' : 'No integrations found'}
loading={count === undefined}
data-testid="integrations-table"
rowKey="id"
data={results}
@ -171,13 +177,14 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
className={cx('integrations-table')}
rowClassName={cx('integrations-table-row')}
pagination={{
page,
page: store.currentPage['integrations'],
total: Math.ceil((count || 0) / ITEMS_PER_PAGE),
onChange: this.handleChangePage,
}}
/>
</div>
</div>
{alertReceiveChannelId && (
<IntegrationForm
onHide={() => {
@ -209,21 +216,17 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
);
}
renderNotFound() {
return (
<div className={cx('loader')}>
<Text type="secondary">Not found</Text>
</div>
);
}
renderName = (item: AlertReceiveChannel) => {
const {
query: { p },
} = this.props;
const { query } = this.props;
return (
<PluginLink query={{ page: 'integrations', id: item.id, p }}>
<PluginLink
query={{
page: 'integrations',
id: item.id,
...query,
}}
>
<Text type="link" size="medium">
<Emoji
className={cx('title')}
@ -469,8 +472,16 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
];
};
invalidateRequestFn = (requestedPage: number) => {
const { store } = this.props;
return requestedPage !== store.getCurrentPage(PAGE.Integrations);
};
handleChangePage = (page: number) => {
this.setState({ page }, this.update);
const { store } = this.props;
store.currentPage['integrations'] = page;
this.update();
};
onIntegrationEditClick = (id: AlertReceiveChannel['id']) => {
@ -486,19 +497,23 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
this.setState({ confirmationModal: undefined });
};
handleIntegrationsFiltersChange = (integrationsFilters: Filters) => {
this.setState({ integrationsFilters }, () => this.debouncedUpdateIntegrations());
handleIntegrationsFiltersChange = (integrationsFilters: Filters, isOnMount: boolean) => {
this.setState({ integrationsFilters }, () => this.debouncedUpdateIntegrations(isOnMount));
};
applyFilters = () => {
applyFilters = async (isOnMount: boolean) => {
const { store } = this.props;
const { alertReceiveChannelStore } = store;
const { integrationsFilters } = this.state;
return alertReceiveChannelStore.updatePaginatedItems(integrationsFilters).then(() => {
this.setState({ page: 1 });
LocationHelper.update({ p: 1 }, 'partial');
});
const newPage = isOnMount ? store.getCurrentPage(PAGE.Integrations) : 1;
return alertReceiveChannelStore
.updatePaginatedItems(integrationsFilters, newPage, false, () => this.invalidateRequestFn(newPage))
.then(() => {
store.setCurrentPage(PAGE.Integrations, newPage);
LocationHelper.update({ p: newPage }, 'partial');
});
};
debouncedUpdateIntegrations = debounce(this.applyFilters, FILTERS_DEBOUNCE_MS);

View file

@ -1,16 +0,0 @@
.select {
width: 400px;
}
.header {
display: flex;
justify-content: space-between;
}
.title {
margin-bottom: var(--title-marginBottom);
}
.info-box {
width: 100%;
}

View file

@ -1,37 +0,0 @@
import React from 'react';
import { Alert } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import PluginLink from 'components/PluginLink/PluginLink';
import styles from './Maintenance.module.css';
const cx = cn.bind(styles);
interface MaintenancePageProps {}
@observer
class MaintenancePage extends React.Component<MaintenancePageProps> {
render() {
return (
<>
<Alert
severity="info"
className={cx('info-box')}
// @ts-ignore
title={
<>
Maintenance mode is now controlled at the{' '}
<PluginLink query={{ page: 'integrations' }}> Integration</PluginLink> level. This page will soon be
removed.
</>
}
/>
</>
);
}
}
export default MaintenancePage;

View file

@ -36,7 +36,7 @@ import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { openErrorNotification, openNotification } from 'utils';
import { isUserActionAllowed, UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import { PAGE, PLUGIN_ROOT } from 'utils/consts';
import styles from './OutgoingWebhooks.module.scss';
import { WebhookFormActionType } from './OutgoingWebhooks.types';
@ -225,7 +225,7 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
<div className={cx('filters')}>
<RemoteFilters
query={query}
page="webhooks"
page={PAGE.Webhooks}
grafanaTeamStore={store.grafanaTeamStore}
onChange={this.handleFiltersChange}
/>

View file

@ -1,7 +1,6 @@
import EscalationsChainsPage from 'pages/escalation-chains/EscalationChains';
import IncidentPage from 'pages/incident/Incident';
import IncidentsPage from 'pages/incidents/Incidents';
import MaintenancePage from 'pages/maintenance/Maintenance';
import OutgoingWebhooks from 'pages/outgoing_webhooks/OutgoingWebhooks';
import SchedulePage from 'pages/schedule/Schedule';
import SchedulesPage from 'pages/schedules/Schedules';
@ -55,10 +54,6 @@ export const routes: { [id: string]: NavRoute } = [
component: OutgoingWebhooks,
id: 'outgoing_webhooks',
},
{
component: MaintenancePage,
id: 'maintenance',
},
{
component: SettingsPage,
id: 'settings',

View file

@ -32,7 +32,7 @@ import { WithStoreProps, PageProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import LocationHelper from 'utils/LocationHelper';
import { UserActions } from 'utils/authorization';
import { PLUGIN_ROOT, TABLE_COLUMN_MAX_WIDTH } from 'utils/consts';
import { PAGE, PLUGIN_ROOT, TABLE_COLUMN_MAX_WIDTH } from 'utils/consts';
import styles from './Schedules.module.css';
@ -153,7 +153,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
<div className={cx('schedules__filters-container')}>
<RemoteFilters
query={query}
page="schedules"
page={PAGE.Schedules}
grafanaTeamStore={store.grafanaTeamStore}
onChange={this.handleSchedulesFiltersChange}
/>

View file

@ -86,14 +86,6 @@
"action": "grafana-oncall-app.outgoing-webhooks:read",
"addToNav": true
},
{
"type": "page",
"name": "Maintenance",
"path": "/a/grafana-oncall-app/maintenance",
"role": "Viewer",
"action": "grafana-oncall-app.maintenance:read",
"addToNav": true
},
{
"type": "page",
"name": "Settings",

View file

@ -27,7 +27,6 @@ import Incident from 'pages/incident/Incident';
import Incidents from 'pages/incidents/Incidents';
import Integration from 'pages/integration/Integration';
import Integrations from 'pages/integrations/Integrations';
import Maintenance from 'pages/maintenance/Maintenance';
import OutgoingWebhooks from 'pages/outgoing_webhooks/OutgoingWebhooks';
import Schedule from 'pages/schedule/Schedule';
import Schedules from 'pages/schedules/Schedules';
@ -157,9 +156,6 @@ export const Root = observer((props: AppRootProps) => {
<Route path={getRoutesForPage('outgoing_webhooks')} exact>
<OutgoingWebhooks query={query} />
</Route>
<Route path={getRoutesForPage('maintenance')} exact>
<Maintenance />
</Route>
<Route path={getRoutesForPage('settings')} exact>
<SettingsPage />
</Route>

View file

@ -37,6 +37,7 @@ import {
CLOUD_VERSION_REGEX,
GRAFANA_LICENSE_CLOUD,
GRAFANA_LICENSE_OSS,
PAGE,
PLUGIN_ROOT,
} from 'utils/consts';
import FaroHelper from 'utils/faro';
@ -79,6 +80,9 @@ export class RootBaseStore {
@observable
incidentsPage: any = this.initialQuery.p ? Number(this.initialQuery.p) : 1;
@observable
currentPage: { [key: string]: number } = {};
@observable
onCallApiUrl: string;
@ -297,4 +301,13 @@ export class RootBaseStore {
const settings = await PluginState.getGrafanaPluginSettings();
return settings.jsonData?.onCallApiUrl;
}
getCurrentPage = (page: PAGE): number => {
return this.currentPage[page];
};
@action
setCurrentPage = (page: PAGE, value: number) => {
this.currentPage[page] = value;
};
}

View file

@ -46,3 +46,11 @@ export const TABLE_COLUMN_MAX_WIDTH = 1500;
export const generateAssignToTeamInputDescription = (objectName: string): string =>
`Assigning to a team allows you to filter ${objectName} and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details.`;
export enum PAGE {
Integrations = 'integrations',
Escalations = 'escalation_chains',
Incidents = 'incidents',
Webhooks = 'webhooks',
Schedules = 'schedules',
}