Improve loading initial data (#3450)
# What this PR does - Split initial data into required base data and master data (options for dropdowns) - Prioritize loading base data over master data - Enable retrying single promises instead of always retrying all of them when only one fails ## Which issue(s) this PR fixes https://github.com/grafana/oncall/issues/3300 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required)
This commit is contained in:
parent
64d8bed0eb
commit
efe274b465
19 changed files with 129 additions and 81 deletions
|
|
@ -125,6 +125,7 @@
|
|||
"@grafana/labels": "~1.3.5",
|
||||
"@grafana/runtime": "9.3.0-beta1",
|
||||
"@grafana/ui": "^10.2.0",
|
||||
"@lifeomic/attempt": "^3.0.3",
|
||||
"@opentelemetry/api": "^1.3.0",
|
||||
"array-move": "^4.0.0",
|
||||
"change-case": "^4.1.1",
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import React, { FC, ReactNode } from 'react';
|
|||
interface RenderConditionallyProps {
|
||||
shouldRender?: boolean;
|
||||
children: ReactNode;
|
||||
backupChildren?: ReactNode;
|
||||
}
|
||||
|
||||
const RenderConditionally: FC<RenderConditionallyProps> = ({ shouldRender, children }) =>
|
||||
shouldRender ? <>{children}</> : null;
|
||||
const RenderConditionally: FC<RenderConditionallyProps> = ({ shouldRender, children, backupChildren = null }) =>
|
||||
shouldRender ? <>{children}</> : <>{backupChildren}</>;
|
||||
|
||||
export default RenderConditionally;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ const EscalationChainSteps = observer((props: EscalationChainStepsProps) => {
|
|||
escalationPolicyStore.updateEscalationPolicies(id);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
escalationPolicyStore.updateWebEscalationPolicyOptions();
|
||||
escalationPolicyStore.updateEscalationPolicyOptions();
|
||||
escalationPolicyStore.updateNumMinutesInWindowOptions();
|
||||
}, []);
|
||||
|
||||
const handleSortEnd = useCallback(
|
||||
({ oldIndex, newIndex }: any) => {
|
||||
escalationPolicyStore.moveEscalationPolicyToPosition(oldIndex, newIndex, id);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const WebhookTriggerType = {
|
|||
};
|
||||
|
||||
export function createForm(
|
||||
presets: OutgoingWebhookPreset[],
|
||||
presets: OutgoingWebhookPreset[] = [],
|
||||
hasLabelsFeature?: boolean
|
||||
): {
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin
|
|||
);
|
||||
|
||||
const allNotificationPolicies = userStore.notificationPolicies[userPk];
|
||||
|
||||
const title = (
|
||||
<Text.Title level={5}>
|
||||
<HorizontalGroup>
|
||||
|
|
@ -91,11 +90,9 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin
|
|||
);
|
||||
}
|
||||
|
||||
const notificationPolicies =
|
||||
allNotificationPolicies &&
|
||||
allNotificationPolicies.filter(
|
||||
(notificationPolicy: NotificationPolicyType) => notificationPolicy.important === isImportant
|
||||
);
|
||||
const notificationPolicies = allNotificationPolicies?.filter(
|
||||
(notificationPolicy: NotificationPolicyType) => notificationPolicy.important === isImportant
|
||||
);
|
||||
|
||||
const offset = isImportant
|
||||
? allNotificationPolicies.findIndex((notificationPolicy: NotificationPolicyType) => notificationPolicy.important)
|
||||
|
|
|
|||
|
|
@ -317,10 +317,9 @@ export class AlertReceiveChannelStore extends BaseStore {
|
|||
return this.updateChannelFilters(channelFilter.alert_receive_channel, true);
|
||||
}
|
||||
|
||||
@action
|
||||
@action.bound
|
||||
async updateAlertReceiveChannelOptions() {
|
||||
const response = await makeRequest(`/alert_receive_channels/integration_options/`, {});
|
||||
|
||||
this.alertReceiveChannelOptions = response;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export class CloudStore extends BaseStore {
|
|||
return await makeRequest(`${this.path}${id}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
@action.bound
|
||||
async loadCloudConnectionStatus() {
|
||||
this.cloudConnectionStatus = await this.getCloudConnectionStatus();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,26 +34,23 @@ export class EscalationPolicyStore extends BaseStore {
|
|||
this.path = '/escalation_policies/';
|
||||
}
|
||||
|
||||
@action
|
||||
@action.bound
|
||||
async updateWebEscalationPolicyOptions() {
|
||||
const response = await makeRequest('/escalation_policies/escalation_options/', {});
|
||||
|
||||
this.webEscalationChoices = response;
|
||||
}
|
||||
|
||||
@action
|
||||
@action.bound
|
||||
async updateEscalationPolicyOptions() {
|
||||
const response = await makeRequest('/escalation_policies/', {
|
||||
method: 'OPTIONS',
|
||||
});
|
||||
|
||||
this.escalationChoices = get(response, 'actions.POST', []);
|
||||
}
|
||||
|
||||
@action
|
||||
@action.bound
|
||||
async updateNumMinutesInWindowOptions() {
|
||||
const response = await makeRequest('/escalation_policies/num_minutes_in_window_options/', {});
|
||||
|
||||
this.numMinutesInWindowOptions = response;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export class GrafanaTeamStore extends BaseStore {
|
|||
};
|
||||
}
|
||||
|
||||
@action
|
||||
@action.bound
|
||||
async updateItems(query = '', includeNoTeam = true, onlyIncludeNotifiableTeams = false, short = true) {
|
||||
const result = await makeRequest<GrafanaTeam[]>(`${this.path}`, {
|
||||
params: {
|
||||
|
|
@ -40,7 +40,6 @@ export class GrafanaTeamStore extends BaseStore {
|
|||
only_include_notifiable_teams: onlyIncludeNotifiableTeams ? 'true' : 'false',
|
||||
},
|
||||
});
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
...result.reduce<TeamItems>(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { action, observable, runInAction } from 'mobx';
|
||||
import { action, observable } from 'mobx';
|
||||
|
||||
import BaseStore from 'models/base_store';
|
||||
import { makeRequest } from 'network';
|
||||
|
|
@ -23,10 +23,7 @@ export class LabelStore extends BaseStore {
|
|||
@action.bound
|
||||
public async loadKeys() {
|
||||
const { data } = await onCallApi.GET('/labels/keys/', undefined);
|
||||
|
||||
runInAction(() => {
|
||||
this.keys = data;
|
||||
});
|
||||
this.keys = data;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@ export class OrganizationStore extends BaseStore {
|
|||
this.path = '/organization/';
|
||||
}
|
||||
|
||||
@action
|
||||
@action.bound
|
||||
async loadCurrentOrganization() {
|
||||
this.currentOrganization = await makeRequest(this.path, {});
|
||||
const organization = await makeRequest(this.path, {});
|
||||
this.currentOrganization = organization;
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
|
|||
|
|
@ -101,8 +101,8 @@ export class OutgoingWebhookStore extends BaseStore {
|
|||
});
|
||||
}
|
||||
|
||||
@action
|
||||
async updateOutgoingWebhookPresets() {
|
||||
@action.bound
|
||||
async updateOutgoingWebhookPresetsOptions() {
|
||||
const response = await makeRequest(`/webhooks/preset_options/`, {});
|
||||
this.outgoingWebhookPresets = response;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,14 +62,12 @@ export class UserStore extends BaseStore {
|
|||
@action
|
||||
async loadCurrentUser() {
|
||||
const response = await makeRequest('/user/', {});
|
||||
|
||||
const timezone = await this.refreshTimezone(response.pk);
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
[response.pk]: { ...response, timezone },
|
||||
};
|
||||
|
||||
this.currentUserPk = response.pk;
|
||||
}
|
||||
|
||||
|
|
@ -164,7 +162,7 @@ export class UserStore extends BaseStore {
|
|||
return {
|
||||
page_size: this.searchResult.page_size,
|
||||
count: this.searchResult.count,
|
||||
results: this.searchResult.results && this.searchResult.results.map((userPk: User['pk']) => this.items?.[userPk]),
|
||||
results: this.searchResult.results?.map((userPk: User['pk']) => this.items?.[userPk]),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -371,12 +369,11 @@ export class UserStore extends BaseStore {
|
|||
this.updateItem(userPk); // to update notification_chain_verbal
|
||||
}
|
||||
|
||||
@action
|
||||
@action.bound
|
||||
async updateNotificationPolicyOptions() {
|
||||
const response = await makeRequest('/notification_policies/', {
|
||||
method: 'OPTIONS',
|
||||
});
|
||||
|
||||
this.notificationChoices = get(response, 'actions.POST', []);
|
||||
}
|
||||
|
||||
|
|
@ -390,10 +387,9 @@ export class UserStore extends BaseStore {
|
|||
});
|
||||
}
|
||||
|
||||
@action
|
||||
@action.bound
|
||||
async updateNotifyByOptions() {
|
||||
const response = await makeRequest('/notification_policies/notify_by_options/', {});
|
||||
|
||||
this.notifyByOptions = response;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,10 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
confirmationModal: undefined,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.store.outgoingWebhookStore.updateOutgoingWebhookPresetsOptions();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: OutgoingWebhooksProps) {
|
||||
if (prevProps.match.params.id !== this.props.match.params.id && !this.state.outgoingWebhookAction) {
|
||||
this.parseQueryParams();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import LegacyNavTabsBar from 'navbar/LegacyNavTabsBar';
|
|||
import { Redirect, Route, Switch, useLocation } from 'react-router-dom';
|
||||
import { AppRootProps } from 'types';
|
||||
|
||||
import RenderConditionally from 'components/RenderConditionally/RenderConditionally';
|
||||
import Unauthorized from 'components/Unauthorized';
|
||||
import DefaultPageLayout from 'containers/DefaultPageLayout/DefaultPageLayout';
|
||||
import { getMatchedPage, getRoutesForPage, pages } from 'pages';
|
||||
|
|
@ -68,18 +69,22 @@ export const GrafanaPluginRootPage = (props: AppRootProps) => {
|
|||
};
|
||||
|
||||
export const Root = observer((props: AppRootProps) => {
|
||||
const store = useStore();
|
||||
|
||||
const [basicDataLoaded, setBasicDataLoaded] = useState(false);
|
||||
const { isBasicDataLoaded, loadBasicData, loadMasterData } = useStore();
|
||||
|
||||
const [pageTitle, setPageTitle] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
runQueuedUpdateData(0);
|
||||
}, []);
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
loadBasicData();
|
||||
// defer loading master data as it's not used in first sec by user in order to prioritize fetching base data
|
||||
const timeout = setTimeout(() => {
|
||||
loadMasterData();
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let link = document.createElement('link');
|
||||
link.type = 'text/css';
|
||||
|
|
@ -109,6 +114,10 @@ export const Root = observer((props: AppRootProps) => {
|
|||
return (pages[page] || pages[DEFAULT_PAGE]).getPageNav(pageTitle);
|
||||
};
|
||||
|
||||
if (!userHasAccess) {
|
||||
return <Unauthorized requiredUserAction={pagePermissionAction} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultPageLayout {...props} page={page} pageNav={getPageNav()}>
|
||||
{!isTopNavbar() && (
|
||||
|
|
@ -124,11 +133,14 @@ export const Root = observer((props: AppRootProps) => {
|
|||
'page-body': !isTopNavbar(),
|
||||
})}
|
||||
>
|
||||
{userHasAccess ? (
|
||||
// Otherwise we'll run into concurrency issues
|
||||
!basicDataLoaded ? (
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
) : (
|
||||
<RenderConditionally
|
||||
shouldRender={userHasAccess}
|
||||
backupChildren={<Unauthorized requiredUserAction={pagePermissionAction} />}
|
||||
>
|
||||
<RenderConditionally
|
||||
shouldRender={isBasicDataLoaded}
|
||||
backupChildren={<LoadingPlaceholder text="Loading..." />}
|
||||
>
|
||||
<Switch>
|
||||
<Route path={getRoutesForPage('alert-groups')} exact>
|
||||
<Incidents query={query} />
|
||||
|
|
@ -182,7 +194,7 @@ export const Root = observer((props: AppRootProps) => {
|
|||
}}
|
||||
></Redirect>
|
||||
)}
|
||||
></Route>
|
||||
/>
|
||||
<Route
|
||||
path={getRoutesForPage('incidents')}
|
||||
exact
|
||||
|
|
@ -194,30 +206,14 @@ export const Root = observer((props: AppRootProps) => {
|
|||
}}
|
||||
></Redirect>
|
||||
)}
|
||||
></Route>
|
||||
|
||||
/>
|
||||
<Route path="*">
|
||||
<NoMatch />
|
||||
</Route>
|
||||
</Switch>
|
||||
)
|
||||
) : (
|
||||
<Unauthorized requiredUserAction={pagePermissionAction} />
|
||||
)}
|
||||
</RenderConditionally>
|
||||
</RenderConditionally>
|
||||
</div>
|
||||
</DefaultPageLayout>
|
||||
);
|
||||
|
||||
async function runQueuedUpdateData(attemptCount: number) {
|
||||
if (attemptCount === 10) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await store.updateBasicData();
|
||||
setBasicDataLoaded(true);
|
||||
} catch {
|
||||
setTimeout(() => runQueuedUpdateData(attemptCount + 1), 1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { UserGroupStore } from 'models/user_group/user_group';
|
|||
import { makeRequest } from 'network';
|
||||
import { AppFeature } from 'state/features';
|
||||
import PluginState from 'state/plugin';
|
||||
import { retryFailingPromises } from 'utils/async';
|
||||
import {
|
||||
APP_VERSION,
|
||||
CLOUD_VERSION_REGEX,
|
||||
|
|
@ -45,6 +46,9 @@ import FaroHelper from 'utils/faro';
|
|||
// ------ Dashboard ------ //
|
||||
|
||||
export class RootBaseStore {
|
||||
@observable
|
||||
isBasicDataLoaded = false;
|
||||
|
||||
@observable
|
||||
currentTimezone: Timezone = moment.tz.guess() as Timezone;
|
||||
|
||||
|
|
@ -83,7 +87,7 @@ export class RootBaseStore {
|
|||
@observable
|
||||
onCallApiUrl: string;
|
||||
|
||||
// --------------------------
|
||||
// stores
|
||||
userStore = new UserStore(this);
|
||||
cloudStore = new CloudStore(this);
|
||||
directPagingStore = new DirectPagingStore(this);
|
||||
|
|
@ -108,9 +112,8 @@ export class RootBaseStore {
|
|||
labelsStore = new LabelStore(this);
|
||||
loaderStore = LoaderStore;
|
||||
|
||||
// stores
|
||||
|
||||
async updateBasicData() {
|
||||
@action.bound
|
||||
async loadBasicData() {
|
||||
const updateFeatures = async () => {
|
||||
await this.updateFeatures();
|
||||
|
||||
|
|
@ -121,18 +124,21 @@ export class RootBaseStore {
|
|||
}
|
||||
};
|
||||
|
||||
return Promise.all([
|
||||
this.userStore.loadCurrentUser(),
|
||||
this.organizationStore.loadCurrentOrganization(),
|
||||
this.grafanaTeamStore.updateItems(),
|
||||
updateFeatures(),
|
||||
await retryFailingPromises([
|
||||
this.userStore.loadCurrentUser,
|
||||
this.organizationStore.loadCurrentOrganization,
|
||||
this.grafanaTeamStore.updateItems,
|
||||
updateFeatures,
|
||||
]);
|
||||
this.isBasicDataLoaded = true;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
async loadMasterData() {
|
||||
Promise.all([
|
||||
this.userStore.updateNotificationPolicyOptions(),
|
||||
this.userStore.updateNotifyByOptions(),
|
||||
this.alertReceiveChannelStore.updateAlertReceiveChannelOptions(),
|
||||
this.outgoingWebhookStore.updateOutgoingWebhookPresets(),
|
||||
this.escalationPolicyStore.updateWebEscalationPolicyOptions(),
|
||||
this.escalationPolicyStore.updateEscalationPolicyOptions(),
|
||||
this.escalationPolicyStore.updateNumMinutesInWindowOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -282,7 +288,7 @@ export class RootBaseStore {
|
|||
return this.license === GRAFANA_LICENSE_OSS;
|
||||
}
|
||||
|
||||
@observable
|
||||
@action.bound
|
||||
async updateFeatures() {
|
||||
const response = await makeRequest('/features/', {});
|
||||
this.features = response.reduce(
|
||||
|
|
|
|||
33
grafana-plugin/src/utils/async.test.ts
Normal file
33
grafana-plugin/src/utils/async.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { retryFailingPromises } from './async';
|
||||
|
||||
describe('retryFailingPromises', () => {
|
||||
it('should retry only failing promises X times and return correct result', async () => {
|
||||
const MAX_ATTEMPTS = 5;
|
||||
|
||||
// We mimic that fetch1 always resolves, fetch2 always rejects and fetch3 resolves only on 2nd attempt
|
||||
let attempts1 = 0;
|
||||
let attempts2 = 0;
|
||||
let attempts3 = 0;
|
||||
const fetch1 = async () => Promise.resolve(++attempts1);
|
||||
const fetch2 = async () => Promise.reject(++attempts2);
|
||||
const fetch3 = async () =>
|
||||
new Promise((resolve, reject) => {
|
||||
attempts3++;
|
||||
if (attempts3 === 2) {
|
||||
resolve(attempts3);
|
||||
}
|
||||
reject(attempts3);
|
||||
});
|
||||
|
||||
const result = await retryFailingPromises([fetch1, fetch2, fetch3], { maxAttempts: MAX_ATTEMPTS, delayInMs: 50 });
|
||||
|
||||
expect(attempts1).toBe(1);
|
||||
expect(attempts2).toBe(MAX_ATTEMPTS);
|
||||
expect(attempts3).toBe(2);
|
||||
expect(result).toEqual([
|
||||
{ status: 'fulfilled', value: 1 },
|
||||
{ status: 'rejected', reason: 5 },
|
||||
{ status: 'fulfilled', value: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
9
grafana-plugin/src/utils/async.ts
Normal file
9
grafana-plugin/src/utils/async.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { retry } from '@lifeomic/attempt';
|
||||
|
||||
export const retryFailingPromises = async (
|
||||
asyncActions: Array<() => Promise<unknown>>,
|
||||
{ maxAttempts = 3, delayInMs = 500 }: { maxAttempts?: number; delayInMs?: number } = {}
|
||||
) =>
|
||||
maxAttempts === 0
|
||||
? Promise.allSettled(asyncActions)
|
||||
: Promise.allSettled(asyncActions.map((asyncAction) => retry(asyncAction, { maxAttempts, delay: delayInMs })));
|
||||
|
|
@ -2863,6 +2863,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@leeoniya/ufuzzy/-/ufuzzy-1.0.8.tgz#6a01b561749df84ff28637051865fdde3cbfc3a9"
|
||||
integrity sha512-HQ6aJlYpWLq1f9AiApJl0aOIXlJUtuhBOYfSfv5rt3XNYkCBveojtnL6FvOVpJ2gEJ2wqgMW8xOHkLVYAbXghg==
|
||||
|
||||
"@lifeomic/attempt@^3.0.3":
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@lifeomic/attempt/-/attempt-3.0.3.tgz#e742a5b85eb673e2f1746b0f39cb932cbc6145bb"
|
||||
integrity sha512-GlM2AbzrErd/TmLL3E8hAHmb5Q7VhDJp35vIbyPVA5Rz55LZuRr8pwL3qrwwkVNo05gMX1J44gURKb4MHQZo7w==
|
||||
|
||||
"@mapbox/jsonlint-lines-primitives@~2.0.2":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz#ce56e539f83552b58d10d672ea4d6fc9adc7b234"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue