app comp: show current incident and live update
This commit is contained in:
parent
528c9e4368
commit
5f97f73631
10 changed files with 204 additions and 21 deletions
11
priv/frontend/package-lock.json
generated
11
priv/frontend/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 {
|
||||
|
|
15
priv/frontend/src/components/Incident.css
Normal file
15
priv/frontend/src/components/Incident.css
Normal 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);
|
||||
}
|
55
priv/frontend/src/components/Incident.js
Normal file
55
priv/frontend/src/components/Incident.js
Normal 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
|
27
priv/frontend/src/components/Stage.js
Normal file
27
priv/frontend/src/components/Stage.js
Normal 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
|
17
priv/frontend/src/components/Status.css
Normal file
17
priv/frontend/src/components/Status.css
Normal 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);
|
||||
}
|
31
priv/frontend/src/components/Status.js
Normal file
31
priv/frontend/src/components/Status.js
Normal 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
|
|
@ -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 a new issue