app comp: show current incident and live update
This commit is contained in:
parent
528c9e4368
commit
5f97f73631
|
@ -1902,9 +1902,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"classnames": {
|
"classnames": {
|
||||||
"version": "2.2.5",
|
"version": "2.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
|
||||||
"integrity": "sha1-+zgB1FNGdknvNgPH1hoCvRKb3m0="
|
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
|
||||||
},
|
},
|
||||||
"clean-css": {
|
"clean-css": {
|
||||||
"version": "4.1.11",
|
"version": "4.1.11",
|
||||||
|
@ -9157,6 +9157,11 @@
|
||||||
"reduce-css-calc": "1.3.0"
|
"reduce-css-calc": "1.3.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"classnames": {
|
||||||
|
"version": "2.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz",
|
||||||
|
"integrity": "sha1-+zgB1FNGdknvNgPH1hoCvRKb3m0="
|
||||||
|
},
|
||||||
"core-js": {
|
"core-js": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.1.tgz",
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
"@fortawesome/fontawesome-svg-core": "^1.2.0-14",
|
"@fortawesome/fontawesome-svg-core": "^1.2.0-14",
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.1.0-11",
|
"@fortawesome/free-solid-svg-icons": "^5.1.0-11",
|
||||||
"@fortawesome/react-fontawesome": "0.1.0-11",
|
"@fortawesome/react-fontawesome": "0.1.0-11",
|
||||||
|
"classnames": "^2.2.6",
|
||||||
"ms": "^2.1.1",
|
"ms": "^2.1.1",
|
||||||
"nanoevents": "^1.0.5",
|
"nanoevents": "^1.0.5",
|
||||||
"prop-types": "^15.6.2",
|
"prop-types": "^15.6.2",
|
||||||
|
|
|
@ -5,7 +5,8 @@ import Service from './Service.js'
|
||||||
import ServicePlaceholder from './ServicePlaceholder.js'
|
import ServicePlaceholder from './ServicePlaceholder.js'
|
||||||
import DegradedNotice from './DegradedNotice.js'
|
import DegradedNotice from './DegradedNotice.js'
|
||||||
import StreamingClient from '../ws/client'
|
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'
|
import { domain as DOMAIN } from '../config.json'
|
||||||
|
|
||||||
export default class App extends Component {
|
export default class App extends Component {
|
||||||
|
@ -15,10 +16,17 @@ export default class App extends Component {
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
metrics: null,
|
metrics: null,
|
||||||
|
incident: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
async componentDidMount () {
|
async componentDidMount () {
|
||||||
await this.loadMetrics()
|
await this.loadMetrics()
|
||||||
|
await this.loadIncident()
|
||||||
|
|
||||||
|
this.setState({ loading: false })
|
||||||
|
if (this.state.error) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const endpoint = `${DOMAIN}/api/streaming`
|
const endpoint = `${DOMAIN}/api/streaming`
|
||||||
.replace('https', 'wss')
|
.replace('https', 'wss')
|
||||||
|
@ -28,6 +36,16 @@ export default class App extends Component {
|
||||||
|
|
||||||
this.client.on('status', this.handleStatus.bind(this))
|
this.client.on('status', this.handleStatus.bind(this))
|
||||||
this.client.on('latency', this.handleLatency.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]) {
|
handleStatus (name, [, status]) {
|
||||||
|
@ -78,23 +96,24 @@ export default class App extends Component {
|
||||||
log('loading metrics')
|
log('loading metrics')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var resp = await fetch(`${DOMAIN}/api/status`)
|
var resp = await strictFetch(`${DOMAIN}/api/status`)
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
this.setState({
|
this.setState({ error })
|
||||||
error: `Network error: ${err}`,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resp.ok) {
|
this.setState({ metrics: await resp.json() })
|
||||||
this.setState({
|
}
|
||||||
error: `Failed to fetch stats (${resp.status} ${resp.statusText})`,
|
|
||||||
})
|
async loadIncident () {
|
||||||
return
|
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({ incident: await resp.json() })
|
||||||
|
|
||||||
this.setState({ metrics: json, loading: false })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
@ -109,9 +128,12 @@ export default class App extends Component {
|
||||||
))
|
))
|
||||||
|
|
||||||
const down = allServices.filter(([, { status }]) => !status)
|
const down = allServices.filter(([, { status }]) => !status)
|
||||||
const notice = down.length > 0
|
let notice = <Status incident={this.state.incident}/>
|
||||||
? <DegradedNotice services={objectFromEntries(down)}/>
|
if (!this.state.incident && down.length > 0) {
|
||||||
: null
|
notice = down.length > 0
|
||||||
|
? <DegradedNotice services={objectFromEntries(down)}/>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
metrics = (
|
metrics = (
|
||||||
<div className="services">
|
<div className="services">
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
border: solid 1px hsla(0, 100%, 80%, 1);
|
border: solid 1px hsla(0, 100%, 80%, 1);
|
||||||
background: hsla(0, 100%, 90%, 1);
|
background: hsla(0, 100%, 95%, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.degraded-notice header {
|
.degraded-notice header {
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -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
|
|
@ -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
|
|
@ -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);
|
||||||
|
}
|
|
@ -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
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue