├── .babelrc ├── .dockerignore ├── .eslintrc.yml ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── client └── index.js ├── common ├── components │ ├── Counter.js │ ├── Dashboard.js │ ├── Failure.js │ ├── FailureList.js │ ├── Gauge.js │ ├── RickshawGraph.js │ └── Title.js ├── containers │ └── App.js ├── reducers │ ├── connection.js │ ├── failureLists.js │ ├── graphs.js │ ├── index.js │ ├── kitchenSink.js │ └── title.js ├── routes.js └── store │ └── configureStore.js ├── docker_build.sh ├── index.js ├── package.json ├── precommit.sh ├── public ├── application.less ├── counter.less ├── dashboard.less ├── failure-list.less ├── failure.less ├── gauge.less ├── named_colours.less ├── palette.less ├── rickshaw-graph.less ├── rickshaw.less └── title.less ├── sample-dashboard-config.yaml ├── screenshots.gif ├── scripts ├── format-less.sh ├── lint-code.sh ├── list-javascript.sh └── test.sh ├── server ├── bambooCheckFor.js ├── broadcaster.js ├── healthChecksFor.js ├── heapGraph.js ├── index.js ├── monitor.js ├── renderer.js └── server.js ├── start-mon.sh ├── tests ├── .eslintrc.yml ├── common │ └── components │ │ ├── Counter.test.js │ │ ├── Dashboard.test.js │ │ ├── Failure.test.js │ │ ├── FailureList.test.js │ │ └── Gauge.test.js ├── server │ ├── bambooCheckFor.test.js │ ├── broadcaster.test.js │ ├── healthChecksFor.test.js │ └── monitor.test.js └── winstonStub.js ├── webpack.config.js └── webpack.prod.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | presets: ["es2015", "react"], 3 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .idea/ 4 | coverage/ 5 | Dockerfile 6 | .travis.yml 7 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | plugins: 3 | - react 4 | extends: 5 | - airbnb/base 6 | - plugin:react/recommended 7 | parserOptions: 8 | ecmaFeatures: 9 | jsx: true 10 | globals: 11 | window: true 12 | location: true 13 | document: true 14 | rules: 15 | strict: off # turn this on once node and chai support it properly 16 | no-underscore-dangle: off 17 | no-multiple-empty-lines: ["error", {max: 1}] 18 | max-depth: error 19 | max-len: error 20 | complexity: error 21 | max-lines: error 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | certification.csv 2 | .DS_Store 3 | node_modules 4 | pact/*.log 5 | *.log 6 | .idea/ 7 | coverage/ 8 | info.json 9 | *.iml 10 | reports 11 | pacts/ 12 | public/*.css 13 | real-config.yml 14 | public/bundle.js* -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:6-slim 2 | 3 | RUN mkdir /opt/dashinator 4 | WORKDIR /opt/dashinator 5 | 6 | COPY package.json /opt/dashinator/package.json 7 | RUN npm install --loglevel warn 8 | 9 | COPY . /opt/dashinator 10 | 11 | RUN ./node_modules/.bin/webpack --config webpack.prod.config.js 12 | 13 | ENV NODE_ENV production 14 | 15 | ENTRYPOINT ["node", "server/index.js"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Mike Farah 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # dashinator [![Build Status](https://travis-ci.org/mikefarah/dashinator.svg?branch=master)](https://travis-ci.org/mikefarah/dashinator) 3 | dashinator the daringly delightful dashboard. A node + react + redux replacement for [dashing](https://github.com/Shopify/dashing/blob/master/README.md). 4 | 5 | Use it as an information radar for teams. I use it to monitor about 30 micro-services across several environments as well as relevant the CI builds and deployments. I also use it as an information radar to show business intelligence metrics :) 6 | 7 | Did I mention that it's responsive? Works on tablets and mobiles so you can check it (or show it off) on the go :) 8 | 9 | Currently it supports [Bamboo](https://www.atlassian.com/software/bamboo), happy for pull requests to accept other CI tools too. 10 | 11 | ![Screenshot](screenshots.gif) 12 | 13 | ## Usage 14 | 15 | You can use it 'out of the box' by providing a configuration. However, for more advanced customisation you will probably need to clone it and hack away. 16 | 17 | ### GIT 18 | Clone the repo then 19 | ```sh 20 | ./server/index.js myTeamsConfig.yaml 21 | ``` 22 | 23 | ### Docker 24 | 25 | ``` 26 | cat myTeamsConfig.yaml | docker run -i -p 3000:3000 mikefarah/dashinator - 27 | ``` 28 | 29 | Then browse to http://localhost:3000 30 | 31 | ## Example config YAML 32 | 33 | ```yaml 34 | productionEnvironment: 35 | - name: http listener 36 | url: http://localhost:9999/health_check 37 | 38 | testEnvironments: 39 | - name: DEV http listener 40 | url: http://localhost:9999/health_check 41 | - name: QA http listener 42 | url: http://localhost:9999/health_check 43 | 44 | bamboo: 45 | baseUrl: https://bamboo.com 46 | requestOptions: 47 | strictSSL: false 48 | auth: 49 | user: user 50 | password: password 51 | plans: 52 | - AWESOME-PLAN 53 | ``` 54 | 55 | The health_check endpoints are assumed to return a successful HTTP response code if the service is healthy (successful as defined by node's request library). 56 | 57 | dashinator will poll the services and bamboo every 20 seconds and update the dashboard accordingly. 58 | 59 | 60 | ## Ignore self signed certificates 61 | 62 | Set the NODE_TLS_REJECT_UNAUTHORIZED environment variable to 0. 63 | 64 | e.g: 65 | 66 | ``` 67 | cat myTeamsConfig.yaml | docker run -i -p 3000:3000 -e NODE_TLS_REJECT_UNAUTHORIZED=0 mikefarah/dashinator - 68 | ``` 69 | 70 | or 71 | 72 | ```sh 73 | NODE_TLS_REJECT_UNAUTHORIZED=0 ./server/index.js myTeamsConfig.yaml 74 | ``` 75 | 76 | 77 | ## Contributing 78 | 79 | Fork, make changes, run precommit.sh then create a pull request 80 | -------------------------------------------------------------------------------- /client/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import React from 'react'; 3 | import { render } from 'react-dom'; 4 | import { Provider } from 'react-redux'; 5 | import { RouterProvider, routerForBrowser } from 'redux-little-router'; 6 | import io from 'socket.io-client'; 7 | import configureStore from '../common/store/configureStore'; 8 | import App from '../common/containers/App'; 9 | import routes from '../common/routes'; 10 | 11 | const preloadedState = window.__PRELOADED_STATE__; 12 | 13 | const { 14 | routerEnhancer, 15 | routerMiddleware, 16 | } = routerForBrowser({ routes }); 17 | 18 | const store = configureStore(preloadedState, routerEnhancer, routerMiddleware); 19 | 20 | const socket = io.connect(`${location.protocol}//${location.host}`); 21 | 22 | socket.on('disconnect', () => { 23 | store.dispatch({ 24 | type: 'updateConnection', 25 | status: 'disconnected', 26 | }); 27 | }); 28 | socket.on('action', (action) => { 29 | store.dispatch(action); 30 | store.dispatch({ 31 | type: 'updateConnection', 32 | status: 'connected', 33 | }); 34 | }); 35 | 36 | const rootElement = document.getElementById('app'); 37 | 38 | render( 39 | 40 | 41 | 42 | 43 | , 44 | rootElement 45 | ); 46 | -------------------------------------------------------------------------------- /common/components/Counter.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import raf from 'raf'; 3 | import ease from 'ease-component'; 4 | import _ from 'lodash'; 5 | import numeral from 'numeral'; 6 | 7 | const animationTime = 1000; 8 | 9 | class Counter extends React.Component { 10 | 11 | constructor(props) { 12 | super(props); 13 | this.state = { 14 | currentValue: props.value, 15 | begin: props.value, 16 | end: props.value, 17 | }; 18 | } 19 | 20 | componentWillReceiveProps(nextProps) { 21 | if (this.props.value === nextProps.value) { 22 | return; 23 | } 24 | 25 | this.setState({ 26 | begin: this.state.currentValue, 27 | currentValue: this.state.currentValue, 28 | end: nextProps.value, 29 | }); 30 | this.startAnimation(); 31 | } 32 | 33 | componentDidMount() { 34 | this.startAnimation(); 35 | } 36 | 37 | componentWillUnmount() { 38 | this.stop = true; 39 | } 40 | 41 | startAnimation() { 42 | this.start = Date.now(); 43 | this.stop = false; 44 | raf(() => this.animate()); 45 | } 46 | 47 | animate() { 48 | if (this.stop) return; 49 | this.draw(); 50 | raf(() => this.animate()); 51 | } 52 | 53 | draw() { 54 | const begin = this.state.begin; 55 | const end = this.state.end; 56 | 57 | const now = Date.now(); 58 | 59 | if (this.state.currentValue === end || now - this.start >= animationTime) { 60 | this.stop = true; 61 | } 62 | 63 | const percentage = _.clamp((now - this.start) / animationTime, 0, 1); 64 | const easingMultiplier = ease.inOutCirc(percentage); 65 | const currentValue = begin + ((end - begin) * easingMultiplier); 66 | 67 | this.setState({ currentValue }); 68 | } 69 | 70 | render() { 71 | return
72 |
73 |
{this.props.name}
74 |
75 | {numeral(this.state.currentValue).format(this.props.formatString)} 76 |
77 |
{this.props.unit}
78 |
79 |
; 80 | } 81 | } 82 | 83 | Counter.defaultProps = { 84 | formatString: '0.0a', 85 | }; 86 | 87 | Counter.propTypes = { 88 | value: PropTypes.number.isRequired, 89 | name: React.PropTypes.string, 90 | unit: React.PropTypes.string, 91 | formatString: React.PropTypes.string, 92 | }; 93 | 94 | module.exports = Counter; 95 | -------------------------------------------------------------------------------- /common/components/Dashboard.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { AbsoluteFragment as Fragment } from 'redux-little-router'; 3 | import FailureList from './FailureList'; 4 | import Gauge from './Gauge'; 5 | import Counter from './Counter'; 6 | import RickshawGraph from './RickshawGraph'; 7 | import Title from './Title'; 8 | 9 | const Dashboard = ({ connection, testEnvs, production, ci, kitchenSink, graphs, title }) => ( 10 |
11 |
Connection Lost
12 | 13 | <div className='pages'> 14 | <Fragment forRoute='/'> 15 | <div className='columnContainer'> 16 | <div className='rowContainer'> 17 | <FailureList name='Production' state={ production } /> 18 | { kitchenSink && 19 | <RickshawGraph 20 | name='Heap' 21 | series={graphs.heapGraph.series} 22 | formatString='0.0 b' 23 | errorThreshold={162295552} 24 | xAxisFormat='HH:MM:ss'/> 25 | } 26 | </div> 27 | <div className='rowContainer'> 28 | <FailureList name='Test Environments' state={ testEnvs } /> 29 | { kitchenSink && 30 | <Gauge className='monthlyTarget' name='Monthly Target' 31 | value={testEnvs.elapsed * 3} max={90} unit='apples'/> 32 | } 33 | </div> 34 | <div className='rowContainer'> 35 | <FailureList name='CI' state={ ci } /> 36 | { kitchenSink && 37 | <Counter name='Open tickets' value={testEnvs.elapsed}/> 38 | } 39 | </div> 40 | 41 | </div> 42 | </Fragment> 43 | <Fragment forRoute='/extra'> 44 | <div className='columnContainer'> 45 | <div className='rowContainer'> 46 | <FailureList name='Some more widgets' state={ ci } /> 47 | </div> 48 | </div> 49 | </Fragment> 50 | </div> 51 | </div> 52 | ); 53 | 54 | Dashboard.propTypes = { 55 | connection: PropTypes.string.isRequired, 56 | testEnvs: React.PropTypes.object.isRequired, 57 | production: React.PropTypes.object.isRequired, 58 | ci: React.PropTypes.object.isRequired, 59 | kitchenSink: React.PropTypes.bool, 60 | graphs: React.PropTypes.object.isRequired, 61 | title: PropTypes.string, 62 | }; 63 | 64 | export default Dashboard; 65 | -------------------------------------------------------------------------------- /common/components/Failure.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import truncate from 'truncate'; 3 | 4 | const Failure = ({ name, url, reason }) => ( 5 | <div className='failure'> 6 | <a href={ url || '#'}> 7 | <div className='name'>{ name }</div> 8 | <div className='reason'>{truncate(reason, 60)}</div> 9 | <p className='tooltip'>{reason}</p> 10 | </a> 11 | </div> 12 | ); 13 | 14 | Failure.propTypes = { 15 | name: PropTypes.string.isRequired, 16 | url: PropTypes.string, 17 | reason: PropTypes.string, 18 | }; 19 | 20 | export default Failure; 21 | -------------------------------------------------------------------------------- /common/components/FailureList.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import humanizeDuration from 'humanize-duration'; 3 | import Failure from './Failure'; 4 | 5 | const FailureList = ({ name, state }) => ( 6 | <div className={ `failureList ${state.failures.length ? 'has-failures' : 'no-failures'}` }> 7 | <div className='content'> 8 | <span className='title'>{ name }</span> 9 | <div className='list'> 10 | { state.failures.map(f => <Failure key={ `${f.name}-${f.url}-${f.status}` } name={ f.name } reason={f.status} url={ f.url } />) } 11 | </div> 12 | </div> 13 | <div className='footer'> 14 | <span className='description'>{ state.description }</span> 15 | <div><small className='elapsed'>in {humanizeDuration(state.elapsed)}</small></div> 16 | </div> 17 | </div> 18 | ); 19 | 20 | FailureList.propTypes = { 21 | name: PropTypes.string.isRequired, 22 | state: PropTypes.shape({ 23 | description: PropTypes.string, 24 | elapsed: React.PropTypes.number, 25 | failures: PropTypes.arrayOf(PropTypes.shape({ 26 | name: PropTypes.string.isRequired, 27 | url: PropTypes.string, 28 | })).isRequired, 29 | }).isRequired, 30 | }; 31 | 32 | export default FailureList; 33 | -------------------------------------------------------------------------------- /common/components/Gauge.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import _ from 'lodash'; 3 | import ChartistGraph from 'react-chartist'; 4 | 5 | const Gauge = ({ name, value, description, unit, max = 100 }) => { 6 | const data = { 7 | series: [{ 8 | className: 'gauge-fill', 9 | value: _.clamp((value / max) * 100, 0, 100), 10 | }, { 11 | className: 'gauge-empty', 12 | value: _.clamp(((max - value) / max) * 100, 0, 100), 13 | }], 14 | }; 15 | 16 | const options = { 17 | donut: true, 18 | donutWidth: 40, 19 | startAngle: 240, 20 | total: 150, 21 | showLabel: false, 22 | }; 23 | 24 | return (<div className='gauge'> 25 | <div className='content'> 26 | <div className='name'>{name}</div> 27 | <ChartistGraph data={data} options={options} type='Pie' /> 28 | <div className='value'>{value}</div> 29 | <div className='unit'>{unit}</div> 30 | </div> 31 | <div className='footer'> 32 | <span className='description'>{ description }</span> 33 | </div> 34 | </div>); 35 | }; 36 | 37 | Gauge.propTypes = { 38 | name: PropTypes.string.isRequired, 39 | value: PropTypes.number.isRequired, 40 | unit: PropTypes.string, 41 | description: PropTypes.string, 42 | max: PropTypes.number, 43 | }; 44 | 45 | export default Gauge; 46 | -------------------------------------------------------------------------------- /common/components/RickshawGraph.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Rickshaw from 'rickshaw'; 3 | import _ from 'lodash'; 4 | import dateformat from 'dateformat'; 5 | import Counter from './Counter'; 6 | 7 | const colorScheme = [ 8 | '#17941f', 9 | '#ff8000', 10 | '#ffcc00', 11 | '#b35a00', 12 | '#6b006b', 13 | '#80c9ff', 14 | '#aa80ff', 15 | '#ff0000', 16 | '#808080', 17 | '#b38f00', 18 | '#8fb300', 19 | '#b30000', 20 | '#bebebe', 21 | '#80ff80', 22 | '#ffc080', 23 | '#ee00cc', 24 | '#ff8080', 25 | '#666600', 26 | '#ffbfff', 27 | '#00ffcc', 28 | '#cc6699', 29 | '#999900', 30 | ]; 31 | 32 | class RickshawGraph extends React.Component { 33 | 34 | componentDidMount() { 35 | this.graph = new Rickshaw.Graph({ 36 | element: this.chartContainer, 37 | series: this.getSeriesWithColors(), 38 | renderer: this.props.renderer || 'line', 39 | }); 40 | 41 | const resize = () => { 42 | if (this.graph) { 43 | this.graph.setSize(); 44 | this.graph.render(); 45 | } 46 | }; 47 | 48 | window.addEventListener('resize', _.debounce(resize, 300)); 49 | 50 | this.graphLegend = new Rickshaw.Graph.Legend({ 51 | graph: this.graph, element: this.legend, 52 | }); 53 | 54 | const xAxis = new Rickshaw.Graph.Axis.X({ 55 | graph: this.graph, 56 | tickFormat: (x) => { 57 | const date = new Date(x * 1000); 58 | return dateformat(date, this.props.xAxisFormat); 59 | }, 60 | }); 61 | xAxis.render(); 62 | 63 | const yAxis = new Rickshaw.Graph.Axis.Y({ 64 | graph: this.graph, 65 | tickFormat: Rickshaw.Fixtures.Number.formatKMBT, 66 | }); 67 | 68 | // eslint-disable-next-line no-new 69 | new Rickshaw.Graph.HoverDetail({ 70 | graph: this.graph, 71 | formatter(series, x, y) { 72 | const rawDate = new Date(x * 1000); 73 | const date = `<span class="date">${dateformat(rawDate, 'dS mmmm yyyy, h:MM:ss TT')}</span>`; 74 | const swatch = `<span class="rickshaw_detail_swatch" style="background-color: ${series.color}"></span>`; 75 | const content = `${swatch}${series.name}: ${y.toFixed(2)} <br> ${date}`; 76 | return content; 77 | }, 78 | }); 79 | 80 | yAxis.render(); 81 | 82 | this.graph.render(); 83 | } 84 | 85 | getSeriesWithColors() { 86 | const palette = new Rickshaw.Color.Palette({ scheme: colorScheme }); 87 | return this.props.series.map(s => 88 | Object.assign({ color: palette.color() }, s) 89 | ); 90 | } 91 | 92 | componentWillUpdate() { 93 | if (this.graph) { 94 | this.graph.series = this.getSeriesWithColors(); 95 | this.graph.series.active = () => this.graph.series.filter(s => !s.disabled); 96 | this.graph.update(); 97 | this.graphLegend.render(); 98 | } 99 | } 100 | 101 | maxValue() { 102 | if (this.props.series) { 103 | return _.chain(this.props.series) 104 | .map(s => _.last(s.data)) 105 | .sortBy(d => d.x) 106 | .map(d => d.y) 107 | .last() 108 | .value() || 0; 109 | } 110 | return 0; 111 | } 112 | 113 | className() { 114 | if (this.props.errorThreshold && this.maxValue() > this.props.errorThreshold) { 115 | return 'rickshaw-graph failure'; 116 | } 117 | return 'rickshaw-graph'; 118 | } 119 | 120 | render() { 121 | let counter = <div/>; 122 | if (!this.props.hideCounter) { 123 | counter = <Counter value={this.maxValue()} formatString={this.props.formatString}/>; 124 | } 125 | 126 | return ( 127 | <div className={this.className()}> 128 | <div className='content'> 129 | <div className='title'>{this.props.name}</div> 130 | {counter} 131 | <div className='chart' 132 | ref={(container) => { this.chartContainer = container; }} 133 | /> 134 | <div 135 | ref={(container) => { this.legend = container; }} 136 | /> 137 | </div> 138 | </div> 139 | ); 140 | } 141 | } 142 | 143 | RickshawGraph.propTypes = { 144 | name: React.PropTypes.string, 145 | series: React.PropTypes.array, 146 | hideCounter: React.PropTypes.string, 147 | formatString: React.PropTypes.string, 148 | renderer: React.PropTypes.string, 149 | errorThreshold: React.PropTypes.number, 150 | xAxisFormat: React.PropTypes.string, 151 | }; 152 | module.exports = RickshawGraph; 153 | -------------------------------------------------------------------------------- /common/components/Title.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const Title = ({ titleText }) => { 4 | const showTitle = titleText && titleText.trim().length > 0; 5 | if (showTitle) { 6 | return ( 7 | <header className='titleContainer'> 8 | <h1>{ titleText }</h1> 9 | </header> 10 | ); 11 | } 12 | return null; 13 | }; 14 | 15 | Title.propTypes = { 16 | titleText: PropTypes.string, 17 | }; 18 | 19 | export default Title; 20 | -------------------------------------------------------------------------------- /common/containers/App.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import Dashboard from '../components/Dashboard'; 3 | 4 | const mapStateToProps = state => state; 5 | 6 | export default connect(mapStateToProps)(Dashboard); 7 | -------------------------------------------------------------------------------- /common/reducers/connection.js: -------------------------------------------------------------------------------- 1 | const connection = (state = 'connected', action) => { 2 | if (action.type === 'updateConnection') { 3 | return action.status; 4 | } 5 | return state; 6 | }; 7 | 8 | export default connection; 9 | -------------------------------------------------------------------------------- /common/reducers/failureLists.js: -------------------------------------------------------------------------------- 1 | const updateFailureList = updateListCategory => (state = { 2 | failures: [], 3 | }, action) => { 4 | if (action.type === updateListCategory) { 5 | return Object.assign({}, state, { 6 | failures: action.failures, 7 | description: action.description, 8 | elapsed: action.elapsed, 9 | }); 10 | } 11 | return state; 12 | }; 13 | 14 | const testEnvs = updateFailureList('updateTestEnvs'); 15 | const production = updateFailureList('updateProduction'); 16 | const ci = updateFailureList('updateCi'); 17 | 18 | module.exports = { 19 | testEnvs, 20 | production, 21 | ci, 22 | }; 23 | -------------------------------------------------------------------------------- /common/reducers/graphs.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | const graphs = (state = { graphs: {} }, action) => { 4 | if (action.type === 'updateGraph') { 5 | return Object.assign({}, state, { [action.name]: _.omit(action, ['name', 'type']) }); 6 | } 7 | return state; 8 | }; 9 | 10 | export default graphs; 11 | -------------------------------------------------------------------------------- /common/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import connection from './connection'; 3 | import kitchenSink from './kitchenSink'; 4 | import graphs from './graphs'; 5 | import title from './title'; 6 | import * as FailureListActions from './failureLists'; 7 | 8 | const rootReducer = combineReducers( 9 | Object.assign({}, { connection, kitchenSink, graphs, title }, FailureListActions.default)); 10 | 11 | export default rootReducer; 12 | -------------------------------------------------------------------------------- /common/reducers/kitchenSink.js: -------------------------------------------------------------------------------- 1 | const kitchenSink = (state = false, action) => { 2 | if (action.type === 'updateKitchenSink') { 3 | return action.value; 4 | } 5 | return state; 6 | }; 7 | 8 | export default kitchenSink; 9 | -------------------------------------------------------------------------------- /common/reducers/title.js: -------------------------------------------------------------------------------- 1 | const title = (state = null, action) => { 2 | if (action.type === 'updateTitle') { 3 | return action.value; 4 | } 5 | return state; 6 | }; 7 | 8 | export default title; 9 | -------------------------------------------------------------------------------- /common/routes.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '/': { 3 | title: 'main', 4 | '/extra': { 5 | title: 'more graphs!', 6 | }, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /common/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import rootReducer from '../reducers'; 4 | 5 | const configureStore = (preloadedState, routerEnhancer, routerMiddleware) => { 6 | const store = createStore( 7 | rootReducer, 8 | preloadedState, 9 | compose(routerEnhancer, applyMiddleware(thunk, routerMiddleware)) 10 | ); 11 | 12 | if (module.hot) { 13 | // Enable Webpack hot module replacement for reducers 14 | module.hot.accept('../reducers', () => { 15 | // eslint-disable-next-line 16 | const nextRootReducer = require('../reducers').default; 17 | store.replaceReducer(nextRootReducer); 18 | }); 19 | } 20 | 21 | return store; 22 | }; 23 | 24 | export default configureStore; 25 | -------------------------------------------------------------------------------- /docker_build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | version=$(cat package.json | jq .version -r) 4 | docker build -t mikefarah/dashinator:${version} . 5 | 6 | docker tag mikefarah/dashinator:${version} mikefarah/dashinator:latest 7 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('./client'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dashinator", 3 | "version": "1.3.0", 4 | "description": "Dashinator the daringly delightful dashboard.", 5 | "scripts": { 6 | "test": "./precommit.sh", 7 | "start": "node server/index.js" 8 | }, 9 | "keywords": [ 10 | "dashboard", 11 | "information radar", 12 | "dashing" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "git@github.com:mikefarah/dashinator.git" 17 | }, 18 | "bin": { 19 | "dashinator": "./server/index.js" 20 | }, 21 | "license": "MIT", 22 | "homepage": "https://github.com/mikefarah/dashinator", 23 | "dependencies": { 24 | "babel-polyfill": "^6.3.14", 25 | "babel-register": "^6.4.3", 26 | "buffer-shims": "^1.0.0", 27 | "compression-webpack-plugin": "^0.3.2", 28 | "core-util-is": "^1.0.2", 29 | "dateformat": "^2.0.0", 30 | "ease-component": "^1.0.0", 31 | "express": "^4.13.3", 32 | "gather-stream": "^1.0.0", 33 | "humanize-duration": "^3.9.1", 34 | "less-middleware": "^2.2.0", 35 | "lodash": "^4.16.2", 36 | "numeral": "^2.0.1", 37 | "process-nextick-args": "^1.0.7", 38 | "raf": "^3.3.0", 39 | "react": "^15.4.1", 40 | "react-chartist": "^0.10.2", 41 | "react-dom": "^15.4.1", 42 | "react-redux": "^4.2.1", 43 | "redux": "^3.2.1", 44 | "redux-little-router": "^12.1.2", 45 | "redux-thunk": "^1.0.3", 46 | "request": "^2.75.0", 47 | "request-promise-native": "^1.0.3", 48 | "rickshaw": "^1.6.0", 49 | "serve-static": "^1.10.0", 50 | "socket.io": "^1.4.8", 51 | "socket.io-client": "^1.4.8", 52 | "truncate": "^2.0.0", 53 | "util-deprecate": "^1.0.2", 54 | "webpack": "^1.11.0", 55 | "webpack-dev-middleware": "^1.4.0", 56 | "webpack-hot-middleware": "^2.9.1", 57 | "winston": "^2.2.0", 58 | "yamljs": "^0.2.8" 59 | }, 60 | "devDependencies": { 61 | "babel-core": "^6.3.15", 62 | "babel-jest": "^16.0.0", 63 | "babel-loader": "^6.2.0", 64 | "babel-preset-es2015": "^6.16.0", 65 | "babel-preset-react": "^6.3.13", 66 | "babel-preset-react-hmre": "^1.1.1", 67 | "babel-runtime": "^6.3.13", 68 | "cssbrush": "^0.5.0", 69 | "enzyme": "^2.4.1", 70 | "eslint": "^3.6.1", 71 | "eslint-config-airbnb": "^12.0.0", 72 | "eslint-plugin-import": "^1.16.0", 73 | "eslint-plugin-jsx-a11y": "^2.2.2", 74 | "eslint-plugin-react": "^6.3.0", 75 | "jest": "^17.0.3", 76 | "jest-cli": "^17.0.3", 77 | "js-beautify": "^1.6.4", 78 | "nodemon": "^1.10.2", 79 | "react-addons-test-utils": "^15.4.1" 80 | }, 81 | "esformatter": { 82 | "plugins": [ 83 | "esformatter-jsx" 84 | ], 85 | "jsx": { 86 | "formatJSXExpressions": true, 87 | "JSXExpressionsSingleLine": true, 88 | "formatJSX": true, 89 | "attrsOnSameLineAsTag": true, 90 | "maxAttrsOnTag": 1, 91 | "firstAttributeOnSameLine": false, 92 | "spaceInJSXExpressionContainers": " ", 93 | "alignWithFirstAttribute": true, 94 | "htmlOptions": { 95 | "brace_style": "collapse", 96 | "indent_char": " ", 97 | "indent_size": 2, 98 | "max_preserve_newlines": 2, 99 | "preserve_newlines": true 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /precommit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | echo 'formatting less' 6 | ./scripts/format-less.sh 7 | 8 | echo 'linting' 9 | ./scripts/lint-code.sh 10 | 11 | echo 'test' 12 | ./scripts/test.sh -------------------------------------------------------------------------------- /public/application.less: -------------------------------------------------------------------------------- 1 | @import 'palette'; 2 | @import 'named_colours'; 3 | @import 'dashboard'; 4 | @import 'failure-list'; 5 | @import 'failure'; 6 | @import 'gauge'; 7 | @import 'counter'; 8 | @import 'rickshaw-graph'; 9 | @import 'title'; 10 | 11 | .title { 12 | font-family: Arial, Helvetica, sans-serif; 13 | font-size: 3.2vh; 14 | color: white; 15 | text-shadow: 0 0 10px #000; 16 | } 17 | 18 | .littleText { 19 | font-size: 1.6vh; 20 | } 21 | 22 | @media only screen and (max-device-width: 699px) { 23 | .dashboard .columnContainer { 24 | flex-wrap: wrap; 25 | 26 | & > div { 27 | width: 100%; 28 | height: 90vh; 29 | } 30 | } 31 | } 32 | 33 | @media only screen and (min-device-width: 700px) and (max-device-width: 1024px) { 34 | .dashboard .columnContainer { 35 | flex-wrap: wrap; 36 | 37 | & > div { 38 | width: 50%; 39 | height: 90vh; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /public/counter.less: -------------------------------------------------------------------------------- 1 | .counter { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | width: 100%; 6 | height: 100%; 7 | background-color: @color-secondary-1-0; 8 | 9 | .content { 10 | margin: auto; 11 | } 12 | 13 | .title, 14 | .unit { 15 | .title(); 16 | 17 | margin-top: 10px; 18 | } 19 | 20 | .value { 21 | font-family: 'Arial Black', Gadget, sans-serif; 22 | font-size: 110px; 23 | font-weight: bold; 24 | color: white; 25 | text-shadow: 0 0 4px #000; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /public/dashboard.less: -------------------------------------------------------------------------------- 1 | .dashboard { 2 | @padding: 10px; 3 | 4 | display: flex; 5 | flex-direction: column; 6 | height: 100vh; 7 | text-align: center; 8 | 9 | .connectionAlert { 10 | display: none; 11 | } 12 | 13 | .pages > div, 14 | .pages { 15 | display: flex; 16 | flex-direction: column; 17 | height: 100%; 18 | } 19 | 20 | .connectionAlert.disconnected { 21 | font-family: 'Arial Black', Gadget, sans-serif; 22 | font-size: 30px; 23 | position: absolute; 24 | z-index: 2; 25 | display: block; 26 | width: ~"calc(100% - " @padding ~")"; 27 | margin: 20px 5px 0 5px; 28 | padding: 30px 0 30px; 29 | text-align: center; 30 | color: white; 31 | background: black; 32 | } 33 | 34 | .columnContainer { 35 | display: flex; 36 | height: 100%; 37 | 38 | > div { 39 | box-sizing: border-box; 40 | width: 100%; 41 | height: 100%; 42 | padding: @padding / 2; 43 | 44 | flex-grow: 1; 45 | } 46 | } 47 | 48 | .rowContainer { 49 | display: flex; 50 | flex-direction: column; 51 | height: 100%; 52 | 53 | > div { 54 | width: 100%; 55 | margin-bottom: @padding; 56 | border-radius: 5px; 57 | 58 | flex-grow: 1; 59 | 60 | &:last-child { 61 | margin-bottom: 0; 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /public/failure-list.less: -------------------------------------------------------------------------------- 1 | .failureList { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100%; 5 | transition: background-color 2s; 6 | background-color: @success-3; 7 | 8 | .content { 9 | margin: auto; 10 | } 11 | 12 | &.has-failures { 13 | background-color: @failure-0; 14 | 15 | .footer { 16 | color: @failure-1; 17 | } 18 | } 19 | 20 | .list { 21 | padding-top: 10px; 22 | padding-left: 0; 23 | } 24 | 25 | > div { 26 | margin: auto; 27 | } 28 | 29 | .footer { 30 | .littleText(); 31 | 32 | font-family: sans-serif; 33 | margin: 0; 34 | padding-bottom: 10px; 35 | color: @success-2; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /public/failure.less: -------------------------------------------------------------------------------- 1 | .failure { 2 | position: relative; 3 | padding-bottom: 5px; 4 | 5 | a { 6 | font-family: Arial, Helvetica, sans-serif; 7 | font-size: 2.4vh; 8 | padding-bottom: 15px; 9 | text-decoration: none; 10 | color: @failure-1; 11 | } 12 | 13 | .reason { 14 | .littleText(); 15 | } 16 | 17 | &:hover .tooltip { 18 | display: block; 19 | visibility: visible; 20 | } 21 | 22 | .tooltip { 23 | .littleText(); 24 | 25 | position: absolute; 26 | z-index: 1; 27 | display: none; 28 | visibility: hidden; 29 | max-width: 25vh; 30 | margin-left: 10px; 31 | padding: 10px; 32 | white-space: pre-wrap; 33 | color: white; 34 | border: 3px white solid; 35 | border-radius: 5px; 36 | background: @failure-0; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /public/gauge.less: -------------------------------------------------------------------------------- 1 | .gauge { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | width: 100%; 6 | height: 100%; 7 | background-color: @color-secondary-2-0; 8 | 9 | .content { 10 | margin: auto; 11 | } 12 | 13 | .ct-chart { 14 | position: absolute; 15 | top: 10%; 16 | right: 5%; 17 | bottom: -8%; 18 | left: 5%; 19 | 20 | svg { 21 | overflow: visible; 22 | } 23 | 24 | .ct-slice-donut { 25 | stroke-width: 9% !important; 26 | } 27 | 28 | @media only screen and (max-device-width: 1300px) { 29 | .ct-slice-donut { 30 | stroke-width: 7% !important; 31 | } 32 | } 33 | } 34 | 35 | .name { 36 | padding-top: 60px; 37 | } 38 | 39 | .unit, 40 | .name { 41 | .title(); 42 | 43 | position: relative; 44 | z-index: 1; 45 | } 46 | 47 | .value { 48 | font-family: 'Arial Black', Gadget, sans-serif; 49 | font-size: 70px; 50 | font-weight: bold; 51 | display: inline; 52 | color: white; 53 | } 54 | 55 | .footer { 56 | font-family: sans-serif; 57 | margin: 0; 58 | padding-bottom: 10px; 59 | color: @color-secondary-2-1; 60 | } 61 | 62 | .gauge-fill path { 63 | stroke: @color-secondary-2-1; 64 | stroke-linecap: round; 65 | } 66 | 67 | .gauge-empty path { 68 | stroke: @color-secondary-2-4; 69 | stroke-linecap: round; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /public/named_colours.less: -------------------------------------------------------------------------------- 1 | @success-0: @color-complement-0; 2 | @success-1: @color-complement-1; 3 | @success-2: @color-complement-2; 4 | @success-3: @color-complement-3; 5 | @success-4: @color-complement-4; 6 | 7 | @failure-0: @color-primary-0; 8 | @failure-1: @color-primary-1; 9 | @failure-2: @color-primary-2; 10 | @failure-3: @color-primary-3; 11 | @failure-4: @color-primary-4; 12 | -------------------------------------------------------------------------------- /public/palette.less: -------------------------------------------------------------------------------- 1 | /* LESS - http://lesscss.org style sheet */ 2 | /* Palette color codes */ 3 | /* Palette URL: http://paletton.com/#uid=c012Q1e3t0kr5nD48Gqcft9tZcQ-K6I */ 4 | 5 | /* Feel free to copy&paste color codes to your application */ 6 | 7 | /* MIXINS */ 8 | 9 | /* As hex codes */ 10 | 11 | @color-primary-0: #bc211d; /* Main Primary color */ 12 | @color-primary-1: #ffdfde; 13 | @color-primary-2: #e8928f; 14 | @color-primary-3: #660906; 15 | @color-primary-4: #350100; 16 | 17 | @color-secondary-1-0: #bc971d; /* Main Secondary color (1) */ 18 | @color-secondary-1-1: #fff7de; 19 | @color-secondary-1-2: #e8d38f; 20 | @color-secondary-1-3: #665006; 21 | @color-secondary-1-4: #352900; 22 | 23 | @color-secondary-2-0: #185678; /* Main Secondary color (2) */ 24 | @color-secondary-2-1: #abbbc3; 25 | @color-secondary-2-2: #5e8193; 26 | @color-secondary-2-3: #072d41; 27 | @color-secondary-2-4: #021722; 28 | 29 | @color-complement-0: #17941f; /* Main Complement color */ 30 | @color-complement-1: #c0dcc2; 31 | @color-complement-2: #71b775; 32 | @color-complement-3: #05510a; 33 | @color-complement-4: #002a03; 34 | -------------------------------------------------------------------------------- /public/rickshaw-graph.less: -------------------------------------------------------------------------------- 1 | @import 'rickshaw'; 2 | 3 | @legendHeight: 34px; 4 | 5 | .rickshaw-graph { 6 | position: relative; 7 | display: flex; 8 | flex-direction: column; 9 | width: 100%; 10 | height: 100%; 11 | transition: background-color 2s; 12 | background-color: @color-secondary-2-0; 13 | 14 | &.failure { 15 | background-color: @failure-0; 16 | } 17 | 18 | .content { 19 | margin: auto; 20 | } 21 | 22 | .x_ticks_d3, 23 | .y_ticks { 24 | font-family: sans-serif; 25 | 26 | fill: white; 27 | } 28 | 29 | .path { 30 | stroke-width: 5px; 31 | stroke-linecap: round; 32 | } 33 | 34 | .rickshaw_legend { 35 | position: absolute; 36 | right: 0; 37 | bottom: 0; 38 | left: 0; 39 | height: @legendHeight; 40 | padding: 0; 41 | padding-bottom: 2px; 42 | background: none; 43 | 44 | ul { 45 | display: flex; 46 | align-items: center; 47 | justify-content: center; 48 | height: 100%; 49 | 50 | flex-wrap: wrap; 51 | 52 | li { 53 | display: inline-block; 54 | } 55 | } 56 | 57 | .label { 58 | font-size: 1.5vh; 59 | font-weight: 100; 60 | } 61 | 62 | .swatch { 63 | width: 10px; 64 | height: 10px; 65 | border-radius: 4px; 66 | } 67 | } 68 | 69 | .title { 70 | position: relative; 71 | z-index: 1; 72 | } 73 | 74 | .chart { 75 | position: absolute; 76 | top: 4px; 77 | right: 0; 78 | bottom: @legendHeight + 2; 79 | left: 0; 80 | overflow: hidden; 81 | } 82 | 83 | @media only screen and (max-device-width: 699px) { 84 | @legendHeight: 64px; 85 | 86 | .chart { 87 | bottom: @legendHeight + 2; 88 | } 89 | 90 | .rickshaw_legend { 91 | height: @legendHeight; 92 | } 93 | } 94 | 95 | @media only screen and (min-device-width: 700px) and (max-device-width: 1024px) { 96 | @legendHeight: 40px; 97 | 98 | .chart { 99 | bottom: @legendHeight + 2; 100 | } 101 | 102 | .rickshaw_legend { 103 | height: @legendHeight; 104 | 105 | .label { 106 | font-size: 1.2vh; 107 | } 108 | } 109 | } 110 | 111 | .counter { 112 | z-index: 1; 113 | background: none; 114 | 115 | .name { 116 | margin: 0; 117 | } 118 | 119 | .value { 120 | font-size: 6vh; 121 | margin-top: -15px; 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /public/rickshaw.less: -------------------------------------------------------------------------------- 1 | .rickshaw_detail_swatch { 2 | display: inline-block; 3 | float: right; 4 | width: 10px; 5 | height: 10px; 6 | margin: 0 4px 0 0; 7 | } 8 | 9 | .rickshaw_graph .detail { 10 | position: absolute; 11 | z-index: 2; 12 | top: 0; 13 | bottom: 0; 14 | width: 1px; 15 | -webkit-transition: opacity .25s linear; 16 | -moz-transition: opacity .25s linear; 17 | -o-transition: opacity .25s linear; 18 | transition: opacity .25s linear; 19 | pointer-events: none; 20 | background: rgba(0,0,0,.1); 21 | } 22 | 23 | .rickshaw_graph .detail.inactive { 24 | opacity: 0; 25 | } 26 | 27 | .rickshaw_graph .detail .item.active { 28 | opacity: 1; 29 | } 30 | 31 | .rickshaw_graph .detail .x_label { 32 | font-family: Arial,sans-serif; 33 | font-size: 12px; 34 | position: absolute; 35 | padding: 6px; 36 | white-space: nowrap; 37 | opacity: .5; 38 | border: 1px solid #e0e0e0; 39 | border-radius: 3px; 40 | background: #fff; 41 | } 42 | 43 | .rickshaw_graph .detail .x_label.left { 44 | left: 0; 45 | } 46 | 47 | .rickshaw_graph .detail .x_label.right { 48 | right: 0; 49 | } 50 | 51 | .rickshaw_graph .detail .item { 52 | font-family: Arial,sans-serif; 53 | font-size: 12px; 54 | position: absolute; 55 | z-index: 2; 56 | margin-top: -1em; 57 | margin-right: 1em; 58 | margin-left: 1em; 59 | padding: .25em; 60 | white-space: nowrap; 61 | opacity: 0; 62 | color: #fff; 63 | border: 1px solid rgba(0,0,0,.4); 64 | border-radius: 3px; 65 | background: rgba(0,0,0,.4); 66 | } 67 | 68 | .rickshaw_graph .detail .item.left { 69 | left: 0; 70 | } 71 | 72 | .rickshaw_graph .detail .item.right { 73 | right: 0; 74 | } 75 | 76 | .rickshaw_graph .detail .item.active { 77 | opacity: 1; 78 | background: rgba(0,0,0,.8); 79 | } 80 | 81 | .rickshaw_graph .detail .item:after { 82 | position: absolute; 83 | display: block; 84 | width: 0; 85 | height: 0; 86 | content: ''; 87 | border: 5px solid transparent; 88 | } 89 | 90 | .rickshaw_graph .detail .item.left:after { 91 | top: 1em; 92 | left: -5px; 93 | margin-top: -5px; 94 | border-right-color: rgba(0,0,0,.8); 95 | border-left-width: 0; 96 | } 97 | 98 | .rickshaw_graph .detail .item.right:after { 99 | top: 1em; 100 | right: -5px; 101 | margin-top: -5px; 102 | border-right-width: 0; 103 | border-left-color: rgba(0,0,0,.8); 104 | } 105 | 106 | .rickshaw_graph .detail .dot { 107 | position: absolute; 108 | display: none; 109 | -moz-box-sizing: content-box; 110 | box-sizing: content-box; 111 | width: 4px; 112 | height: 4px; 113 | margin-top: -3.5px; 114 | margin-left: -3px; 115 | border-width: 2px; 116 | border-style: solid; 117 | border-radius: 5px; 118 | background: #fff; 119 | background-clip: padding-box; 120 | box-shadow: 0 0 2px rgba(0,0,0,.6); 121 | } 122 | 123 | .rickshaw_graph .detail .dot.active { 124 | display: block; 125 | } 126 | 127 | .rickshaw_graph { 128 | position: relative; 129 | } 130 | 131 | .rickshaw_graph svg { 132 | display: block; 133 | overflow: hidden; 134 | } 135 | 136 | .rickshaw_graph .x_tick { 137 | position: absolute; 138 | top: 0; 139 | bottom: 0; 140 | width: 0; 141 | pointer-events: none; 142 | border-left: 1px dotted rgba(0,0,0,.2); 143 | } 144 | 145 | .rickshaw_graph .x_tick .title { 146 | font-family: Arial,sans-serif; 147 | font-size: 12px; 148 | position: absolute; 149 | bottom: 1px; 150 | margin-left: 3px; 151 | white-space: nowrap; 152 | opacity: .5; 153 | } 154 | 155 | .rickshaw_annotation_timeline { 156 | position: relative; 157 | height: 1px; 158 | margin-top: 10px; 159 | border-top: 1px solid #e0e0e0; 160 | } 161 | 162 | .rickshaw_annotation_timeline .annotation { 163 | position: absolute; 164 | top: -3px; 165 | width: 6px; 166 | height: 6px; 167 | margin-left: -2px; 168 | border-radius: 5px; 169 | background-color: rgba(0,0,0,.25); 170 | } 171 | 172 | .rickshaw_graph .annotation_line { 173 | position: absolute; 174 | top: 0; 175 | bottom: -6px; 176 | display: none; 177 | width: 0; 178 | border-left: 2px solid rgba(0,0,0,.3); 179 | } 180 | 181 | .rickshaw_graph .annotation_line.active { 182 | display: block; 183 | } 184 | 185 | .rickshaw_graph .annotation_range { 186 | position: absolute; 187 | top: 0; 188 | bottom: -6px; 189 | display: none; 190 | background: rgba(0,0,0,.1); 191 | } 192 | 193 | .rickshaw_graph .annotation_range.active { 194 | display: block; 195 | } 196 | 197 | .rickshaw_graph .annotation_range.active.offscreen { 198 | display: none; 199 | } 200 | 201 | .rickshaw_annotation_timeline .annotation .content { 202 | font-size: 12px; 203 | position: relative; 204 | z-index: 20; 205 | top: 18px; 206 | left: -11px; 207 | display: none; 208 | width: 160px; 209 | padding: 5px; 210 | padding: 6px 8px 8px; 211 | cursor: pointer; 212 | opacity: .9; 213 | color: #000; 214 | border-radius: 3px; 215 | background: #fff; 216 | box-shadow: 0 0 2px rgba(0,0,0,.8); 217 | } 218 | 219 | .rickshaw_annotation_timeline .annotation .content:before { 220 | position: absolute; 221 | top: -11px; 222 | content: '\25b2'; 223 | color: #fff; 224 | text-shadow: 0 -1px 1px rgba(0,0,0,.8); 225 | } 226 | 227 | .rickshaw_annotation_timeline .annotation.active, 228 | .rickshaw_annotation_timeline .annotation:hover { 229 | cursor: none; 230 | background-color: rgba(0,0,0,.8); 231 | } 232 | 233 | .rickshaw_annotation_timeline .annotation .content:hover { 234 | z-index: 50; 235 | } 236 | 237 | .rickshaw_annotation_timeline .annotation.active .content { 238 | display: block; 239 | } 240 | 241 | .rickshaw_annotation_timeline .annotation:hover .content { 242 | z-index: 50; 243 | display: block; 244 | } 245 | 246 | .rickshaw_graph .y_axis, 247 | .rickshaw_graph .x_axis_d3 { 248 | fill: none; 249 | } 250 | 251 | .rickshaw_graph .y_ticks .tick line, 252 | .rickshaw_graph .x_ticks_d3 .tick { 253 | pointer-events: none; 254 | 255 | stroke: rgba(0,0,0,.16); 256 | stroke-width: 2px; 257 | shape-rendering: crisp-edges; 258 | } 259 | 260 | .rickshaw_graph .y_grid .tick, 261 | .rickshaw_graph .x_grid_d3 .tick { 262 | z-index: -1; 263 | 264 | stroke: rgba(0,0,0,.2); 265 | stroke-width: 1px; 266 | stroke-dasharray: 1 1; 267 | } 268 | 269 | .rickshaw_graph .y_grid .tick[data-y-value='0'] { 270 | stroke-dasharray: 1 0; 271 | } 272 | 273 | .rickshaw_graph .y_grid path, 274 | .rickshaw_graph .x_grid_d3 path { 275 | fill: none; 276 | stroke: none; 277 | } 278 | 279 | .rickshaw_graph .y_ticks path, 280 | .rickshaw_graph .x_ticks_d3 path { 281 | fill: none; 282 | stroke: gray; 283 | } 284 | 285 | .rickshaw_graph .y_ticks text, 286 | .rickshaw_graph .x_ticks_d3 text { 287 | font-size: 12px; 288 | pointer-events: none; 289 | opacity: .5; 290 | } 291 | 292 | .rickshaw_graph .x_tick.glow .title, 293 | .rickshaw_graph .y_ticks.glow text { 294 | color: #000; 295 | text-shadow: -1px 1px 0 rgba(255,255,255,.1),1px -1px 0 rgba(255,255,255,.1),1px 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1),0 -1px 0 rgba(255,255,255,.1),1px 0 0 rgba(255,255,255,.1),-1px 0 0 rgba(255,255,255,.1),-1px -1px 0 rgba(255,255,255,.1); 296 | 297 | fill: #000; 298 | } 299 | 300 | .rickshaw_graph .x_tick.inverse .title, 301 | .rickshaw_graph .y_ticks.inverse text { 302 | color: #fff; 303 | text-shadow: -1px 1px 0 rgba(0,0,0,.8),1px -1px 0 rgba(0,0,0,.8),1px 1px 0 rgba(0,0,0,.8),0 1px 0 rgba(0,0,0,.8),0 -1px 0 rgba(0,0,0,.8),1px 0 0 rgba(0,0,0,.8),-1px 0 0 rgba(0,0,0,.8),-1px -1px 0 rgba(0,0,0,.8); 304 | 305 | fill: #fff; 306 | } 307 | 308 | .rickshaw_legend { 309 | font-family: Arial; 310 | font-size: 12px; 311 | position: relative; 312 | display: inline-block; 313 | padding: 12px 5px; 314 | color: #fff; 315 | border-radius: 2px; 316 | background: #404040; 317 | } 318 | 319 | .rickshaw_legend:hover { 320 | z-index: 10; 321 | } 322 | 323 | .rickshaw_legend .swatch { 324 | width: 10px; 325 | height: 10px; 326 | border: 1px solid rgba(0,0,0,.2); 327 | } 328 | 329 | .rickshaw_legend .line { 330 | line-height: 140%; 331 | clear: both; 332 | padding-right: 15px; 333 | } 334 | 335 | .rickshaw_legend .line .swatch { 336 | display: inline-block; 337 | margin-right: 3px; 338 | border-radius: 2px; 339 | } 340 | 341 | .rickshaw_legend .label { 342 | font-size: inherit; 343 | font-weight: 400; 344 | line-height: normal; 345 | display: inline; 346 | margin: 0; 347 | padding: 0; 348 | white-space: nowrap; 349 | color: inherit; 350 | background-color: transparent; 351 | text-shadow: none; 352 | } 353 | 354 | .rickshaw_legend .action:hover { 355 | opacity: .6; 356 | } 357 | 358 | .rickshaw_legend .action { 359 | font-size: 10px; 360 | font-size: 14px; 361 | margin-right: .2em; 362 | cursor: pointer; 363 | opacity: .2; 364 | } 365 | 366 | .rickshaw_legend .line.disabled { 367 | opacity: .4; 368 | } 369 | 370 | .rickshaw_legend ul { 371 | margin: 0; 372 | margin: 2px; 373 | padding: 0; 374 | list-style-type: none; 375 | cursor: pointer; 376 | } 377 | 378 | .rickshaw_legend li { 379 | min-width: 80px; 380 | padding: 0 0 0 2px; 381 | white-space: nowrap; 382 | } 383 | 384 | .rickshaw_legend li:hover { 385 | border-radius: 3px; 386 | background: rgba(255,255,255,.08); 387 | } 388 | 389 | .rickshaw_legend li:active { 390 | border-radius: 3px; 391 | background: rgba(255,255,255,.2); 392 | } 393 | -------------------------------------------------------------------------------- /public/title.less: -------------------------------------------------------------------------------- 1 | .titleContainer { 2 | display: flex; 3 | box-sizing: border-box; 4 | width: 100%; 5 | height: 10vh; 6 | padding: 5px; 7 | 8 | h1 { 9 | font-family: Arial,Helvetica,sans-serif; 10 | font-size: 3.2vh; 11 | margin: 0; 12 | padding: 2.5vh; 13 | color: white; 14 | border-radius: 5px; 15 | background-color: @color-secondary-2-0; 16 | text-shadow: 0 0 4px #000; 17 | 18 | flex-grow: 1; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /sample-dashboard-config.yaml: -------------------------------------------------------------------------------- 1 | title: Sample Dashboard 2 | kitchenSink: true 3 | productionEnvironment: 4 | - name: http listener 5 | url: http://localhost:9999 6 | requestOptions: 7 | auth: 8 | user: cat 9 | password: dog 10 | 11 | 12 | testEnvironments: 13 | - name: DEV http listener 14 | url: http://localhost:9999 15 | - name: QA http listener 16 | url: http://localhost:9999 17 | 18 | bamboo: 19 | baseUrl: https://localhost:9999/bamboo 20 | requestOptions: 21 | strictSSL: false 22 | auth: 23 | user: user 24 | password: password 25 | plans: 26 | - AWESOME-PLAN -------------------------------------------------------------------------------- /screenshots.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikefarah/dashinator/1cacfc2b6eb68780bbf65c97dc77516ea69c360a/screenshots.gif -------------------------------------------------------------------------------- /scripts/format-less.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ./node_modules/.bin/cssbrush public/*.less 4 | -------------------------------------------------------------------------------- /scripts/lint-code.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ./scripts/list-javascript.sh | xargs ./node_modules/.bin/eslint --fix -------------------------------------------------------------------------------- /scripts/list-javascript.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | find . -name "*.js" -not -path "*/node_modules/*" -not -path "./coverage/*" -not -path "./public/bundle*" 4 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ./node_modules/.bin/jest --coverage -------------------------------------------------------------------------------- /server/bambooCheckFor.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | const request = require('request-promise-native'); 3 | 4 | const checkLatestBuild = (bambooConfig, plan) => { 5 | const requestOptions = Object.assign({}, bambooConfig.requestOptions, { 6 | json: true, 7 | url: `${bambooConfig.baseUrl}/rest/api/latest/result/${plan}?os_authType=basic&max-result=1`, 8 | }); 9 | return request(requestOptions) 10 | .then((response) => { 11 | const result = response.results.result[0]; 12 | const name = result.plan.shortName; 13 | const status = result.buildState === 'Successful' ? 'OK' : result.buildState; 14 | const url = `${bambooConfig.baseUrl}/browse/${result.buildResultKey}`; 15 | return { name, url, status }; 16 | }) 17 | .catch((err) => { 18 | winston.error(`Error accessing bamboo plan ${plan} with config ${JSON.stringify(bambooConfig)}`, err); 19 | return { name: err.message, url: '#', status: 'Exception' }; 20 | }); 21 | }; 22 | 23 | const bambooCheckFor = bambooConfig => () => 24 | Promise.all(bambooConfig.plans.map(plan => checkLatestBuild(bambooConfig, plan))) 25 | .then(results => ({ 26 | results, 27 | description: `Monitoring ${bambooConfig.plans.length} bamboo plan(s)`, 28 | })); 29 | 30 | module.exports = bambooCheckFor; 31 | -------------------------------------------------------------------------------- /server/broadcaster.js: -------------------------------------------------------------------------------- 1 | import socketIo from 'socket.io'; 2 | import winston from 'winston'; 3 | 4 | class Broadcaster { 5 | 6 | constructor() { 7 | this.connections = []; 8 | } 9 | 10 | attach(server) { 11 | const io = socketIo(); 12 | io.attach(server); 13 | io.on('connection', socket => this.addSocket(socket)); 14 | } 15 | 16 | addSocket(socket) { 17 | this.connections.push(socket); 18 | socket.on('disconnect', () => { 19 | const index = this.connections.indexOf(socket); 20 | this.connections.splice(index, 1); 21 | }); 22 | } 23 | 24 | broadcast(action) { 25 | winston.info('Broadcasting', action); 26 | this.connections.forEach(socket => socket.emit('action', action)); 27 | } 28 | 29 | } 30 | 31 | module.exports = Broadcaster; 32 | -------------------------------------------------------------------------------- /server/healthChecksFor.js: -------------------------------------------------------------------------------- 1 | import request from 'request-promise-native'; 2 | import winston from 'winston'; 3 | import _ from 'lodash'; 4 | import fs from 'fs'; 5 | 6 | const requestFor = (service) => { 7 | const requestOptions = Object.assign({ 8 | timeout: 2000, 9 | uri: service.url, 10 | resolveWithFullResponse: true, 11 | }, service.requestOptions); 12 | 13 | ['ca', 'key', 'cert'].forEach((field) => { 14 | const filePath = _.get(requestOptions, `agentOptions.${field}File`); 15 | if (filePath) { 16 | requestOptions.agentOptions[field] = fs.readFileSync(filePath); 17 | } 18 | }); 19 | winston.info(`Checking service health of ${service.name}`); 20 | return request(requestOptions); 21 | }; 22 | 23 | const checkServiceHealth = service => requestFor(service) 24 | .then(() => 'OK') 25 | .catch((err) => { 26 | winston.warn(`Error checking ${service.url}`, err); 27 | if (err.response) { 28 | return `${err.response.statusCode} - ${err.response.body}`; 29 | } 30 | return err.message.replace(/^Error: /, ''); 31 | }) 32 | .then(status => Object.assign({}, _.pick(service, ['name', 'url']), { 33 | status, 34 | })); 35 | 36 | const healthChecksFor = servers => () => 37 | Promise.all(servers.map(s => checkServiceHealth(s))) 38 | .then(results => ({ 39 | results, 40 | description: `Monitoring ${servers.length} service(s)`, 41 | })); 42 | 43 | module.exports = healthChecksFor; 44 | -------------------------------------------------------------------------------- /server/heapGraph.js: -------------------------------------------------------------------------------- 1 | const MAX_LEN = 20; 2 | 3 | const intervalMs = 1000; 4 | 5 | const add = (array, timeStamp, value) => { 6 | array.push({ x: timeStamp, y: value }); 7 | if (array.length > MAX_LEN) { 8 | array.shift(); 9 | } 10 | }; 11 | 12 | class HeapGraph { 13 | constructor(broadcaster) { 14 | this.broadcaster = broadcaster; 15 | this.heapUsed = []; 16 | this.heapTotal = []; 17 | this.series = [{ 18 | name: 'heapUsed', 19 | data: this.heapUsed, 20 | }, { 21 | name: 'heapTotal', 22 | data: this.heapTotal, 23 | }]; 24 | } 25 | 26 | updateState() { 27 | const memoryUsage = process.memoryUsage(); 28 | const timeStamp = new Date().getTime() / 1000; 29 | add(this.heapUsed, timeStamp, memoryUsage.heapUsed); 30 | add(this.heapTotal, timeStamp, memoryUsage.heapTotal); 31 | } 32 | 33 | monitor() { 34 | this.updateState(); 35 | this.broadcast(); 36 | setTimeout(() => this.monitor(), intervalMs); 37 | } 38 | 39 | getState() { 40 | return { series: this.series }; 41 | } 42 | 43 | broadcast() { 44 | this.broadcaster.broadcast(Object.assign({ type: 'updateGraph', name: 'heapGraph' }, this.getState())); 45 | } 46 | 47 | } 48 | 49 | module.exports = HeapGraph; 50 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('babel-register'); 4 | require('./server'); 5 | -------------------------------------------------------------------------------- /server/monitor.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import winston from 'winston'; 3 | 4 | const intervalMs = 20000; 5 | 6 | class Monitor { 7 | constructor(broadcaster, actionType, runCheck) { 8 | this.broadcaster = broadcaster; 9 | this.actionType = actionType; 10 | this.failures = []; 11 | this.runCheck = runCheck; 12 | } 13 | 14 | monitor() { 15 | const startTime = Date.now(); 16 | return this.runCheck() 17 | .then((state) => { 18 | const elapsed = Date.now() - startTime; 19 | this.updateState(Object.assign({}, state, { elapsed })); 20 | }) 21 | .then(() => setTimeout(() => this.monitor(), intervalMs)) 22 | .catch((err) => { 23 | winston.error(err); 24 | this.updateState({ results: [{ name: err.message, status: 'Exception', url: '#' }] }); 25 | setTimeout(() => this.monitor(), intervalMs); 26 | }); 27 | } 28 | 29 | updateState(state) { 30 | this.failures = _.reject(state.results, s => s.status === 'OK'); 31 | this.description = state.description; 32 | this.elapsed = state.elapsed; 33 | this.broadcast(); 34 | } 35 | 36 | getState() { 37 | return _.pick(this, ['failures', 'description', 'elapsed']); 38 | } 39 | 40 | broadcast() { 41 | this.broadcaster.broadcast(Object.assign({ type: this.actionType }, this.getState())); 42 | } 43 | } 44 | 45 | module.exports = Monitor; 46 | -------------------------------------------------------------------------------- /server/renderer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { RouterProvider, routerForExpress } from 'redux-little-router'; 4 | import { renderToString } from 'react-dom/server'; 5 | import configureStore from '../common/store/configureStore'; 6 | import routes from '../common/routes'; 7 | import App from '../common/containers/App'; 8 | 9 | function renderFullPage(html, preloadedState) { 10 | return ` 11 | <!doctype html> 12 | <html> 13 | <head> 14 | <title>Dashboard Dashinator Style 15 | 16 | 17 | 18 | 19 | 20 |
${html}
21 | 24 | 25 | 26 | 27 | `; 28 | } 29 | 30 | const handleRender = currentStateFunc => (req, res) => { 31 | const currentState = currentStateFunc(); 32 | 33 | const { 34 | routerEnhancer, 35 | routerMiddleware, 36 | } = routerForExpress({ 37 | routes, 38 | request: req, 39 | }); 40 | const store = configureStore(currentState, routerEnhancer, routerMiddleware); 41 | 42 | const html = renderToString( 43 | 44 | 45 | 46 | 47 | 48 | ); 49 | 50 | const finalState = store.getState(); 51 | 52 | res.send(renderFullPage(html, finalState)); 53 | }; 54 | 55 | module.exports = handleRender; 56 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | import Express from 'express'; 2 | 3 | import webpack from 'webpack'; 4 | import webpackDevMiddleware from 'webpack-dev-middleware'; 5 | import webpackHotMiddleware from 'webpack-hot-middleware'; 6 | import lessMiddleware from 'less-middleware'; 7 | import winston from 'winston'; 8 | import Yaml from 'yamljs'; 9 | import gather from 'gather-stream'; 10 | import fs from 'fs'; 11 | import webpackConfig from '../webpack.config'; 12 | 13 | import Monitor from './monitor'; 14 | import healthChecksFor from './healthChecksFor'; 15 | import bambooCheckFor from './bambooCheckFor'; 16 | import handleRender from './renderer'; 17 | import Broadcaster from './broadcaster'; 18 | 19 | import HeapGraph from './heapGraph'; 20 | 21 | const broadcaster = new Broadcaster(); 22 | 23 | const app = new Express(); 24 | app.use(lessMiddleware('public')); 25 | 26 | const port = 3000; 27 | 28 | if (process.env.NODE_ENV !== 'production') { 29 | winston.warn('Using webpack HOT-LOADING, this shouldnt happen in prod'); 30 | // Use this middleware to set up hot module reloading via webpack. 31 | const compiler = webpack(webpackConfig); 32 | app.use(webpackDevMiddleware(compiler, { 33 | noInfo: true, 34 | publicPath: webpackConfig.output.publicPath, 35 | })); 36 | app.use(webpackHotMiddleware(compiler)); 37 | } else { 38 | app.get('*.js', (req, res, next) => { 39 | // eslint-disable-next-line no-param-reassign 40 | req.url = `${req.url}.gz`; 41 | res.set('Content-Encoding', 'gzip'); 42 | next(); 43 | }); 44 | } 45 | 46 | app.use(Express.static('public')); 47 | 48 | function start(configuration) { 49 | const dashboardConfig = Yaml.parse(configuration); 50 | 51 | const productionHealthChecks = healthChecksFor(dashboardConfig.productionEnvironment); 52 | const production = new Monitor(broadcaster, 'updateProduction', productionHealthChecks); 53 | production.monitor(); 54 | 55 | const testEnvHealthChecks = healthChecksFor(dashboardConfig.testEnvironments); 56 | const testEnvs = new Monitor(broadcaster, 'updateTestEnvs', testEnvHealthChecks); 57 | testEnvs.monitor(); 58 | 59 | const bambooCheck = bambooCheckFor(dashboardConfig.bamboo); 60 | const bamboo = new Monitor(broadcaster, 'updateCi', bambooCheck); 61 | bamboo.monitor(); 62 | 63 | const heapGraph = new HeapGraph(broadcaster); 64 | heapGraph.monitor(); 65 | 66 | const preloadedState = () => ({ 67 | testEnvs: testEnvs.getState(), 68 | production: production.getState(), 69 | ci: bamboo.getState(), 70 | kitchenSink: dashboardConfig.kitchenSink, 71 | graphs: { 72 | heapGraph: heapGraph.getState(), 73 | }, 74 | title: dashboardConfig.title, 75 | }); 76 | 77 | app.use(handleRender(preloadedState)); 78 | 79 | const server = app.listen(port, (error) => { 80 | if (error) { 81 | winston.error(error); 82 | } else { 83 | winston.info(`==> Listening on port ${port}. Open up http://localhost:${port}/ in your browser.`); 84 | } 85 | }); 86 | 87 | broadcaster.attach(server); 88 | } 89 | 90 | if (process.argv.length < 3) { 91 | winston.info('Usage:'); 92 | winston.info('dasher [config.yml | -]'); 93 | winston.info('Note that you can provide the config via stdin if you pass "-"'); 94 | winston.info('\n--Sample config--\n'); 95 | winston.info(fs.readFileSync(`${__dirname}/../sample-dashboard-config.yaml`).toString()); 96 | process.exit(1); 97 | } 98 | 99 | const fileArg = process.argv[2]; 100 | const stream = fileArg === '-' ? process.stdin : fs.createReadStream(fileArg); 101 | stream.pipe(gather((error, configuration) => { 102 | if (error) { 103 | winston.error(error); 104 | process.exit(1); 105 | } 106 | start(configuration.toString()); 107 | })); 108 | -------------------------------------------------------------------------------- /start-mon.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ./node_modules/.bin/nodemon server/index.js -------------------------------------------------------------------------------- /tests/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | jest: true 4 | rules: 5 | import/no-extraneous-dependencies: [error, { devDependencies: true }] 6 | globals: 7 | context: true -------------------------------------------------------------------------------- /tests/common/components/Counter.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | 4 | import Counter from '../../../common/components/Counter'; 5 | 6 | jest.useFakeTimers(); 7 | 8 | describe('Counter', () => { 9 | let counter; 10 | 11 | beforeEach(() => { 12 | counter = mount(); 13 | }); 14 | 15 | it('renders the name', () => { 16 | expect(counter.find('.title').text()).toEqual('test'); 17 | }); 18 | 19 | it('renders the unit', () => { 20 | expect(counter.find('.unit').text()).toEqual('seconds'); 21 | }); 22 | 23 | it('renders the value', () => { 24 | expect(counter.find('.value').text()).toEqual('30.0'); 25 | }); 26 | 27 | context('format the value', () => { 28 | beforeEach(() => { 29 | counter.setProps({ value: 1024, formatString: '0b' }); 30 | jest.runAllTimers(); 31 | }); 32 | 33 | it('renders the value', () => { 34 | expect(counter.find('.value').text()).toEqual('1KB'); 35 | }); 36 | }); 37 | 38 | context('updating the value', () => { 39 | beforeEach(() => { 40 | counter.setProps({ value: 40 }); 41 | jest.runAllTimers(); 42 | }); 43 | 44 | it('renders the value', () => { 45 | expect(counter.find('.value').text()).toEqual('40.0'); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /tests/common/components/Dashboard.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import Dashboard from '../../../common/components/Dashboard'; 5 | 6 | describe('Dashboard', () => { 7 | let dashboard; 8 | 9 | context('with the kitchen sink', () => { 10 | beforeEach(() => { 11 | dashboard = shallow( 12 | 20 | ); 21 | }); 22 | 23 | it('renders the counter', () => { 24 | expect(dashboard.find('Counter').length).toEqual(1); 25 | }); 26 | 27 | it('renders the heapGraph', () => { 28 | expect(dashboard.find('RickshawGraph').length).toEqual(1); 29 | }); 30 | 31 | it('renders the gauge', () => { 32 | expect(dashboard.find('.monthlyTarget').length).toEqual(1); 33 | }); 34 | }); 35 | 36 | context('without the kitchen sink', () => { 37 | beforeEach(() => { 38 | dashboard = shallow( 39 | 46 | ); 47 | }); 48 | 49 | it('sets the connection status on the connection alert', () => { 50 | expect(dashboard.find('.connectionAlert.disconnected').length).toEqual(1); 51 | }); 52 | 53 | it('does not render a counter', () => { 54 | expect(dashboard.find('Counter').length).toEqual(0); 55 | }); 56 | 57 | it('does not render a gauge', () => { 58 | expect(dashboard.find('.monthlyTarget').length).toEqual(0); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /tests/common/components/Failure.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import Failure from '../../../common/components/Failure'; 5 | 6 | describe('Failure', () => { 7 | let failureComponent; 8 | 9 | beforeEach(() => { 10 | failureComponent = shallow(); 11 | }); 12 | 13 | it("has a 'failure' class name for styling", () => { 14 | expect(failureComponent.find('.failure').length).toEqual(1); 15 | }); 16 | 17 | it('renders the link', () => { 18 | expect(failureComponent.find('a').prop('href')).toEqual('http://blah'); 19 | }); 20 | 21 | it('renders the name', () => { 22 | expect(failureComponent.find('a .name').text()).toEqual('test'); 23 | }); 24 | 25 | it('renders the reason', () => { 26 | expect(failureComponent.find('a .reason').text()).toEqual('because'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /tests/common/components/FailureList.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import FailureList from '../../../common/components/FailureList'; 5 | import Failure from '../../../common/components/Failure'; 6 | 7 | describe('FailureList', () => { 8 | let component; 9 | 10 | context('there are no failures', () => { 11 | beforeEach(() => { 12 | component = shallow(); 13 | }); 14 | 15 | it("has a 'no-failures' class name", () => { 16 | expect(component.find('.no-failures').length).toEqual(1); 17 | }); 18 | 19 | it("does not have a 'has-failures' class name", () => { 20 | expect(component.find('.has-failures').length).toEqual(0); 21 | }); 22 | 23 | it('renders the description', () => { 24 | expect(component.find('.footer .description').text()).toEqual('cat'); 25 | }); 26 | 27 | it('renders the elapsed time', () => { 28 | expect(component.find('.footer .elapsed').text()).toEqual('in 1.234 seconds'); 29 | }); 30 | }); 31 | 32 | context('there are failures', () => { 33 | const failures = [{ 34 | name: 'blah', 35 | url: 'http://somewhere', 36 | }]; 37 | 38 | beforeEach(() => { 39 | component = shallow(); 40 | }); 41 | 42 | it("does not have a 'no-failures' class name", () => { 43 | expect(component.find('.no-failures').length).toEqual(0); 44 | }); 45 | 46 | it("has a 'has-failures' class name", () => { 47 | expect(component.find('.has-failures').length).toEqual(1); 48 | }); 49 | 50 | it('renders the failure', () => { 51 | const failureNode = component.find(Failure); 52 | expect(failureNode.length).toEqual(1); 53 | expect(failureNode.props()).toEqual(failures[0]); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/common/components/Gauge.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import Gauge from '../../../common/components/Gauge'; 5 | 6 | describe('Gauge', () => { 7 | let gauge; 8 | 9 | beforeEach(() => { 10 | gauge = shallow(); 11 | }); 12 | 13 | it('renders the name', () => { 14 | expect(gauge.find('.name').text()).toEqual('test'); 15 | }); 16 | 17 | it('renders the description', () => { 18 | expect(gauge.find('.description').text()).toEqual('cats'); 19 | }); 20 | 21 | it('renders a donut chart', () => { 22 | expect(gauge.find('ChartistGraph').isEmpty()).toEqual(false); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/server/bambooCheckFor.test.js: -------------------------------------------------------------------------------- 1 | import requestStub from 'request-promise-native'; 2 | import winstonStub from '../winstonStub'; 3 | import bambooCheckFor from '../../server/bambooCheckFor'; 4 | 5 | jest.mock('winston', () => winstonStub); 6 | jest.mock('request-promise-native', () => jest.fn(() => Promise.resolve())); 7 | 8 | describe('bambooCheckFor', () => { 9 | let bambooCheck; 10 | 11 | const bambooConfig = { 12 | plans: ['blah'], 13 | baseUrl: 'http://base', 14 | requestOptions: { 15 | auth: { 16 | user: 'fred', 17 | password: '1234', 18 | }, 19 | }, 20 | }; 21 | 22 | const bambooResultFor = buildState => ({ 23 | results: { 24 | result: [{ 25 | plan: { 26 | shortName: 'My Plan', 27 | }, 28 | buildState, 29 | buildResultKey: 'ABC-1234', 30 | }], 31 | }, 32 | }); 33 | 34 | beforeEach(() => { 35 | bambooCheck = bambooCheckFor(bambooConfig); 36 | }); 37 | 38 | context('build successful', () => { 39 | beforeEach(() => { 40 | requestStub.mockImplementation(() => Promise.resolve(bambooResultFor('Successful'))); 41 | }); 42 | 43 | it('returns an OK status', () => 44 | bambooCheck().then(state => 45 | expect(state).toEqual({ 46 | results: [{ 47 | name: 'My Plan', 48 | status: 'OK', 49 | url: 'http://base/browse/ABC-1234', 50 | }], 51 | description: 'Monitoring 1 bamboo plan(s)' }) 52 | ) 53 | ); 54 | 55 | it('makes a request with the config provided', () => 56 | bambooCheck().then(() => 57 | expect(requestStub).toBeCalledWith({ 58 | json: true, 59 | url: 'http://base/rest/api/latest/result/blah?os_authType=basic&max-result=1', 60 | auth: { 61 | user: 'fred', 62 | password: '1234', 63 | }, 64 | }) 65 | )); 66 | }); 67 | 68 | context('build failed', () => { 69 | beforeEach(() => { 70 | requestStub.mockImplementation(() => Promise.resolve(bambooResultFor('FAILED'))); 71 | }); 72 | 73 | it('returns the failure status', () => 74 | bambooCheck().then(state => 75 | expect(state).toEqual({ results: [{ 76 | name: 'My Plan', 77 | status: 'FAILED', 78 | url: 'http://base/browse/ABC-1234', 79 | }], 80 | description: 'Monitoring 1 bamboo plan(s)' }) 81 | ) 82 | ); 83 | }); 84 | 85 | context('exception accessing bamboo', () => { 86 | beforeEach(() => { 87 | requestStub.mockImplementation(() => Promise.reject(new Error('Access Denied'))); 88 | }); 89 | 90 | it('returns the exceptiom details', () => 91 | bambooCheck().then(state => 92 | expect(state.results).toEqual([{ 93 | name: 'Access Denied', 94 | status: 'Exception', 95 | url: '#', 96 | }]) 97 | ) 98 | ); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /tests/server/broadcaster.test.js: -------------------------------------------------------------------------------- 1 | import socketIoStub from 'socket.io'; 2 | import winstonStub from '../winstonStub'; 3 | import Broadcaster from '../../server/broadcaster'; 4 | 5 | jest.mock('winston', () => winstonStub); 6 | 7 | jest.mock('socket.io'); 8 | 9 | describe('Broadcaster', () => { 10 | let socket; 11 | let broadcaster; 12 | let ioStub; 13 | 14 | beforeEach(() => { 15 | broadcaster = new Broadcaster(); 16 | ioStub = { 17 | attach: jest.fn(), 18 | emit: jest.fn(), 19 | on: jest.fn(), 20 | }; 21 | socketIoStub.mockImplementation(() => ioStub); 22 | 23 | socket = { 24 | on: jest.fn(), 25 | emit: jest.fn(), 26 | }; 27 | }); 28 | 29 | describe('attach', () => { 30 | const server = { 31 | express: 'server', 32 | }; 33 | 34 | beforeEach(() => { 35 | ioStub.on.mockImplementation((event, handleFunc) => handleFunc(socket)); 36 | broadcaster.addSocket = jest.fn(); 37 | broadcaster.attach(server); 38 | }); 39 | 40 | it('attaches socket io to the server', () => { 41 | expect(ioStub.attach).toBeCalledWith(server); 42 | }); 43 | 44 | it('listens to connection events', () => { 45 | expect(ioStub.on.mock.calls[0][0]).toEqual('connection'); 46 | }); 47 | 48 | it('delegates to addSocket when a connection occurs', () => { 49 | expect(broadcaster.addSocket).toBeCalledWith(socket); 50 | }); 51 | }); 52 | 53 | describe('addSocket', () => { 54 | let disconnectFunction; 55 | beforeEach(() => { 56 | socket.on.mockImplementation((event, handleFunc) => (disconnectFunction = handleFunc)); 57 | broadcaster.addSocket(socket); 58 | }); 59 | 60 | it('adds the socket to the connections', () => { 61 | expect(broadcaster.connections).toEqual([socket]); 62 | }); 63 | 64 | it('listens to socket disconnect events', () => { 65 | expect(socket.on.mock.calls[0][0]).toEqual('disconnect'); 66 | }); 67 | 68 | context('socket disconnects', () => { 69 | beforeEach(() => disconnectFunction()); 70 | 71 | it('removes the socket from connections', () => { 72 | expect(broadcaster.connections).toEqual([]); 73 | }); 74 | }); 75 | }); 76 | 77 | describe('broadcast', () => { 78 | const action = { 79 | something: 'great', 80 | }; 81 | 82 | beforeEach(() => { 83 | broadcaster.addSocket(socket); 84 | broadcaster.broadcast(action); 85 | }); 86 | 87 | it('emits the action to the connected sockets', () => { 88 | expect(socket.emit).toBeCalledWith('action', action); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /tests/server/healthChecksFor.test.js: -------------------------------------------------------------------------------- 1 | import requestStub from 'request-promise-native'; 2 | import winstonStub from '../winstonStub'; 3 | import healthChecksFor from '../../server/healthChecksFor'; 4 | 5 | jest.mock('winston', () => winstonStub); 6 | jest.mock('request-promise-native', () => jest.fn(() => Promise.resolve())); 7 | 8 | describe('healthChecksFor', () => { 9 | const services = [{ 10 | name: 'my service', 11 | url: 'http://blah', 12 | }]; 13 | 14 | let healthCheck; 15 | 16 | beforeEach(() => { 17 | healthCheck = healthChecksFor(services); 18 | }); 19 | 20 | context('service is healthy', () => { 21 | let state; 22 | 23 | beforeEach(() => 24 | healthCheck().then(result => (state = result)) 25 | ); 26 | 27 | it('returns OK', () => { 28 | expect(state).toEqual({ 29 | results: [ 30 | { 31 | name: 'my service', 32 | status: 'OK', 33 | url: 'http://blah', 34 | }], 35 | description: 'Monitoring 1 service(s)', 36 | }); 37 | }); 38 | 39 | it('makes the request', () => { 40 | expect(requestStub).toBeCalledWith({ 41 | resolveWithFullResponse: true, 42 | uri: 'http://blah', 43 | timeout: 2000, 44 | }); 45 | }); 46 | }); 47 | 48 | context('with request options', () => { 49 | beforeEach(() => 50 | healthChecksFor([{ 51 | name: 'my service with request options', 52 | url: 'http://whatever', 53 | requestOptions: { timeout: 3000, auth: { user: 'cat', password: 'dog' } }, 54 | }])()); 55 | 56 | it('makes the request with the options', () => { 57 | expect(requestStub).toBeCalledWith({ 58 | auth: { 59 | password: 'dog', 60 | user: 'cat', 61 | }, 62 | timeout: 3000, 63 | resolveWithFullResponse: true, 64 | uri: 'http://whatever', 65 | }); 66 | }); 67 | }); 68 | 69 | context('service fails with a bad response', () => { 70 | let error; 71 | 72 | beforeEach(() => { 73 | error = new Error('bad response'); 74 | error.response = { 75 | statusCode: 503, 76 | body: 'Forbidden!', 77 | }; 78 | requestStub.mockImplementation(() => Promise.reject(error)); 79 | }); 80 | 81 | it('returns the error message', () => healthCheck() 82 | .then(state => expect(state).toEqual({ 83 | results: [ 84 | { 85 | name: 'my service', 86 | status: '503 - Forbidden!', 87 | url: 'http://blah', 88 | }], 89 | description: 'Monitoring 1 service(s)', 90 | }) 91 | )); 92 | }); 93 | 94 | context('service fails', () => { 95 | beforeEach(() => { 96 | requestStub.mockImplementation(() => Promise.reject(new Error('no'))); 97 | }); 98 | 99 | it('returns the error message', () => healthCheck() 100 | .then(state => expect(state).toEqual({ 101 | results: [ 102 | { 103 | name: 'my service', 104 | status: 'no', 105 | url: 'http://blah', 106 | }], 107 | description: 'Monitoring 1 service(s)', 108 | }) 109 | )); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /tests/server/monitor.test.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import winstonStub from '../winstonStub'; 3 | 4 | import Monitor from '../../server/monitor'; 5 | 6 | jest.useFakeTimers(); 7 | jest.mock('winston', () => winstonStub); 8 | 9 | describe('Monitor', () => { 10 | let runCheck; 11 | const broadcasterStub = { 12 | broadcast: jest.fn(), 13 | }; 14 | 15 | const actionType = 'updateSomething'; 16 | let monitor; 17 | 18 | beforeEach(() => { 19 | runCheck = jest.fn(() => Promise.resolve()); 20 | monitor = new Monitor(broadcasterStub, actionType, runCheck); 21 | }); 22 | 23 | describe('monitor', () => { 24 | beforeEach(() => { 25 | monitor.updateState = jest.fn(); 26 | }); 27 | 28 | context('runCheck succeeds', () => { 29 | const state = { 30 | results: [{ name: 'test', url: 'somewere', status: 'OK' }], 31 | description: 'sweet', 32 | }; 33 | 34 | beforeEach(() => { 35 | runCheck.mockImplementation(() => Promise.resolve(state)); 36 | return monitor.monitor(); 37 | }); 38 | 39 | it('calls runCheck', () => { 40 | expect(runCheck.mock.calls.length).toEqual(1); 41 | }); 42 | 43 | it('updates the state the new state', () => { 44 | const actual = _.omit(monitor.updateState.mock.calls[0][0], ['elapsed']); 45 | expect(actual).toEqual(_.omit(state, ['elapsed'])); 46 | }); 47 | 48 | it('calculates the elapsed time', () => { 49 | expect(monitor.updateState.mock.calls[0][0].elapsed).toBeGreaterThanOrEqual(0); 50 | }); 51 | 52 | it('schedules to call itself', () => { 53 | jest.runOnlyPendingTimers(); 54 | expect(runCheck.mock.calls.length).toEqual(2); 55 | }); 56 | }); 57 | 58 | context('runCheck fails', () => { 59 | beforeEach(() => { 60 | runCheck.mockImplementation(() => Promise.reject(new Error('badness'))); 61 | return monitor.monitor(); 62 | }); 63 | 64 | it('updates the state with an error', () => { 65 | expect(monitor.updateState).toBeCalledWith({ 66 | results: [{ name: 'badness', status: 'Exception', url: '#' }], 67 | }); 68 | }); 69 | 70 | it('schedules to call itself', () => { 71 | jest.runOnlyPendingTimers(); 72 | expect(runCheck.mock.calls.length).toEqual(2); 73 | }); 74 | }); 75 | }); 76 | 77 | describe('updateState', () => { 78 | const serverResults1 = { 79 | name: 'serverResults1', 80 | status: 'OK', 81 | }; 82 | const serverResults2 = { 83 | name: 'serverResults2', 84 | status: 'Failure', 85 | }; 86 | 87 | const state = { 88 | results: [serverResults1, serverResults2], 89 | description: 'checking things!', 90 | }; 91 | 92 | beforeEach(() => { 93 | monitor.broadcast = jest.fn(); 94 | monitor.updateState(state); 95 | }); 96 | 97 | it('sets the failures property to the failed health checks', () => { 98 | expect(monitor.failures).toEqual([serverResults2]); 99 | }); 100 | 101 | it('sets the description', () => { 102 | expect(monitor.description).toEqual(state.description); 103 | }); 104 | 105 | it('calls broadcast to broadcast the results', () => { 106 | expect(monitor.broadcast.mock.calls.length).toBeGreaterThan(0); 107 | }); 108 | }); 109 | 110 | describe('broadcast', () => { 111 | const failures = ['badness']; 112 | 113 | beforeEach(() => { 114 | monitor.failures = failures; 115 | monitor.broadcast(); 116 | }); 117 | 118 | it('broadcasts the update action', () => { 119 | expect(broadcasterStub.broadcast).toBeCalledWith({ 120 | type: actionType, 121 | failures, 122 | }); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /tests/winstonStub.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | error: jest.fn(), 3 | warn: jest.fn(), 4 | info: jest.fn(), 5 | }; 6 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: 'cheap-source-map', 6 | entry: [ 7 | 'webpack-hot-middleware/client', 8 | './client/index.js', 9 | ], 10 | output: { 11 | path: path.join(__dirname, 'dist'), 12 | filename: 'bundle.js', 13 | }, 14 | plugins: [ 15 | new webpack.optimize.OccurrenceOrderPlugin(), 16 | new webpack.HotModuleReplacementPlugin(), 17 | ], 18 | module: { 19 | loaders: [{ 20 | test: /\.js$/, 21 | loader: 'babel-loader', 22 | exclude: /node_modules/, 23 | include: __dirname, 24 | query: { 25 | presets: ['react-hmre'], 26 | }, 27 | }], 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const CompressionPlugin = require('compression-webpack-plugin'); 4 | 5 | module.exports = { 6 | entry: [ 7 | './client/index.js', 8 | ], 9 | output: { 10 | path: path.join(__dirname, 'public'), 11 | filename: 'bundle.js', 12 | }, 13 | plugins: [ 14 | new webpack.optimize.OccurrenceOrderPlugin(), 15 | new webpack.DefinePlugin({ 16 | 'process.env': { 17 | NODE_ENV: JSON.stringify('production'), 18 | }, 19 | }), 20 | new webpack.optimize.DedupePlugin(), 21 | new webpack.optimize.UglifyJsPlugin({ mangle: false, compress: { warnings: false } }), 22 | new webpack.optimize.AggressiveMergingPlugin(), 23 | new CompressionPlugin({ 24 | asset: '[path].gz[query]', 25 | algorithm: 'gzip', 26 | test: /\.js$|\.css$|\.html$/, 27 | threshold: 10240, 28 | minRatio: 0.8, 29 | }), 30 | ], 31 | module: { 32 | loaders: [{ 33 | test: /\.js$/, 34 | loader: 'babel-loader', 35 | exclude: /node_modules/, 36 | include: __dirname, 37 | query: { 38 | presets: ['es2015', 'react'], 39 | babelrc: false, 40 | }, 41 | }], 42 | }, 43 | }; 44 | --------------------------------------------------------------------------------