diff --git a/packages/netlify-cms-backend-bitbucket/src/implementation.ts b/packages/netlify-cms-backend-bitbucket/src/implementation.ts index 4cbf4b5d..2dfa2895 100644 --- a/packages/netlify-cms-backend-bitbucket/src/implementation.ts +++ b/packages/netlify-cms-backend-bitbucket/src/implementation.ts @@ -46,6 +46,15 @@ import { GitLfsClient } from './git-lfs-client'; 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 export default class BitbucketBackend implements Implementation { lock: AsyncLock; @@ -114,16 +123,36 @@ export default class BitbucketBackend implements Implementation { } async status() { - const auth = - (await this.api - ?.user() - .then(user => !!user) - .catch(e => { - console.warn('Failed getting Bitbucket user', e); - return false; - })) || false; + const api = await fetch(BITBUCKET_STATUS_ENDPOINT) + .then(res => res.json()) + .then(res => { + return res['components'] + .filter((statusComponent: BitbucketStatusComponent) => + BITBUCKET_OPERATIONAL_UNITS.includes(statusComponent.name), + ) + .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() { diff --git a/packages/netlify-cms-backend-git-gateway/src/implementation.ts b/packages/netlify-cms-backend-git-gateway/src/implementation.ts index 7fbbdac8..62afbb56 100644 --- a/packages/netlify-cms-backend-git-gateway/src/implementation.ts +++ b/packages/netlify-cms-backend-git-gateway/src/implementation.ts @@ -35,6 +35,15 @@ import GitLabAPI from './GitLabAPI'; import AuthenticationPage from './AuthenticationPage'; 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 = { logout: () => void; currentUser: () => User; @@ -179,15 +188,33 @@ export default class GitGateway implements Implementation { } async status() { - const auth = - (await this.tokenPromise?.() - .then(token => !!token) - .catch(e => { - console.warn('Failed getting Identity token', e); - return false; - })) || false; + const api = await fetch(GIT_GATEWAY_STATUS_ENDPOINT) + .then(res => res.json()) + .then(res => { + return res['components'] + .filter((statusComponent: GitGatewayStatus) => + GIT_GATEWAY_OPERATIONAL_UNITS.includes(statusComponent.name), + ) + .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() { diff --git a/packages/netlify-cms-backend-github/src/implementation.tsx b/packages/netlify-cms-backend-github/src/implementation.tsx index a2d4d55b..c8d5d901 100644 --- a/packages/netlify-cms-backend-github/src/implementation.tsx +++ b/packages/netlify-cms-backend-github/src/implementation.tsx @@ -43,6 +43,15 @@ type ApiFile = { id: string; type: string; name: string; path: string; size: num 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 { lock: AsyncLock; api: API | null; @@ -112,16 +121,36 @@ export default class GitHub implements Implementation { } async status() { - const auth = - (await this.api - ?.getUser() - .then(user => !!user) - .catch(e => { - console.warn('Failed getting GitHub user', e); - return false; - })) || false; + const api = await fetch(GITHUB_STATUS_ENDPOINT) + .then(res => res.json()) + .then(res => { + return res['components'] + .filter((statusComponent: GitHubStatusComponent) => + GITHUB_OPERATIONAL_UNITS.includes(statusComponent.name), + ) + .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() { diff --git a/packages/netlify-cms-backend-gitlab/src/implementation.ts b/packages/netlify-cms-backend-gitlab/src/implementation.ts index 16fe56fe..9972b4f4 100644 --- a/packages/netlify-cms-backend-gitlab/src/implementation.ts +++ b/packages/netlify-cms-backend-gitlab/src/implementation.ts @@ -97,7 +97,7 @@ export default class GitLab implements Implementation { return false; })) || false; - return { auth }; + return { auth: { status: auth }, api: { status: true, statusPage: '' } }; } authComponent() { diff --git a/packages/netlify-cms-backend-proxy/src/implementation.ts b/packages/netlify-cms-backend-proxy/src/implementation.ts index 1eba44fd..36beb15f 100644 --- a/packages/netlify-cms-backend-proxy/src/implementation.ts +++ b/packages/netlify-cms-backend-proxy/src/implementation.ts @@ -64,7 +64,7 @@ export default class ProxyBackend implements Implementation { } status() { - return Promise.resolve({ auth: true }); + return Promise.resolve({ auth: { status: true }, api: { status: true, statusPage: '' } }); } authComponent() { diff --git a/packages/netlify-cms-backend-test/src/implementation.ts b/packages/netlify-cms-backend-test/src/implementation.ts index de7e2535..c56ad08f 100644 --- a/packages/netlify-cms-backend-test/src/implementation.ts +++ b/packages/netlify-cms-backend-test/src/implementation.ts @@ -102,7 +102,7 @@ export default class TestBackend implements Implementation { } status() { - return Promise.resolve({ auth: true }); + return Promise.resolve({ auth: { status: true }, api: { status: true, statusPage: '' } }); } authComponent() { diff --git a/packages/netlify-cms-core/src/actions/status.ts b/packages/netlify-cms-core/src/actions/status.ts index 11a2f1ed..72294d28 100644 --- a/packages/netlify-cms-core/src/actions/status.ts +++ b/packages/netlify-cms-core/src/actions/status.ts @@ -4,7 +4,7 @@ import { ThunkDispatch } from 'redux-thunk'; import { AnyAction } from 'redux'; import { actions as notifActions } from 'redux-notifications'; -const { notifSend } = notifActions; +const { notifSend, notifDismiss } = notifActions; export const STATUS_REQUEST = 'STATUS_REQUEST'; 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 { type: STATUS_SUCCESS, payload: { status }, @@ -26,7 +29,7 @@ export function statusSuccess(status: { auth: boolean }) { export function statusFailure(error: Error) { return { type: STATUS_FAILURE, - error, + payload: { error }, }; } @@ -42,7 +45,30 @@ export function checkBackendStatus() { const backend = currentBackend(state.config); 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) { const key = 'ui.toast.onLoggedOut'; const existingNotification = state.notifs.find(n => n.message?.key === key); diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index f2b895d4..aec699ff 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -181,11 +181,17 @@ export class Backend { async status() { 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++) { status = await this.implementation!.status(); // return on first success - if (Object.values(status).every(s => s === true)) { + if (Object.values(status).every(s => s.status === true)) { return status; } else { await new Promise(resolve => setTimeout(resolve, i * 1000)); diff --git a/packages/netlify-cms-core/src/reducers/status.ts b/packages/netlify-cms-core/src/reducers/status.ts index 336499bd..a1ff1d3b 100644 --- a/packages/netlify-cms-core/src/reducers/status.ts +++ b/packages/netlify-cms-core/src/reducers/status.ts @@ -3,11 +3,14 @@ import { AnyAction } from 'redux'; import { STATUS_REQUEST, STATUS_SUCCESS, STATUS_FAILURE } from '../actions/status'; import { Status } from '../types/redux'; -export interface EntriesAction extends AnyAction { - payload: { status: { auth: boolean }; error?: Error }; +interface StatusAction extends AnyAction { + 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) { case STATUS_REQUEST: return state.set('isFetching', true); diff --git a/packages/netlify-cms-lib-util/src/implementation.ts b/packages/netlify-cms-lib-util/src/implementation.ts index 1fb48d83..ba1482df 100644 --- a/packages/netlify-cms-lib-util/src/implementation.ts +++ b/packages/netlify-cms-lib-util/src/implementation.ts @@ -141,7 +141,10 @@ export interface Implementation { ) => Promise<{ entries: ImplementationEntry[]; cursor: Cursor }>; isGitBackend?: () => boolean; - status: () => Promise<{ auth: boolean }>; + status: () => Promise<{ + auth: { status: boolean }; + api: { status: boolean; statusPage: string }; + }>; } const MAX_CONCURRENT_DOWNLOADS = 10; diff --git a/packages/netlify-cms-lib-util/src/unsentRequest.js b/packages/netlify-cms-lib-util/src/unsentRequest.js index 8a0ef517..3daa50ef 100644 --- a/packages/netlify-cms-lib-util/src/unsentRequest.js +++ b/packages/netlify-cms-lib-util/src/unsentRequest.js @@ -12,7 +12,7 @@ const isAbortControllerSupported = () => { const timeout = 60; const fetchWithTimeout = (input, init) => { - if (init.signal || !isAbortControllerSupported()) { + if ((init && init.signal) || !isAbortControllerSupported()) { return fetch(input, init); } const controller = new AbortController(); diff --git a/packages/netlify-cms-locales/src/en/index.js b/packages/netlify-cms-locales/src/en/index.js index 4eab2bd4..342ca833 100644 --- a/packages/netlify-cms-locales/src/en/index.js +++ b/packages/netlify-cms-locales/src/en/index.js @@ -224,6 +224,8 @@ const en = { onDeleteUnpublishedChanges: 'Unpublished changes deleted', onFailToAuth: '%{details}', 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: {