diff --git a/priv/frontend/package-lock.json b/priv/frontend/package-lock.json index f4073ed..6c332a4 100644 --- a/priv/frontend/package-lock.json +++ b/priv/frontend/package-lock.json @@ -1902,9 +1902,9 @@ } }, "classnames": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz", - "integrity": "sha1-+zgB1FNGdknvNgPH1hoCvRKb3m0=" + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", + "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" }, "clean-css": { "version": "4.1.11", @@ -9157,6 +9157,11 @@ "reduce-css-calc": "1.3.0" }, "dependencies": { + "classnames": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz", + "integrity": "sha1-+zgB1FNGdknvNgPH1hoCvRKb3m0=" + }, "core-js": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.1.tgz", diff --git a/priv/frontend/package.json b/priv/frontend/package.json index c40c68a..894df5f 100644 --- a/priv/frontend/package.json +++ b/priv/frontend/package.json @@ -7,6 +7,7 @@ "@fortawesome/fontawesome-svg-core": "^1.2.0-14", "@fortawesome/free-solid-svg-icons": "^5.1.0-11", "@fortawesome/react-fontawesome": "0.1.0-11", + "classnames": "^2.2.6", "ms": "^2.1.1", "nanoevents": "^1.0.5", "prop-types": "^15.6.2", diff --git a/priv/frontend/src/components/App.js b/priv/frontend/src/components/App.js index 229085f..f4b163f 100644 --- a/priv/frontend/src/components/App.js +++ b/priv/frontend/src/components/App.js @@ -5,7 +5,8 @@ import Service from './Service.js' import ServicePlaceholder from './ServicePlaceholder.js' import DegradedNotice from './DegradedNotice.js' import StreamingClient from '../ws/client' -import { log, objectFromEntries } from '../util.js' +import Status from './Status' +import { log, objectFromEntries, strictFetch } from '../util.js' import { domain as DOMAIN } from '../config.json' export default class App extends Component { @@ -15,10 +16,17 @@ export default class App extends Component { loading: true, error: null, metrics: null, + incident: null, } async componentDidMount () { await this.loadMetrics() + await this.loadIncident() + + this.setState({ loading: false }) + if (this.state.error) { + return + } const endpoint = `${DOMAIN}/api/streaming` .replace('https', 'wss') @@ -28,6 +36,16 @@ export default class App extends Component { this.client.on('status', this.handleStatus.bind(this)) this.client.on('latency', this.handleLatency.bind(this)) + + this.client.on('incident_new', (incident) => { + this.setState({ incident }) + }) + this.client.on('incident_update', (incident) => { + this.setState({ incident }) + }) + this.client.on('incident_close', () => { + this.setState({ incident: null }) + }) } handleStatus (name, [, status]) { @@ -78,23 +96,24 @@ export default class App extends Component { log('loading metrics') try { - var resp = await fetch(`${DOMAIN}/api/status`) - } catch (err) { - this.setState({ - error: `Network error: ${err}`, - }) + var resp = await strictFetch(`${DOMAIN}/api/status`) + } catch (error) { + this.setState({ error }) } - if (!resp.ok) { - this.setState({ - error: `Failed to fetch stats (${resp.status} ${resp.statusText})`, - }) - return + this.setState({ metrics: await resp.json() }) + } + + async loadIncident () { + log('loading current incident') + + try { + var resp = await strictFetch(`${DOMAIN}/api/incidents/current`) + } catch (error) { + this.setState({ error }) } - const json = await resp.json() - - this.setState({ metrics: json, loading: false }) + this.setState({ incident: await resp.json() }) } render () { @@ -109,9 +128,12 @@ export default class App extends Component { )) const down = allServices.filter(([, { status }]) => !status) - const notice = down.length > 0 - ? - : null + let notice = + if (!this.state.incident && down.length > 0) { + notice = down.length > 0 + ? + : null + } metrics = (
diff --git a/priv/frontend/src/components/DegradedNotice.css b/priv/frontend/src/components/DegradedNotice.css index b9268cd..449f06d 100644 --- a/priv/frontend/src/components/DegradedNotice.css +++ b/priv/frontend/src/components/DegradedNotice.css @@ -3,7 +3,7 @@ padding: 1rem; margin: 1rem 0; border: solid 1px hsla(0, 100%, 80%, 1); - background: hsla(0, 100%, 90%, 1); + background: hsla(0, 100%, 95%, 1); } .degraded-notice header { diff --git a/priv/frontend/src/components/Incident.css b/priv/frontend/src/components/Incident.css new file mode 100644 index 0000000..82f463b --- /dev/null +++ b/priv/frontend/src/components/Incident.css @@ -0,0 +1,15 @@ +.incident h2 { + margin-top: 0; + margin-bottom: 1rem; +} + +.incident p { + margin: 0; + hyphens: auto; +} + +.incident footer { + margin-top: 1rem; + font-size: 0.75em; + color: rgba(0, 0, 0, 0.5); +} diff --git a/priv/frontend/src/components/Incident.js b/priv/frontend/src/components/Incident.js new file mode 100644 index 0000000..3d58d4c --- /dev/null +++ b/priv/frontend/src/components/Incident.js @@ -0,0 +1,55 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import './Incident.css' +import Stage from './Stage' +import ms from 'ms' + +const OUTAGE_TYPES = { + major_outage: 'Major Outage', + outage: 'Outage', + partial_outage: 'Partial Outage', + degraded_service: 'Degraded Service', +} + +const Incident = ({ incident }) => { + const { content, end_date: end, start_date: start, title, type, stages } = incident + const ago = ms(Date.now() - start * 1000) + const agoEnd = end ? ms(Date.now - end * 1000) : null + + const stageNodes = stages.map( + (stage) => + ) + + return ( +
+

{OUTAGE_TYPES[type] || type}: {title}

+

{content}

+
+ Started {ago} ago + {end ? `, ended ${agoEnd} ago` : null} +
+ {stageNodes.length + ? ( +
+ {stageNodes} +
+ ) + : null + } +
+ ) +} + +Incident.propTypes = { + incident: PropTypes.shape({ + content: PropTypes.string, + end_date: PropTypes.number, + start_date: PropTypes.number, + title: PropTypes.string, + type: PropTypes.string, + stages: PropTypes.array, + }), +} + +export default Incident diff --git a/priv/frontend/src/components/Stage.js b/priv/frontend/src/components/Stage.js new file mode 100644 index 0000000..cc8ae67 --- /dev/null +++ b/priv/frontend/src/components/Stage.js @@ -0,0 +1,27 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import ms from 'ms' + +const Stage = ({ stage }) => { + const { created_at: createdAt, title, content } = stage + const ago = ms(Date.now() - createdAt * 1000) + + return ( +
+

{title}

+

{content}

+
{ago} ago
+
+ ) +} + +Stage.propTypes = { + stage: PropTypes.shape({ + created_at: PropTypes.number, + title: PropTypes.string, + content: PropTypes.string, + }) +} + +export default Stage diff --git a/priv/frontend/src/components/Status.css b/priv/frontend/src/components/Status.css new file mode 100644 index 0000000..c7d7d42 --- /dev/null +++ b/priv/frontend/src/components/Status.css @@ -0,0 +1,17 @@ +.status { + margin: 1rem 0; + border: solid 1px var(--border); + background-color: var(--bg); + padding: 1em; + border-radius: 0.25rem; +} + +.status-bad { + --border: hsla(0, 100%, 80%, 1); + --bg: hsla(0, 100%, 95%, 1); +} + +.status-good { + --border: hsla(120, 100%, 80%, 1); + --bg: hsla(120, 100%, 95%, 1); +} diff --git a/priv/frontend/src/components/Status.js b/priv/frontend/src/components/Status.js new file mode 100644 index 0000000..fada855 --- /dev/null +++ b/priv/frontend/src/components/Status.js @@ -0,0 +1,31 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +import './Status.css' +import Incident from './Incident' + +const Status = ({ incident }) => { + let view = null + if (incident) { + view = + } else { + view = 'All systems operational' + } + + const className = classnames( + 'status', + incident == null ? 'status-good' : 'status-bad', + ) + return ( +
+ {view} +
+ ) +} + +Status.propTypes = { + incident: PropTypes.object, +} + +export default Status diff --git a/priv/frontend/src/util.js b/priv/frontend/src/util.js index ba96c3e..af6e6f6 100644 --- a/priv/frontend/src/util.js +++ b/priv/frontend/src/util.js @@ -14,3 +14,13 @@ export function objectFromEntries (entries) { {} ) } + +export async function strictFetch (...args) { + const resp = await fetch(...args) + + if (!resp.ok) { + throw new Error(`Failed to fetch: ${resp.status} ${resp.statusText}`) + } + + return resp +}