app comp: show current incident and live update

This commit is contained in:
slice 2018-07-18 14:59:21 -07:00
parent 528c9e4368
commit 5f97f73631
No known key found for this signature in database
GPG Key ID: 1508C19D7436A26D
10 changed files with 204 additions and 21 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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
? <DegradedNotice services={objectFromEntries(down)}/>
: null
let notice = <Status incident={this.state.incident}/>
if (!this.state.incident && down.length > 0) {
notice = down.length > 0
? <DegradedNotice services={objectFromEntries(down)}/>
: null
}
metrics = (
<div className="services">

View File

@ -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 {

View File

@ -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);
}

View File

@ -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) => <Stage key={stage.title} stage={stage}/>
)
return (
<div className="incident">
<h2>{OUTAGE_TYPES[type] || type}: {title}</h2>
<p>{content}</p>
<footer>
Started {ago} ago
{end ? `, ended ${agoEnd} ago` : null}
</footer>
{stageNodes.length
? (
<div className="stages">
{stageNodes}
</div>
)
: null
}
</div>
)
}
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

View File

@ -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 (
<div className="stage">
<h3>{title}</h3>
<p>{content}</p>
<footer>{ago} ago</footer>
</div>
)
}
Stage.propTypes = {
stage: PropTypes.shape({
created_at: PropTypes.number,
title: PropTypes.string,
content: PropTypes.string,
})
}
export default Stage

View File

@ -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);
}

View File

@ -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 = <Incident incident={incident}/>
} else {
view = 'All systems operational'
}
const className = classnames(
'status',
incident == null ? 'status-good' : 'status-bad',
)
return (
<div className={className}>
{view}
</div>
)
}
Status.propTypes = {
incident: PropTypes.object,
}
export default Status

View File

@ -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
}