129 |
{this.props.name}
130 | {counter}
131 |
{ this.chartContainer = container; }}
133 | />
134 |
{ this.legend = container; }}
136 | />
137 |
138 |
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 |
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 |
12 |
13 |
14 |
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 |
--------------------------------------------------------------------------------