├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── app.js ├── config.js ├── dashboards └── index.jsx ├── docker-compose.yml ├── jobs.js ├── jobs ├── popular_subreddits.js └── reddit_headline.js ├── package.json ├── styles ├── _variables.scss └── default.scss ├── test ├── setup.js └── widgets │ └── number_widgets.spec.js ├── views └── index.hbs ├── webpack.config.js ├── widgets ├── image_widget.jsx ├── image_widget.scss ├── list_widget.jsx ├── list_widget.scss ├── number_widget.jsx ├── number_widget.scss ├── text_widget.jsx └── widget.jsx └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react" 5 | ], 6 | "plugins": [["resolver", {"resolveDirs": ["", "node_modules"]}]] 7 | } 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # /node_modules and /bower_components ignored by default 2 | 3 | # Ignore files compiled from TypeScript and CoffeeScript 4 | **/*.{ts,coffee}.js 5 | 6 | # Ignore built files except build/index.js 7 | build/ 8 | 9 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { "node": true, "browser": true, "es6": true, "mocha": true}, 3 | "extends": ["eslint:recommended", "plugin:react/recommended"], 4 | "plugins": [ 5 | "react" 6 | ], 7 | "parserOptions": { 8 | "sourceType": "module" 9 | } 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dump.rdb 2 | node_modules/ 3 | build/ 4 | npm-debug.log 5 | 6 | # Local configuration 7 | .vscode/ 8 | .env -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: "6" 3 | script: 4 | - npm run coveralls 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.7 2 | 3 | LABEL maintainer "Yoan Rousseau, yOan " 4 | 5 | RUN mkdir -p /opt/app/ 6 | 7 | WORKDIR /opt/app/ 8 | 9 | EXPOSE 3000 10 | 11 | VOLUME [ "/opt/app/jobs/" ] 12 | VOLUME [ "/opt/app/dashboards/" ] 13 | 14 | COPY . . 15 | 16 | RUN yarn install 17 | 18 | ENTRYPOINT [ "yarn", "start" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 David Underwood 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/davefp/handsome.svg?branch=master)](https://travis-ci.org/davefp/handsome) 2 | [![Coverage Status](https://coveralls.io/repos/github/davefp/handsome/badge.svg?branch=master)](https://coveralls.io/github/davefp/handsome?branch=master) 3 | # Handsome Dashboard Framework 4 | 5 | ## What is Handsome? 6 | 7 | Handsome is a dashboard framework written in javascript. 8 | 9 | It is currently a work-in-progress. 10 | 11 | Handsome is a cousin to [Dashing](http://dashing.io). 12 | 13 | # Getting Started 14 | 15 | ## Prerequisites 16 | 17 | You will need *node* and [*yarn*](https://yarnpkg.com/en/) installed before you can do anything. 18 | 19 | You'll also need *redis* installed. [Read the quickstart guide to get going quickly](http://redis.io/topics/quickstart). 20 | 21 | ## Installation and setup 22 | 23 | ### Easy setup with Docker-compose 24 | 25 | This will help you to start with handsome, no redis or npm/yarn requirement, you just need docker-compose & docker. 26 | 27 | Build service: 28 | 29 | `$ docker-compose build` 30 | 31 | Launch the app 32 | 33 | `$ docker-compose up` 34 | 35 | Now visit to see the default dashboard! 36 | 37 | Hooray! You're running Handsome in docker!! 38 | 39 | ### docker 40 | 41 | To run the standalone image, you need to have a redis server running. 42 | 43 | Start redis: 44 | 45 | `$ redis-server --protected-mode no` 46 | 47 | `--protected-mode` is disabled to allow container connection. This is NOT recommend for production use. 48 | 49 | Now you can build & run the docker image: 50 | 51 | `$ docker build -t handsome .` 52 | 53 | ``` 54 | $ docker run -d -p 3000:3000 \ 55 | -e REDIS_SERVER_HOST= \ 56 | -v :/opt/app/jobs/ \ 57 | -v :/opt/app/dashboards/ \ 58 | handsome 59 | ``` 60 | 61 | with : 62 | * : the ip address of a running redis (you can use `ip a` to show your ip) 63 | * : folder on host where handsome can find the jobs 64 | * : folder on host where handsome can find the dashboards 65 | 66 | Example: 67 | ``` 68 | $ docker run -d -p 3000:3000 \ 69 | -e REDIS_SERVER_HOST=192.168.0.1 \ 70 | -v /home/y0an/handsome/jobs/:/opt/app/jobs/ \ 71 | -v /home/y0an/handsome/dashboards/:/opt/app/dashboards/ \ 72 | handsome 73 | ``` 74 | 75 | ### Standard without docker 76 | 77 | Clone this repository (or fork it and then clone). 78 | 79 | Install dependencies: 80 | 81 | `$ yarn install` 82 | 83 | This will also build your js bundle and place it in the `build` directory. 84 | 85 | Start redis: 86 | 87 | `$ redis-server` 88 | 89 | Start your Handsome server: 90 | 91 | `$ yarn start` 92 | 93 | Now visit to see the default dashboard. 94 | 95 | Hooray! You're running Handsome. 96 | 97 | ## A bit more detail 98 | 99 | Behind the scenes, Handsome runs a simple [Express](http://expressjs.com/) app to serve widget data and repeatedly schedule jobs to generate new widget data. The data is stored in redis. 100 | 101 | In development, the app will auto-generate and serve the client-side assets. Changing a source file will cause the relevant bundle to be regenerated on the fly. 102 | 103 | # Adding your own dashboard 104 | 105 | The default dashboard is a bit boring, so let's add a new one. 106 | 107 | Create a new JSX file under the dashboards directory: 108 | 109 | `$ touch dashboards/my_dashboard.jsx` 110 | 111 | The skeleton of a dashboard is a simple ReactDOM.render call: 112 | 113 | ``` 114 | // my_dashboard.jsx 115 | 116 | ReactDOM.render( 117 |
118 | //Widgets go here! 119 |
, 120 | document.getElementById('content') 121 | ); 122 | ``` 123 | 124 | Now you can populate the dashboard with widgets by simply adding the appropriate React components as children of the existing div. 125 | 126 | Each widget needs a `name` so that it knows where to call for updates. Each widget type can also have its own properties. The text widget, for example, takes a 'title' property. 127 | 128 | Add a text widget to your dashboard: 129 | 130 | ``` 131 | // my_dashboard.jsx 132 | 133 | ReactDOM.render( 134 |
135 | 136 |
, 137 | document.getElementById('content') 138 | ); 139 | ``` 140 | 141 | That's it! You can now navigate to http://localhost:3000/my_dashboard and see your dashboard and widgets. 142 | 143 | # Adding Data 144 | 145 | Your new dashboard is boring. It's got a widget, but there's no data going to it. You can fix that by adding a new job. 146 | 147 | Create a new job file: 148 | 149 | `$ touch jobs/my_job.js` 150 | 151 | Jobs need to export the following: 152 | 153 | * An `interval`, which is the period between each run of the job in milliseconds 154 | * A `promise`, which is a function that takes two arguments: `fulfill` and `reject`. Call `fulfill` with the widget data on success or `reject` with an error message if the job fails. 155 | 156 | This function is used to create a [Promise](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Promise). 157 | 158 | Here's an example to go with our new widget above that fetches the title of the top Reddit post every minute: 159 | 160 | ``` 161 | import request from "request" 162 | const url = "https://www.reddit.com/subreddits/popular.json"; 163 | 164 | export const interval = 300000; 165 | export const promise = (fulfill, reject) => { 166 | request(url, (error, response, body) => { 167 | if (!error && response.statusCode == 200) { 168 | var json = JSON.parse(body); 169 | var subreddit_list = json['data']['children'].slice(0,19).map(function(item) { 170 | return item['data']['display_name']; 171 | }); 172 | fulfill({top_subreddits: {list: subreddit_list}}); 173 | } else { 174 | reject(error); 175 | } 176 | }); 177 | }; 178 | ``` 179 | 180 | 181 | # Making Custom Widgets 182 | 183 | Create JSX and Sass files for your widget: 184 | 185 | ``` 186 | $ touch widgets/my_widget.jsx 187 | $ touch widgets/my_widget.scss 188 | ``` 189 | 190 | The widget itself should be an ES6 class that extends the BaseWidget like so: 191 | 192 | ``` 193 | import React from 'react'; 194 | import BaseWidget from './widget.jsx'; 195 | 196 | import './my_widget.scss'; 197 | 198 | export default class MyWidget extends BaseWidget { 199 | 200 | constructor(props) { 201 | super(props); 202 | this.state = {title: "init", text: "init"}; 203 | } 204 | 205 | render() { 206 | return ( 207 |
208 |

{this.props.title}

209 |

{this.state.text}

210 |
211 | ); 212 | } 213 | } 214 | ``` 215 | 216 | At a bare minimum it should also implement the `render` method and set some initial state in the constructor so that it can be drawn and have some default data to be shown while waiting for the server. 217 | 218 | The Sass file should import the variables defined in `styles/_variables.scss` and all styles should be scoped to the widget in question: 219 | 220 | ``` 221 | @import '../styles/variables'; 222 | 223 | .widget.my_widget { 224 | background-color: $color_4; 225 | .h2 { 226 | font-size: 500%; 227 | } 228 | } 229 | ``` 230 | 231 | # Using a different storage engine 232 | 233 | Handsome stores widget data in redis by default, but it uses [cacheman](https://github.com/cayasso/cacheman) as an interface. 234 | 235 | This means that you can use any storage engine that cacheman supports. [Here are the available storage engines](https://github.com/cayasso/cacheman#supported-engines) 236 | 237 | To switch engines, install the corresponding cacheman package (e.g. `cacheman-mongo`) for MongoDB and then update the storage options in `config.js` to use the new engine: 238 | 239 | ``` 240 | var Cacheman = require('cacheman'); 241 | 242 | var storage_options = { 243 | engine: 'mongo', 244 | port: 9999, 245 | host: '127.0.0.1', 246 | username: 'user', 247 | ... 248 | }; 249 | 250 | exports.getStorage = function() { 251 | return new Cacheman('handsome', storage_options) 252 | }; 253 | ``` 254 | 255 | # How does Handsome differ from Dashing? 256 | 257 | Handsome's front-end is powered by [React](https://facebook.github.io/react/), while Dashing's is powered by [Batman.js](http://batmanjs.org/) 258 | 259 | Handsome's back-end is a node/express app, while Dashing runs Sinatra. 260 | 261 | Handsome uses a polling model to update dashboards, while Dashing streams data using [Server Sent Events](https://en.wikipedia.org/wiki/Server-sent_events). 262 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console, no-unused-vars */ 2 | var express = require('express'); 3 | var exphbs = require('express-handlebars'); 4 | var app = express(); 5 | var moment = require('moment'); 6 | 7 | var config = require(__dirname + '/config.js'); 8 | var storage = config.getStorage(); 9 | 10 | const path = require('path'); 11 | 12 | app.engine('hbs', exphbs({defaultLayout: 'index', extname: '.hbs', layoutsDir: 'views/'})); 13 | app.set('view engine', 'hbs'); 14 | 15 | app.set('port', (process.env.PORT || 3000)); 16 | 17 | app.get('/', function (req, res) { 18 | res.render('index', {name: 'index'}); 19 | }); 20 | 21 | app.get('/:dashboard', function(req, res) { 22 | res.render('index', {name: req.params.dashboard, layout: false}); 23 | }); 24 | 25 | app.get('/widgets/:widget.json', function(req, res) { 26 | storage.get(req.params.widget, function(err, reply) { 27 | if(err) { 28 | res.json({'error': err}); 29 | } else if(reply === null) { 30 | console.log(req.params.widget, "no data") 31 | } else { 32 | var reply_json = JSON.parse(reply); 33 | console.log(req.params.widget, reply_json) 34 | try { 35 | var next_time = moment(reply_json.next_time); 36 | delete reply_json.next_time; 37 | var now = moment(); 38 | if (now.isBefore(next_time)) { 39 | reply_json.updates_in_millis = moment.duration(next_time.diff(now)).asMilliseconds(); 40 | } else { 41 | reply_json.updates_in_millis = 5000; 42 | } 43 | res.json(reply_json); 44 | } catch (e) { 45 | console.log(e) 46 | } 47 | } 48 | }) 49 | }); 50 | 51 | app.listen(app.get('port'), function () { 52 | console.log("Up and running on port " + app.get('port')); 53 | }); 54 | 55 | // Serve our bundle 56 | if(process.env.NODE_ENV === 'production') { 57 | // serve the contents of the build folder 58 | app.use("/assets", express.static('build')); 59 | } else { 60 | // serve using webpack middleware 61 | var webpack = require('webpack'); 62 | var webpackDevMiddleware = require('webpack-dev-middleware'); 63 | var webpackConfig = require('./webpack.config'); 64 | var compiler = webpack(webpackConfig); 65 | app.use(webpackDevMiddleware(compiler, { 66 | publicPath: '/assets/', 67 | stats: {colors: true} 68 | })); 69 | } 70 | 71 | // load our jobs 72 | require('./jobs.js'); 73 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | var Cacheman = require('cacheman'); 2 | 3 | var storage_options = { 4 | engine: 'redis', 5 | port: process.env.REDIS_SERVER_PORT || 6379, 6 | host: process.env.REDIS_SERVER_HOST || "127.0.0.1", 7 | password: process.env.REDIS_SERVER_PASSWORD || undefined, 8 | prefix: process.env.REDIS_PREFIX || "handsome", 9 | ttl: -1 10 | }; 11 | 12 | exports.getStorage = function() { 13 | return new Cacheman('handsome', storage_options) 14 | }; 15 | -------------------------------------------------------------------------------- /dashboards/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Packery from 'packery'; 4 | import ImageWidget from 'widgets/image_widget'; 5 | import ListWidget from 'widgets/list_widget'; 6 | import NumberWidget from 'widgets/number_widget'; 7 | import TextWidget from 'widgets/text_widget'; 8 | 9 | import "styles/default.scss"; 10 | 11 | ReactDOM.render( 12 |
13 | 14 | 15 | 16 | 17 |
, 18 | document.getElementById('content') 19 | ); 20 | 21 | new Packery("#dashboard", {itemSelector: ".widget", gutter: 10}); 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.2' 2 | 3 | networks: 4 | handsome_backend: 5 | 6 | services: 7 | handsome_ui: 8 | build: . 9 | networks: 10 | - handsome_backend 11 | environment: 12 | - REDIS_SERVER_HOST=redis 13 | ports: 14 | - "3000:3000" 15 | redis: 16 | image: redis 17 | networks: 18 | - handsome_backend 19 | -------------------------------------------------------------------------------- /jobs.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console, no-unused-vars */ 2 | var config = require('./config.js'); 3 | var storage = config.getStorage(); 4 | 5 | var moment = require('moment'); 6 | 7 | var jobs = require('require-all')(__dirname + '/jobs'); 8 | 9 | function update_widget(name, data, next_time) { 10 | console.log("updating widget: " + name); 11 | storage.set(name, JSON.stringify({ 12 | payload: data, 13 | next_time: next_time 14 | }), function(err, res) { 15 | if(err) { 16 | console.log(err); 17 | } 18 | }); 19 | } 20 | 21 | function reschedule(job) { 22 | setTimeout(function() {start_recurring_job(job)}, job.interval) 23 | } 24 | 25 | function start_recurring_job(job) { 26 | new Promise(job.promise) 27 | .then( 28 | function(widget_data) { 29 | for (var widget in widget_data) { 30 | update_widget(widget, widget_data[widget], moment().add(job.interval, 'ms')); 31 | } 32 | reschedule(job); 33 | } 34 | ) 35 | .catch( 36 | function(error) { 37 | console.log(error); 38 | reschedule(job); 39 | } 40 | ); 41 | } 42 | 43 | for (var job in jobs) { 44 | console.log("Starting job: " + job) 45 | start_recurring_job(jobs[job]); 46 | } 47 | -------------------------------------------------------------------------------- /jobs/popular_subreddits.js: -------------------------------------------------------------------------------- 1 | import request from 'request' 2 | const url = 'https://www.reddit.com/subreddits/popular.json'; 3 | 4 | export const interval = 300000; 5 | export const promise = (fulfill, reject) => { 6 | request(url, (error, response, body) => { 7 | if (!error && response.statusCode == 200) { 8 | var json = JSON.parse(body); 9 | var subreddit_list = json['data']['children'].slice(0,19).map(function(item) { 10 | return item['data']['display_name']; 11 | }); 12 | fulfill({top_subreddits: {list: subreddit_list}}); 13 | } else { 14 | reject(error); 15 | } 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /jobs/reddit_headline.js: -------------------------------------------------------------------------------- 1 | import request from 'request' 2 | const url = "https://www.reddit.com/r/todayilearned.json?limit=5"; 3 | 4 | export const interval = 300000; 5 | export const promise = (fulfill, reject) => { 6 | request(url, (error, response, body) => { 7 | if (!error && response.statusCode == 200) { 8 | var json = JSON.parse(body); 9 | for (var i = 0; i < json["data"]["children"].length; i++) { 10 | var child = json["data"]["children"][i]; 11 | if(child["data"]["stickied"]) { 12 | continue; 13 | } 14 | fulfill({ 15 | reddit_headline: {text: child["data"]["title"]}, 16 | reddit_score: {number: child["data"]["score"]} 17 | }); 18 | break; 19 | } 20 | } else { 21 | reject(error); 22 | } 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "handsome", 3 | "version": "0.1.1", 4 | "description": "A handsome dashboard framework written in javascript", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "babel-node app.js", 8 | "postinstall": "yarn run build", 9 | "build": "webpack -p --progress --colors", 10 | "test": "./node_modules/.bin/mocha --compilers js:babel-core/register --require ignore-styles --require test/setup.js --recursive", 11 | "lint": "eslint .", 12 | "cover": "./node_modules/.bin/nyc --reporter=lcov --extension .jsx npm run test", 13 | "cover:report": "npm run cover && ./node_modules/.bin/nyc report", 14 | "coveralls": "npm run cover:report && cat coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/davefp/handsome.git" 19 | }, 20 | "keywords": [ 21 | "dashboard" 22 | ], 23 | "author": "David Underwood", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/davefp/handsome/issues" 27 | }, 28 | "homepage": "https://github.com/davefp/handsome", 29 | "dependencies": { 30 | "babel-cli": "^6.24.1", 31 | "babel-core": "^6.24.1", 32 | "babel-loader": "^7.0.0", 33 | "babel-plugin-resolver": "^1.1.0", 34 | "babel-polyfill": "^6.23.0", 35 | "babel-preset-es2015": "^6.6.0", 36 | "babel-preset-react": "^6.5.0", 37 | "cacheman": "^2.2.1", 38 | "cacheman-redis": "^1.1.2", 39 | "css-loader": "^0.23.1", 40 | "express": "^4.13.4", 41 | "express-handlebars": "^3.0.0", 42 | "jquery": "^2.2.3", 43 | "moment": "^2.12.0", 44 | "node-sass": "^3.4.2", 45 | "numeral": "^1.5.3", 46 | "packery": "^2.0.0", 47 | "react": "^15.0.1", 48 | "react-dom": "^15.0.1", 49 | "redis": "^2.6.0-1", 50 | "request": "^2.71.0", 51 | "require-all": "^2.0.0", 52 | "sass-loader": "^3.2.0", 53 | "style-loader": "^0.13.1", 54 | "webpack": "^2.5.1" 55 | }, 56 | "devDependencies": { 57 | "babel-preset-env": "^1.4.0", 58 | "chai": "^3.5.0", 59 | "coveralls": "^2.11.9", 60 | "enzyme": "^2.3.0", 61 | "eslint": "^3.19.0", 62 | "eslint-plugin-react": "^7.0.1", 63 | "ignore-styles": "^2.0.0", 64 | "jsdom": "^9.2.1", 65 | "mocha": "^2.5.3", 66 | "nyc": "^6.4.4", 67 | "react-addons-test-utils": "^15.1.0", 68 | "webpack-dev-middleware": "^1.6.1" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $widget_width: 250px; 2 | $widget_height: 300px; 3 | $widget_padding: 21px; 4 | $gutter: 10px; 5 | 6 | $max_columns: 4; 7 | 8 | $bg_color: #616161; 9 | $color_1: #00A1CB; 10 | $color_2: #61AE24; 11 | $color_3: #D70060; 12 | $color_4: #F18D05; 13 | -------------------------------------------------------------------------------- /styles/default.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | 3 | body { 4 | padding: 0; 5 | margin: 0; 6 | font-family: sans-serif; 7 | background-color: $bg_color; 8 | } 9 | 10 | #dashboard { 11 | max-width: $max_columns * ($widget_width + $gutter) - $gutter; 12 | padding: $gutter; 13 | margin: 0 auto; 14 | } 15 | 16 | .widget { 17 | width: $widget_width - $widget_padding*2; 18 | height: $widget_height - $widget_padding*2; 19 | padding: $widget_padding; 20 | background-color: $color_1; 21 | color: #FFF; 22 | h1 { 23 | color: rgba(255, 255, 255, 0.8); 24 | margin-top: 0; 25 | } 26 | } 27 | 28 | @for $i from 1 through 4 { 29 | .w#{$i} { 30 | width: ($widget_width * $i) + ($gutter * ($i - 1)) - ($widget_padding * 2); 31 | } 32 | .h#{$i} { 33 | height: ($widget_height * $i) + ($gutter * ($i - 1)) - ($widget_padding * 2); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import {jsdom} from 'jsdom'; 2 | 3 | const exposedProperties = ['window', 'navigator', 'document']; 4 | 5 | global.document = jsdom(''); 6 | global.window = document.defaultView; 7 | Object.keys(document.defaultView).forEach((property) => { 8 | if (typeof global[property] === 'undefined') { 9 | exposedProperties.push(property); 10 | global[property] = document.defaultView[property]; 11 | } 12 | }); 13 | 14 | global.navigator = { 15 | userAgent: 'node.js' 16 | }; 17 | -------------------------------------------------------------------------------- /test/widgets/number_widgets.spec.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | import NumberWidget from '../../widgets/number_widget'; 3 | import {shallow, mount} from 'enzyme'; 4 | import React from 'react'; 5 | 6 | describe('', () => { 7 | 8 | const shallowWrapper = shallow(); 9 | const mountWrapper = mount(); 10 | 11 | it('sets the title', () => { 12 | expect(shallowWrapper.find('h1').text()).to.equal("bar"); 13 | }); 14 | 15 | it('sets the width and height to default value', () => { 16 | expect(mountWrapper.prop('width')).to.equal(1); 17 | expect(mountWrapper.prop('height')).to.equal(1); 18 | }); 19 | 20 | it('sets number to be default value', () => { 21 | expect(shallowWrapper.find('.number').text()).to.equal("0"); 22 | }); 23 | 24 | it('set a custom width & height', () => { 25 | const customWrapper = mount(); 26 | expect(customWrapper.prop('width')).to.equal("10"); 27 | expect(customWrapper.prop('height')).to.equal("100"); 28 | }); 29 | }) 30 | -------------------------------------------------------------------------------- /views/index.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{name}} Dashboard 4 | 5 | 6 |
7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | var fs = require("fs"); 3 | var webpack = require("webpack"); 4 | 5 | const PATHS = { 6 | dashboards: path.join(__dirname, 'dashboards'), 7 | build: path.join(__dirname, 'build'), 8 | jobs: path.join(__dirname, 'jobs'), 9 | widgets: path.join(__dirname, 'widgets'), 10 | styles: path.join(__dirname, 'styles') 11 | }; 12 | 13 | // grab all dashboards 14 | var dashboardPaths = fs.readdirSync(PATHS.dashboards).reduce(function(map, filename) { 15 | map[path.basename(filename, '.jsx')] = path.join(PATHS.dashboards, filename); 16 | return map; 17 | }, {}); 18 | 19 | var webConfig = { 20 | entry: dashboardPaths, 21 | target: 'web', 22 | output: { path: PATHS.build, filename: '[name].dashboard.bundle.js'}, 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.(js|jsx)$/, 27 | use: { 28 | loader: 'babel-loader', 29 | // Enable caching for improved performance during development 30 | // It uses default OS directory by default. If you need something 31 | // more custom, pass a path to it. I.e., babel?cacheDirectory= 32 | options: {cacheDirectory: true} 33 | }, 34 | include: [PATHS.dashboards, PATHS.widgets], 35 | }, 36 | { 37 | test: /\.scss$/, 38 | use: ['style-loader', 'css-loader', 'sass-loader'], 39 | include: [PATHS.styles, PATHS.widgets] 40 | } 41 | ] 42 | }, 43 | plugins: [ 44 | new webpack.optimize.CommonsChunkPlugin({name: 'commons', filename: 'common.bundle.js'}), 45 | new webpack.EnvironmentPlugin({"NODE_ENV": 'development'}) 46 | ], 47 | resolve: { 48 | extensions: ['.js', '.jsx', '.scss'], 49 | modules: [ 50 | path.resolve(__dirname), 51 | 'node_modules' 52 | ] 53 | } 54 | }; 55 | 56 | module.exports = webConfig -------------------------------------------------------------------------------- /widgets/image_widget.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import BaseWidget from './widget.jsx'; 3 | 4 | import './image_widget.scss'; 5 | 6 | export default class ImageWidget extends BaseWidget { 7 | 8 | updateWidget() { 9 | // no-op since this is a static image 10 | return; 11 | } 12 | 13 | render() { 14 | return ( 15 |
16 | 17 |
18 | ); 19 | } 20 | } 21 | 22 | ImageWidget.defaultProps.image_url = "http://placehold.it/208x258" 23 | -------------------------------------------------------------------------------- /widgets/image_widget.scss: -------------------------------------------------------------------------------- 1 | @import '../styles/variables'; 2 | 3 | .widget.image_widget { 4 | background-color: #444; 5 | } 6 | -------------------------------------------------------------------------------- /widgets/list_widget.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import BaseWidget from './widget.jsx'; 3 | 4 | import './list_widget.scss'; 5 | 6 | export default class ListWidget extends BaseWidget { 7 | 8 | constructor(props) { 9 | super(props); 10 | this.state = {title: "init", list: ["init"]}; 11 | } 12 | 13 | render() { 14 | var list = this.state.list.map(function (item) { 15 | return ( 16 |
  • 17 | {item} 18 |
  • 19 | ); 20 | }); 21 | return ( 22 |
    23 |

    {this.props.title}

    24 |
      25 | {list} 26 |
    27 |
    28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /widgets/list_widget.scss: -------------------------------------------------------------------------------- 1 | @import '../styles/variables'; 2 | 3 | .widget.list_widget { 4 | ul { 5 | list-style-type: none; 6 | padding-left: 0; 7 | } 8 | li { 9 | font-size: 1.3em; 10 | line-height: 1.2em; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /widgets/number_widget.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import BaseWidget from './widget.jsx'; 3 | import Numeral from 'numeral'; 4 | 5 | import './number_widget.scss'; 6 | 7 | export default class NumberWidget extends BaseWidget { 8 | 9 | constructor(props) { 10 | super(props); 11 | this.state = {title: "init", number: 0}; 12 | } 13 | 14 | render() { 15 | return ( 16 |
    17 |

    {this.props.title}

    18 |
    {Numeral(this.state.number).format(this.props.formatString)}
    19 |
    20 | ); 21 | } 22 | } 23 | 24 | NumberWidget.defaultProps.formatString = '0.[00]a'; 25 | -------------------------------------------------------------------------------- /widgets/number_widget.scss: -------------------------------------------------------------------------------- 1 | @import "../styles/variables"; 2 | 3 | .widget.number_widget { 4 | background-color: $color_4; 5 | .number { 6 | font-size: 500%; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /widgets/text_widget.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import BaseWidget from './widget.jsx'; 3 | 4 | export default class TextWidget extends BaseWidget { 5 | 6 | constructor(props) { 7 | super(props); 8 | this.state = {title: "init", text: "init"}; 9 | } 10 | 11 | render() { 12 | return ( 13 |
    14 |

    {this.props.title}

    15 |

    {this.state.text}

    16 |
    17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /widgets/widget.jsx: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import React from 'react'; 3 | 4 | export default class BaseWidget extends React.Component { 5 | 6 | constructor(props) { 7 | super(props); 8 | this.timeout_id = null; 9 | } 10 | 11 | updateWidget() { 12 | $.ajax({ 13 | url: 'widgets/' + this.props.name + '.json', 14 | dataType: 'json', 15 | success: function(data) { 16 | this.setState(data['payload']); 17 | this.reschedule(data['updates_in_millis']); 18 | }.bind(this), 19 | error: function(xhr, status, err) { 20 | console.error(this.props.url, status, err.toString()); 21 | this.reschedule(1000); 22 | }.bind(this) 23 | }); 24 | } 25 | 26 | reschedule(interval) { 27 | if(this.timeout_id) { 28 | clearTimeout(this.timeout_id) 29 | this.timeout_id = null; 30 | } 31 | if(interval < 1000) { interval = 1000; } 32 | this.timeout_id = setTimeout(() => this.updateWidget(), interval) 33 | } 34 | 35 | componentDidMount() { 36 | this.updateWidget(); 37 | } 38 | } 39 | 40 | BaseWidget.defaultProps = { 41 | width: 1, 42 | height: 1 43 | }; 44 | --------------------------------------------------------------------------------