Skip to content

Commit 6185665

Browse files
committed
feat: add status bar
1 parent fce2a69 commit 6185665

File tree

12 files changed

+209
-11
lines changed

12 files changed

+209
-11
lines changed

packages/decap-cms-backend-github/src/API.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export interface Config {
6565
cmsLabelPrefix: string;
6666
baseUrl?: string;
6767
getUser: ({ token }: { token: string }) => Promise<GitHubUser>;
68+
onRateLimitInfo?: (info: { used: number; limit: number; remaining: number; reset: number; resource: string }) => void;
6869
}
6970

7071
interface TreeFile {
@@ -197,6 +198,7 @@ export default class API {
197198
cmsLabelPrefix: string;
198199
baseUrl?: string;
199200
getUser: ({ token }: { token: string }) => Promise<GitHubUser>;
201+
onRateLimitInfo?: (info: { used: number; limit: number; remaining: number; reset: number; resource: string }) => void;
200202
_userPromise?: Promise<GitHubUser>;
201203
_metadataSemaphore?: Semaphore;
202204

@@ -226,6 +228,7 @@ export default class API {
226228
this.initialWorkflowStatus = config.initialWorkflowStatus;
227229
this.baseUrl = config.baseUrl;
228230
this.getUser = config.getUser;
231+
this.onRateLimitInfo = config.onRateLimitInfo;
229232
}
230233

231234
static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Decap CMS';
@@ -291,6 +294,8 @@ export default class API {
291294
}
292295

293296
parseResponse(response: Response) {
297+
this.extractRateLimitInfo(response.headers);
298+
294299
const contentType = response.headers.get('Content-Type');
295300
if (contentType && contentType.match(/json/)) {
296301
return this.parseJsonResponse(response);
@@ -304,6 +309,28 @@ export default class API {
304309
return textPromise;
305310
}
306311

312+
protected extractRateLimitInfo(headers: Headers) {
313+
if (!this.onRateLimitInfo) {
314+
return;
315+
}
316+
317+
const used = headers.get('x-ratelimit-used');
318+
const limit = headers.get('x-ratelimit-limit');
319+
const remaining = headers.get('x-ratelimit-remaining');
320+
const reset = headers.get('x-ratelimit-reset');
321+
const resource = headers.get('x-ratelimit-resource');
322+
323+
if (used && limit && remaining && reset && resource) {
324+
this.onRateLimitInfo({
325+
used: parseInt(used, 10),
326+
limit: parseInt(limit, 10),
327+
remaining: parseInt(remaining, 10),
328+
reset: parseInt(reset, 10),
329+
resource,
330+
});
331+
}
332+
}
333+
307334
handleRequestError(error: FetchError, responseStatus: number) {
308335
throw new APIError(error.message, responseStatus, API_NAME);
309336
}

packages/decap-cms-backend-github/src/GraphQLAPI.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,21 @@ export default class GraphQLAPI extends API {
113113
},
114114
};
115115
});
116-
const httpLink = createHttpLink({ uri: `${this.apiRoot}/graphql` });
116+
117+
// Custom fetch that captures rate limit headers
118+
function fetchWithRateLimit(uri: string, options: RequestInit) {
119+
return fetch(uri, options).then(response => {
120+
// Extract rate limit info from response headers
121+
this.extractRateLimitInfo(response.headers);
122+
return response;
123+
});
124+
}
125+
126+
const httpLink = createHttpLink({
127+
uri: `${this.apiRoot}/graphql`,
128+
fetch: fetchWithRateLimit as any,
129+
});
130+
117131
return new ApolloClient({
118132
link: authLink.concat(httpLink),
119133
cache: new InMemoryCache({ fragmentMatcher }),

packages/decap-cms-backend-github/src/implementation.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export default class GitHub implements Implementation {
6767
API: API | null;
6868
useWorkflow?: boolean;
6969
initialWorkflowStatus: string;
70+
dispatch?: any;
7071
};
7172
originRepo: string;
7273
isBranchConfigured: boolean;
@@ -333,6 +334,16 @@ export default class GitHub implements Implementation {
333334
this.branch = repoInfo.default_branch;
334335
}
335336
}
337+
338+
const onRateLimitInfo = this.options.dispatch
339+
? (info: { used: number; limit: number; remaining: number; reset: number; resource: string }) => {
340+
this.options.dispatch!({
341+
type: 'SET_RATE_LIMIT_INFO',
342+
payload: { rateLimitInfo: info },
343+
});
344+
}
345+
: undefined;
346+
336347
const apiCtor = this.useGraphql ? GraphQLAPI : API;
337348
this.api = new apiCtor({
338349
token: this.token,
@@ -347,6 +358,7 @@ export default class GitHub implements Implementation {
347358
initialWorkflowStatus: this.options.initialWorkflowStatus,
348359
baseUrl: this.baseUrl,
349360
getUser: this.currentUser,
361+
onRateLimitInfo,
350362
});
351363
const user = await this.api!.user();
352364
const isCollab = await this.api!.hasWriteAccess().catch(error => {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Rate limit info type and callback
3+
*/
4+
export interface RateLimitCallback {
5+
(rateLimitInfo: RateLimitInfo): void;
6+
}
7+
8+
export interface RateLimitInfo {
9+
used: number;
10+
limit: number;
11+
remaining: number;
12+
reset: number;
13+
resource: string;
14+
}
15+
16+
/**
17+
* Extracts rate limit information from response headers
18+
*/
19+
export function extractRateLimitInfo(headers: Headers): RateLimitInfo | null {
20+
const used = headers.get('x-ratelimit-used');
21+
const limit = headers.get('x-ratelimit-limit');
22+
const remaining = headers.get('x-ratelimit-remaining');
23+
const reset = headers.get('x-ratelimit-reset');
24+
const resource = headers.get('x-ratelimit-resource');
25+
26+
if (!used || !limit || !remaining || !reset || !resource) {
27+
return null;
28+
}
29+
30+
return {
31+
used: parseInt(used, 10),
32+
limit: parseInt(limit, 10),
33+
remaining: parseInt(remaining, 10),
34+
reset: parseInt(reset, 10),
35+
resource,
36+
};
37+
}

packages/decap-cms-core/src/actions/auth.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,10 @@ export function logout() {
5252
} as const;
5353
}
5454

55-
// Check if user data token is cached and is valid
5655
export function authenticateUser() {
5756
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
5857
const state = getState();
59-
const backend = currentBackend(state.config);
58+
const backend = currentBackend(state.config, dispatch);
6059
dispatch(authenticating());
6160
return Promise.resolve(backend.currentUser())
6261
.then(user => {
@@ -79,7 +78,7 @@ export function authenticateUser() {
7978
export function loginUser(credentials: Credentials) {
8079
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
8180
const state = getState();
82-
const backend = currentBackend(state.config);
81+
const backend = currentBackend(state.config, dispatch);
8382

8483
dispatch(authenticating());
8584
return backend

packages/decap-cms-core/src/actions/status.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import { addNotification, dismissNotification } from './notifications';
44
import type { ThunkDispatch } from 'redux-thunk';
55
import type { AnyAction } from 'redux';
66
import type { State } from '../types/redux';
7+
import type { RateLimitInfo } from '../reducers/status';
78

89
export const STATUS_REQUEST = 'STATUS_REQUEST';
910
export const STATUS_SUCCESS = 'STATUS_SUCCESS';
1011
export const STATUS_FAILURE = 'STATUS_FAILURE';
12+
export const SET_RATE_LIMIT_INFO = 'SET_RATE_LIMIT_INFO';
1113

1214
export function statusRequest() {
1315
return {
@@ -32,6 +34,13 @@ export function statusFailure(error: Error) {
3234
} as const;
3335
}
3436

37+
export function setRateLimitInfo(rateLimitInfo: RateLimitInfo) {
38+
return {
39+
type: SET_RATE_LIMIT_INFO,
40+
payload: { rateLimitInfo },
41+
} as const;
42+
}
43+
3544
export function checkBackendStatus() {
3645
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
3746
try {
@@ -95,5 +104,5 @@ export function checkBackendStatus() {
95104
}
96105

97106
export type StatusAction = ReturnType<
98-
typeof statusRequest | typeof statusSuccess | typeof statusFailure
107+
typeof statusRequest | typeof statusSuccess | typeof statusFailure | typeof setRateLimitInfo
99108
>;

packages/decap-cms-core/src/backend.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ interface BackendOptions {
256256
backendName: string;
257257
config: CmsConfig;
258258
authStore?: AuthStore;
259+
dispatch?: any;
259260
}
260261

261262
export interface MediaFile {
@@ -291,6 +292,7 @@ interface ImplementationInitOptions {
291292
useWorkflow: boolean;
292293
updateUserCredentials: (credentials: Credentials) => void;
293294
initialWorkflowStatus: string;
295+
dispatch?: any;
294296
}
295297

296298
type Implementation = BackendImplementation & {
@@ -354,14 +356,15 @@ export class Backend {
354356
user?: User | null;
355357
backupSync: AsyncLock;
356358

357-
constructor(implementation: Implementation, { backendName, authStore, config }: BackendOptions) {
359+
constructor(implementation: Implementation, { backendName, authStore, config, dispatch }: BackendOptions) {
358360
// We can't reliably run this on exit, so we do cleanup on load.
359361
this.deleteAnonymousBackup();
360362
this.config = config;
361363
this.implementation = implementation.init(this.config, {
362364
useWorkflow: selectUseWorkflow(this.config),
363365
updateUserCredentials: this.updateUserCredentials,
364366
initialWorkflowStatus: status.first(),
367+
dispatch,
365368
});
366369
this.backendName = backendName;
367370
this.authStore = authStore;
@@ -1369,7 +1372,7 @@ export class Backend {
13691372
}
13701373
}
13711374

1372-
export function resolveBackend(config: CmsConfig) {
1375+
export function resolveBackend(config: CmsConfig, dispatch?: any) {
13731376
if (!config.backend.name) {
13741377
throw new Error('No backend defined in configuration');
13751378
}
@@ -1381,18 +1384,18 @@ export function resolveBackend(config: CmsConfig) {
13811384
if (!backend) {
13821385
throw new Error(`Backend not found: ${name}`);
13831386
} else {
1384-
return new Backend(backend, { backendName: name, authStore, config });
1387+
return new Backend(backend, { backendName: name, authStore, config, dispatch });
13851388
}
13861389
}
13871390

13881391
export const currentBackend = (function () {
13891392
let backend: Backend;
13901393

1391-
return (config: CmsConfig) => {
1394+
return (config: CmsConfig, dispatch?: any) => {
13921395
if (backend) {
13931396
return backend;
13941397
}
13951398

1396-
return (backend = resolveBackend(config));
1399+
return (backend = resolveBackend(config, dispatch));
13971400
};
13981401
})();

packages/decap-cms-core/src/components/App/App.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import Workflow from '../Workflow/Workflow';
2121
import Editor from '../Editor/Editor';
2222
import NotFoundPage from './NotFoundPage';
2323
import Header from './Header';
24+
import StatusBar from './StatusBar';
2425

2526
TopBarProgress.config({
2627
barColors: {
@@ -253,6 +254,7 @@ class App extends React.Component {
253254
</Switch>
254255
{useMediaLibrary ? <MediaLibrary /> : null}
255256
</AppMainContainer>
257+
<StatusBar />
256258
</>
257259
);
258260
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import React from 'react';
2+
import styled from '@emotion/styled';
3+
import { translate } from 'react-polyglot';
4+
import { connect } from 'react-redux';
5+
6+
const StatusBarContainer = styled.footer`
7+
width: 100%;
8+
border-top: 1px solid darkgray;
9+
padding: 12px 24px;
10+
font-size: 12px;
11+
display: flex;
12+
gap: 16px;
13+
`;
14+
15+
import type { State } from '../../types/redux';
16+
17+
interface StatusBarProps {
18+
rateLimitInfo?: {
19+
used: number;
20+
limit: number;
21+
remaining: number;
22+
reset: number;
23+
resource: string;
24+
};
25+
appVersion?: string;
26+
backendName?: string;
27+
t: (key: string) => string,
28+
}
29+
30+
function formatResetTime(resetTimestamp: number): string {
31+
const date = new Date(resetTimestamp * 1000);
32+
return date.toLocaleTimeString('en-US', { hour12: false });
33+
}
34+
35+
function formatPercentage(used: number, limit: number): string {
36+
const percentage = limit > 0 ? Math.round((used / limit) * 10) / 10 : 0;
37+
return percentage.toString().replace('.', ',');
38+
}
39+
40+
function StatusBar({ rateLimitInfo, appVersion, backendName, t}: StatusBarProps) {
41+
return (
42+
<StatusBarContainer>
43+
{appVersion && (
44+
<span>Decap CMS {appVersion}</span>
45+
)}
46+
47+
{backendName && (
48+
<span>{backendName} {t('app.statusBar.backend')}</span>
49+
)}
50+
51+
{rateLimitInfo && (
52+
<span>
53+
{rateLimitInfo.used} / {rateLimitInfo.limit} ({formatPercentage(rateLimitInfo.used, rateLimitInfo.limit)}%) {t('app.statusBar.requestsUsed')}, {t('app.statusBar.resetAt')} {formatResetTime(rateLimitInfo.reset)}
54+
</span>
55+
)}
56+
</StatusBarContainer>
57+
);
58+
}
59+
60+
function mapStateToProps(state: State) {
61+
return {
62+
rateLimitInfo: state.status?.rateLimitInfo,
63+
backendName: state.config?.backend?.name,
64+
};
65+
}
66+
67+
export default connect(mapStateToProps)(translate()(StatusBar));

0 commit comments

Comments
 (0)