Skip to content

Commit 124860d

Browse files
committed
Implement and show upptime status in footer
Fetching data from the readme of the repo hehe - couldn't find a better api
1 parent a1e2b2b commit 124860d

File tree

7 files changed

+189
-3
lines changed

7 files changed

+189
-3
lines changed

app/actions/ActionTypes.ts

+7
Original file line numberDiff line numberDiff line change
@@ -411,3 +411,10 @@ export const Thread = {
411411
DELETE: generateStatuses('Thread.DELETE') as AAT,
412412
UPDATE: generateStatuses('Thread.UPDATE') as AAT,
413413
};
414+
415+
/**
416+
* Actions for fetching system status from Upptime
417+
*/
418+
export const SystemStatus = {
419+
FETCH: generateStatuses('SystemStatus.FETCH') as AAT,
420+
};

app/actions/StatusActions.ts

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { createAsyncThunk } from '@reduxjs/toolkit';
2+
3+
export type SystemStatus = {
4+
status: 'operational' | 'degraded' | 'major';
5+
message: string;
6+
};
7+
8+
type UptimeService = {
9+
name: string;
10+
status: 'up' | 'down' | 'degraded';
11+
uptime: string;
12+
};
13+
14+
export const fetchSystemStatus = createAsyncThunk(
15+
'status/fetch',
16+
async (): Promise<SystemStatus> => {
17+
const response = await fetch(
18+
'https://raw.githubusercontent.com/webkom/uptime/master/history/summary.json',
19+
);
20+
21+
if (!response.ok) {
22+
throw new Error('Failed to fetch system status');
23+
}
24+
25+
const data = (await response.json()) as UptimeService[];
26+
27+
const servicesDown = data.filter(
28+
(service) => service.status === 'down',
29+
).length;
30+
const servicesDegraded = data.filter(
31+
(service) => service.status === 'degraded',
32+
).length;
33+
34+
let status: SystemStatus['status'];
35+
let message: string;
36+
37+
if (servicesDown > 0) {
38+
status = 'major';
39+
message = `${servicesDown} ${servicesDown === 1 ? 'tjeneste er' : 'tjenester er'} nede`;
40+
} else if (servicesDegraded > 0) {
41+
status = 'degraded';
42+
message = `${servicesDegraded} ${servicesDegraded === 1 ? 'tjeneste har' : 'tjenester har'} redusert ytelse`;
43+
} else {
44+
status = 'operational';
45+
message = `Alle tjenester opererer normalt`;
46+
}
47+
48+
return { status, message };
49+
},
50+
);

app/components/Footer/Footer.module.css

+41-2
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,51 @@
2626
}
2727
}
2828

29+
.statusDot {
30+
position: relative;
31+
display: inline-flex;
32+
height: var(--spacing-sm);
33+
width: var(--spacing-sm);
34+
}
35+
36+
.statusDotCore {
37+
position: relative;
38+
display: inline-flex;
39+
height: 100%;
40+
width: 100%;
41+
border-radius: 50%;
42+
}
43+
44+
.statusDotPing {
45+
position: absolute;
46+
display: inline-flex;
47+
height: 100%;
48+
width: 100%;
49+
border-radius: 50%;
50+
opacity: 0.6;
51+
animation: ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;
52+
}
53+
54+
@keyframes ping {
55+
75%,
56+
100% {
57+
transform: scale(2);
58+
opacity: 0;
59+
}
60+
}
61+
62+
.statusLink {
63+
margin-top: var(--spacing-sm);
64+
margin-left: calc(-1 * var(--spacing-sm));
65+
font-weight: 400;
66+
}
67+
2968
/* stylelint-disable no-descending-specificity */
3069
.footerContent a {
3170
color: var(--color-red-8);
3271
margin-bottom: var(--spacing-sm);
3372

34-
&:hover {
73+
&:hover:not(.statusLink) {
3574
color: var(--color-red-7);
3675
}
3776
}
@@ -43,7 +82,7 @@
4382
html[data-theme='dark'] .footerContent a {
4483
color: var(--color-red-2);
4584

46-
&:hover {
85+
&:hover:not(.statusLink) {
4786
color: var(--color-red-3);
4887
}
4988
}

app/components/Footer/index.tsx

+52-1
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,42 @@
1-
import { Flex, Icon, Image } from '@webkom/lego-bricks';
1+
import { Flex, Icon, Image, LinkButton } from '@webkom/lego-bricks';
2+
import { usePreparedEffect } from '@webkom/react-prepare';
23
import cx from 'classnames';
34
import { Facebook, Instagram, Linkedin, Slack } from 'lucide-react';
45
import moment from 'moment-timezone';
56
import { Link } from 'react-router-dom';
7+
import { fetchSystemStatus } from 'app/actions/StatusActions';
68
import netcompany from 'app/assets/netcompany_white.svg';
79
import octocat from 'app/assets/octocat.png';
810
import { useIsLoggedIn } from 'app/reducers/auth';
11+
import { useAppDispatch, useAppSelector } from 'app/store/hooks';
912
import utilityStyles from 'app/styles/utilities.css';
1013
import Circle from '../Circle';
1114
import styles from './Footer.module.css';
1215

1316
const Footer = () => {
17+
const dispatch = useAppDispatch();
18+
const systemStatus = useAppSelector((state) => state.status.systemStatus);
1419
const loggedIn = useIsLoggedIn();
20+
21+
usePreparedEffect(
22+
'fetchSystemStatus',
23+
() => dispatch(fetchSystemStatus()),
24+
[],
25+
);
26+
27+
const getStatusColor = (status?: string) => {
28+
switch (status) {
29+
case 'operational':
30+
return 'var(--success-color)';
31+
case 'degraded':
32+
return 'var(--color-orange-6)';
33+
case 'major':
34+
return 'var(--danger-color)';
35+
default:
36+
return 'var(--color-gray-6)';
37+
}
38+
};
39+
1540
return (
1641
<footer className={styles.footer}>
1742
<div className={styles.footerContent}>
@@ -54,6 +79,32 @@ const Footer = () => {
5479
Backend
5580
</a>
5681
</Flex>
82+
{systemStatus?.status && systemStatus?.message && (
83+
<LinkButton
84+
flat
85+
size="small"
86+
href="https://status.abakus.no"
87+
rel="noopener noreferrer"
88+
target="_blank"
89+
className={styles.statusLink}
90+
>
91+
<span className={styles.statusDot}>
92+
<span
93+
className={styles.statusDotPing}
94+
style={{
95+
backgroundColor: getStatusColor(systemStatus.status),
96+
}}
97+
/>
98+
<span
99+
className={styles.statusDotCore}
100+
style={{
101+
backgroundColor: getStatusColor(systemStatus.status),
102+
}}
103+
/>
104+
</span>
105+
{systemStatus.message}
106+
</LinkButton>
107+
)}
57108
</div>
58109

59110
<div className={cx(styles.section, styles.rightSection)}>

app/reducers/status.ts

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { createSlice } from '@reduxjs/toolkit';
2+
import { SystemStatus as SystemStatusActions } from 'app/actions/ActionTypes';
3+
import { fetchSystemStatus } from 'app/actions/StatusActions';
4+
import { EntityType } from 'app/store/models/entities';
5+
import createLegoAdapter from 'app/utils/legoAdapter/createLegoAdapter';
6+
import type { SystemStatus } from 'app/actions/StatusActions';
7+
import type { RootState } from 'app/store/createRootReducer';
8+
9+
type ExtraStatusState = {
10+
systemStatus: SystemStatus | null;
11+
};
12+
13+
const legoAdapter = createLegoAdapter(EntityType.SystemStatus);
14+
15+
const statusSlice = createSlice({
16+
name: EntityType.SystemStatus,
17+
initialState: legoAdapter.getInitialState({
18+
systemStatus: null,
19+
} as ExtraStatusState),
20+
reducers: {},
21+
extraReducers: legoAdapter.buildReducers({
22+
fetchActions: [SystemStatusActions.FETCH],
23+
extraCases: (addCase) => {
24+
addCase(fetchSystemStatus.fulfilled, (state, action) => {
25+
state.systemStatus = action.payload;
26+
});
27+
},
28+
}),
29+
});
30+
31+
export default statusSlice.reducer;
32+
33+
export const selectSystemStatus = (state: RootState) =>
34+
state.status.systemStatus;

app/store/createRootReducer.ts

+2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import registrations from 'app/reducers/registrations';
3838
import restrictedMails from 'app/reducers/restrictedMails';
3939
import routing from 'app/reducers/routing';
4040
import search from 'app/reducers/search';
41+
import status from 'app/reducers/status';
4142
import surveySubmissions from 'app/reducers/surveySubmissions';
4243
import surveys from 'app/reducers/surveys';
4344
import tags from 'app/reducers/tags';
@@ -94,6 +95,7 @@ const createRootReducer = () => {
9495
threads,
9596
toasts,
9697
users,
98+
status,
9799
});
98100
};
99101

app/store/models/entities.ts

+3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type OAuth2Grant from './OAuth2Grant';
22
import type { EntityId } from '@reduxjs/toolkit';
3+
import type { SystemStatus } from 'app/actions/StatusActions';
34
import type { UnknownAnnouncement } from 'app/store/models/Announcement';
45
import type { UnknownArticle } from 'app/store/models/Article';
56
import type Comment from 'app/store/models/Comment';
@@ -74,6 +75,7 @@ export enum EntityType {
7475
Tags = 'tags',
7576
Thread = 'threads',
7677
Users = 'users',
78+
SystemStatus = 'systemStatus',
7779
}
7880

7981
// Most fetch success redux actions are normalized such that payload.entities is a subset of this interface.
@@ -115,6 +117,7 @@ export default interface Entities {
115117
[EntityType.Tags]: Record<EntityId, UnknownTag>;
116118
[EntityType.Thread]: Record<EntityId, UnknownThread>;
117119
[EntityType.Users]: Record<EntityId, UnknownUser>;
120+
[EntityType.SystemStatus]: Record<EntityId, SystemStatus>;
118121
}
119122

120123
type InferEntityType<T> = {

0 commit comments

Comments
 (0)