├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .jscsrc ├── Gruntfile.js ├── LICENSE ├── README.md ├── circle.yml ├── docs └── example.jpg ├── package.json ├── src ├── app │ ├── ConfigurationStore.js │ ├── PasswordPrompt.js │ ├── StatusDashboard.js │ ├── StatusIndicator.js │ └── StatusStore.js ├── config.json ├── favicon.png ├── flux │ ├── Action.js │ ├── CachingStore.js │ ├── Store.js │ └── SubscribeMixin.js ├── index.html ├── main.js ├── source │ ├── DockerCloudService.js │ ├── DropwizardHealthcheck.js │ ├── GithubBranches.js │ ├── Loggly.js │ ├── Message.js │ ├── RssAws.js │ ├── RssBase.js │ ├── Source.js │ ├── SourceTypes.js │ ├── StatusCode.js │ ├── StatusIo.js │ ├── VstsBase.js │ ├── VstsBranches.js │ └── VstsBuild.js ├── style.less ├── touch-icon.png └── util │ ├── AppVersion.js │ ├── BuildUtils.js │ ├── Logger.js │ ├── Mixins.js │ ├── RegExps.js │ └── UrlParameters.js └── test ├── TestUtils.js ├── es6Setup.js ├── flux ├── ActionTest.js ├── CachingStoreTest.js ├── StoreTest.js └── SubscribeMixinTest.js ├── source └── DropwizardHealthcheckTest.js ├── testSetup.js └── util ├── AppVersionTest.js ├── BuildUtilsTest.js ├── LoggerTest.js ├── MixinsTest.js └── RegExpsTest.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["transform-runtime", "transform-object-rest-spread"], 3 | "presets": ["es2015", "react"], 4 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 4 4 | charset = utf-8 5 | end_of_line = crlf 6 | 7 | [*.yml] 8 | indent_size = 2 9 | 10 | [package.json] 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 6, 4 | "ecmaFeatures": { 5 | "experimentalObjectRestSpread": true, 6 | "jsx": true 7 | }, 8 | "sourceType": "module" 9 | }, 10 | "env": { 11 | "browser": true, 12 | "mocha": true, 13 | "es6": true 14 | }, 15 | "globals": { 16 | "__filename": true, 17 | "process": true 18 | }, 19 | "plugins": [ 20 | "react", 21 | "lodash" 22 | ], 23 | "extends": "eslint:recommended", 24 | "rules": { 25 | "consistent-return": 2, 26 | "curly": 2, 27 | "dot-location": [2, "property"], 28 | "dot-notation": 2, 29 | "eqeqeq": 2, 30 | "no-alert": 2, 31 | "no-caller": 2, 32 | "no-case-declarations": 2, 33 | "no-eval": 2, 34 | "no-extend-native": 2, 35 | "no-implied-eval": 2, 36 | "no-invalid-this": 2, 37 | "no-lone-blocks": 2, 38 | "no-multi-spaces": 2, 39 | "no-native-reassign": 2, 40 | "no-new-func": 2, 41 | "no-new-wrappers": 2, 42 | "no-return-assign": 2, 43 | "no-script-url": 2, 44 | "no-self-compare": 2, 45 | "no-sequences": 2, 46 | "no-throw-literal": 2, 47 | "no-useless-call": 2, 48 | "no-void": 2, 49 | "no-with": 2, 50 | "radix": 2, 51 | 52 | "no-catch-shadow": 2, 53 | "no-shadow": 2, 54 | 55 | "array-bracket-spacing": 2, 56 | "brace-style": 2, 57 | "comma-spacing": 2, 58 | "comma-style": 2, 59 | "computed-property-spacing": 2, 60 | "indent": 2, 61 | "jsx-quotes": 2, 62 | "key-spacing": 2, 63 | "keyword-spacing": 2, 64 | "new-cap": 2, 65 | "new-parens": 2, 66 | "no-array-constructor": 2, 67 | "no-new-object": 2, 68 | "no-spaced-func": 2, 69 | "no-trailing-spaces": 2, 70 | "no-unneeded-ternary": 2, 71 | "object-curly-spacing": 2, 72 | "quotes": 2, 73 | "semi-spacing": 2, 74 | "semi": 2, 75 | "space-before-blocks": 2, 76 | "space-in-parens": 2, 77 | "space-infix-ops": 2, 78 | "space-unary-ops": 2, 79 | 80 | "arrow-parens": [2, "as-needed"], 81 | "arrow-spacing": 2, 82 | "constructor-super": 2, 83 | "no-class-assign": 2, 84 | "no-const-assign": 2, 85 | "no-dupe-class-members": 2, 86 | "no-this-before-super": 2, 87 | "object-shorthand": [2, "methods"], 88 | "prefer-arrow-callback": 2, 89 | "prefer-spread": 2, 90 | 91 | "no-implicit-globals": 2, 92 | "no-new-symbol": 2, 93 | "no-useless-constructor": 2, 94 | "no-whitespace-before-property": 2, 95 | "prefer-rest-params": 2, 96 | "template-curly-spacing": 2, 97 | 98 | "react/jsx-curly-spacing": 2, 99 | "react/jsx-no-duplicate-props": 2, 100 | "react/jsx-no-undef": 2, 101 | "react/jsx-uses-react": 2, 102 | "react/jsx-uses-vars": 2, 103 | "react/no-did-mount-set-state": 2, 104 | "react/no-did-update-set-state": 2, 105 | "react/no-direct-mutation-state": 2, 106 | "react/no-unknown-property": 2, 107 | "react/prefer-es6-class": 2, 108 | "react/react-in-jsx-scope": 2, 109 | "react/self-closing-comp": 2, 110 | "react/sort-comp": 2, 111 | "react/wrap-multilines": 2, 112 | 113 | "lodash/no-double-unwrap": 2, 114 | "lodash/chain-style": [2, "explicit"], 115 | "lodash/prefer-lodash-method": 2, 116 | "lodash/prefer-lodash-typecheck": 2, 117 | "lodash/prefer-startswith": 2 118 | } 119 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | target 4 | node_modules 5 | npm-debug.log -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "disallowKeywords": ["with"], 3 | "disallowMixedSpacesAndTabs": true, 4 | "disallowNewlineBeforeBlockStatements": true, 5 | "disallowQuotedKeysInObjects": "allButReserved", 6 | "disallowSpaceAfterObjectKeys": true, 7 | "disallowSpaceAfterPrefixUnaryOperators": true, 8 | "disallowSpaceBeforePostfixUnaryOperators": true, 9 | "disallowSpacesInCallExpression": true, 10 | "disallowSpacesInNamedFunctionExpression": {"beforeOpeningRoundBrace": true}, 11 | "disallowSpacesInsideParentheses": true, 12 | "disallowTrailingComma": true, 13 | "disallowTrailingWhitespace": "ignoreEmptyLines", 14 | 15 | "requireBlocksOnNewline": true, 16 | "requireCamelCaseOrUpperCaseIdentifiers": "ignoreProperties", 17 | "requireCapitalizedConstructors": true, 18 | "requireCapitalizedConstructorsNew": true, 19 | "requireCommaBeforeLineBreak": true, 20 | "requireCurlyBraces": true, 21 | "requireDotNotation": true, 22 | "requireParenthesesAroundIIFE": true, 23 | "requireSpaceAfterBinaryOperators": true, 24 | "requireSpaceAfterKeywords": true, 25 | "requireSpaceBeforeBlockStatements": true, 26 | "requireSpaceBeforeObjectValues": true, 27 | "requireSpaceBetweenArguments": true, 28 | "requireSpacesInForStatement": true, 29 | 30 | "validateIndentation": 4, 31 | "validateParameterSeparator": ", ", 32 | "validateQuoteMarks": "\"", 33 | 34 | "esnext": true 35 | } -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /*global module, require, process*/ 2 | /*eslint prefer-arrow-callback: 0, no-invalid-this: 0, object-curly-spacing: 0*/ 3 | var webpack = require("webpack"); 4 | var _ = require("lodash"); 5 | var HtmlPlugin = require("html-webpack-plugin"); 6 | var ExtractTextPlugin = require("extract-text-webpack-plugin"); 7 | var CompressionPlugin = require("compression-webpack-plugin"); 8 | 9 | module.exports = function (grunt) { 10 | grunt.initConfig({}); 11 | 12 | var pkg = grunt.file.readJSON("./package.json"); 13 | 14 | grunt.loadNpmTasks("grunt-webpack"); 15 | grunt.config.set("webpack", { 16 | build: { 17 | context: "src", 18 | entry: { 19 | main: "./main.js", 20 | vendor: _.without(_.keys(pkg.dependencies), "bootstrap", "bootswatch") 21 | }, 22 | output: { 23 | path: "target/dist", 24 | filename: "main-[chunkhash].min.js" 25 | }, 26 | module: { 27 | loaders: [{ 28 | loader: "babel", 29 | test: /\.js$/, 30 | exclude: /node_modules/, 31 | query: { 32 | cacheDirectory: true 33 | } 34 | }, { 35 | loader: ExtractTextPlugin.extract("style", "css"), 36 | test: /\.css$/ 37 | }, { 38 | loader: ExtractTextPlugin.extract("style", "css!less"), 39 | test: /\.less$/ 40 | }, { 41 | loader: "file", 42 | test: /\.(png|jpg|woff2?|ttf|eot|svg)$/, 43 | query: { 44 | name: "[name]-[hash].[ext]" 45 | } 46 | }, { 47 | loader: "json", 48 | test: /\.json$/ 49 | }] 50 | }, 51 | plugins: [ 52 | // Keep the same module order between builds so the output file stays the same if there are no changes. 53 | new webpack.optimize.OccurenceOrderPlugin(), 54 | new webpack.optimize.CommonsChunkPlugin("vendor", "vendor-[chunkhash].min.js"), 55 | new HtmlPlugin({ 56 | template: "./index.html" 57 | }), 58 | new webpack.DefinePlugin({ 59 | "process.env": { 60 | // Disable React's development checks. 61 | NODE_ENV: JSON.stringify("production") 62 | } 63 | }), 64 | new ExtractTextPlugin("main-[contenthash].css"), 65 | new webpack.optimize.UglifyJsPlugin({ 66 | minimize: true, 67 | // Remove all comments. 68 | comments: /a^/g, 69 | compress: { 70 | warnings: false 71 | } 72 | }), 73 | new CompressionPlugin(), 74 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/) 75 | ], 76 | node: { 77 | __filename: true 78 | }, 79 | progress: false 80 | } 81 | }); 82 | 83 | grunt.config.set("webpack-dev-server", { 84 | start: { 85 | webpack: { 86 | context: "src", 87 | entry: [ 88 | "webpack-dev-server/client?http://localhost:8080", 89 | "webpack/hot/only-dev-server", 90 | "./main.js" 91 | ], 92 | output: { 93 | path: "target", 94 | filename: "main.js" 95 | }, 96 | module: { 97 | loaders: [{ 98 | loader: "babel", 99 | test: /\.js$/, 100 | exclude: /node_modules/, 101 | query: { 102 | cacheDirectory: true, 103 | plugins: [["react-transform", { 104 | transforms: [{ 105 | transform: "react-transform-hmr", 106 | imports: ["react"], 107 | locals: ["module"] 108 | }, { 109 | transform: "react-transform-catch-errors", 110 | imports: ["react", "redbox-react"] 111 | }] 112 | }]] 113 | } 114 | }, { 115 | loader: "style!css", 116 | test: /\.css$/ 117 | }, { 118 | loader: "style!css!less", 119 | test: /\.less$/ 120 | }, { 121 | loader: "file", 122 | test: /\.(png|jpg|woff2?|ttf|eot|svg)$/, 123 | query: { 124 | name: "name=[name]-[hash].[ext]" 125 | } 126 | }, { 127 | loader: "json", 128 | test: /\.json$/ 129 | }] 130 | }, 131 | plugins: [ 132 | new HtmlPlugin({ 133 | template: "./index.html" 134 | }), 135 | new webpack.HotModuleReplacementPlugin(), 136 | new webpack.DefinePlugin({ 137 | "process.env": { 138 | NODE_ENV: JSON.stringify("development") 139 | } 140 | }) 141 | ], 142 | node: { 143 | __filename: true 144 | }, 145 | watch: true, 146 | keepalive: true, 147 | devtool: "cheap-module-eval-source-map" 148 | }, 149 | publicPath: "/", 150 | hot: true, 151 | keepAlive: true 152 | } 153 | }); 154 | 155 | grunt.loadNpmTasks("grunt-jscs"); 156 | var src = ["src/**/*.js", "test/**/*.js", "Gruntfile.js"]; 157 | grunt.config.set("jscs", { 158 | options: { 159 | config: ".jscsrc" 160 | }, 161 | dev: { 162 | src: src 163 | }, 164 | fix: { 165 | options: { 166 | fix: true 167 | }, 168 | src: src 169 | }, 170 | ci: { 171 | options: { 172 | reporter: "junit", 173 | reporterOutput: "target/style.xml" 174 | }, 175 | src: src 176 | } 177 | }); 178 | 179 | grunt.loadNpmTasks("grunt-eslint"); 180 | grunt.config.set("eslint", { 181 | options: { 182 | configFile: ".eslintrc" 183 | }, 184 | dev: { 185 | src: src 186 | }, 187 | ci: { 188 | options: { 189 | format: "junit", 190 | outputFile: "target/lint.xml" 191 | }, 192 | src: src 193 | } 194 | }); 195 | 196 | grunt.loadNpmTasks("grunt-mocha-test"); 197 | var testSrc = ["test/**/*Test.js"]; 198 | grunt.config.set("mochaTest", { 199 | options: { 200 | require: [ 201 | "test/es6Setup", 202 | "test/testSetup" 203 | ] 204 | }, 205 | dev: { 206 | src: testSrc 207 | }, 208 | ci: { 209 | options: { 210 | reporter: "xunit", 211 | captureFile: "target/tests.xml", 212 | quiet: true 213 | }, 214 | src: testSrc 215 | } 216 | }); 217 | 218 | grunt.loadNpmTasks("grunt-contrib-clean"); 219 | grunt.config.set("clean", { 220 | dist: ["target/dist/*"] 221 | }); 222 | 223 | grunt.loadNpmTasks("grunt-aws"); 224 | grunt.config.set("s3", { 225 | options: { 226 | accessKeyId: "", 227 | secretAccessKey: "", 228 | region: "us-east-1", 229 | bucket: "" 230 | }, 231 | upload: { 232 | files: [{ 233 | cwd: "target/dist/", 234 | src: "**", 235 | dest: "status/" 236 | }, { 237 | cwd: "src/", 238 | src: "config.json", 239 | dest: "status/" 240 | }] 241 | } 242 | }); 243 | 244 | 245 | grunt.registerTask("dev", ["webpack-dev-server:start"]); 246 | grunt.registerTask("test", ["eslint:dev", "jscs:dev", "mochaTest:dev"]); 247 | grunt.registerTask("build", ["clean:dist", "webpack:build"]); 248 | grunt.registerTask("upload", ["s3:upload"]); 249 | 250 | grunt.registerTask("ci", ["eslint:ci", "jscs:ci", "mochaTest:ci", "build"]); 251 | }; 252 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Bo Gotthardt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # simple-dashboard 2 | [![Circle CI](https://circleci.com/gh/Lugribossk/simple-dashboard.svg?style=shield)](https://circleci.com/gh/Lugribossk/simple-dashboard) 3 | [![Dependency Status](https://david-dm.org/Lugribossk/simple-dashboard.svg)](https://david-dm.org/Lugribossk/simple-dashboard) 4 | [![devDependency Status](https://david-dm.org/Lugribossk/simple-dashboard/dev-status.svg)](https://david-dm.org/Lugribossk/simple-dashboard#info=devDependencies) 5 | 6 | A straightforward dashboard for showing an overview of the status of servers and infrastructure. 7 | Runs entirely in the browser as static Javascript so it can be hosted easily without needing to set up and maintain yet another server. 8 | 9 | ![Example](/docs/example.jpg) 10 | 11 | ## Configuration 12 | The dashboard is configured via a JSON config file that defines where to get status information from. 13 | 14 | ```json 15 | { 16 | "title": "Infrastructure status", 17 | "sources": [{ 18 | "type": "statusio", 19 | "title": "Docker", 20 | "link": "http://status.docker.com", 21 | "id": "533c6539221ae15e3f000031" 22 | }, { 23 | "type": "rss-aws", 24 | "title": "CloudFront", 25 | "id": "cloudfront" 26 | }, { 27 | "type": "rss-aws", 28 | "title": "EC2 US East", 29 | "id": "ec2-us-east-1" 30 | }, { 31 | "type": "dropwizard", 32 | "title": "Production - Healthcheck", 33 | "adminPath": "http://localhost:9090/admin" 34 | }] 35 | } 36 | ``` 37 | 38 | Name|Description 39 | ---|--- 40 | title|Title to show at the top of the dashboard. Optional. 41 | sources|List of status sources to show and their individual configurations. 42 | panels|List of panels that split the screen 43 | 44 | ## Status sources 45 | 46 | ### General options 47 | Options for all the status sources. 48 | 49 | Name|Default|Description 50 | ---|---|--- 51 | type||Which kind of source this is, must be one of the types listed below, e.g. `status-code` or `vsts-branches`. 52 | title||Title displayed on status indicator, e.g. `Production Healthcheck`. 53 | interval|60|Number of seconds between status checks. 54 | 55 | ### Docker Cloud service 56 | Status of a [Docker Cloud](https://cloud.docker.com/) (formerly Tutum) service. 57 | 58 | Name|Default|Description 59 | ---|---|--- 60 | type||`docker-cloud-service` 61 | id||Service ID. 62 | username||Docker Cloud account username. 63 | apiKey||Docker Cloud account API key. 64 | 65 | ### Dropwizard healthcheck 66 | The status of a [Dropwizard](http://www.dropwizard.io) service's [health checks](http://www.dropwizard.io/manual/core.html#health-checks). 67 | 68 | By default Dropwizard is not set up to allow cross-origin requests, so you will have to add a servlet filter to the admin port that does this. 69 | TODO example. 70 | 71 | Name|Default|Description 72 | ---|---|--- 73 | type||`dropwizard` 74 | adminPath||Path to the admin port for your service, e.g. `http://localhost:8081` for a local server with the default admin settings. 75 | 76 | ### GitHub branches 77 | All the branches of a GitHub repository. Also shows any open pull requests from those branches to master. 78 | 79 | Can also show the [status](https://developer.github.com/v3/repos/statuses/) of the latest commit in each branch. 80 | This is set by many build system that integrate with GitHub such as CircleCI. 81 | 82 | Name|Default|Description 83 | ---|---|--- 84 | type||`github-branches` 85 | owner||Repository owner name, i.e. the user or organization the repo is located under. 86 | repo||Repository name. 87 | token||Personal access token. 88 | showStatus|false|Also show build status. The build status is only set if an external system pushes it to Github, e.g. as part of a continuous integration setup with Travis or CircleCI. 89 | 90 | ### Loggly 91 | Number of WARN and ERROR log messages in [Loggly](http://www.loggly.com). 92 | 93 | Name|Default|Description 94 | ---|---|--- 95 | type||`loggly` 96 | username||Username. 97 | password||Password. 98 | account||Account name, from the Loggly URL. 99 | tag||A tag to filter by, e.g. to separate logs from different environments. 100 | from|`-24h`|Count log messages newer than this. 101 | 102 | 103 | ### Static message 104 | A static message. 105 | 106 | Name|Default|Description 107 | ---|---|--- 108 | type||`message` 109 | status|success|How the status indicator should look, either `success`, `warning`, `danger` or `info`. 110 | message||Message to display. 111 | 112 | ### Amazon Web Services status 113 | One of the statuses from Amazon Web Services' [Service Health Dashboard](http://status.aws.amazon.com/). 114 | 115 | Name|Default|Description 116 | ---|---|--- 117 | type||`rss-aws` 118 | id||ID of the status feed to follow as seen in the RSS link, e.g. `ec2-us-east-1`. 119 | 120 | ### Response status code 121 | Whether an arbitrary URL returned a successful status code. Any status code below 400 counts as successful. 122 | 123 | Make sure that the server is set up to allow cross-origin requests. 124 | 125 | Name|Default|Description 126 | ---|---|--- 127 | type||`status-code` 128 | url||URL to request and check response status code for. 129 | link|url|Link when clicking on the status indicator. 130 | 131 | ### Status.io 132 | Status from a service dashboard hosted by [Status.io](http://status.io). Many web services use this for their status pages. 133 | 134 | Name|Default|Description 135 | ---|---|--- 136 | type||`statusio` 137 | id||Status.io's ID for the service you want to check, e.g. `533c6539221ae15e3f000031` for Docker. There doesn't seem to be an easy way to find this yourself, but you can probably get it by asking customer support for the service you want to check. 138 | link||Link to the service's status page, e.g. `https://status.docker.com`. 139 | 140 | ### Visual Studio Team Services branches 141 | Build status of the latest commit for all the branches in a Visual Studio Team Services Git repository. Also shows highlights branches with an open pull request to master. 142 | 143 | Name|Default|Description 144 | ---|---|--- 145 | type||`vsts-branches` 146 | repoId|ID of the repository, can be found in the URL in the control panel under Version Control. 147 | account|Account subdomain. 148 | project|Project name. 149 | token|[Personal Access Token](https://www.visualstudio.com/en-us/get-started/setup/use-personal-access-tokens-to-authenticate). 150 | 151 | ### Visual Studio Team Services build 152 | Build status of the latest commit for a single branch in a Visual Studio Team Services Git repository. 153 | 154 | Name|Default|Description 155 | ---|---|--- 156 | type||`vsts-build` 157 | branch|master|Branch to show status for. 158 | definition||Name of the build definition to show status for. 159 | account| 160 | project| 161 | token| 162 | 163 | 164 | 165 | ## Complications 166 | 167 | ### Credentials 168 | 169 | If you put secrets such as Github tokens in the configuration file, then you should either encrupt the secret or only upload the dashboard to a non-public site. 170 | 171 | Values can be encrypted with `window.encrypt("password", "value")` which should then be placed in config.json as e.g. `{"token": {"encrypted": "..."}}` 172 | 173 | ### Cross-origin requests 174 | 175 | TODO 176 | 177 | 178 | ## Setup 179 | - Install NodeJS 180 | - `npm install -g grunt-cli` 181 | - `npm install` 182 | 183 | ## Development 184 | - `grunt dev` 185 | - Open `localhost:8080` 186 | - The development configuration file will be loaded directly from `src/config.json` 187 | 188 | ### Adding new source types 189 | 190 | 1. Create a new subclass of `Source` that overrides `getStatus()`. 191 | 2. Define its type in the configuration file by adding it as a static property on your subclass named `type`. 192 | 3. Add it to the list in `SourceTypes`. 193 | 194 | ## Building 195 | - `grunt build` 196 | - The files in `target/dist` can then be placed on a server. The real config.json configuration file you want to use should be placed next to index.html. -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 4.2.2 4 | 5 | dependencies: 6 | override: 7 | - npm install -g grunt-cli 8 | - npm install 9 | 10 | test: 11 | override: 12 | - grunt ci 13 | - cp target/*.xml $CIRCLE_TEST_REPORTS 14 | -------------------------------------------------------------------------------- /docs/example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lugribossk/simple-dashboard/a8058d98e6add724df8e758cd5135c21a4f4fea4/docs/example.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-dashboard", 3 | "version": "1.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "bluebird": "3.3.5", 7 | "bootstrap": "3.3.6", 8 | "bootswatch": "3.3.6", 9 | "immutable": "3.8.1", 10 | "lodash": "4.4.0", 11 | "moment": "2.13.0", 12 | "piecon": "0.5.0", 13 | "react": "15.0.2", 14 | "react-addons-linked-state-mixin": "15.0.2", 15 | "react-addons-pure-render-mixin": "15.0.2", 16 | "react-bootstrap": "0.30.7", 17 | "react-dom": "15.0.2", 18 | "sjcl": "1.0.3", 19 | "superagent": "1.8.3", 20 | "superagent-bluebird-promise": "3.0.0" 21 | }, 22 | "devDependencies": { 23 | "babel-core": "6.8.0", 24 | "babel-loader": "6.2.4", 25 | "babel-plugin-react-transform": "2.0.2", 26 | "babel-plugin-transform-object-rest-spread": "6.8.0", 27 | "babel-plugin-transform-runtime": "6.8.0", 28 | "babel-preset-es2015": "6.6.0", 29 | "babel-preset-react": "6.5.0", 30 | "babel-register": "6.8.0", 31 | "babel-runtime": "6.6.1", 32 | "compression-webpack-plugin": "0.2.0", 33 | "css-loader": "0.23.1", 34 | "eslint": "2.9.0", 35 | "eslint-plugin-lodash": "1.8.4", 36 | "eslint-plugin-react": "5.1.1", 37 | "extract-text-webpack-plugin": "1.0.1", 38 | "file-loader": "0.8.5", 39 | "grunt": "1.0.1", 40 | "grunt-aws": "0.6.2", 41 | "grunt-contrib-clean": "1.0.0", 42 | "grunt-eslint": "18.1.0", 43 | "grunt-jscs": "2.8.0", 44 | "grunt-mocha-test": "0.12.7", 45 | "grunt-webpack": "1.0.11", 46 | "html-webpack-plugin": "2.19.0", 47 | "jscs": "2.9.0", 48 | "jsdom": "9.0.0", 49 | "json-loader": "0.5.4", 50 | "less": "2.7.1", 51 | "less-loader": "2.2.3", 52 | "mocha": "2.4.5", 53 | "react-addons-test-utils": "15.0.2", 54 | "react-hot-loader": "1.3.0", 55 | "react-transform-catch-errors": "1.0.2", 56 | "react-transform-hmr": "1.0.4", 57 | "redbox-react": "1.2.4", 58 | "sinon": "1.17.4", 59 | "style-loader": "0.13.1", 60 | "unexpected": "10.13.2", 61 | "unexpected-moment": "1.0.2", 62 | "unexpected-sinon": "10.2.0", 63 | "url-loader": "0.5.7", 64 | "webpack": "1.13.0", 65 | "webpack-dev-server": "1.14.1" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/app/ConfigurationStore.js: -------------------------------------------------------------------------------- 1 | /*globals require, process */ 2 | import _ from "lodash"; 3 | import sjcl from "sjcl"; 4 | import request from "superagent-bluebird-promise"; 5 | import CachingStore from "../flux/CachingStore"; 6 | import Logger from "../util/Logger"; 7 | import Message from "../source/Message"; 8 | import SOURCE_TYPES from "../source/SourceTypes"; 9 | 10 | var log = new Logger(__filename); 11 | 12 | window.encrypt = (password, data) => { 13 | log.info("Encrypted value:", JSON.stringify(sjcl.encrypt(password, data))); 14 | }; 15 | 16 | export default class ConfigurationStore extends CachingStore { 17 | constructor(configFileName = "config.json") { 18 | super(__filename); 19 | this.state = _.defaults(this.getCachedState() || {}, { 20 | sources: [], 21 | panels: {}, 22 | password: null, 23 | passwordNeeded: false 24 | }); 25 | this.configFileName = configFileName; 26 | 27 | this._fetchConfig(); 28 | } 29 | 30 | onChanged(listener) { 31 | return this._registerListener("sources", listener); 32 | } 33 | 34 | getSources() { 35 | return this.state.sources; 36 | } 37 | 38 | getPanels() { 39 | return this.state.panels; 40 | } 41 | 42 | getPassword() { 43 | return this.state.password; 44 | } 45 | 46 | isPasswordNeeded() { 47 | return this.state.passwordNeeded; 48 | } 49 | 50 | setPassword(newPass) { 51 | this.setState({password: newPass}); 52 | this.saveToLocalStorage(); 53 | this._fetchConfig(); 54 | } 55 | 56 | decrypt(data, password) { 57 | if (_.isString(data)) { 58 | return data; 59 | } 60 | if (!password) { 61 | this.setState({passwordNeeded: true}); 62 | this._trigger("sources"); 63 | return null; 64 | } 65 | 66 | try { 67 | return sjcl.decrypt(password, data.encrypted); 68 | } catch (e) { 69 | this.setState({ 70 | password: null, 71 | passwordNeeded: true 72 | }); 73 | log.error("Password incorrect.", e); 74 | return null; 75 | } 76 | } 77 | 78 | marshalState() { 79 | return { 80 | password: this.state.password 81 | }; 82 | } 83 | 84 | unmarshalState(data) { 85 | return { 86 | password: data.password 87 | }; 88 | } 89 | 90 | _createSource(sourceConfig, def1, def2) { 91 | var type = sourceConfig.type; 92 | var SourceType = _.find(SOURCE_TYPES, {type: type}); 93 | if (!SourceType) { 94 | var err = "Unknown source type '" + type + "' in configuration."; 95 | log.error(err); 96 | return new Message({ 97 | title: sourceConfig.title, 98 | status: "warning", 99 | message: err 100 | }); 101 | } 102 | var defaults = _.defaults( 103 | def1[type] || {}, 104 | def1.all || {}, 105 | def2[type] || {}, 106 | def2.all || {} 107 | ); 108 | 109 | return new SourceType(_.defaults(sourceConfig, defaults), { 110 | decrypt: encrypted => this.decrypt(encrypted, this.state.password) 111 | }); 112 | } 113 | 114 | _parseConfig(config) { 115 | var panels = {}; 116 | var sources = []; 117 | 118 | var createPanel = (panel, index) => { 119 | if (!panels[index]) { 120 | panels[index] = { 121 | title: panel.title, 122 | sources: [] 123 | }; 124 | } 125 | 126 | _.forEach(panel.sources, sourceConfig => { 127 | var source = this._createSource(sourceConfig, panel.defaults || {}, config.defaults || {}); 128 | sources.push(source); 129 | 130 | panels[index].sources.push(source); 131 | }); 132 | }; 133 | 134 | if (config.panels) { 135 | _.forEach(config.panels, createPanel); 136 | 137 | if (config.sources) { 138 | log.warn("Both 'panels' and 'sources' were specified at top level in configuration, ignoring 'sources'."); 139 | } 140 | } else { 141 | createPanel(config, 0); 142 | } 143 | 144 | this.setState({ 145 | sources: sources, 146 | panels: panels 147 | }); 148 | } 149 | 150 | _fetchConfig() { 151 | let jsonPromise; 152 | if (process.env.NODE_ENV !== "production") { 153 | jsonPromise = Promise.resolve(require("../config.json")); 154 | } else { 155 | jsonPromise = request.get(this.configFileName) 156 | .promise() 157 | .catch(e => { 158 | log.error("Configuration file '" + this.configFileName + "' not found.", e); 159 | throw e; 160 | }) 161 | .then(response => { 162 | return JSON.parse(response.text); 163 | }); 164 | } 165 | return jsonPromise 166 | .then(json => { 167 | this._parseConfig(json); 168 | }) 169 | .catch(e => log.error("Unable to load configuration", e)); 170 | } 171 | } -------------------------------------------------------------------------------- /src/app/PasswordPrompt.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PureRenderMixin from "react-addons-pure-render-mixin"; 3 | import LinkedStateMixin from "react-addons-linked-state-mixin"; 4 | import {Glyphicon, Modal, FormControl, Button, Panel} from "react-bootstrap"; 5 | import Mixins from "../util/Mixins"; 6 | 7 | export default class PasswordPrompt extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | this.state = { 11 | showModal: false, 12 | password: "" 13 | }; 14 | 15 | this.props.configStore.onChanged(() => this.forceUpdate()); 16 | } 17 | 18 | savePassword(e) { 19 | e.preventDefault(); 20 | this.props.configStore.setPassword(this.state.password); 21 | this.setState({ 22 | showModal: false 23 | }); 24 | } 25 | 26 | deletePassword() { 27 | this.props.configStore.setPassword(null); 28 | this.setState({ 29 | showModal: false 30 | }); 31 | } 32 | 33 | renderModalBody() { 34 | if (this.props.configStore.getPassword()) { 35 | return ( 36 | 37 |

A password for unlocking the configuration file has been saved.

38 | 39 |
40 | ); 41 | } 42 | 43 | return ( 44 | 45 |

The configuration file contains sensitive keys or passwords that have been encrypted.

46 |

Entering the password will unlock them, and save the password on this computer for the future.

47 |
this.savePassword(e)}> 48 | 49 | 50 | 51 |
52 | ); 53 | } 54 | 55 | render() { 56 | if (!this.props.configStore.isPasswordNeeded() && !this.props.configStore.getPassword()) { 57 | return null; 58 | } 59 | 60 | return ( 61 |
62 | this.setState({showModal: true})}> 64 | 65 | 66 | 67 | this.setState({showModal: false, password: ""})} 69 | animation={false}> 70 | 71 | Configuration 72 | 73 | {this.renderModalBody()} 74 | 75 |
76 | ); 77 | } 78 | } 79 | 80 | Mixins.add(PasswordPrompt.prototype, [PureRenderMixin, LinkedStateMixin]); -------------------------------------------------------------------------------- /src/app/StatusDashboard.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PureRenderMixin from "react-addons-pure-render-mixin"; 3 | import _ from "lodash"; 4 | import {Col} from "react-bootstrap"; 5 | import Piecon from "piecon"; 6 | import Mixins from "../util/Mixins"; 7 | import ConfigurationStore from "./ConfigurationStore"; 8 | import StatusStore from "./StatusStore"; 9 | import StatusIndicator from "./StatusIndicator"; 10 | import UrlParameters from "../util/UrlParameters"; 11 | import AppVersion from "../util/AppVersion"; 12 | import PasswordPrompt from "./PasswordPrompt"; 13 | 14 | export default class StatusDashboard extends React.Component { 15 | constructor(props) { 16 | super(props); 17 | this.configStore = new ConfigurationStore(UrlParameters.fromQuery().config); 18 | this.statusStore = new StatusStore(this.configStore); 19 | 20 | this.state = { 21 | panels: [], 22 | statuses: [] 23 | }; 24 | 25 | this.statusStore.onStatusChanged(() => { 26 | this.setState({ 27 | panels: this.statusStore.getPanelsWithStatuses(), 28 | statuses: this.statusStore.getStatuses() 29 | }); 30 | this.updateFavicon(); 31 | }); 32 | 33 | this.updateFavicon(); 34 | AppVersion.reloadImmediatelyOnChange(); 35 | } 36 | 37 | updateFavicon() { 38 | var color = "#5cb85c"; 39 | if (_.find(this.state.statuses, {status: "danger"})) { 40 | color = "#d9534f"; 41 | } else if (_.find(this.state.statuses, {status: "warning"})) { 42 | color = "#f0ad4e"; 43 | } else if (_.find(this.state.statuses, {status: "info"})) { 44 | color = "#5bc0de"; 45 | } 46 | 47 | Piecon.setProgress(100); 48 | Piecon.setOptions({ 49 | color: color, 50 | background: "#ffffff" 51 | }); 52 | } 53 | 54 | renderPanels() { 55 | return _.map(this.state.panels, (panel, index) => { 56 | var columns = panel.columns || 12 / this.state.panels.length; 57 | return ( 58 | 59 | {panel.title && 60 |
61 |

{panel.title}

62 |
} 63 |
64 | {_.map(panel.statuses, status => )} 65 |
66 | 67 | ); 68 | }); 69 | } 70 | 71 | render() { 72 | return ( 73 |
74 | {this.renderPanels()} 75 | 76 | 77 |
78 | ); 79 | } 80 | } 81 | 82 | Mixins.add(StatusDashboard.prototype, [PureRenderMixin]); -------------------------------------------------------------------------------- /src/app/StatusIndicator.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PureRenderMixin from "react-addons-pure-render-mixin"; 3 | import _ from "lodash"; 4 | import moment from "moment"; 5 | import Mixins from "../util/Mixins"; 6 | import {Alert, ProgressBar} from "react-bootstrap"; 7 | 8 | export default class StatusIndicator extends React.Component { 9 | constructor(props) { 10 | super(props); 11 | this.state = { 12 | now: moment() 13 | }; 14 | 15 | this.interval = setInterval(() => this.setState({now: moment()}), 5000); 16 | } 17 | 18 | componentWillUnmount() { 19 | clearInterval(this.interval); 20 | } 21 | 22 | renderMessage(message) { 23 | if (!message.message) { 24 | return null; 25 | } 26 | 27 | var name = message.name; 28 | if (message.link) { 29 | name = ( 30 | {message.name} 31 | ); 32 | } 33 | return ( 34 |
35 | {message.name && 36 |

{name} {message.detailName}

} 37 |

{message.message}

38 |
39 | ); 40 | } 41 | 42 | renderProgress() { 43 | if (!this.props.progress) { 44 | return null; 45 | } 46 | 47 | var percent = this.props.progress.percent(this.state.now); 48 | var label = ""; 49 | 50 | var remaining = this.props.progress.remaining(this.state.now); 51 | if (remaining) { 52 | var positiveRemaining = Math.ceil(Math.max(remaining.asMinutes(), 1)); 53 | label = positiveRemaining + " minute" + (positiveRemaining === 1 ? "" : "s") + " remaining"; 54 | } 55 | return ( 56 | 57 | ); 58 | } 59 | 60 | render() { 61 | return ( 62 | 63 |
64 |

65 | 66 | {this.props.title} 67 | 68 |

69 |
70 | 71 | {_.map(this.props.messages, message => this.renderMessage(message))} 72 | {this.renderProgress()} 73 |
74 | ); 75 | } 76 | } 77 | 78 | Mixins.add(StatusIndicator.prototype, [PureRenderMixin]); 79 | -------------------------------------------------------------------------------- /src/app/StatusStore.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import moment from "moment"; 3 | import {OrderedMap, Set} from "immutable"; 4 | import Store from "../flux/Store"; 5 | import Logger from "../util/Logger"; 6 | 7 | var log = new Logger(__filename); 8 | 9 | export default class StatusStore extends Store { 10 | constructor(configStore) { 11 | super(); 12 | this.configStore = configStore; 13 | this.timeoutIds = []; 14 | this.state = { 15 | sources: this.configStore.getSources(), 16 | panels: this.configStore.getPanels(), // Maps panel index to obj with sources 17 | statuses: new OrderedMap(), // Maps source to statuses 18 | timeoutIds: new Set() 19 | }; 20 | 21 | this.configStore.onChanged(() => { 22 | this.setState({ 23 | sources: this.configStore.getSources(), 24 | panels: this.configStore.getPanels(), 25 | statuses: this._createInitialStatuses(this.configStore.getSources()) 26 | }); 27 | this._setupStatusFetching(); 28 | }); 29 | } 30 | 31 | onStatusChanged(listener) { 32 | return this._registerListener("statuses", listener); 33 | } 34 | 35 | getStatuses() { 36 | return _.flatten(this.state.statuses.toArray()); 37 | } 38 | 39 | getPanelsWithStatuses() { 40 | var withStatuses = []; 41 | _.forEach(this.state.panels, panel => { 42 | var withStatus = { 43 | title: panel.title, 44 | statuses: _.flatten(_.map(panel.sources, source => this.state.statuses.get(source))) 45 | }; 46 | withStatuses.push(withStatus); 47 | }); 48 | return withStatuses; 49 | } 50 | 51 | _createInitialStatuses(sources) { 52 | return new OrderedMap().withMutations(map => { 53 | _.forEach(sources, source => { 54 | map.set(source, [{ 55 | title: source.title, 56 | status: "info", 57 | messages: [{ 58 | message: "Waiting for first status..." 59 | }] 60 | }]); 61 | }); 62 | }); 63 | } 64 | 65 | _setupStatusFetching() { 66 | _.forEach(this.state.timeoutIds.toArray(), timeoutId => window.clearTimeout(timeoutId)); 67 | 68 | _.forEach(this.state.sources, source => { 69 | var lastTimeoutId; 70 | var fetchStatus = () => { 71 | return source.getStatus() 72 | .timeout(10000) 73 | .catch(e => { 74 | log.error("Error while getting status:", e); 75 | return { 76 | title: source.title, 77 | status: "danger", 78 | messages: [{ 79 | message: "Unable to determine status" 80 | }] 81 | }; 82 | }) 83 | .then(statuses => { 84 | if (!this.state.statuses.get(source)) { 85 | // This must have been in progress when the source was removed. 86 | return; 87 | } 88 | if (!_.isArray(statuses)) { 89 | statuses = [statuses]; 90 | } 91 | 92 | var timeoutId = window.setTimeout(fetchStatus, source.getInterval(moment()) * 1000); 93 | 94 | this.setState({ 95 | statuses: this.state.statuses.set(source, statuses), 96 | timeoutIds: this.state.timeoutIds.delete(lastTimeoutId).add(timeoutId) 97 | }); 98 | lastTimeoutId = timeoutId; 99 | }); 100 | }; 101 | 102 | fetchStatus(); 103 | }); 104 | } 105 | } -------------------------------------------------------------------------------- /src/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "panels": [{ 3 | "title": "Infrastructure", 4 | "sources": [{ 5 | "type": "statusio", 6 | "title": "Tutum", 7 | "link": "http://status.tutum.co", 8 | "id": "536beeeafd254d60080002ae" 9 | }, { 10 | "type": "rss-aws", 11 | "title": "CloudFront", 12 | "id": "cloudfront" 13 | }, { 14 | "type": "rss-aws", 15 | "title": "EC2 US East", 16 | "id": "ec2-us-east-1" 17 | }, { 18 | "type": "dropwizard", 19 | "title": "Production - Healthcheck", 20 | "adminPath": "http://localhost:9090/admin" 21 | }] 22 | }, { 23 | "title": "Branches", 24 | "sources": [{ 25 | "type": "github-branches", 26 | "owner": "Lugribossk", 27 | "repo": "simple-dashboard", 28 | "token": "", 29 | "showStatus": true 30 | }] 31 | }] 32 | } -------------------------------------------------------------------------------- /src/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lugribossk/simple-dashboard/a8058d98e6add724df8e758cd5135c21a4f4fea4/src/favicon.png -------------------------------------------------------------------------------- /src/flux/Action.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | var actions = {}; 4 | 5 | /** 6 | * A Flux "action" that components can trigger to change state in the stores. 7 | */ 8 | export default class Action { 9 | /** 10 | * @param {String} name A unique identifier for the action. 11 | * @returns {Function} Function that triggers the action (technically not instanceof Action). 12 | */ 13 | constructor (name) { 14 | if (actions[name]) { 15 | return actions[name]; 16 | } 17 | this.name = name; 18 | this.listeners = []; 19 | 20 | var me = this; 21 | function triggerAction(...args) { 22 | _.forEach(me.listeners, listener => { 23 | listener(...args); 24 | }); 25 | } 26 | 27 | _.assign(triggerAction, this); 28 | // Trying to do this by mass-assigning from the prototype does not seem to work, I guess the methods are set as non-enumerable? 29 | triggerAction.onDispatch = this.onDispatch; 30 | 31 | actions[name] = triggerAction; 32 | return triggerAction; 33 | } 34 | 35 | /** 36 | * Listen for the action being triggered. 37 | * @param {Function} listener 38 | * @returns {Function} Function that removes the listener again 39 | */ 40 | onDispatch(listener) { 41 | this.listeners.push(listener); 42 | return () => { 43 | _.remove(this.listeners, el => { 44 | return el === listener; 45 | }); 46 | }; 47 | } 48 | } -------------------------------------------------------------------------------- /src/flux/CachingStore.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import Store from "./Store"; 3 | 4 | /** 5 | * Specialized store that saves its state in localstorage as JSON between sessions. 6 | */ 7 | export default class CachingStore extends Store { 8 | /** 9 | * @param {String} storageKey The key to save data under in localstorage. 10 | * @param {Window} [win] 11 | */ 12 | constructor(storageKey, win) { 13 | super(); 14 | this.storageKey = storageKey; 15 | this.window = win || window; 16 | 17 | this.window.addEventListener("unload", () => this.saveToLocalStorage()); 18 | } 19 | 20 | saveToLocalStorage() { 21 | this.window.localStorage.setItem(this.storageKey, JSON.stringify(this.marshalState())); 22 | } 23 | 24 | /** 25 | * Get the cached state. 26 | * Use this when assigning state in your subclass constructor. 27 | * @returns {Object} 28 | */ 29 | getCachedState() { 30 | var rawData = this.window.localStorage.getItem(this.storageKey); 31 | if (rawData) { 32 | var data = JSON.parse(rawData); 33 | return this.unmarshalState(data); 34 | } else { 35 | return null; 36 | } 37 | } 38 | 39 | marshalState() { 40 | return this.state; 41 | } 42 | 43 | /** 44 | * Optionally modify data after retrieving it (e.g. to replace raw objects with classes). 45 | * @param {Object} data 46 | * @returns {Object} 47 | */ 48 | unmarshalState(data) { 49 | return data; 50 | } 51 | 52 | /** 53 | * Transforms a list of untyped objects into a list of class instances, created with the objects. 54 | * @param {Object[]} list 55 | * @param {*} Klass 56 | * @returns {*[]} 57 | */ 58 | static listOf(list, Klass) { 59 | if (!list) { 60 | return []; 61 | } 62 | return _.map(list, item => { 63 | return new Klass(item); 64 | }); 65 | } 66 | 67 | /** 68 | * Transforms a map with untyped object values into a map with class instance values, created with the objects. 69 | * @param {Object} obj 70 | * @param {*} Klass 71 | * @returns {Object} 72 | */ 73 | static mapOf(obj, Klass) { 74 | if (!obj) { 75 | return {}; 76 | } 77 | return _.reduce(obj, (result, value, key) => { 78 | result[key] = new Klass(value); 79 | return result; 80 | }, {}); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/flux/Store.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | /** 4 | * A Flux "store", a repository of data state that listens to actions and triggers events when changed. 5 | */ 6 | export default class Store { 7 | constructor() { 8 | this.listeners = {}; 9 | this.state = {}; 10 | } 11 | 12 | /** 13 | * Change the stored data. 14 | * @param {Object} newState 15 | */ 16 | setState(newState) { 17 | _.assign(this.state, newState); 18 | _.forEach(newState, (value, key) => { 19 | this._trigger(key); 20 | }); 21 | } 22 | 23 | /** 24 | * Register a listener that automatically triggers when the named key in state is changed. 25 | * @param {String} name 26 | * @param {Function} listener 27 | * @returns {Function} 28 | * @private 29 | */ 30 | _registerListener(name, listener) { 31 | if (!this.listeners[name]) { 32 | this.listeners[name] = []; 33 | } 34 | this.listeners[name].push(listener); 35 | 36 | return () => { 37 | _.remove(this.listeners[name], el => { 38 | return el === listener; 39 | }); 40 | }; 41 | } 42 | 43 | /** 44 | * Trigger listeners with the specified name with the provided data. 45 | * @param {String} name 46 | * @param {*} data 47 | * @private 48 | */ 49 | _trigger(name, ...data) { 50 | if (this.listeners[name]) { 51 | _.forEach(this.listeners[name], listener => { 52 | listener(...data); 53 | }); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/flux/SubscribeMixin.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | /** 4 | * Component mixin for automatically unregistering listeners on unmount. 5 | */ 6 | export default { 7 | subscribe(unsubscriber) { 8 | if (!this.unsubscribers) { 9 | this.unsubscribers = []; 10 | } 11 | this.unsubscribers.push(unsubscriber); 12 | }, 13 | 14 | componentWillUnmount() { 15 | _.forEach(this.unsubscribers, unsubscriber => { 16 | unsubscriber(); 17 | }); 18 | } 19 | }; -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Status 7 | 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | /*global require*/ 2 | require("babel-runtime/core-js/promise").default = require("bluebird"); 3 | Promise.config({ 4 | warnings: false 5 | }); 6 | 7 | import React from "react"; 8 | import ReactDOM from "react-dom"; 9 | import StatusDashboard from "./app/StatusDashboard"; 10 | import "./style.less"; 11 | 12 | ReactDOM.render( 13 | , 14 | document.getElementById("main") 15 | ); -------------------------------------------------------------------------------- /src/source/DockerCloudService.js: -------------------------------------------------------------------------------- 1 | import Promise from "bluebird"; 2 | import request from "superagent-bluebird-promise"; 3 | import moment from "moment"; 4 | import Source from "./Source"; 5 | 6 | // https://docs.docker.com/apidocs/docker-cloud/ 7 | export default class DockerCloudService extends Source { 8 | constructor(data, util) { 9 | super(data); 10 | this.id = data.id; 11 | this.username = data.username; 12 | this.apiKey = util.decrypt(data.apiKey); 13 | } 14 | 15 | fetchData() { 16 | return request.get("https://cloud.docker.com/api/app/v1/service/" + this.id + "/") 17 | .auth(this.username, this.apiKey) 18 | .promise() 19 | .catch(() => null); 20 | } 21 | 22 | getStatus() { 23 | if (!this.apiKey) { 24 | return Promise.resolve({ 25 | title: this.title, 26 | status: "warning", 27 | messages: [{ 28 | message: "API key not configured" 29 | }] 30 | }); 31 | } 32 | 33 | return this.fetchData().then(response => { 34 | let status = null; 35 | let message = ""; 36 | 37 | if (!response || !response.body) { 38 | status = "danger"; 39 | message = "No response from API"; 40 | } else { 41 | let { 42 | state, 43 | target_num_containers: targetContainers, 44 | current_num_containers: currentContainers, 45 | synchronized, 46 | started_datetime: startedAt 47 | } = response.body; 48 | 49 | if (state === "Redeploying") { 50 | status = "info"; 51 | message = "Redeploying"; 52 | 53 | } else if (state === "Scaling") { 54 | status = "info"; 55 | message = "Scaling from " + currentContainers + " containers to " + targetContainers; 56 | 57 | } else if (state === "Running") { 58 | if (!synchronized) { 59 | status = "warning"; 60 | message = "Service definition not synchronized with containers"; 61 | } else { 62 | status = "success"; 63 | // E.g. Mon, 13 Oct 2014 11:01:43 +0000 64 | message = "Started " + moment(startedAt, "ddd, D MMMM YYYY HH:mm:ss ZZ").fromNow(); 65 | } 66 | } else { 67 | status = "danger"; 68 | message = state; 69 | } 70 | } 71 | 72 | return { 73 | title: this.title, 74 | link: "https://cloud.docker.com/container/service/" + this.id + "/show", 75 | status: status, 76 | messages: [{ 77 | message: message 78 | }] 79 | }; 80 | }); 81 | } 82 | } 83 | 84 | DockerCloudService.type = "docker-cloud-service"; -------------------------------------------------------------------------------- /src/source/DropwizardHealthcheck.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import request from "superagent-bluebird-promise"; 3 | import Source from "./Source"; 4 | 5 | // Cross-Origin-Resource-Sharing must be set up for the healthcheck endpoint 6 | export default class DropwizardHealthCheck extends Source { 7 | constructor(data) { 8 | super(data); 9 | this.adminPath = data.adminPath; 10 | } 11 | 12 | fetchData() { 13 | return request.get(this.adminPath + "/healthcheck") 14 | .promise() 15 | .catch(e => e); // The healthcheck returns an error status code if anything is unhealthy. 16 | } 17 | 18 | getStatus() { 19 | return this.fetchData() 20 | .then(response => { 21 | var status = "success"; 22 | var messages = []; 23 | 24 | if (!response || !response.body) { 25 | status = "danger"; 26 | messages.push({ 27 | message: "No response from healthcheck" 28 | }); 29 | } else { 30 | _.forEach(response.body, (data, name) => { 31 | if (_.isBoolean(data.healthy) && data.healthy) { 32 | return; 33 | } 34 | 35 | status = "danger"; 36 | messages.push({ 37 | name: name, 38 | message: data.message || (data.error && data.error.stack && data.error.stack[0]) 39 | }); 40 | }); 41 | } 42 | 43 | return { 44 | title: this.title, 45 | link: this.adminPath + "/healthcheck?pretty=true", 46 | status: status, 47 | messages: messages 48 | }; 49 | }); 50 | } 51 | } 52 | 53 | DropwizardHealthCheck.type = "dropwizard"; -------------------------------------------------------------------------------- /src/source/GithubBranches.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import moment from "moment"; 3 | import Promise from "bluebird"; 4 | import request from "superagent-bluebird-promise"; 5 | import Source from "./Source"; 6 | import BuildUtils from "../util/BuildUtils"; 7 | 8 | var shortName = ref => ref.substr("refs/heads/".length); 9 | 10 | export default class GithubBranches extends Source { 11 | constructor(data, util) { 12 | super(data); 13 | this.owner = data.owner; 14 | this.repo = data.repo; 15 | this.token = util.decrypt(data.token); 16 | this.showStatus = data.showStatus; 17 | } 18 | 19 | fetchData(path) { 20 | return request("https://api.github.com/repos/" + this.owner + "/" + this.repo + path) 21 | .set("Authorization", "token " + this.token); 22 | } 23 | 24 | fetchBranches() { 25 | return this.fetchData("/git/refs/heads") 26 | .promise() 27 | .then(response => response.body); 28 | } 29 | 30 | fetchPullRequests() { 31 | return this.fetchData("/pulls") 32 | .query("state=open") 33 | .query("base=master") 34 | .promise() 35 | .then(response => response.body); 36 | } 37 | 38 | fetchStatuses(ref) { 39 | return this.fetchData("/commits/" + ref + "/statuses") 40 | .promise() 41 | .then(response => response.body); 42 | } 43 | 44 | createStatus(branch) { 45 | var status = { 46 | title: shortName(branch.ref), 47 | link: branch.url, 48 | status: "info", 49 | messages: [] 50 | }; 51 | 52 | if (!this.showStatus) { 53 | return Promise.resolve(status); 54 | } 55 | 56 | return this.fetchStatuses(branch.ref) 57 | .then(statuses => { 58 | if (statuses.length === 0) { 59 | return status; 60 | } 61 | 62 | // TODO Support multiple contexts 63 | var latestStatus = statuses[0]; 64 | if (latestStatus.state === "success") { 65 | status.status = "success"; 66 | } else if (latestStatus.state === "pending") { 67 | status.status = "info"; 68 | var start = moment(latestStatus.created_at); 69 | var avg = BuildUtils.getEstimatedDuration(this.getDurations(statuses)); 70 | status.progress = { 71 | percent: now => BuildUtils.getEstimatedPercentComplete(now, start, avg), 72 | remaining: now => BuildUtils.getEstimatedTimeRemaining(now, start, avg) 73 | }; 74 | } else { 75 | status.status = "danger"; 76 | } 77 | 78 | status.messages.push({ 79 | name: latestStatus.context, 80 | link: latestStatus.target_url, 81 | message: latestStatus.description 82 | }); 83 | 84 | return status; 85 | }); 86 | } 87 | 88 | getDurations(statuses) { 89 | var durations = []; 90 | var lookingForPending = false; 91 | var finish; 92 | 93 | _.forEach(statuses, status => { 94 | if (lookingForPending) { 95 | if (status.state === "pending") { 96 | lookingForPending = false; 97 | var start = moment(status.created_at); 98 | var duration = moment.duration(finish.diff(start)); 99 | durations.push(duration); 100 | } 101 | } else { 102 | if (status.state !== "pending") { 103 | lookingForPending = true; 104 | finish = moment(status.created_at); 105 | } 106 | } 107 | }); 108 | 109 | return durations; 110 | } 111 | 112 | getStatus() { 113 | if (!this.owner || !this.repo || !this.token) { 114 | return Promise.resolve({ 115 | title: this.title, 116 | status: "warning", 117 | messages: [{ 118 | message: "Credentials not configured." 119 | }] 120 | }); 121 | } 122 | 123 | return Promise.all([this.fetchBranches(), this.fetchPullRequests()]) 124 | .catch(() => { 125 | return { 126 | title: this.title, 127 | link: "https://github.com/" + this.owner + "/" + this.repo, 128 | status: "danger", 129 | messages: [{ 130 | message: "No response from API" 131 | }] 132 | }; 133 | }) 134 | .spread((branches, prs) => { 135 | return Promise.all(_.map(branches, branch => { 136 | return this.createStatus(branch) 137 | .then(status => { 138 | var name = shortName(branch.ref); 139 | var branchPr = _.find(prs, pr => pr.head.ref === name); 140 | if (branchPr) { 141 | status.messages.push({ 142 | name: "Pull request: " + branchPr.title, 143 | link: branchPr.html_url, 144 | message: "Created by " + branchPr.user.login + " " + moment(branchPr.created_at).fromNow() 145 | }); 146 | } 147 | 148 | return status; 149 | }); 150 | })); 151 | }); 152 | } 153 | } 154 | 155 | GithubBranches.type = "github-branches"; 156 | -------------------------------------------------------------------------------- /src/source/Loggly.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import request from "superagent-bluebird-promise"; 3 | import Source from "./Source"; 4 | 5 | export default class Loggly extends Source { 6 | constructor(data, util) { 7 | super(data); 8 | this.username = data.username; 9 | this.password = util.decrypt(data.password); 10 | this.account = data.account; 11 | this.tag = data.tag; 12 | this.from = data.from || "-24h"; 13 | } 14 | 15 | fetchData() { 16 | return request.get("http://" + this.account + ".loggly.com/apiv2/fields/json.level/") 17 | .auth(this.username, this.password) 18 | .query({ 19 | q: "tag:" + this.tag, 20 | from: this.from 21 | }) 22 | .promise(); 23 | } 24 | 25 | getStatus() { 26 | if (!this.username || !this.password || !this.account) { 27 | return Promise.resolve({ 28 | title: this.title, 29 | status: "warning", 30 | messages: [{ 31 | message: "Credentials not configured." 32 | }] 33 | }); 34 | } 35 | 36 | return this.fetchData() 37 | .then(response => { 38 | let status; 39 | let messages = []; 40 | if (!response || !response.body) { 41 | status = "danger"; 42 | messages.push({ 43 | message: "No response from Loggly API." 44 | }); 45 | } else { 46 | let terms = response.body["json.level"]; 47 | let counts = _.reduce(terms, (result, term) => { 48 | result[term.term.toLocaleUpperCase()] = term.count; 49 | return result; 50 | }, {}); 51 | 52 | let fromLabel = this.from.substr(1); 53 | if (counts.WARN > 0) { 54 | status = "warning"; 55 | messages.push({ 56 | message: "" + counts.WARN + " log messages with WARN level in the last " + fromLabel + "." 57 | }); 58 | } 59 | if (counts.ERROR > 0) { 60 | status = "danger"; 61 | messages.push({ 62 | detailName: "WARN", 63 | message: "" + counts.ERROR + " log messages with ERROR level in the last " + fromLabel + "." 64 | }); 65 | } 66 | if (!counts.WARN && !counts.ERROR) { 67 | status = "success"; 68 | messages.push({ 69 | detailName: "ERROR", 70 | message: "No log messages with WARN or ERROR level in the last " + fromLabel + "." 71 | }); 72 | } 73 | } 74 | 75 | let search = encodeURIComponent("tag:" + this.tag + " AND (json.level:warn OR json.level:error)"); 76 | return { 77 | title: this.title, 78 | link: "https://" + this.account + ".loggly.com/search#terms=" + search + "&from=" + this.from, 79 | status: status, 80 | messages: messages 81 | }; 82 | }); 83 | } 84 | } 85 | 86 | Loggly.type = "loggly"; -------------------------------------------------------------------------------- /src/source/Message.js: -------------------------------------------------------------------------------- 1 | import Source from "./Source"; 2 | 3 | export default class Message extends Source { 4 | constructor(data) { 5 | super(data); 6 | this.status = data.status || "success"; 7 | this.message = data.message; 8 | } 9 | 10 | getStatus() { 11 | return Promise.resolve({ 12 | title: this.title, 13 | status: this.status, 14 | messages: [{ 15 | message: this.message 16 | }] 17 | }); 18 | } 19 | } 20 | 21 | Message.type = "message"; -------------------------------------------------------------------------------- /src/source/RssAws.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import RssBase from "./RssBase"; 3 | 4 | export default class RssAws extends RssBase { 5 | constructor(data) { 6 | super(data); 7 | this.id = data.id; 8 | } 9 | 10 | getStatus() { 11 | return this.fetchFeed("http://status.aws.amazon.com/rss/" + this.id + ".rss") 12 | .then(data => { 13 | var status; 14 | var message = ""; 15 | 16 | var latestEntry = data.responseData.feed.entries[0]; 17 | 18 | if (_.includes(latestEntry.title, "Service is operating normally") || 19 | _.includes(latestEntry.content, "service is operating normally")) { 20 | status = "success"; 21 | } else { 22 | if (_.includes(latestEntry.title, "Informational message")) { 23 | status = "warning"; 24 | } else { 25 | status = "danger"; 26 | } 27 | message = latestEntry.content; 28 | } 29 | 30 | return { 31 | title: this.title, 32 | link: "http://status.aws.amazon.com/", 33 | status: status, 34 | messages: [{ 35 | message: message 36 | }] 37 | }; 38 | }); 39 | } 40 | } 41 | 42 | RssAws.type = "rss-aws"; -------------------------------------------------------------------------------- /src/source/RssBase.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import request from "superagent-bluebird-promise"; 3 | import Source from "./Source"; 4 | 5 | request.Request.prototype.jsonp = function (name = "callback") { 6 | this.callbackQueryName = name; 7 | this.callbackFunctionName = "superagentCallback" + new Date().valueOf() + _.parseInt(Math.random() * 1000); 8 | window[this.callbackFunctionName] = data => { 9 | delete window[this.callbackFunctionName]; 10 | document.getElementsByTagName("head")[0].removeChild(this.scriptElement); 11 | 12 | this.callback(null, {body: data}); 13 | }; 14 | return this; 15 | }; 16 | 17 | var oldEnd = request.Request.prototype.end; 18 | request.Request.prototype.end = function (callback) { 19 | if (!this.callbackFunctionName) { 20 | return oldEnd.call(this, callback); 21 | } 22 | 23 | this.callback = callback; 24 | this.query({[this.callbackQueryName]: this.callbackFunctionName}); 25 | 26 | var queryString = request.serializeObject(this._query.join("&")); 27 | var url = this.url + (_.includes(this.url, "?") ? "&" : "?") + queryString; 28 | 29 | this.scriptElement = document.createElement("script"); 30 | this.scriptElement.src = url; 31 | document.getElementsByTagName("head")[0].appendChild(this.scriptElement); 32 | 33 | return this; 34 | }; 35 | 36 | export default class RssBase extends Source { 37 | constructor(data) { 38 | super(data); 39 | this.interval = data.interval || 600; 40 | } 41 | 42 | fetchFeed(url) { 43 | return request.get("http://ajax.googleapis.com/ajax/services/feed/load") 44 | .query({ 45 | v: "1.0", 46 | q: url 47 | }) 48 | .jsonp("callback") 49 | .promise() 50 | .then(response => { 51 | if (!response || !response.body) { 52 | throw new Error("Empty response."); 53 | } 54 | 55 | return response.body; 56 | }); 57 | } 58 | } -------------------------------------------------------------------------------- /src/source/Source.js: -------------------------------------------------------------------------------- 1 | import Promise from "bluebird"; 2 | import moment from "moment"; 3 | 4 | export default class Source { 5 | constructor(data) { 6 | this.title = data.title; 7 | this.interval = data.interval || 60; 8 | } 9 | 10 | getInterval() { 11 | return this.interval; 12 | } 13 | 14 | getStatus() { 15 | return Promise.resolve({ 16 | title: this.title, 17 | link: "http://example.com", 18 | status: "success", 19 | messages: [{ 20 | name: "Example", 21 | link: "http://example.com", 22 | detailName: "Example", 23 | message: "Example" 24 | }], 25 | progress: { 26 | percent: () => 50, 27 | remaining: () => moment.duration(5, "minutes") 28 | } 29 | }); 30 | } 31 | } 32 | 33 | Source.type = ""; -------------------------------------------------------------------------------- /src/source/SourceTypes.js: -------------------------------------------------------------------------------- 1 | import DropwizardHealthcheck from "./DropwizardHealthcheck"; 2 | import GithubBranches from "./GithubBranches"; 3 | import Loggly from "./Loggly"; 4 | import Message from "./Message"; 5 | import RssAws from "./RssAws"; 6 | import StatusCode from "./StatusCode"; 7 | import StatusIo from "./StatusIo"; 8 | import DockerCloudService from "./DockerCloudService"; 9 | import VstsBranches from "./VstsBranches"; 10 | import VstsBuild from "./VstsBuild"; 11 | 12 | // Register all Source subclasses so they can be instantiated from the configuration. 13 | export default [ 14 | DockerCloudService, 15 | DropwizardHealthcheck, 16 | GithubBranches, 17 | Loggly, 18 | Message, 19 | RssAws, 20 | StatusCode, 21 | StatusIo, 22 | VstsBranches, 23 | VstsBuild 24 | ]; -------------------------------------------------------------------------------- /src/source/StatusCode.js: -------------------------------------------------------------------------------- 1 | import request from "superagent-bluebird-promise"; 2 | import Source from "./Source"; 3 | 4 | export default class StatusCode extends Source { 5 | constructor(data) { 6 | super(data); 7 | this.url = data.url; 8 | this.link = data.link || data.url; 9 | } 10 | 11 | fetchData() { 12 | return request.get(this.url) 13 | .promise(); 14 | } 15 | 16 | getStatus() { 17 | return this.fetchData() 18 | .catch(response => { 19 | return { 20 | title: this.title, 21 | link: this.link, 22 | status: "danger", 23 | messages: [{ 24 | message: "Response had status code " + response.status 25 | }] 26 | }; 27 | }) 28 | .then(() => { 29 | return { 30 | title: this.title, 31 | link: this.link, 32 | status: "success", 33 | messages: [] 34 | }; 35 | }); 36 | } 37 | } 38 | 39 | StatusCode.type = "status-code"; -------------------------------------------------------------------------------- /src/source/StatusIo.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import request from "superagent-bluebird-promise"; 3 | import Source from "./Source"; 4 | 5 | export default class StatusIo extends Source { 6 | constructor(data) { 7 | super(data); 8 | this.link = data.link; 9 | this.id = data.id; 10 | } 11 | 12 | fetchData() { 13 | return request.get("https://api.status.io/1.0/status/" + this.id) 14 | .promise() 15 | .catch(e => e); 16 | } 17 | 18 | getStatus() { 19 | return this.fetchData().then(response => { 20 | var status = "success"; 21 | var messages = []; 22 | 23 | if (!response || !response.body || !response.body.result) { 24 | status = "danger"; 25 | messages.push({ 26 | name: "No response from Status.io API" 27 | }); 28 | } else { 29 | var worstStatusCode = 0; 30 | _.forEach(response.body.result.status, s => { 31 | _.forEach(s.containers, c => { 32 | if (c.status_code > 100) { 33 | messages.push({ 34 | name: s.name, 35 | detailName: c.name, 36 | message: c.status 37 | }); 38 | 39 | if (c.status_code > worstStatusCode) { 40 | worstStatusCode = c.status_code; 41 | } 42 | } 43 | }); 44 | }); 45 | 46 | // http://kb.status.io/developers/status-codes 47 | if (worstStatusCode >= 500) { 48 | status = "danger"; 49 | } else if (worstStatusCode >= 300) { 50 | status = "warning"; 51 | } 52 | } 53 | 54 | return { 55 | title: this.title, 56 | link: this.link, 57 | status: status, 58 | messages: messages 59 | }; 60 | }); 61 | } 62 | } 63 | 64 | StatusIo.type = "statusio"; -------------------------------------------------------------------------------- /src/source/VstsBase.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import moment from "moment"; 3 | import request from "superagent-bluebird-promise"; 4 | import Source from "./Source"; 5 | import BuildUtils from "../util/BuildUtils"; 6 | 7 | export default class VstsBase extends Source { 8 | constructor(data, util) { 9 | super(data); 10 | this.account = data.account; 11 | this.project = data.project; 12 | this.token = util.decrypt(data.token); 13 | } 14 | 15 | getStatus() { 16 | if (!this.account || !this.project || !this.token) { 17 | return Promise.resolve({ 18 | title: this.title, 19 | status: "warning", 20 | messages: [{ 21 | message: "Credentials not configured." 22 | }] 23 | }); 24 | } 25 | return null; 26 | } 27 | 28 | getBaseUrl() { 29 | return "https://" + this.account + ".visualstudio.com/DefaultCollection/"; 30 | } 31 | 32 | getBuildQuery() { 33 | let out = {}; 34 | if (this.branch) { 35 | out.sourceBranch = this.branch; 36 | } 37 | if (this.definition) { 38 | out.definition = { 39 | name: this.definition 40 | }; 41 | } 42 | if (!this.branch && !this.definition) { 43 | out.sourceBranch = "refs/heads/master"; 44 | } 45 | return out; 46 | } 47 | 48 | fetchBuilds() { 49 | return request.get(this.getBaseUrl() + this.project + "/_apis/build/builds") 50 | .auth("", this.token) 51 | .promise() 52 | .then(response => response.body.value); 53 | } 54 | 55 | fetchGitData(path) { 56 | return request.get(this.getBaseUrl() + "_apis/git/repositories/" + this.repoId + path) 57 | .auth("", this.token) 58 | .query("api-version=1.0"); 59 | } 60 | 61 | createStatus(builds, buildQuery) { 62 | let link; 63 | let status; 64 | let message; 65 | let progress; 66 | 67 | let targetBuilds = _.filter(builds, buildQuery); 68 | 69 | if (targetBuilds.length === 0) { 70 | status = "info"; 71 | message = "No builds found."; 72 | } else { 73 | let build = targetBuilds[0]; 74 | link = build._links.web.href; 75 | 76 | let finishedAgo = moment(build.finishTime).fromNow(); 77 | if (build.status !== "completed") { 78 | status = "info"; 79 | message = "Build in progress"; 80 | 81 | let start = moment(build.startTime); 82 | let avg = BuildUtils.getEstimatedDuration(this.getDurations(builds, buildQuery)); 83 | progress = { 84 | percent: now => BuildUtils.getEstimatedPercentComplete(now, start, avg), 85 | remaining: now => BuildUtils.getEstimatedTimeRemaining(now, start, avg) 86 | }; 87 | } else { 88 | if (build.result === "partiallySucceeded") { 89 | status = "warning"; 90 | message = "Partially succeeded " + finishedAgo; 91 | } else if (build.result === "succeeded") { 92 | status = "success"; 93 | message = "Built " + finishedAgo; 94 | } else if (build.result === "canceled") { 95 | status = "danger"; 96 | message = "Canceled " + finishedAgo; 97 | } else { 98 | status = "danger"; 99 | message = "Failed " + finishedAgo; 100 | } 101 | } 102 | } 103 | 104 | return { 105 | title: this.title, 106 | link: link, 107 | status: status, 108 | messages: [{ 109 | message: message 110 | }], 111 | progress: progress 112 | }; 113 | } 114 | 115 | getDurations(builds, branchName) { 116 | let targetBuilds = builds; 117 | // If there are any builds with the target branch then only use those. 118 | let branchBuilds = _.filter(builds, {sourceBranch: branchName}); 119 | if (branchBuilds.length > 0) { 120 | targetBuilds = branchBuilds; 121 | } 122 | let successBuilds = _.filter(targetBuilds, {status: "success"}); 123 | if (successBuilds.length > 0) { 124 | targetBuilds = successBuilds; 125 | } 126 | 127 | return _.filter(_.map(targetBuilds, ({startTime, finishTime}) => { 128 | if (!startTime || !finishTime) { 129 | return null; 130 | } 131 | 132 | let start = moment(startTime); 133 | let finish = moment(finishTime); 134 | return moment.duration(finish.diff(start)); 135 | })); 136 | } 137 | } -------------------------------------------------------------------------------- /src/source/VstsBranches.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import moment from "moment"; 3 | import Promise from "bluebird"; 4 | import VstsBase from "./VstsBase"; 5 | import Logger from "../util/Logger"; 6 | 7 | let log = new Logger(__filename); 8 | 9 | let textToDuration = text => { 10 | let match = /(\d+) ?(\w+)/.exec(text); 11 | return moment.duration(_.parseInt(match[1]), match[2]); 12 | }; 13 | 14 | export default class VstsBranches extends VstsBase { 15 | constructor(data, util) { 16 | super(data, util); 17 | this.repoId = data.repoId; 18 | this.newerThan = data.newerThan; 19 | } 20 | 21 | fetchBranches() { 22 | return this.fetchGitData("/stats/branches") 23 | .promise() 24 | .then(response => response.body.value); 25 | } 26 | 27 | fetchPullRequests() { 28 | return this.fetchGitData("/pullrequests") 29 | .query({ 30 | status: "Active", 31 | targetRefName: "refs/heads/master" 32 | }) 33 | .promise() 34 | .then(response => response.body.value); 35 | } 36 | 37 | getStatus() { 38 | let defaultStatus = super.getStatus(); 39 | if (defaultStatus) { 40 | return defaultStatus; 41 | } 42 | 43 | return Promise.all([this.fetchBranches(), this.fetchBuilds(), this.fetchPullRequests()]) 44 | .spread((branches, builds, prs) => { 45 | let interestingBranches = branches; 46 | if (this.newerThan) { 47 | interestingBranches = _.filter(branches, branch => { 48 | return moment(branch.commit.author.date).isAfter(moment().subtract(textToDuration(this.newerThan))); 49 | }); 50 | } 51 | 52 | return _.map(interestingBranches, branch => { 53 | let status = this.createStatus(builds, {sourceBranch: "refs/heads/" + branch.name}); 54 | status.title = branch.name; 55 | 56 | let branchPr = _.find(prs, {sourceRefName: "refs/heads/" + branch.name}); 57 | if (branchPr) { 58 | status.messages.push({ 59 | name: "Pull request: " + branchPr.title, 60 | link: this.getBaseUrl() + "_git/" + this.project + "/pullrequest/" + branchPr.pullRequestId, 61 | message: "Created by " + branchPr.createdBy.displayName + " " + moment(branchPr.creationDate).fromNow() 62 | }); 63 | } 64 | 65 | return status; 66 | }); 67 | }) 68 | .catch(e => { 69 | log.error(e); 70 | return { 71 | title: this.title, 72 | link: this.getBaseUrl() + this.project + "/_build", 73 | status: "danger", 74 | messages: [{ 75 | message: "No response from API" 76 | }] 77 | }; 78 | }); 79 | } 80 | } 81 | 82 | VstsBranches.type = "vsts-branches"; 83 | -------------------------------------------------------------------------------- /src/source/VstsBuild.js: -------------------------------------------------------------------------------- 1 | import VstsBase from "./VstsBase"; 2 | 3 | export default class VstsBuild extends VstsBase { 4 | constructor(data, util) { 5 | super(data, util); 6 | this.branch = data.branch; 7 | this.definition = data.definition; 8 | } 9 | 10 | getStatus() { 11 | let defaultStatus = super.getStatus(); 12 | if (defaultStatus) { 13 | return defaultStatus; 14 | } 15 | 16 | return this.fetchBuilds() 17 | .then(builds => this.createStatus(builds, this.getBuildQuery())) 18 | .catch(() => { 19 | return { 20 | title: this.title, 21 | link: this.getBaseUrl() + this.project + "/_build", 22 | status: "danger", 23 | messages: [{ 24 | message: "No response from API" 25 | }] 26 | }; 27 | }); 28 | } 29 | } 30 | 31 | VstsBuild.type = "vsts-build"; 32 | -------------------------------------------------------------------------------- /src/style.less: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/less/bootstrap.less"; 2 | @import "~bootswatch/darkly/variables.less"; 3 | @import "~bootswatch/darkly/bootswatch.less"; 4 | 5 | html, 6 | body, 7 | #main, 8 | .full-size { 9 | height: 100%; 10 | width: 100%; 11 | } 12 | 13 | .panel-container { 14 | display: flex; 15 | flex-direction: column; 16 | height: 100%; 17 | padding: 5px 0 5px 5px; 18 | 19 | &:last-of-type { 20 | padding-right: 5px; 21 | } 22 | } 23 | 24 | .panel-item { 25 | h1 { 26 | margin-left: 20px; 27 | } 28 | } 29 | 30 | .status-container { 31 | display: flex; 32 | flex-wrap: wrap; 33 | height: 100%; 34 | align-items: stretch; 35 | 36 | } 37 | 38 | .status-item { 39 | flex: 1 1 400px; 40 | margin: 5px 5px 5px 5px; 41 | 42 | display: flex; 43 | flex-direction: column; 44 | } 45 | 46 | .status-item > div { 47 | flex: 1; 48 | 49 | display: flex; 50 | flex-direction: column; 51 | justify-content: center; 52 | } 53 | 54 | .progress { 55 | height: 30px; 56 | background-color: rgba(0, 0, 0, 0.2); 57 | 58 | .progress-bar { 59 | font-size: 13px; 60 | line-height: 30px; 61 | } 62 | } 63 | 64 | .alert a { 65 | color: inherit; 66 | } -------------------------------------------------------------------------------- /src/touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lugribossk/simple-dashboard/a8058d98e6add724df8e758cd5135c21a4f4fea4/src/touch-icon.png -------------------------------------------------------------------------------- /src/util/AppVersion.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import request from "superagent-bluebird-promise"; 3 | import Logger from "./Logger"; 4 | import RegExps from "./RegExps"; 5 | 6 | var log = new Logger(__filename); 7 | 8 | /** 9 | * Utility for checking if the application has been updated on the server while running. 10 | * Periodically loads the app's HTML and checks if it links to new versions of the css and js files. 11 | */ 12 | export default class AppVersion { 13 | constructor(win = window) { 14 | this.window = win; 15 | this.listeners = []; 16 | this.linkSrcs = []; 17 | this.scriptSrcs = []; 18 | 19 | _.forEach(this.window.document.querySelectorAll("link"), link => { 20 | if (link.href) { 21 | this.linkSrcs.push(link.href.substr(link.href.lastIndexOf("/") + 1)); 22 | } 23 | }); 24 | _.forEach(this.window.document.querySelectorAll("script"), script => { 25 | if (script.src) { 26 | this.scriptSrcs.push(script.src.substr(script.src.lastIndexOf("/") + 1)); 27 | } 28 | }); 29 | 30 | if (this.linkSrcs.length > 0 && this.scriptSrcs.length > 0) { 31 | this.window.setInterval(this.checkVersion.bind(this), 30 * 60 * 1000); 32 | } else { 33 | log.info("Not production, version checking disabled."); 34 | } 35 | } 36 | 37 | onVersionChange(listener) { 38 | this.listeners.push(listener); 39 | } 40 | 41 | checkVersion() { 42 | return request.get("") 43 | .then(data => { 44 | var changed = false; 45 | 46 | _.forEach(RegExps.getAllMatches(/ { 47 | if (!_.includes(this.linkSrcs, name)) { 48 | changed = true; 49 | } 50 | }); 51 | _.forEach(RegExps.getAllMatches(/" + 14 | "" + 15 | "" + 16 | ""})); 17 | } 18 | 19 | describe("AppVersion", () => { 20 | var mockWindow; 21 | beforeEach(() => { 22 | mockWindow = { 23 | document: { 24 | querySelectorAll: sinon.stub() 25 | }, 26 | setInterval: sinon.spy(), 27 | location: { 28 | reload: sinon.spy() 29 | } 30 | }; 31 | 32 | mockWindow.document.querySelectorAll.withArgs("link") 33 | .returns([{href: "static/main-12345.css"}]); 34 | mockWindow.document.querySelectorAll.withArgs("script") 35 | .returns([{src: "static/main-12345.js"}, {src: "static/vendor-12345.js"}]); 36 | }); 37 | 38 | it("should get current version from elements in document when created.", () => { 39 | var version = new AppVersion(mockWindow); 40 | 41 | expect(version.linkSrcs, "to contain", "main-12345.css"); 42 | expect(version.scriptSrcs, "to contain", "main-12345.js", "vendor-12345.js"); 43 | }); 44 | 45 | describe("checkVersion() should get potential new version from AJAX loading the page again,", () => { 46 | it("and resolve with true if it is different.", done => { 47 | mockRequestGet("NEW"); 48 | 49 | var version = new AppVersion(mockWindow); 50 | 51 | version.checkVersion() 52 | .then(changed => { 53 | expect(changed, "to be true"); 54 | done(); 55 | }); 56 | }); 57 | 58 | it("and resolve with false if it is the same.", done => { 59 | mockRequestGet("12345"); 60 | 61 | var version = new AppVersion(mockWindow); 62 | 63 | version.checkVersion() 64 | .then(changed => { 65 | expect(changed, "to be false"); 66 | done(); 67 | }); 68 | }); 69 | }); 70 | 71 | describe("reloadRoutedOnChange()", () => { 72 | it("should reload on route change after version change.", done => { 73 | mockRequestGet("NEW"); 74 | var mockRouter = { 75 | onRouteChange: sinon.spy() 76 | }; 77 | var version = AppVersion.reloadRoutedOnChange(mockRouter, mockWindow); 78 | 79 | version.checkVersion() 80 | .then(() => { 81 | mockRouter.onRouteChange.getCall(0).args[0](); 82 | 83 | expect(mockWindow.location.reload, "was called once"); 84 | done(); 85 | }); 86 | }); 87 | }); 88 | }); -------------------------------------------------------------------------------- /test/util/BuildUtilsTest.js: -------------------------------------------------------------------------------- 1 | import expect from "unexpected"; 2 | import moment from "moment"; 3 | import BuildUtils from "../../src/util/BuildUtils"; 4 | 5 | describe("BuildUtils", () => { 6 | it("should calculate remaining time.", () => { 7 | let remaining = BuildUtils.getEstimatedTimeRemaining(moment(100), moment(0), moment.duration(150)); 8 | 9 | expect(remaining.asMilliseconds(), "to be", 50); 10 | }); 11 | 12 | it("should calculate percent complete.", () => { 13 | let percent = BuildUtils.getEstimatedPercentComplete(moment(100), moment(0), moment.duration(150)); 14 | 15 | expect(percent, "to be", 67); 16 | }); 17 | 18 | it("should estimate duration as slightly higher than the average of the previous durations.", () => { 19 | let avg = BuildUtils.getEstimatedDuration([moment.duration(50000), moment.duration(150000)]); 20 | 21 | expect(avg.asSeconds(), "to be", 110); 22 | }); 23 | }); -------------------------------------------------------------------------------- /test/util/LoggerTest.js: -------------------------------------------------------------------------------- 1 | /*eslint no-console: 0*/ 2 | import expect from "unexpected"; 3 | import sinon from "sinon"; 4 | import Logger from "../../src/util/Logger"; 5 | 6 | describe("Logger", () => { 7 | afterEach(() => { 8 | Logger.setLogLevelAll(Logger.LogLevel.DEBUG); 9 | }); 10 | 11 | describe("info()", () => { 12 | it("should print name and arguments with console.info by default.", () => { 13 | sinon.stub(console, "info"); 14 | var log = new Logger("test1"); 15 | var obj = {}; 16 | 17 | log.info("blah", obj); 18 | 19 | expect(console.info, "was called with", "[test1]", "blah", obj); 20 | }); 21 | 22 | it("should not print anything when level is WARN.", () => { 23 | sinon.stub(console, "info"); 24 | var log = new Logger("test2"); 25 | 26 | log.setLogLevel(Logger.LogLevel.WARN); 27 | log.info("blah"); 28 | 29 | expect(console.info, "was not called"); 30 | }); 31 | }); 32 | 33 | describe("warn()", () => { 34 | it("should print name and arguments with console.warn by default.", () => { 35 | sinon.stub(console, "warn"); 36 | var log = new Logger("test3"); 37 | var obj = {}; 38 | 39 | log.warn("blah", obj); 40 | 41 | expect(console.warn, "was called with", "[test3]", "blah", obj); 42 | }); 43 | 44 | it("should not print anything when level is ERROR.", () => { 45 | sinon.stub(console, "warn"); 46 | var log = new Logger("test4"); 47 | 48 | log.setLogLevel(Logger.LogLevel.ERROR); 49 | log.warn("blah"); 50 | 51 | expect(console.warn, "was not called"); 52 | }); 53 | }); 54 | 55 | describe("error()", () => { 56 | it("error should print name and arguments with console.error by default.", () => { 57 | sinon.stub(console, "error"); 58 | var log = new Logger("test5"); 59 | var obj = {}; 60 | 61 | log.error("blah", obj); 62 | 63 | expect(console.error, "was called with", "[test5]", "blah", obj); 64 | }); 65 | 66 | it("should not print anything when level is OFF.", () => { 67 | sinon.stub(console, "error"); 68 | var log = new Logger("test6"); 69 | 70 | log.setLogLevel(Logger.LogLevel.OFF); 71 | log.error("blah"); 72 | 73 | expect(console.error, "was not called"); 74 | }); 75 | }); 76 | 77 | describe("debug()", () => { 78 | beforeEach(() => { 79 | console.debug = sinon.spy(); 80 | }); 81 | afterEach(() => { 82 | delete console.debug; 83 | }); 84 | 85 | it("debug should print name and arguments with console.debug by default.", () => { 86 | var log = new Logger("test7"); 87 | var obj = {}; 88 | 89 | log.debug("blah", obj); 90 | 91 | expect(console.debug, "was called with", "[test7]", "blah", obj); 92 | }); 93 | 94 | it("should not print anything when level is INFO.", () => { 95 | var log = new Logger("test8"); 96 | 97 | log.setLogLevel(Logger.LogLevel.INFO); 98 | log.debug("blah"); 99 | 100 | expect(console.debug, "was not called"); 101 | }); 102 | }); 103 | 104 | describe("trace()", () => { 105 | beforeEach(() => { 106 | console.trace = sinon.spy(); 107 | }); 108 | afterEach(() => { 109 | delete console.trace; 110 | }); 111 | 112 | it("should not print anything by default.", () => { 113 | var log = new Logger("test9"); 114 | 115 | log.trace("blah"); 116 | 117 | expect(console.trace, "was not called"); 118 | }); 119 | 120 | it("should print name and arguments with console.trace when level is TRACE.", () => { 121 | var log = new Logger("test10"); 122 | var obj = {}; 123 | 124 | log.setLogLevel(Logger.LogLevel.TRACE); 125 | log.trace("blah", obj); 126 | 127 | expect(console.trace, "was called with", "[test10]", "blah", obj); 128 | }); 129 | }); 130 | 131 | describe("default log level", () => { 132 | it("change should affect new loggers.", () => { 133 | sinon.stub(console, "warn"); 134 | Logger.setLogLevelAll(Logger.LogLevel.ERROR); 135 | var log = new Logger("test12"); 136 | 137 | log.warn("blah"); 138 | 139 | expect(console.warn, "was not called"); 140 | }); 141 | 142 | it("change should affect existing loggers.", () => { 143 | sinon.stub(console, "warn"); 144 | var log = new Logger("test13"); 145 | Logger.setLogLevelAll(Logger.LogLevel.ERROR); 146 | 147 | log.warn("blah"); 148 | 149 | expect(console.warn, "was not called"); 150 | }); 151 | }); 152 | 153 | describe("extracting name from __filename", () => { 154 | it("should work with Windows names.", () => { 155 | var log = new Logger("C:\\Code\\react-experiment\\src\\util\\MyClass.js"); 156 | 157 | expect(log.name, "to be", "MyClass"); 158 | }); 159 | 160 | it("should work with Linux names.", () => { 161 | var log = new Logger("/blah/blah/react-experiment/src/util/MyClass.js"); 162 | 163 | expect(log.name, "to be", "MyClass"); 164 | }); 165 | }); 166 | 167 | it("constructor should return previously created instance with same name.", () => { 168 | var log1 = new Logger("test11"); 169 | var log2 = new Logger("test11"); 170 | 171 | expect(log1, "to be", log2); 172 | }); 173 | }); -------------------------------------------------------------------------------- /test/util/MixinsTest.js: -------------------------------------------------------------------------------- 1 | import expect from "unexpected"; 2 | import sinon from "sinon"; 3 | import Mixins from "../../src/util/Mixins"; 4 | 5 | describe("Mixins", () => { 6 | it("should copy methods from mixin to context.", () => { 7 | var mixin = { 8 | test() {} 9 | }; 10 | var context = {}; 11 | 12 | Mixins.add(context, [mixin]); 13 | 14 | expect(context.test, "to be", mixin.test); 15 | }); 16 | 17 | it("should merge React lifecycle methods with context.", () => { 18 | var mixin = { 19 | componentWillUnmount: sinon.spy() 20 | }; 21 | var originalContextUnmount = sinon.spy(); 22 | var context = { 23 | componentWillUnmount: originalContextUnmount 24 | }; 25 | 26 | Mixins.add(context, [mixin]); 27 | context.componentWillUnmount(); 28 | 29 | expect(mixin.componentWillUnmount, "was called"); 30 | expect(originalContextUnmount, "was called"); 31 | }); 32 | 33 | it("should merge React lifecycle methods with multiple mixins.", () => { 34 | var mixin1 = { 35 | componentWillUnmount: sinon.spy() 36 | }; 37 | var mixin2 = { 38 | componentWillUnmount: sinon.spy() 39 | }; 40 | var context = {}; 41 | 42 | Mixins.add(context, [mixin1, mixin2]); 43 | context.componentWillUnmount(); 44 | 45 | expect(mixin1.componentWillUnmount, "was called"); 46 | expect(mixin2.componentWillUnmount, "was called"); 47 | }); 48 | }); -------------------------------------------------------------------------------- /test/util/RegExpsTest.js: -------------------------------------------------------------------------------- 1 | import expect from "unexpected"; 2 | import RegExps from "../../src/util/RegExps"; 3 | 4 | describe("RegExps", () => { 5 | it("should extract multiple regex matches.", () => { 6 | var matches = RegExps.getAllMatches(/(test)/g, "test, test"); 7 | 8 | expect(matches, "to equal", ["test", "test"]); 9 | }); 10 | 11 | it("should throw an error on non-global regexes.", () => { 12 | var invalidInput = () => { 13 | return RegExps.getAllMatches(/(test)/, "test, test"); 14 | }; 15 | 16 | expect(invalidInput, "to throw error"); 17 | }); 18 | }); --------------------------------------------------------------------------------