Merge branch 'master' of gitlab.com:elixire/elstat

This commit is contained in:
Luna Mendes 2018-08-30 22:54:37 -03:00
commit 673e5bb0fc
19 changed files with 174 additions and 152 deletions

View File

@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"arrowParens": "always",
"proseWrap": "always"
}

View File

@ -5,11 +5,11 @@ import { Router } from '@reach/router'
import Dashboard from './Dashboard' import Dashboard from './Dashboard'
import Incidents from './Incidents' import Incidents from './Incidents'
export default function App () { export default function App() {
return ( return (
<Router> <Router>
<Dashboard path="/"/> <Dashboard path="/" />
<Incidents path="/incidents/"/> <Incidents path="/incidents/" />
</Router> </Router>
) )
} }

View File

@ -19,7 +19,7 @@ export default class Dashboard extends Component {
incident: null, incident: null,
} }
async componentDidMount () { async componentDidMount() {
try { try {
await this.loadMetrics() await this.loadMetrics()
await this.loadIncident() await this.loadIncident()
@ -53,7 +53,7 @@ export default class Dashboard extends Component {
}) })
} }
handleStatus (name, [, status]) { handleStatus(name, [, status]) {
const { status: statuses } = this.state.metrics const { status: statuses } = this.state.metrics
log('updating status on:', name) log('updating status on:', name)
@ -75,7 +75,7 @@ export default class Dashboard extends Component {
}) })
} }
handleLatency (name, data) { handleLatency(name, data) {
const { metrics } = this.state const { metrics } = this.state
log('adding latency entry:', data) log('adding latency entry:', data)
@ -97,50 +97,56 @@ export default class Dashboard extends Component {
}) })
} }
async loadMetrics () { async loadMetrics() {
log('loading metrics') log('loading metrics')
const resp = await strictFetch(`${DOMAIN}/api/status`) const resp = await strictFetch(`${DOMAIN}/api/status`)
this.setState({ metrics: await resp.json() }) this.setState({ metrics: await resp.json() })
} }
async loadIncident () { async loadIncident() {
log('loading current incident') log('loading current incident')
const resp = await strictFetch(`${DOMAIN}/api/incidents/current`) const resp = await strictFetch(`${DOMAIN}/api/incidents/current`)
this.setState({ incident: await resp.json() }) this.setState({ incident: await resp.json() })
} }
renderNotice (services) { renderNotice(services) {
const down = services.filter(([, { status }]) => !status) const down = services.filter(([, { status }]) => !status)
// DegradedNotice should only be shown when there is no ongoing incident, // DegradedNotice should only be shown when there is no ongoing incident,
// and any services are reported as down. // and any services are reported as down.
if (!this.state.incident && down.length > 0) { if (!this.state.incident && down.length > 0) {
return <DegradedNotice services={objectFromEntries(down)}/> return <DegradedNotice services={objectFromEntries(down)} />
} }
return <Status incident={this.state.incident}/> return <Status incident={this.state.incident} />
} }
renderServices (services) { renderServices(services) {
const { graph: graphs } = this.state.metrics const { graph: graphs, uptime: uptimes } = this.state.metrics
return services.map(([name, info]) => ( return services.map(([name, info]) => (
<Service name={name} key={name} graph={graphs[name]} {...info}/> <Service
name={name}
key={name}
graph={graphs[name]}
uptime={parseFloat(uptimes[name])}
{...info}
/>
)) ))
} }
renderPlaceholders () { renderPlaceholders() {
return ( return (
<React.Fragment> <React.Fragment>
<ServicePlaceholder/> <ServicePlaceholder />
<ServicePlaceholder/> <ServicePlaceholder />
<ServicePlaceholder/> <ServicePlaceholder />
</React.Fragment> </React.Fragment>
) )
} }
renderDashboardContent () { renderDashboardContent() {
if (this.state.error) { if (this.state.error) {
return <div className="error">{this.state.error}</div> return <div className="error">{this.state.error}</div>
} }
@ -162,7 +168,7 @@ export default class Dashboard extends Component {
) )
} }
render () { render() {
return ( return (
<Page> <Page>
<h1>elstatus</h1> <h1>elstatus</h1>

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types'
import './DegradedNotice.css' import './DegradedNotice.css'
export default function DegradedNotice ({ services }) { export default function DegradedNotice({ services }) {
const keys = Object.keys(services) const keys = Object.keys(services)
const serviceNames = keys.join(', ') const serviceNames = keys.join(', ')
@ -12,7 +12,10 @@ export default function DegradedNotice ({ services }) {
return ( return (
<div className="degraded-notice"> <div className="degraded-notice">
<header>{keys.length} service{plural} {indicative} unreachable</header> <header>
{keys.length} service
{plural} {indicative} unreachable
</header>
<p> <p>
elstat is having trouble contacting <strong>{serviceNames}</strong>. elstat is having trouble contacting <strong>{serviceNames}</strong>.
</p> </p>

View File

@ -24,11 +24,11 @@ export default class Graph extends Component {
screenWidth: window.innerWidth, screenWidth: window.innerWidth,
} }
componentDidMount () { componentDidMount() {
window.addEventListener('resize', this.handleScreenChange) window.addEventListener('resize', this.handleScreenChange)
} }
componentWillUnmount () { componentWillUnmount() {
window.removeEventListener('resize', this.handleScreenChange) window.removeEventListener('resize', this.handleScreenChange)
} }
@ -38,7 +38,7 @@ export default class Graph extends Component {
}) })
} }
processData () { processData() {
const { data } = this.props const { data } = this.props
const objects = data.map(([timestamp, latency]) => ({ const objects = data.map(([timestamp, latency]) => ({
@ -50,27 +50,23 @@ export default class Graph extends Component {
return objects.sort(({ timestamp: a }, { timestamp: b }) => a - b) return objects.sort(({ timestamp: a }, { timestamp: b }) => a - b)
} }
isSmallScreen () { isSmallScreen() {
return this.state.screenWidth < 500 return this.state.screenWidth < 500
} }
render () { render() {
const yAxis = this.isSmallScreen() const yAxis = this.isSmallScreen() ? null : (
? null <YAxis
: ( dataKey="latency"
<YAxis tickLine={false}
dataKey="latency" tickFormatter={(tick) => `${tick}ms`}
tickLine={false} />
tickFormatter={(tick) => `${tick}ms`} )
/>
)
return ( return (
<div className="graph-container"> <div className="graph-container">
<ResponsiveContainer width="100%" height={175}> <ResponsiveContainer width="100%" height={175}>
<AreaChart <AreaChart data={this.processData()}>
data={this.processData()}
>
<XAxis <XAxis
dataKey="timestamp" dataKey="timestamp"
tickFormatter={(tick) => ms(Date.now() - tick)} tickFormatter={(tick) => ms(Date.now() - tick)}
@ -78,7 +74,7 @@ export default class Graph extends Component {
/> />
{yAxis} {yAxis}
<CartesianGrid strokeDasharray="1 1"/> <CartesianGrid strokeDasharray="1 1" />
<Tooltip <Tooltip
isAnimationActive={false} isAnimationActive={false}
formatter={(value) => `${value}ms`} formatter={(value) => `${value}ms`}
@ -86,11 +82,7 @@ export default class Graph extends Component {
separator=": " separator=": "
labelFormatter={() => null} labelFormatter={() => null}
/> />
<ReferenceLine <ReferenceLine y={1000} label="1s" stroke="pink" />
y={1000}
label="1s"
stroke="pink"
/>
<Area <Area
type="monotone" type="monotone"
dataKey="latency" dataKey="latency"

View File

@ -12,36 +12,39 @@ const OUTAGE_TYPES = {
degraded_service: 'Degraded Service', degraded_service: 'Degraded Service',
} }
export default function Incident ({ incident }) { export default function Incident({ incident }) {
const { content, end_date: end, start_date: start, title, type, stages } = incident const {
content,
end_date: end,
start_date: start,
title,
type,
stages,
} = incident
const startDate = new Date(start * 1000) const startDate = new Date(start * 1000)
const endDate = end ? new Date(end * 1000) : null const endDate = end ? new Date(end * 1000) : null
const tooltip = `Started ${startDate.toLocaleString()}` + const tooltip =
`Started ${startDate.toLocaleString()}` +
(endDate ? `, ended ${endDate.toLocaleString()}` : '') (endDate ? `, ended ${endDate.toLocaleString()}` : '')
const ago = ms(Date.now() - start * 1000) const ago = ms(Date.now() - start * 1000)
const agoEnd = end ? ms(Date.now() - end * 1000) : null const agoEnd = end ? ms(Date.now() - end * 1000) : null
const stageNodes = stages.map( const stageNodes = stages
(stage) => <Stage key={stage.title} stage={stage}/> .map((stage) => <Stage key={stage.title} stage={stage} />)
).reverse() // show newest first .reverse() // show newest first
return ( return (
<div className="incident"> <div className="incident">
<h2>{OUTAGE_TYPES[type] || type}: {title}</h2> <h2>
{OUTAGE_TYPES[type] || type}: {title}
</h2>
<p>{content}</p> <p>{content}</p>
<footer title={tooltip}> <footer title={tooltip}>
Started {ago} ago Started {ago} ago
{end ? `, ended ${agoEnd} ago` : null} {end ? `, ended ${agoEnd} ago` : null}
</footer> </footer>
{stageNodes.length {stageNodes.length ? <div className="stages">{stageNodes}</div> : null}
? (
<div className="stages">
{stageNodes}
</div>
)
: null
}
</div> </div>
) )
} }

View File

@ -11,26 +11,26 @@ export default class Incidents extends React.Component {
page: 0, page: 0,
} }
componentDidMount () { componentDidMount() {
this.fetchIncidents() this.fetchIncidents()
} }
async fetchIncidents () { async fetchIncidents() {
const resp = await strictFetch(`${DOMAIN}/api/incidents/${this.state.page}`) const resp = await strictFetch(`${DOMAIN}/api/incidents/${this.state.page}`)
this.setState({ incidents: await resp.json() }) this.setState({ incidents: await resp.json() })
} }
renderIncidents () { renderIncidents() {
if (!this.state.incidents) { if (!this.state.incidents) {
return null return null
} }
return this.state.incidents.map( return this.state.incidents.map((incident) => (
(incident) => <Incident key={incident.id} incident={incident}/> <Incident key={incident.id} incident={incident} />
) ))
} }
render () { render() {
return ( return (
<Page> <Page>
<h1>Incidents</h1> <h1>Incidents</h1>

View File

@ -3,12 +3,8 @@ import PropTypes from 'prop-types'
import './Page.css' import './Page.css'
export default function Page ({ children }) { export default function Page({ children }) {
return ( return <div className="page">{children}</div>
<div className="page">
{children}
</div>
)
} }
Page.propTypes = { Page.propTypes = {

View File

@ -11,13 +11,13 @@
margin: 0; margin: 0;
} }
.service .latency { .service .information {
margin-left: 0.5em; margin-left: 0.5em;
color: hsl(0, 0%, 45%); color: hsl(0, 0%, 45%);
} }
@media (max-width: 500px) { @media (max-width: 500px) {
.service .latency { .service .information {
font-size: 1rem; font-size: 1rem;
} }
} }
@ -32,13 +32,13 @@
} }
.service.service-alive .emoji { .service.service-alive .emoji {
color: #2ECC40; color: #2ecc40;
} }
.service.service-slow .emoji { .service.service-slow .emoji {
color: #FF851B; color: #ff851b;
} }
.service.service-dead .emoji { .service.service-dead .emoji {
color: #FF4136; color: #ff4136;
} }

View File

@ -7,10 +7,9 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import './Service.css' import './Service.css'
import Graph from './Graph.js' import Graph from './Graph.js'
import config from '../config.json' import config from '../config.json'
import { truncateToTwoPlaces } from '../util'
const { const { slow_threshold: SLOW_THRESHOLD = 1500 } = config
slow_threshold: SLOW_THRESHOLD = 1500,
} = config
const icons = { const icons = {
alive: 'check-circle', alive: 'check-circle',
@ -24,7 +23,7 @@ const titles = {
dead: 'This service is unresponsive.', dead: 'This service is unresponsive.',
} }
export function getServiceState (status, latency, threshold = SLOW_THRESHOLD) { export function getServiceState(status, latency, threshold = SLOW_THRESHOLD) {
if (status && latency > threshold) { if (status && latency > threshold) {
return 'slow' return 'slow'
} }
@ -32,35 +31,41 @@ export function getServiceState (status, latency, threshold = SLOW_THRESHOLD) {
return status ? 'alive' : 'dead' return status ? 'alive' : 'dead'
} }
export default function Service ({ graph, name, status, latency, description }) { export default function Service({
graph,
name,
status,
latency,
description,
uptime,
}) {
const state = getServiceState(status, latency) const state = getServiceState(status, latency)
const title = titles[state] const title = titles[state]
const icon = icons[state] const icon = icons[state]
const className = classnames( const className = classnames('service', `service-${state}`)
'service', const uptimePercentage = truncateToTwoPlaces(uptime)
`service-${state}`
)
return ( return (
<div className={className}> <div className={className}>
<header> <header>
<div className="emoji" title={title}> <div className="emoji" title={title}>
<FontAwesomeIcon title={title} icon={icon}/> <FontAwesomeIcon title={title} icon={icon} />
</div> </div>
<h2 className="title"> <h2 className="title">
{name} {latency ? ( {name}{' '}
<span className="latency"> {latency ? (
{Math.round(latency)}ms <span className="information">
{Math.round(latency)}
ms ({uptimePercentage}
%)
</span> </span>
) : null} ) : null}
</h2> </h2>
</header> </header>
<p className="description"> <p className="description">{description}</p>
{description} {graph ? <Graph data={graph} /> : null}
</p>
{graph ? <Graph data={graph}/> : null}
</div> </div>
) )
} }
@ -71,9 +76,7 @@ Service.defaultProps = {
} }
Service.propTypes = { Service.propTypes = {
graph: PropTypes.arrayOf( graph: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)),
PropTypes.arrayOf(PropTypes.number),
),
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
status: PropTypes.bool.isRequired, status: PropTypes.bool.isRequired,
latency: PropTypes.number, latency: PropTypes.number,

View File

@ -5,7 +5,7 @@ import 'react-placeholder/lib/reactPlaceholder.css'
import './ServicePlaceholder.css' import './ServicePlaceholder.css'
export default function ServicePlaceholder () { export default function ServicePlaceholder() {
return ( return (
<div className="service-placeholder"> <div className="service-placeholder">
<div className="title"> <div className="title">

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types'
import ms from 'ms' import ms from 'ms'
export default function Stage ({ stage }) { export default function Stage({ stage }) {
const { created_at: createdAt, title, content } = stage const { created_at: createdAt, title, content } = stage
const ago = ms(Date.now() - createdAt * 1000) const ago = ms(Date.now() - createdAt * 1000)

View File

@ -5,21 +5,19 @@ import classnames from 'classnames'
import './Status.css' import './Status.css'
import Incident from './Incident' import Incident from './Incident'
export default function Status ({ incident }) { export default function Status({ incident }) {
const incidentOngoing = incident && incident.ongoing === 1 const incidentOngoing = incident && incident.ongoing === 1
const view = incidentOngoing const view = incidentOngoing ? (
? <Incident incident={incident}/> <Incident incident={incident} />
: 'All systems operational' ) : (
'All systems operational'
)
const className = classnames( const className = classnames(
'status', 'status',
incidentOngoing ? 'status-bad' : 'status-good', incidentOngoing ? 'status-bad' : 'status-good'
)
return (
<div className={className}>
{view}
</div>
) )
return <div className={className}>{view}</div>
} }
Status.propTypes = { Status.propTypes = {

View File

@ -4,23 +4,34 @@ import { shallow } from 'enzyme'
import DegradedNotice from '../DegradedNotice' import DegradedNotice from '../DegradedNotice'
it('renders without crashing', () => { it('renders without crashing', () => {
shallow(<DegradedNotice services={{ first: null, second: null }}/>) shallow(<DegradedNotice services={{ first: null, second: null }} />)
}) })
it('shows one service', () => { it('shows one service', () => {
const comp = shallow(<DegradedNotice services={{ first: null }}/>) const comp = shallow(<DegradedNotice services={{ first: null }} />)
expect(comp.contains(<header>1 service is unreachable</header>)).toEqual(true) expect(comp.contains(<header>1 service is unreachable</header>)).toEqual(true)
expect(comp.contains( expect(
<p>elstat is having trouble contacting <strong>first</strong>.</p> comp.contains(
)).toEqual(true) <p>
elstat is having trouble contacting <strong>first</strong>.
</p>
)
).toEqual(true)
}) })
it('shows multiple services', () => { it('shows multiple services', () => {
const comp = shallow(<DegradedNotice services={{ first: null, second: null, third: null }}/>) const comp = shallow(
expect(comp.contains( <DegradedNotice services={{ first: null, second: null, third: null }} />
<header>3 services are unreachable</header> )
)).toEqual(true) expect(comp.contains(<header>3 services are unreachable</header>)).toEqual(
expect(comp.contains( true
<p>elstat is having trouble contacting <strong>first, second, third</strong>.</p> )
)).toEqual(true) expect(
comp.contains(
<p>
elstat is having trouble contacting{' '}
<strong>first, second, third</strong>.
</p>
)
).toEqual(true)
}) })

View File

@ -3,34 +3,39 @@ import { shallow } from 'enzyme'
import Service, { getServiceState } from '../Service' import Service, { getServiceState } from '../Service'
const graph = [ const graph = [[1000, 50], [2000, 30], [3000, 60]]
[1000, 50],
[2000, 30],
[3000, 60],
]
const props = { const props = {
name: 'sample service', name: 'sample service',
description: 'a cool service', description: 'a cool service',
latency: 50.5, latency: 50.5,
uptime: 99.9994,
} }
describe('<Service/>', () => { describe('<Service/>', () => {
it('renders without crashing', () => { it('renders without crashing', () => {
shallow(<Service graph={null} status {...props}/>) shallow(<Service graph={null} status {...props} />)
}) })
it('omits information', () => { it('omits information', () => {
const comp = shallow(<Service graph={null} status {...props} latency={null}/>) const comp = shallow(
<Service graph={null} status {...props} latency={null} />
)
expect(comp.find('h2.title').text()).toEqual('sample service ') expect(comp.find('h2.title').text()).toEqual('sample service ')
}) })
it('renders proper information', () => { it('renders proper information', () => {
const comp = shallow(<Service graph={graph} status {...props}/>) const comp = shallow(<Service graph={graph} status {...props} />)
expect(comp.prop('className')).toEqual('service service-alive') expect(comp.prop('className')).toEqual('service service-alive')
expect(comp.find('h2.title').text()).toEqual('sample service 51ms') expect(comp.find('h2.title').text()).toEqual(
expect(comp.contains(<p className="description">a cool service</p>)).toEqual(true) 'sample service 51ms (99.9994%)'
expect(comp.contains(<span className="latency">51ms</span>)).toEqual(true) )
expect(
comp.contains(<p className="description">a cool service</p>)
).toEqual(true)
expect(
comp.contains(<span className="information">51ms (99.9994%)</span>)
).toEqual(true)
}) })
}) })

View File

@ -5,10 +5,6 @@ import {
faExclamationCircle, faExclamationCircle,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
export default function register () { export default function register() {
library.add( library.add(faCheckCircle, faTimesCircle, faExclamationCircle)
faCheckCircle,
faTimesCircle,
faExclamationCircle,
)
} }

View File

@ -7,7 +7,4 @@ import register from './icons.js'
register() register()
ReactDOM.render( ReactDOM.render(<App />, document.getElementById('root'))
<App/>,
document.getElementById('root')
)

View File

@ -8,14 +8,19 @@ export const logger = ({ name, color }) => (...args) =>
export const log = logger({ name: 'elstat', color: 'purple' }) export const log = logger({ name: 'elstat', color: 'purple' })
export function objectFromEntries (entries) { export function objectFromEntries(entries) {
return entries.reduce( return entries.reduce(
(object, [key, value]) => ({ ...object, [key]: value }), (object, [key, value]) => ({ ...object, [key]: value }),
{} {}
) )
} }
export async function strictFetch (...args) { export function truncateToTwoPlaces(number) {
// https://stackoverflow.com/a/4187164/2491753
return number.toString().match(/^-?\d+(?:\.\d{0,4})?/)[0]
}
export async function strictFetch(...args) {
const resp = await fetch(...args) const resp = await fetch(...args)
if (!resp.ok) { if (!resp.ok) {

View File

@ -5,7 +5,7 @@ import { logger } from '../util'
const log = logger({ name: 'ws', color: 'green' }) const log = logger({ name: 'ws', color: 'green' })
export default class StreamingClient extends NanoEvents { export default class StreamingClient extends NanoEvents {
constructor (url, metrics) { constructor(url, metrics) {
super() super()
this.url = url this.url = url
@ -14,11 +14,11 @@ export default class StreamingClient extends NanoEvents {
this.delay = 1000 this.delay = 1000
} }
send (packet) { send(packet) {
this.ws.send(JSON.stringify(packet)) this.ws.send(JSON.stringify(packet))
} }
subscribe () { subscribe() {
if (!this.metrics) { if (!this.metrics) {
log('not subscribing to channels -- no initial metrics') log('not subscribing to channels -- no initial metrics')
return return
@ -36,7 +36,7 @@ export default class StreamingClient extends NanoEvents {
this.send({ op: OP.SUBSCRIBE, channels }) this.send({ op: OP.SUBSCRIBE, channels })
} }
handle (packet) { handle(packet) {
const { op, c: channel, d: data } = packet const { op, c: channel, d: data } = packet
if ([OP.SUBSCRIBED, OP.UNSUBSCRIBED].includes(op)) { if ([OP.SUBSCRIBED, OP.UNSUBSCRIBED].includes(op)) {
@ -52,11 +52,11 @@ export default class StreamingClient extends NanoEvents {
} }
} }
connect () { connect() {
const begin = Date.now() const begin = Date.now()
log('connecting') log('connecting')
const ws = this.ws = new WebSocket(this.url) const ws = (this.ws = new WebSocket(this.url))
window.ws = ws window.ws = ws
ws.onopen = () => { ws.onopen = () => {
@ -68,7 +68,7 @@ export default class StreamingClient extends NanoEvents {
ws.onclose = ({ code, reason }) => { ws.onclose = ({ code, reason }) => {
log( log(
`ws closed with code ${code} (reason: ${reason || '<none>'}); ` + `ws closed with code ${code} (reason: ${reason || '<none>'}); ` +
`attempting to reconnect in ${this.delay}ms` `attempting to reconnect in ${this.delay}ms`
) )
setTimeout(() => this.connect(), this.delay) setTimeout(() => this.connect(), this.delay)