feat: add backend status down indicator (#3889)

This commit is contained in:
Nigel Huang 2020-06-15 10:59:28 -04:00 committed by GitHub
parent 2b01e009c6
commit a50edc7055
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 165 additions and 40 deletions

View File

@ -46,6 +46,15 @@ import { GitLfsClient } from './git-lfs-client';
const MAX_CONCURRENT_DOWNLOADS = 10; const MAX_CONCURRENT_DOWNLOADS = 10;
const STATUS_PAGE = 'https://bitbucket.status.atlassian.com';
const BITBUCKET_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`;
const BITBUCKET_OPERATIONAL_UNITS = ['API', 'Authentication and user management', 'Git LFS'];
type BitbucketStatusComponent = {
id: string;
name: string;
status: string;
};
// Implementation wrapper class // Implementation wrapper class
export default class BitbucketBackend implements Implementation { export default class BitbucketBackend implements Implementation {
lock: AsyncLock; lock: AsyncLock;
@ -114,16 +123,36 @@ export default class BitbucketBackend implements Implementation {
} }
async status() { async status() {
const auth = const api = await fetch(BITBUCKET_STATUS_ENDPOINT)
(await this.api .then(res => res.json())
?.user() .then(res => {
.then(user => !!user) return res['components']
.catch(e => { .filter((statusComponent: BitbucketStatusComponent) =>
console.warn('Failed getting Bitbucket user', e); BITBUCKET_OPERATIONAL_UNITS.includes(statusComponent.name),
return false; )
})) || false; .every(
(statusComponent: BitbucketStatusComponent) => statusComponent.status === 'operational',
);
})
.catch(e => {
console.warn('Failed getting BitBucket status', e);
return true;
});
return { auth }; let auth = false;
// no need to check auth if api is down
if (api) {
auth =
(await this.api
?.user()
.then(user => !!user)
.catch(e => {
console.warn('Failed getting Bitbucket user', e);
return false;
})) || false;
}
return { auth: { status: auth }, api: { status: api, statusPage: STATUS_PAGE } };
} }
authComponent() { authComponent() {

View File

@ -35,6 +35,15 @@ import GitLabAPI from './GitLabAPI';
import AuthenticationPage from './AuthenticationPage'; import AuthenticationPage from './AuthenticationPage';
import { getClient, Client } from './netlify-lfs-client'; import { getClient, Client } from './netlify-lfs-client';
const STATUS_PAGE = 'https://www.netlifystatus.com';
const GIT_GATEWAY_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`;
const GIT_GATEWAY_OPERATIONAL_UNITS = ['Git Gateway'];
type GitGatewayStatus = {
id: string;
name: string;
status: string;
};
type NetlifyIdentity = { type NetlifyIdentity = {
logout: () => void; logout: () => void;
currentUser: () => User; currentUser: () => User;
@ -179,15 +188,33 @@ export default class GitGateway implements Implementation {
} }
async status() { async status() {
const auth = const api = await fetch(GIT_GATEWAY_STATUS_ENDPOINT)
(await this.tokenPromise?.() .then(res => res.json())
.then(token => !!token) .then(res => {
.catch(e => { return res['components']
console.warn('Failed getting Identity token', e); .filter((statusComponent: GitGatewayStatus) =>
return false; GIT_GATEWAY_OPERATIONAL_UNITS.includes(statusComponent.name),
})) || false; )
.every((statusComponent: GitGatewayStatus) => statusComponent.status === 'operational');
})
.catch(e => {
console.warn('Failed getting Git Gateway status', e);
return true;
});
return { auth }; let auth = false;
// no need to check auth if api is down
if (api) {
auth =
(await this.tokenPromise?.()
.then(token => !!token)
.catch(e => {
console.warn('Failed getting Identity token', e);
return false;
})) || false;
}
return { auth: { status: auth }, api: { status: api, statusPage: STATUS_PAGE } };
} }
async getAuthClient() { async getAuthClient() {

View File

@ -43,6 +43,15 @@ type ApiFile = { id: string; type: string; name: string; path: string; size: num
const { fetchWithTimeout: fetch } = unsentRequest; const { fetchWithTimeout: fetch } = unsentRequest;
const STATUS_PAGE = 'https://www.githubstatus.com';
const GITHUB_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`;
const GITHUB_OPERATIONAL_UNITS = ['API Requests', 'Issues, Pull Requests, Projects'];
type GitHubStatusComponent = {
id: string;
name: string;
status: string;
};
export default class GitHub implements Implementation { export default class GitHub implements Implementation {
lock: AsyncLock; lock: AsyncLock;
api: API | null; api: API | null;
@ -112,16 +121,36 @@ export default class GitHub implements Implementation {
} }
async status() { async status() {
const auth = const api = await fetch(GITHUB_STATUS_ENDPOINT)
(await this.api .then(res => res.json())
?.getUser() .then(res => {
.then(user => !!user) return res['components']
.catch(e => { .filter((statusComponent: GitHubStatusComponent) =>
console.warn('Failed getting GitHub user', e); GITHUB_OPERATIONAL_UNITS.includes(statusComponent.name),
return false; )
})) || false; .every(
(statusComponent: GitHubStatusComponent) => statusComponent.status === 'operational',
);
})
.catch(e => {
console.warn('Failed getting GitHub status', e);
return true;
});
return { auth }; let auth = false;
// no need to check auth if api is down
if (api) {
auth =
(await this.api
?.getUser()
.then(user => !!user)
.catch(e => {
console.warn('Failed getting GitHub user', e);
return false;
})) || false;
}
return { auth: { status: auth }, api: { status: api, statusPage: STATUS_PAGE } };
} }
authComponent() { authComponent() {

View File

@ -97,7 +97,7 @@ export default class GitLab implements Implementation {
return false; return false;
})) || false; })) || false;
return { auth }; return { auth: { status: auth }, api: { status: true, statusPage: '' } };
} }
authComponent() { authComponent() {

View File

@ -64,7 +64,7 @@ export default class ProxyBackend implements Implementation {
} }
status() { status() {
return Promise.resolve({ auth: true }); return Promise.resolve({ auth: { status: true }, api: { status: true, statusPage: '' } });
} }
authComponent() { authComponent() {

View File

@ -102,7 +102,7 @@ export default class TestBackend implements Implementation {
} }
status() { status() {
return Promise.resolve({ auth: true }); return Promise.resolve({ auth: { status: true }, api: { status: true, statusPage: '' } });
} }
authComponent() { authComponent() {

View File

@ -4,7 +4,7 @@ import { ThunkDispatch } from 'redux-thunk';
import { AnyAction } from 'redux'; import { AnyAction } from 'redux';
import { actions as notifActions } from 'redux-notifications'; import { actions as notifActions } from 'redux-notifications';
const { notifSend } = notifActions; const { notifSend, notifDismiss } = notifActions;
export const STATUS_REQUEST = 'STATUS_REQUEST'; export const STATUS_REQUEST = 'STATUS_REQUEST';
export const STATUS_SUCCESS = 'STATUS_SUCCESS'; export const STATUS_SUCCESS = 'STATUS_SUCCESS';
@ -16,7 +16,10 @@ export function statusRequest() {
}; };
} }
export function statusSuccess(status: { auth: boolean }) { export function statusSuccess(status: {
auth: { status: boolean };
api: { status: boolean; statusPage: string };
}) {
return { return {
type: STATUS_SUCCESS, type: STATUS_SUCCESS,
payload: { status }, payload: { status },
@ -26,7 +29,7 @@ export function statusSuccess(status: { auth: boolean }) {
export function statusFailure(error: Error) { export function statusFailure(error: Error) {
return { return {
type: STATUS_FAILURE, type: STATUS_FAILURE,
error, payload: { error },
}; };
} }
@ -42,7 +45,30 @@ export function checkBackendStatus() {
const backend = currentBackend(state.config); const backend = currentBackend(state.config);
const status = await backend.status(); const status = await backend.status();
const authError = status.auth === false; const backendDownKey = 'ui.toast.onBackendDown';
const previousBackendDownNotifs = state.notifs.filter(n => n.message?.key === backendDownKey);
if (status.api.status === false) {
if (previousBackendDownNotifs.length === 0) {
dispatch(
notifSend({
message: {
details: status.api.statusPage,
key: 'ui.toast.onBackendDown',
},
kind: 'danger',
}),
);
}
return dispatch(statusSuccess(status));
} else if (status.api.status === true && previousBackendDownNotifs.length > 0) {
// If backend is up, clear all the danger messages
previousBackendDownNotifs.forEach(notif => {
dispatch(notifDismiss(notif.id));
});
}
const authError = status.auth.status === false;
if (authError) { if (authError) {
const key = 'ui.toast.onLoggedOut'; const key = 'ui.toast.onLoggedOut';
const existingNotification = state.notifs.find(n => n.message?.key === key); const existingNotification = state.notifs.find(n => n.message?.key === key);

View File

@ -181,11 +181,17 @@ export class Backend {
async status() { async status() {
const attempts = 3; const attempts = 3;
let status: { auth: boolean } = { auth: false }; let status: {
auth: { status: boolean };
api: { status: boolean; statusPage: string };
} = {
auth: { status: false },
api: { status: false, statusPage: '' },
};
for (let i = 1; i <= attempts; i++) { for (let i = 1; i <= attempts; i++) {
status = await this.implementation!.status(); status = await this.implementation!.status();
// return on first success // return on first success
if (Object.values(status).every(s => s === true)) { if (Object.values(status).every(s => s.status === true)) {
return status; return status;
} else { } else {
await new Promise(resolve => setTimeout(resolve, i * 1000)); await new Promise(resolve => setTimeout(resolve, i * 1000));

View File

@ -3,11 +3,14 @@ import { AnyAction } from 'redux';
import { STATUS_REQUEST, STATUS_SUCCESS, STATUS_FAILURE } from '../actions/status'; import { STATUS_REQUEST, STATUS_SUCCESS, STATUS_FAILURE } from '../actions/status';
import { Status } from '../types/redux'; import { Status } from '../types/redux';
export interface EntriesAction extends AnyAction { interface StatusAction extends AnyAction {
payload: { status: { auth: boolean }; error?: Error }; payload: {
status: { auth: { status: boolean }; api: { status: boolean; statusPage: string } };
error?: Error;
};
} }
const status = (state = Map(), action: EntriesAction) => { const status = (state = Map(), action: StatusAction) => {
switch (action.type) { switch (action.type) {
case STATUS_REQUEST: case STATUS_REQUEST:
return state.set('isFetching', true); return state.set('isFetching', true);

View File

@ -141,7 +141,10 @@ export interface Implementation {
) => Promise<{ entries: ImplementationEntry[]; cursor: Cursor }>; ) => Promise<{ entries: ImplementationEntry[]; cursor: Cursor }>;
isGitBackend?: () => boolean; isGitBackend?: () => boolean;
status: () => Promise<{ auth: boolean }>; status: () => Promise<{
auth: { status: boolean };
api: { status: boolean; statusPage: string };
}>;
} }
const MAX_CONCURRENT_DOWNLOADS = 10; const MAX_CONCURRENT_DOWNLOADS = 10;

View File

@ -12,7 +12,7 @@ const isAbortControllerSupported = () => {
const timeout = 60; const timeout = 60;
const fetchWithTimeout = (input, init) => { const fetchWithTimeout = (input, init) => {
if (init.signal || !isAbortControllerSupported()) { if ((init && init.signal) || !isAbortControllerSupported()) {
return fetch(input, init); return fetch(input, init);
} }
const controller = new AbortController(); const controller = new AbortController();

View File

@ -224,6 +224,8 @@ const en = {
onDeleteUnpublishedChanges: 'Unpublished changes deleted', onDeleteUnpublishedChanges: 'Unpublished changes deleted',
onFailToAuth: '%{details}', onFailToAuth: '%{details}',
onLoggedOut: 'You have been logged out, please back up any data and login again', onLoggedOut: 'You have been logged out, please back up any data and login again',
onBackendDown:
'The backend service is experiencing an outage. See %{details} for more information',
}, },
}, },
workflow: { workflow: {