Repository: vudash/vudash Branch: master Commit: b560ef804a10 Files: 244 Total size: 252.0 KB Directory structure: gitextract_gganvaua/ ├── .codeclimate.json ├── .gitignore ├── .travis.yml ├── README.MD ├── docs/ │ ├── .nojekyll │ ├── README.md │ ├── _coverpage.md │ ├── api/ │ │ └── README.md │ ├── datasources/ │ │ └── README.md │ ├── developers/ │ │ └── README.md │ ├── index.html │ ├── transformers/ │ │ └── README.md │ └── widgets/ │ └── README.md ├── lerna.json ├── package.json ├── packages/ │ ├── core/ │ │ ├── README.md │ │ ├── app.js │ │ ├── bin/ │ │ │ └── vudash.js │ │ ├── dashboards/ │ │ │ ├── simple.json │ │ │ └── template.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── cli/ │ │ │ │ ├── create.js │ │ │ │ ├── help.js │ │ │ │ └── logo.js │ │ │ ├── config-validator/ │ │ │ │ ├── config-validator.spec.js │ │ │ │ └── index.js │ │ │ ├── dashboard/ │ │ │ │ ├── bundler/ │ │ │ │ │ └── index.js │ │ │ │ ├── compiler/ │ │ │ │ │ ├── compiler.spec.js │ │ │ │ │ ├── configuration-builder/ │ │ │ │ │ │ ├── configuration-builder.js │ │ │ │ │ │ ├── configuration-builder.spec.js │ │ │ │ │ │ └── index.js │ │ │ │ │ └── index.js │ │ │ │ ├── dashboard.js │ │ │ │ ├── dashboard.spec.js │ │ │ │ ├── emitter/ │ │ │ │ │ ├── emitter.js │ │ │ │ │ ├── emitter.spec.js │ │ │ │ │ └── index.js │ │ │ │ ├── index.js │ │ │ │ ├── loader/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── loader.js │ │ │ │ │ └── loader.spec.js │ │ │ │ ├── parser/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── parser.js │ │ │ │ │ └── parser.spec.js │ │ │ │ ├── renderer/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── renderer.js │ │ │ │ │ └── renderer.spec.js │ │ │ │ └── schema/ │ │ │ │ ├── index.js │ │ │ │ ├── schema.js │ │ │ │ └── schema.spec.js │ │ │ ├── dashboard-event/ │ │ │ │ ├── dashboard-event.js │ │ │ │ └── index.js │ │ │ ├── datasource/ │ │ │ │ ├── datasource.spec.js │ │ │ │ ├── dummy-datasource/ │ │ │ │ │ ├── dummy-datasource.spec.js │ │ │ │ │ └── index.js │ │ │ │ ├── index.js │ │ │ │ ├── locator/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── locator.spec.js │ │ │ │ └── validator/ │ │ │ │ ├── index.js │ │ │ │ └── validator.spec.js │ │ │ ├── datasource-binder/ │ │ │ │ ├── datasource-binder.js │ │ │ │ ├── datasource-binder.spec.js │ │ │ │ ├── datasource-emitter.js │ │ │ │ └── index.js │ │ │ ├── datasource-loader/ │ │ │ │ ├── datasource-loader.js │ │ │ │ ├── datasource-loader.spec.js │ │ │ │ └── index.js │ │ │ ├── errors/ │ │ │ │ ├── configuration.error.js │ │ │ │ ├── index.js │ │ │ │ ├── not-found.error.js │ │ │ │ ├── plugin-registration.error.js │ │ │ │ └── widget-registration.error.js │ │ │ ├── id-gen/ │ │ │ │ ├── id-gen.js │ │ │ │ ├── id-gen.spec.js │ │ │ │ └── index.js │ │ │ ├── plugins/ │ │ │ │ ├── api/ │ │ │ │ │ ├── api.js │ │ │ │ │ ├── handlers/ │ │ │ │ │ │ ├── dashboards/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── put.js │ │ │ │ │ │ │ └── put.spec.js │ │ │ │ │ │ └── view/ │ │ │ │ │ │ └── current/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── put.js │ │ │ │ │ │ └── put.spec.js │ │ │ │ │ └── index.js │ │ │ │ ├── socket/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── socket.js │ │ │ │ ├── static/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── static.js │ │ │ │ └── ui/ │ │ │ │ ├── handlers/ │ │ │ │ │ ├── dashboard/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── index/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── index.spec.js │ │ │ │ ├── index.js │ │ │ │ ├── ui.js │ │ │ │ └── ui.spec.js │ │ │ ├── public/ │ │ │ │ ├── css/ │ │ │ │ │ ├── listing.css │ │ │ │ │ └── style.css │ │ │ │ └── js/ │ │ │ │ ├── audio.js │ │ │ │ └── object-assign.polyfill.js │ │ │ ├── resolver/ │ │ │ │ ├── index.js │ │ │ │ ├── resolver.js │ │ │ │ └── resolver.spec.js │ │ │ ├── server.js │ │ │ ├── transform-loader/ │ │ │ │ ├── index.js │ │ │ │ ├── transform-loader.js │ │ │ │ └── transform-loader.spec.js │ │ │ ├── upper-camel/ │ │ │ │ ├── index.js │ │ │ │ └── upper-camel.spec.js │ │ │ ├── views/ │ │ │ │ ├── dashboard.html │ │ │ │ └── listing.html │ │ │ ├── widget/ │ │ │ │ ├── history/ │ │ │ │ │ ├── history.js │ │ │ │ │ ├── history.spec.js │ │ │ │ │ └── index.js │ │ │ │ ├── index.js │ │ │ │ ├── loader/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── loader.spec.js │ │ │ │ ├── renderer/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── renderer.spec.js │ │ │ │ ├── validator/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── validator.js │ │ │ │ │ └── validator.spec.js │ │ │ │ ├── widget-position/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── widget-position.spec.js │ │ │ │ └── widget.spec.js │ │ │ ├── widget-binder/ │ │ │ │ ├── index.js │ │ │ │ ├── widget-binder.js │ │ │ │ └── widget-binder.spec.js │ │ │ └── widget-datasource-binding/ │ │ │ ├── index.js │ │ │ ├── widget-datasource-binding.js │ │ │ └── widget-datasource-binding.spec.js │ │ └── test/ │ │ ├── resources/ │ │ │ └── widgets/ │ │ │ ├── broken/ │ │ │ │ ├── package.json │ │ │ │ └── widget.js │ │ │ ├── configurable/ │ │ │ │ ├── component.html │ │ │ │ ├── package.json │ │ │ │ └── widget.js │ │ │ ├── example/ │ │ │ │ ├── markup.html │ │ │ │ ├── package.json │ │ │ │ └── widget.js │ │ │ └── missing/ │ │ │ ├── package.json │ │ │ └── widget.js │ │ └── util/ │ │ ├── dashboard.builder.js │ │ ├── datasource.builder.js │ │ └── widget.builder.js │ ├── datasource-google-sheets/ │ │ ├── datasource.js │ │ ├── datasource.spec.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── config-validator/ │ │ │ │ ├── config-validator.spec.js │ │ │ │ └── index.js │ │ │ └── google-sheets-transport/ │ │ │ ├── google-sheets-transport.spec.js │ │ │ └── index.js │ │ └── test/ │ │ ├── config.util.js │ │ ├── example.credentials.test.json │ │ └── example.invalid-credentials.test.json │ ├── datasource-random/ │ │ ├── datasource.js │ │ ├── datasource.spec.js │ │ ├── package.json │ │ └── src/ │ │ ├── datasource-validation/ │ │ │ └── index.js │ │ └── random-transport/ │ │ ├── index.js │ │ └── index.spec.js │ ├── datasource-rest/ │ │ ├── datasource.js │ │ ├── package.json │ │ └── src/ │ │ ├── datasource-validation/ │ │ │ ├── datasource-validation.js │ │ │ ├── datasource-validation.spec.js │ │ │ └── index.js │ │ └── rest-transport/ │ │ ├── index.js │ │ └── rest-transport.spec.js │ ├── datasource-value/ │ │ ├── datasource.js │ │ ├── package.json │ │ └── src/ │ │ ├── datasource-validation/ │ │ │ ├── datasource-validation.spec.js │ │ │ └── index.js │ │ └── value-transport/ │ │ ├── index.js │ │ └── index.spec.js │ ├── transformer-jq/ │ │ ├── index.js │ │ ├── lib/ │ │ │ ├── transformer.js │ │ │ └── transformer.spec.js │ │ └── package.json │ ├── transformer-map/ │ │ ├── package.json │ │ └── transformer.js │ ├── widget-chart/ │ │ ├── package.json │ │ └── src/ │ │ ├── client/ │ │ │ ├── chart-options.js │ │ │ └── markup.html │ │ └── server/ │ │ ├── index.js │ │ ├── widget.js │ │ └── widget.spec.js │ ├── widget-ci/ │ │ ├── .gitignore │ │ ├── README.MD │ │ ├── package.json │ │ └── src/ │ │ ├── build-status.enum.js │ │ ├── client/ │ │ │ └── markup.html │ │ ├── engines/ │ │ │ ├── circleci/ │ │ │ │ ├── circleci.spec.js │ │ │ │ └── index.js │ │ │ ├── factory.js │ │ │ └── travis/ │ │ │ ├── index.js │ │ │ └── travis.spec.js │ │ └── server/ │ │ ├── index.js │ │ ├── validation.js │ │ ├── widget.js │ │ └── widget.spec.js │ ├── widget-gauge/ │ │ ├── .gitignore │ │ ├── README.MD │ │ ├── package.json │ │ └── src/ │ │ ├── client/ │ │ │ └── markup.html │ │ └── server/ │ │ ├── index.js │ │ ├── widget.js │ │ └── widget.spec.js │ ├── widget-health/ │ │ ├── README.MD │ │ ├── package.json │ │ └── src/ │ │ ├── client/ │ │ │ └── component.html │ │ └── server/ │ │ ├── index.js │ │ └── widget.js │ ├── widget-progress/ │ │ ├── README.MD │ │ ├── package.json │ │ └── src/ │ │ ├── client/ │ │ │ └── component.html │ │ └── server/ │ │ ├── index.js │ │ └── widget.js │ ├── widget-statistic/ │ │ ├── .gitignore │ │ ├── README.MD │ │ ├── package.json │ │ └── src/ │ │ ├── client/ │ │ │ └── markup.html │ │ └── server/ │ │ ├── index.js │ │ ├── widget.js │ │ └── widget.spec.js │ ├── widget-status/ │ │ ├── README.MD │ │ ├── package.json │ │ └── src/ │ │ ├── client/ │ │ │ └── markup.html │ │ ├── health-status.js │ │ ├── providers/ │ │ │ ├── github/ │ │ │ │ ├── github.spec.js │ │ │ │ └── index.js │ │ │ ├── index.js │ │ │ └── statuspageio/ │ │ │ ├── index.js │ │ │ └── statuspageio.spec.js │ │ └── server/ │ │ ├── index.js │ │ ├── validator.js │ │ └── widget.js │ └── widget-time/ │ ├── .gitignore │ ├── README.md │ ├── package.json │ └── src/ │ ├── client/ │ │ └── markup.html │ ├── server/ │ │ ├── alarms.js │ │ ├── index.js │ │ ├── validation.js │ │ ├── widget.js │ │ └── widget.spec.js │ └── time/ │ ├── index.js │ └── time.spec.js └── test/ └── unit.lab.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .codeclimate.json ================================================ { "version": "2", "exclude_patterns": [ "**/**/**.spec.js" ] } ================================================ FILE: .gitignore ================================================ node_modules *.log ================================================ FILE: .travis.yml ================================================ language: node_js node_js: - "9" matrix: fast_finish: true cache: directories: - ~/.npm - node_modules - packages/**/node_modules env: matrix: - PACKAGE=vudash - PACKAGE=@vudash/datasource-rest - PACKAGE=@vudash/datasource-random - PACKAGE=@vudash/datasource-value - PACKAGE=@vudash/datasource-google-sheets - PACKAGE=vudash-widget-ci - PACKAGE=vudash-widget-gauge - PACKAGE=vudash-widget-progress - PACKAGE=vudash-widget-statistic - PACKAGE=vudash-widget-time - PACKAGE=vudash-widget-status - PACKAGE=@vudash/widget-chart script: - lerna run lint --scope $TEST_DIR - lerna run test --scope $TEST_DIR ================================================ FILE: README.MD ================================================ # Vudash An open-source, configurable, extensible dashboard for monitoring, marketing, and more. Note that this project is a lerna `monorepo`, individual packages in the vudash family are under `/packages` [![Join the chat at https://gitter.im/vudash/vudash-core](https://badges.gitter.im/vudash/vudash-core.svg)](https://gitter.im/vudash/vudash-core?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Build Status](https://travis-ci.org/vudash/vudash.svg?branch=master)](https://travis-ci.org/vudash/vudash) [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg)](http://standardjs.com/) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/475d7d8cff824b11bee7680de7134d94)](https://www.codacy.com/app/ant/vudash?utm_source=github.com&utm_medium=referral&utm_content=vudash/vudash&utm_campaign=Badge_Grade) [![Maintainability](https://api.codeclimate.com/v1/badges/8e7cf36d54ce0210c0ba/maintainability)](https://codeclimate.com/github/vudash/vudash/maintainability) [![CodeFactor](https://www.codefactor.io/repository/github/vudash/vudash/badge)](https://www.codefactor.io/repository/github/vudash/vudash) See this project on NPM: [Vudash](https://npmjs.org/vudash) # Screenshots ![dashboard](https://user-images.githubusercontent.com/218949/38768859-ca2a2ee4-3ff1-11e8-9d8c-3bf1138b563d.gif) ![crypto](https://user-images.githubusercontent.com/218949/38768861-cf9f70b4-3ff1-11e8-91b5-ea8a27d06fb6.png) # Product Demo * Removed due to domain squatters. Got a dashboard you want to showcase? Let us know! # Quick Start ``` npm -g install vudash vudash create vudash ``` ================================================ FILE: docs/.nojekyll ================================================ ================================================ FILE: docs/README.md ================================================ # Vudash [![Join the chat at https://gitter.im/vudash/vudash-core](https://badges.gitter.im/vudash/vudash-core.svg)](https://gitter.im/vudash/vudash-core?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Build Status](https://travis-ci.org/vudash/vudash.svg?branch=master)](https://travis-ci.org/vudash/vudash) [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg)](http://standardjs.com/) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/475d7d8cff824b11bee7680de7134d94)](https://www.codacy.com/app/ant/vudash?utm_source=github.com&utm_medium=referral&utm_content=vudash/vudash&utm_campaign=Badge_Grade) [![Maintainability](https://api.codeclimate.com/v1/badges/8e7cf36d54ce0210c0ba/maintainability)](https://codeclimate.com/github/vudash/vudash/maintainability) [![CodeFactor](https://www.codefactor.io/repository/github/vudash/vudash/badge)](https://www.codefactor.io/repository/github/vudash/vudash) A dashboard, like dashing, but written in NodeJS. Vudash open source component Writen using hapijs, lab, material ui, socket.io, lerna, and svelte # Quick start In so few lines: ```bash npm install -g vudash vudash create vudash ``` # Usage Install as a global module `npm install -g vudash` and use `vudash create` to create an example dashboard. Add new widgets under `/widgets` and add them to your dashboard under `/dashboards`. You can visit your created dashboard by visiting http://localhost:3300/`dashboard`.dashboard - where `dashboard` is the name of a JSON file within the `/dashboards` directory. Visiting the root of the application will yield a list of all available dashboards, unless the environment variable `DEFAULT_DASHBOARD` is set, in which case that dashboard will be loaded instead. Other dashboards will still be available via the normal methods. # Screenshots ![dashboard](https://user-images.githubusercontent.com/218949/38768859-ca2a2ee4-3ff1-11e8-9d8c-3bf1138b563d.gif) ![crypto](https://user-images.githubusercontent.com/218949/38768861-cf9f70b4-3ff1-11e8-91b5-ea8a27d06fb6.png) # Demo - [Demo Dashboard](http://vudash.herokuapp.com/demo.dashboard) - [Crypto Dashboard](http://vudash.herokuapp.com/crypto.dashboard) If, like me, you learn by example rather than reams of documentation, check out the [Demo Dashboard's Configuration](https://github.com/vudash/vudash-demo/blob/master/dashboards/demo.json) on github. You can then clarify any questions using the documentation below. # Dashboards A dashboard is a collection of widgets separated into rows and columns. ## Creating Dashboards Dashboards are in JSON format and take the form: ```javascript { "name": "Happy", "layout": { "columns": 5, "rows": 4 }, "datasources": { "datasource-exchange-rates": { "module": "@vudash/datasource-rest", "schedule": 30000, "options": { "url": "http://exchangerat.es/api/v1/rates", "method": "get", "graph": "rates.GBP" } } }, "widgets": [ { "position": {"x": 0, "y": 0, "w": 1, "h": 1}, "widget": "./widgets/random" }, { "position": {"x": 3, "y": 0, "w": 2, "h": 1}, "widget": "vudash-widget-time" }, { "position": {"x": 4, "y": 1, "w": 1, "h": 1}, "widget": "./widgets/github" }, { "position": {"x": 0, "y": 1, "w": 2, "h": 1}, "widget": "vudash-widget-statistic", "datasource": "datasource-exchange-rates", "history": 100, "options": { "description": "EUR -> GBP", } }, { "position": {"x": 4, "y": 2, "w": 1, "h": 1}, "widget": "@vudash/widget-ci", "options": { "schedule": 60000, "user": "vudash", "repo": "vudash-widget-ci" } } ] } ``` Where 'widgets' is an array of widgets. The position in the grid (specified by `layout`) is indicated by the widget's `x` and `y` `position` values. The values for `position.w` and `position.h` are the number of grid units the widget occupies in width and height, respectively. The `history` attribute of a widget defines how many historical items a widget should store (i.e. where history is `X`, the widget will store `X` previous values) - the value history can be read by widgets, and used in things like graphs. Widgets can be either a path to a directory containing a widget (see below), or an npm module of the same. If the widget is a npm module, you would need to `npm install --save ` first. ### Environment variables You can use environment variables in your dashboard or widget configuration: ```javascript { "position": { ... }, "widget": "@vudash/widget-ci", "options": { "user": "vudash", "repo": "vudash-widget-ci", "auth": { "$env": "ENVIRONMENT_VARIABLE_NAME" } } } ``` Where the value of `auth` in the configuration will be replaced with the contents of the environment variable `ENVIRONMENT_VARIABLE_NAME`. ## Custom CSS You can add to (or override) the CSS for a dashboard, using the `css` attribute in your dashboard's json configuration. Because the dashboard configuration is in JSON format, your CSS must be too, and uses the [json-to-css package](https://www.npmjs.com/package/json-to-css) to transform json into minified CSS. As a (rather ugly) example, lets change the dashboard's background colour to red. ```javascript { "name": "dashboard-with-custom-css", "layout": { ... }, "css": { "body": { "background-color": "red" } }, "datasources": { ... }, "widgets": [ ... ] } ``` As you can see, the hash under `css` follows the basic format of css, and is rendered into the dashboard after all the default vudash, and widget generated CSS. # Widgets Widgets are configured as an array in the `dashboard.json` file, in the format: ```javascript "widgets": [ { "widget": "./widgets/pluck", // widget file path, node module name, or class definition "datasource": "datasource-xyz", // name of a datasource listed in `datasources` "position": { "x": 1, // x position (row number) of widget "y": 1, // y position (column number) of widget "w": 1, // widget width in columns "h": 1 // widget height in columns }, "options": { // widget specific config "your" : "config" } } ] ``` Widgets have some optional properties: | property name | description | example | | ------------- | ------------------------------------ | ------- | | background | css for "background" style attribute | #ffffff | For a list of built in widgets, see [Widgets](widgets/). For developing widgets see [Developing Widgets](developers/?id=developing-widgets). # Datasources Unless a widget specifies its own data fetching method, data is fetched by a datasource. Datasources are specified as a hash in the `dashboard.json` as follows: ```javascript { "datasources": { "datasource-id": { // can be anything as long as it is unique "module": "../datasource-random", // as with widgets, a node module name or directory "schedule": 1000, // how often (in ms) the datasource should be refreshed "options": { // options for the datasource "method": "string" } } } } ``` Each refresh, the datasource will fetch new data, and tell all widgets that listen to it about the new data. For a list of built in datasources, see [Datasources](datasources/). For developing datasources see [Developing Datasources](developers/?id=developing-datasources). # Configuration When running the server, a number of environment variables are available: | environment variable | description | default value | | ------------------------- | ---------------------------------------------------------------------- | ------------- | | DEFAULT_DASHBOARD | specify default dashboard to mount at / | none | | DISCONNECT_RELOAD_TIMEOUT | default number of milliseconds to wait to reload if server disconnects | 30000 | | API_KEY | api key used to access the vudash api | (random) | | SERVER_URL | external server url (for when node can't resolve it by itself) | (inferred) | # Tips and tricks ## Securing your dashboard with basic auth Want to protect your dashboard from the public eye? You can secure it with basic auth in a few steps: 1. Install basic-auth and http-proxy modules: ```javascript npm i -S basic-auth http-proxy ``` 2. Change the start script in package.json ```json { "scripts": { "start": "node ./proxy" } } ``` 3. Create a simple proxy server called `proxy.js` in your project's root directory ```javascript 'use strict' const http = require('http') const httpProxy = require('http-proxy') const auth = require('basic-auth') const proxy = httpProxy.createProxyServer() function verify (credentials) { const user = process.env.BASIC_AUTH_NAME const pass = process.env.BASIC_AUTH_PASS return credentials && credentials.name === user && credentials.pass === pass } http.createServer((req, res) => { const credentials = auth(req) if (verify (credentials)) { return proxy.web(req, res, { target: 'http://localhost:3300' }) } res.statusCode = 401 res.setHeader('WWW-Authenticate', 'Basic realm="example"') res.end('Access denied') }).listen(process.env.PORT) process.env.PORT = 3300 require('vudash') ``` 4. When you run the project, don't forget your credentials: ```bash BASIC_AUTH_NAME=username BASIC_AUTH_PASS=password npm run start ``` # Troubleshooting * Q. The console shows that the websocket is failing to connect, and my widgets aren't updating. * A. Your hosting provider might not be correctly reporting the external vhost of the server. Add an environment variable `SERVER_URL` with the full url to your server, i.e: `http://www.example.com/` # Contributing ## Running Tests Vudash > 5 is a monorepo! This makes it easier to contribute, and keep track of all the native plugins. Clone the project and run: ``` lerna bootstrap lerna run test ``` # Why create Vudash? * I'll get to the point. I like dashing, but I don't like ruby. * Both Dashing and Dashing-js are stellar efforts, but abandoned. * Jade is an abomination. * Coffeescript is an uneccessary abstraction. * dashing-js has a lot of bugs # Features * will happily run on heroku, now.sh, or any other hosting you fancy. * es6 * all cross-origin requests done server side * websockets rather than polling * websocket fallback to long-poll when sockets aren't available * Custom widgets * Custom dashboards * Simple row-by-row layout * Super simple widget structure # Roadmap - now.sh 5-second howto - You, sending Pull Requests. - Plugins # Credits - Concept and foundation by Antony Jones / Desirable Objects Ltd - Contributions from github committers - Contains svg imagery from flaticons, by [Gregor Cresnar](http://www.flaticon.com/authors/gregor-cresnar), [Vectors Market](http://www.flaticon.com/authors/vectors-market) - Various fixes and improvements by [Alex Voigt](https://github.com/alex-voigt) ================================================ FILE: docs/_coverpage.md ================================================ - Uses websockets for realtime updates - Integrates with a huge number of services - Familiar JSON configuration - Extensible datasource system [GitHub](https://github.com/vudash/vudash) [Get Started](#quick-start) ================================================ FILE: docs/api/README.md ================================================ # API Vudash exposes a very simple RESTful HTTP versioned api which can be used to perform a number of operations on a running dashboard. ## Versioning API endpoints are versioned in their url. Current versions are: `/api/v1` ## Authentication ### Authenticating Requests Authentication to the api is performed by passing an `api-key` parameter as either a `header` or a `request parameter`, i.e: ```bash curl -X GET 'http://your.dashboard.url:3300/api/v1/something/to-do?api-key=abcde12345' ``` or ```bash curl -X GET --header 'api-key: abcde12345' 'http://your.dashboard.url:3300/api/v1/something/to-do' ``` ### API Keys By default, an api key is generated and output to the console when the dashboard loads. However, you can override this api key with the `API_KEY` environment variable, i.e: ```bash $ API_KEY=abcde12345 vudash ``` ## Endpoints Below is a list of operations which can be peformed via the API. This list will grow over time ### PUT /api/v1/view/current Change which dashboard viewers are seeing. This affects *all viewers* of any dashboard, so use it wisely. Possible uses for this endpoint are if you only have a single screen but need multiple dashboards - you can set up a simple cron-job to change the dashboard every N minutes. Another use for this is to set up some sort of IoT button (or a [hacked Amazon Dash button](https://www.npmjs.com/package/homebridge-dash)), to allow your team to view different dashboards on a single screen by pressing a button. ### PUT /api/v1/dashboards/{name} Dynamically add a new dashboard to vudash (or replace an existing one of the same `{name}`). This way, you can add dashboards to a running instance of vudash without redeploying. You pass a payload containing a single key `descriptor`, which contains a dashboard descriptor (the same as you would add a dashboard normally as {name}.json) An example payload might be: ```json { "descriptor": { "name": "my-dynamic-dashboard", "layout": { "columns": 2, "rows": 2 }, "datasources": { "ds-rnd": { "module": "../datasource-random", "schedule": 1000, "options": { "method": "natural", "options": { "max": 100000 } } } }, "widgets": [{ "position": { "x": 0, "y": 0, "w": 2, "h": 2 }, "widget": "../widget-statistic", "datasource": "ds-rnd" }] } } ``` You can then visit the new dashboard as you normally would at `http:///.dashboard`. Note that dynamically added dashboards are *in memory* and do not survive server restarts. If you are adding a new dashboard (i.e. `{name}` is unique), the api will return http status code `201`. If you are replacing an existing one (even one which exists on disk), you will recieve a `200` http status code ================================================ FILE: docs/datasources/README.md ================================================ # Datasources ## What is a Datasource A datasource provides the mechanism for widgets to recieve the information they show. ## How to add a datasource to the dashboard In this guide, we'll install the `value` datasource and use it in a widget. 1. Firstly, install the module required: ``` npm install --save @vudash/datasource-value ``` 2. Next, configure the datasource. We'll give this datasource an id of `value-datasource`, and pass it some default options. Add the datasource to your `.json` file under datasources. Don't forget to set the update schedule. ``` { "datasources": { "my-datasource-id": { "module": "@vudash/datasource-value", "schedule": 30000, "options": { "value": "12345" } } } } ``` 3. Add the datasource to one of your widgets, by adding a `datasource` attribute to the widget's config, with the id of the datasource (`value-datasource`), in the `.json` file, this time under widgets: ``` "widgets": [ ..., { "position": ..., "widget": "vudash-widget-statistic", "datasource": "my-datasource-id", "options": { "description": "Some Description" } } ] ``` 4. You're good to go! Your widget will now use the `value-datasource` to fetch its data. ## Shared datasource configuration When you install a datasource to the dashboard, you can pass it some configuration using the `options` attribute. This can be useful because all consumers of the datasource will receive those options: ```javascript { "datasources": { "datasource-id": { "module": "some-datasource-npm-package-name", "options": { "number": 1, "foo": "bar" } } } } ``` ## Provided Datasources ### Benefits Vudash Datasources are referenced using the `datasource` attribute of a widget. This saves time for a widget developer, and means that any widget can easily fetch data from a number of different sources. ### Supported sources | Datasource name | Source of data | Documentation | |------------------|----------------------------------------------------------------------|------------------------------------------------------| | value | config ```{ value: }``` | [Value Datasource](#value-datasource) | random | [chance.natural({ min: 0, max: 999})](http://chancejs.org) | [Random Datasource](#random-datasource) | rest | http(s) using [request](http://requestjs.org) | [REST Datasource](#REST-datasource) | google-sheets | [Google Sheets](http://drive.google.com) | [Google Sheets Datasource](#google-sheets-datasource) ### Usage in widgets When using a widget which allows a datasource, just pass in the id of the datasource (registered in `datasource`), and any configuration you want it to have. In `dashboard.json` 1. Add the datasource under the `datasource` section. The `options` will contain the configuration for the datasource: ```javascript "datasource": { "datasource-id": { "module": "datasource-package-name", "options": { "url": "http://example.com/some/api", "method": "get" } } } ``` 2. Tell the widget to use the datasource, by modifying your widget entry under `widgets`: ```javascript { "position": { ... }, "widget": "some-widget", "datasource": "datasource-id", "options": { ... } } ``` Configuration is validated when the datasources are registered by the dashboard, if a datasource supports it. ### Value datasource The Vudash Value Datasource returns hardcoded values. #### Basic Config Simply specify the value you want returned. ```javascript { "module": "@vudash/datasource-value", "options": { "value": 2 } } ``` Returns the number 2. #### Arrays and Objects Value Datasource can return anything you can provide in JSON Simply specify the value you want returned. ```javascript { "module": "@vudash/datasource-value", "options": { "value": { "x": [{ "y": [1,2,3,4,5] }, { "z": false }] } } } ``` Will return the object specified by 'value'. ### REST datasource The Vudash REST Datasource allows fetching data from external APIs. #### Basic Config The default method is GET. Simply specify an URL. ```javascript { "module": "@vudash/datasource-rest", "options": { "url": "http://example.com/some/api" } } ``` #### POSTing data Say you wanted to POST to the endpoint `/v1/api` at `https://example.org` on port 3333. Furthermore, you want to send JSON request data as specified in "body" below. ```javascript { "module": "@vudash/datasource-rest", "options": { "method": "post", "url": "https://example.org:3333/v1/api", "body": { "foo": "bar", "one": 2, "three": false } } } ``` #### Query Parameters Say you wanted to POST to the endpoint `/v1/api` at `https://example.org` on port 3333. Furthermore, you want to send JSON request data as specified in "query" below. ```javascript { "module": "@vudash/datasource-rest", "options": { "method": "get", // optional "url": "https://example.net/v1/api", "query": { "param1":"foo", "param2":"bar" } } } ``` #### Parsing data Vudash automatically parses returned JSON which means it can be easily formatted. To determine what is returned, you can use a [transformer](/#/transformers) to modify the json response before the widget receives it. Consult the documentation for information on the transformers available to you and how to use them. ## Troubleshooting SSL You might encounter an error when trying to fetch data from SSL protected servers, such as: ```bash Error in widget datasource-rest (461305c2) { RequestError at ClientRequest.req.once.err (/home/aj/Projects/vudash-core/packages/core/node_modules/got/index.js:73:21) at Object.onceWrapper (events.js:291:19) at emitOne (events.js:96:13) at ClientRequest.emit (events.js:189:7) at TLSSocket.socketErrorListener (_http_client.js:358:9) at emitOne (events.js:96:13) at TLSSocket.emit (events.js:189:7) at emitErrorNT (net.js:1280:8) at _combinedTickCallback (internal/process/next_tick.js:74:11) at process._tickDomainCallback (internal/process/next_tick.js:122:9) code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', message: 'unable to verify the first certificate', host: 'ssl.example.com', hostname: 'ssl.example.com', method: 'GET', path: '/health' } ``` This means that you don't have the correct root CA certificates for node to connect to the endpoint. This is easily fixed, if you are using node > 7.3 however: 1. Find out who the Root CA for the domain you are trying to connect to, using [an ssl analysis tool](https://sslanalyzer.comodoca.com/) 1. Download the root CA's pem file and drop it in your project folder. 1. When you run vudash, pass an environment variable with the path to your root ca's certificate, i.e: `NODE_EXTRA_CA_CERTS='./path/to/root-cas.pem' vudash` More details on this [can be found here](https://git.daplie.com/Daplie/node-ssl-root-cas) ### Random datasource The Vudash Random Datasource returns hardcoded values. #### Basic Config Vudash Random Datasource uses ChanceJS underneath. That means you can it bare, to generate a random integer: ```javascript { "source": "random" } ``` #### Custom Chance Methods or you can define the method used to generate your random data: ```javascript { "module": "@vudash/datasource-random", "options": { "method": "string" } } ``` #### Chance Methods with Custom Parameters or you can define the method AND the parameters passed to the method. In this example, we use `chance.n()` to generate an array of values. You need to pass parameters to `n()`, the method used to generate the values, the number of values to generarate, and the third parameter is the parameters passed to `chance.integer()`. Confused? The code used below is the equivalent of calling [chance.n(chance.integer, 12, {min: 15, max: 32})](http://chancejs.com/#n). ```javascript { "module": "@vudash/datasource-random", "options": { "method": "n", "options": [ "integer", 12, [{ min: 15, max: 32 }] ] } } ``` Generates an array of 12 integers between 15 and 32. ================================================ FILE: docs/developers/README.md ================================================ # Developing Creating widgets and datasources is designed to be quick and painless. They are delivered as simple npm modules, and follow basic node patterns in order to get you up to speed quickly. ## Developing Widgets A widget is a visible indicator of some sort of statistic or other monitor, which emits new data using websockets, and updates its display in the dashboard based on the information given in this data. A widget is packaged as a node module, but a node module can simply be a folder with a `package.json` file. A widget is simply a node module, and really only needs a couple of files. ### package.json ```javascript { "name": "vudash-widget-example", "main": "widget.js", "vudash": { "component": "somefile.html" } } ``` The `main` js file above should reference your main module class, in this example we call it `widget.js` The `vudash.component` is a single file [SvelteJS](http://svelte.technology) component that is an all-in-one (html, css, js), view-component with an immutable-data-tree based state model. ### Writing the server-side component ```javascript 'use strict' const moment = require('moment') class TimeWidget { constructor (options, emitter) { this.options = options // options is the configuration passed under "options" in your dashboard.json this.emitter = emitter // emitter is only useful if you want to emit events yourself (see below) } /** * This method is called by the datasource when it gets new data. **/ update (data) { const now = moment() return { time: now.format('HH:mm:ss'), date: now.format('MMMM Do YYYY') } // just return the data you want to display on the dashboard } } exports.register = function (options, emitter) { return new TimeWidget(options, emitter) } ``` * The first parameter to register is the widget configuration given in the `dashboard.json` file * The second parameter to register is the optional parameter `emitter` which can be used to emit events (at any time) to the dashboard. See `Events` below for more information about this. ### Writing the client side component Client side components are defined using [svelte](https://svelte.technology/) which allows you to build framework-independent client side components with ease. Create your svelte component as a single html file, and reference it as a module-absolute path named `vudash.component` in your widget's package.json. In order to ease development, [a 'harness' exists](https://svelte.technology/repl?version=1.40.1&gist=0ef39c92a284251d65d1e29c63cd1ca8) for rapidly building Vudash widgets using the Svelte REPL. Edit the `widget.html` file there and then simply copy-paste it into the file you reference under `vudash.component` in `package.json`. #### Example of a component package.json ```javascript { "name": "vudash-widget-health", "main": "widget.js", "vudash": { "component": "./component.html" } } ``` component.html ```html

{{ greeting }}

``` See the [Svelte Documentation](https://svelte.technology/guide) for information on how to build svelte components. #### Third party dependencies All components and their dependencies are processed by `rollup` and bundled into a browser-friendly script. You can use third party dependencies in your component by importing them using es6 import syntax. First, install the module as a dependency of your widget module: ```bash npm install thing-maker ``` Then, import it to your component: ```html Hello {{ thing }} ``` It doesn't matter if the module you want to use isn't an es6 module (i.e. doesn't export a default object), because the `rollup` plugin [rollup-plugin-commonjs](https://www.npmjs.com/package/rollup-plugin-commonjs) is used which can convert traditional node modules into es6 modules for you. #### Images You can bundle SVG images as dependencies in your widgets too - the [rollup-plugin-svg](https://www.npmjs.com/package/rollup-plugin-svg) plugin is also included: logo You can [read more about Rollup.js](https://rollupjs.org/) in order to better understand how to optimise your component's client side code. ### Using datasources Datasources are how most widgets get data. Datasources provide an abstraction for fetching data from a multitude of sources, and deliver it as a single blob of json to the widgets. As a developer you should strongly consider providing datasource support in your widget. #### Benefits * Consumer chooses where your widget gets its data * Don't need to implement any data fetching code yourself * Focus your widget on displaying data, not fetching it * Shared configuration for consumers, datasources are configured globally, and/or on a widget level. #### How to 1. When the datasource emits data, it will then be sent to your widget's serverside `update` method where you can make further modifications to it before returning it. ```javascript update(value) { // do something with the value, like add an emoji! return `🌟 ${value}` } ``` 1. The dashboard will then re-emit your data with some metadata, and a history, if configured. It will call your Svelte component's `update` method with the data. You can then add it to your Svelte component's data model for use in the template. ```javascript update ({ data, meta, history }) { this.set({ someValue: data.myValue }) } ``` 1. Then simply use it in your markup ```html

{{ someValue }}

``` ### Writing a component without a datasource Your component doesn't have to use a datasource. It can simply fetch data by itself. This can be done any way you like, but an approach which works as an example is the `vudash-widget-health` widget: ```javascript 'use strict' class HealthWidget { constructor (options, emitter) { this.emitter = emitter this.on = false this.timer = setInterval(function () { this.run() }.bind(this), options.schedule || 1000) this.run() } run () { this.on = !this.on this.emitter.emit('update', { on: this.on }) } destroy () { clearInterval(this.timer) } } exports.register = function (options, emitter) { return new HealthWidget(options, emitter) } // Validation is optional exports.validation = Joi.object({ 'some-option': Joi.string().required() }) ``` Important things to note here are: * We emit our own data using `emitter`. Emitting an event called `update` with your data will result in the widget's view receving the `data` you emit into its `update({ data, meta, history })` method. * We have to call our `run()` method somehow to update the data. This is done using a `setInterval` * In case the user doesn't configure a schedule for the widget in its `options`, we default to 1000ms. * We provide a `destroy()` hook to destroy our timer. This is useful to avoid memory leaks, unecessary fetching, and is especically useful for unit-testing. * We export an optional `validation` schema, which is a Joi schema. It can include default values, too! If this is not exported, it is not called, and options will be passed to your register method verbatim. ## Developing Datasources TBC - for now, have a look at the source code in `vudash/packages/datasource-*` ## Developing Transformers Data can come from datasources in a variety of different formats, not all of which can directly translate to something usable by a widget. [Transformers](/#/transformers) are designed for a dashboard consumer to manipulate data fetched using datasources (or directly inside widgets) before it is emitted to the dashboard. The format of a transformer is relatively simple. Here's a very simple transformer: ```javascript 'use strict' class AddingTransformer { constructor (numberToAdd) { this.numberToAdd = numberToAdd } transform (data) { return data + this.numberToAdd } } module.exports = AddingTransformer ``` A transformer takes the configuration provided by the dashboard (//widgets[]/transformations) as its only constructor argument. When new data is fetched by a datasource or a widget and sent to the dashboard for consumption by listening widgets, it is first transformed by the list of transformers configured in the widget's configuration by calling each transformer's `transform` method. The argument passed to transform is the result of the previous transformer's transform method. You might configure the transformer as part of the following dashboard: ```javascript { "datasources": { "my-datasource-hundred": { "module": "@vudash/datasource-value", "options": { "value": 100 } } } }, { "datasource": "my-datasource-hundred", "transformations": [ { "transformer": "./adding-transformer.js", "options": 100 } ], "options": { "description": "Shows two-hundred" }, "widget": "vudash-widget-statistic" } ``` The resulting dashboard widget would show the value `200` as the value `100` is transformed by adding `100` to it. ## Dashboard Events Events can be emitted using the event emitter which is passed into the register method. These events will cause dashboard-wide actions to happen. ``` emit('plugin', 'audio:play', {data: data}) ``` The current list of events that can be triggered are: | Event | Data | Description | | ------------- |------------------| --------------------------------------------------------------------| | audio:play | `{ data: data }` | Plays an audio clip (once). `data` is a data-uri of the audio file. | ## Working on the Vudash project Vudash uses * ES6 * Svelte * StandardJS Contributions are always welcome, please open a PR. ================================================ FILE: docs/index.html ================================================ Vudash
================================================ FILE: docs/transformers/README.md ================================================ # Transformers Transformers allow you to retrieve information from a data source or widget, and modify it before it is sent to the dashboard. ## How to install a transformer Transformers, like widgets and datasources, are just node modules. Install the module required: ```bash npm install --save @vudash/transformer-map ``` ## Provided Transformers We provide a selection of transformers, or you can [write your own](/#/developers), very simply. ### Map Transformer (@vudash/transformer-map) Maps json data from one structure to another. Selection is done using [Hoek.reach](https://www.npmjs.com/package/hoek) via [reorient](https://www.npmjs.com/package/reorient), so null values, function traversal etc is automatically handled, and won't throw errors. You should consult the [reorient](https://www.npmjs.com/package/reorient) documentation for advanced mapping features such as default values, but in a pinch: Say that your desired API returns the following payload in JSON: ```javascript { "one": { "two": { "three": "abcde" } } } ``` Lets say we wanted the value of "three" buried down in the middle there. It's easy: ```javascript { "position": { ... }, "datasource": "ds-rest", "transformations": [ { "transformer": "@vudash/transformer-map", "options": { "value": "one.two.three" } } ], "options": { "description": "Value of three" }, "widget": "vudash-widget-statistic" } ``` And if you actually want the contents of two? (As an object of course): ```javascript { "position": { ... }, "datasource": "ds-rest", "transformations": [ { "transformer": "@vudash/transformer-map", "options": { "value": "one.two" } } ], "options": { "description": "Value of two" }, "widget": "vudash-widget-statistic" } ``` ### JQ Transformer (@vudash/transformer-jq) The JQ transformer uses the module [jq.node](https://www.npmjs.com/package/jq.node), which is an 'improved, faster' version of jq specifically for node. Usage is a matter of compiling a selector to modify the data as you please Supposing your data looks like this: ```json { "a": { "big": { "json": { "jeff": { "email": "jeff@example.net" }, "joe": { "phone": "+447721981546" }, "emma": { "email": "emma@example.com" }, "grayson": { "email": "wailo@example.net" } } } } } ``` You could use the following configuration to group the above users by email domain. ```javascript { "position": { ... }, "datasource": "ds-rest", "transformations": [ { "transformer": "@vudash/transformer-jq", "options": { "value": "filter(has('email')) | groupBy(flow(get('email'), split('@'), get(1)))" } } ], "options": { "description": "People grouped by email domain" }, "widget": "vudash-widget-statistic" } ``` ================================================ FILE: docs/widgets/README.md ================================================ # Predefined widgets Vudash has a number of widgets which are available on npm, these are in the `packages/` directory of the monorepo, and also available on npm. You will notice that widget definitions `position` and `datasource` attributes. These refer to the position of the widget on the display, and how the widget gets its data, and these are documented in [Datasources](/#/datasource) and the [Main Readme](/#/) respectively. ## Chart Widget Shows Bar, Line, Chart, and Donut graphs for data series. ### Screenshot Line Chart ![line-chart](https://cloud.githubusercontent.com/assets/218949/25781884/57c8d264-3337-11e7-8e46-ae6737d20f50.png) ### Configuration The chart widget has a number of configuration options: | Option | Default | Allowed Values | Description | | --- | --- | | `description` | `` | any string | Widget description, shown at the bottom of the widget | `type` | `line` | `line, bar, pie, donut` | Graph type. Lowercased version of [chartist graph types](https://gionkunz.github.io/chartist-js/examples.html). Also applies some sensible styling to each graph type to make it fit with a Vudash dashboard. | | `labels` | `[]` | an array of label names | Labels which run along the X axis of a chart. Each label relates to its corresponding number in the data provided to the chart. | #### Configuration example You can configure any status page which uses [Atlassian StatusPage](https://www.atlassian.com/software/statuspage) easily: ```javascript { "position": { ... }, "widget": "@vudash/widget-chart", "datasource": { ... }, "options": { "type": "pie", "description": "My Pie Chart", "labels": ["Apples", "Pears", "Peaches", "Lemons", "Oranges"] } } ``` ## CI Widget Connects to CI Providers and displays build results. Currently supports [CircleCI](http://www.circleci.com) and [TravisCI](http://www.travis-ci.org) ### Screenshot Building: ![ci-widget 1](https://cloud.githubusercontent.com/assets/218949/25973853/c90fe034-369d-11e7-80ef-639126d7e1dd.gif) Failing, and Passing: ![ci-widget](https://cloud.githubusercontent.com/assets/218949/25781907/d0242da8-3337-11e7-904d-9c6f00b7ea27.gif) ### Configuration Add to a [Vudash](https://www.npmjs.com/package/vudash) dashboard with the following configuration: #### Simple Configuration The simplest configuration is very straightforward ```javascript { "position": { ... }, "widget": "vudash-widget-ci", "datasource": "some-data-source-id", "options": { "provider": "circleci", "repo": "some-repo", "user": "some-user" } } ``` #### Display You can turn the display of the repository owner on and off, which might be useful if all your repositories belong to a single organisation: ```javascript { "position": { ... }, "widget": "vudash-widget-ci", "datasource": "some-data-source-id", "options": { "hideOwner": true ... } } ``` #### Build Noises ```javascript { "widget": "vudash-widget-ci", "options": { "provider": "travis", // CI Provider (travis or circleci) "user": "your-user", // username, mandatory "repo": "your-repo", // repository name, mandatory "branch": "your-branch" // branch to monitor, optional. "schedule": 60000 // Update frequency in MS (optional), "sounds": { // Sound to play on build state changes (optional) "passed": "/some/local/path/sound.ogg", "failed": "data:audio/ogg;base64, ...", "unknown": "data:audio/ogg;base64, ..." }, "options": { "auth": "xxx" // circleci auth token, only required for circleci } } } ``` Where `your-user` is your github organisation or user name, and `your-repo` is your build/repository name. * The travis plugin currently only deals with public repositories (i.e. travis-ci.org, not .com) ## Gauge Widget Shows a VU-Meter like Gauge which represents numerical figures like percentages ### Screenshot ![gauge-widget](https://cloud.githubusercontent.com/assets/218949/25781923/339cd844-3338-11e7-8e12-0ff197e3876f.gif) ### Configuration Simply include in your dashboard, and configure as required: ```javascript { "widget": "vudash-widget-statistic", "datasource": "some-data-source-id", "options": { "description": "Gauge", // Optional. Description shown below statistic, "maximum": 3983 // Required, the maximum value the gauge can ever reach } } ``` ## Progress Widget Similar to VU Meter, but with a linear progress bar ### Screenshot ![progress](https://cloud.githubusercontent.com/assets/218949/25974011/81164970-369e-11e7-83b3-c42e87febabb.png) ### Configuration The only configuration for the progress widget is the description. ```javascript { "widget": "vudash-widget-progress" "datasource": "some-data-source-id", "options": { "description": "Stuff", // Optional. Default "Progress" Description shown below statistic } } ``` The datasource connected to the progress widget should ideally return numbers between 1 and 100, anything over 100 will be represented as 100% anyway. ## Statistics Widget Shows a statistic, which can be a number, a word, or anything else representable on screen. Optionally can draw a graph of the previous results behind the main one. ### Screenshot ![stats widget](https://cloud.githubusercontent.com/assets/218949/20489789/adb964ca-b003-11e6-917b-c07218625bd3.png) ### Configuration Simply include in your dashboard, and configure as required: ```javascript { "widget": "vudash-widget-statistic", "datasource": "some-data-source-id", "options": { "description": "Visitor Count", // Optional. Default "Statistics" Description shown below statistic, "format": "%s", // Optional. Default %s. Format the incoming data (using sprintf-js), "font-ratio": 4 // Optional. Default 4. Scaling ratio for main statistic (for longer text, increase this number), "colour": "#86797d", // Optional. Defaults to a random colour from a pre-selected "pretty" list. Colour for line / fill-area of graph, if shown. "historyView": "chart" // Optional, defaults to "chart". How historical figures are represented. } } ``` Note that `datasource` tells the widget how to get data, and is using a datasource, which is documented in the [Datasources documentation](/#/datasources) #### History Views There are two ways to represent previous values that the widget has received: * `chart`: Displays a line-graph of the previous historical values which floats behind the widget's content. * `ticker`: Shows a stock-market type ticker, in either red or green depending on the direction the value is heading. Shows difference from previous value, and the same difference represented as a percentage. #### Graphs This widget will graph data which is passed in as an array. This means that if your data-source resolves an array of numbers as data, the last number in the array will be shown as the statistic value, and a line graph will be drawn behind the widget using the remaining numbers. For example `[1,2,3,4,5,6,7]` will result in a widget value of 7, and a graph of 1-6 behind it. ## Status Widget Shows the status of an external service like github, or any API which uses Atlassian StatusPage ### Screenshot ![status-widget](https://cloud.githubusercontent.com/assets/218949/25781933/62f3e344-3338-11e7-9cf8-dc7b29aa1a98.png) ### Configuration Currently this widget has two integrations. #### Atlassian Statuspage You can configure any status page which uses [Atlassian StatusPage](https://www.atlassian.com/software/statuspage) easily: ```javascript { "position": { ... }, "widget": "vudash-widget-status", "datasource": "some-data-source-id", "options": { "schedule": 300000, "type": "statuspageio", "config": { "url": "https://status.newrelic.com/", // URL to the status page "components": [ // List the names of components you want to monitor the status of "APM", "Data Collection", "Alerts" ] } } } ``` #### Github Github status page monitoring is no-configuration. It will tell you when it is up, down, or otherwise. ```javascript { "position": { ... }, "datasource": "some-data-source-id", "widget": "vudash-widget-status", "options": { "schedule": 300000, "type": "github" } } ``` ## Time Widget Simply shows the time, and has optional audiable alarams ### Screenshot ![time-widget](https://cloud.githubusercontent.com/assets/218949/25781881/50fffa66-3337-11e7-89dc-12871a2350b8.png) ### Configuration Simply include in your dashboard: ```javascript { "widget": "vudash-widget-time", "options": { ... } } ``` #### Timezone support The timezone can be set via configuration. The list of allowed timezones is that of the `moment-timezone` library. ```javascript "options": { "timezone": "Europe/London" } ``` #### Alarms This widget can play sounds! Simply pass 'alarms' into your configuration: ```javascript "options": { "alarms": [ { "expression": "5 * * * * *", "actions": [ { "action": "sound", "options": { "data": "data:audio/ogg;base64, ..." } } ] } ] } ``` `expression` is a cron expression which determines when the sound will be played. `actions` is the action to perform when the alarm is triggered. Supported actions are listed below: #### Actions Action: `sound` Options: `data` is a data-uri which contains the clip of audio to be played. You can use a tool like: `http://dopiaza.org/tools/datauri/index.php` to convert your audio clips to data-uris. ## Health Widget A simple widget with a beating heart, to let you know that the dashboard is alive. ### Screenshot ![health-widget](https://cloud.githubusercontent.com/assets/218949/25781948/a0314bfc-3338-11e7-99a9-3d81065b0518.png) ### Configuration There is no configuration for this widget. It runs every second, and the heart will change shape. If the heart stops... your vudash client's websocket has become disconnected from the backend, and your data is out of date. ================================================ FILE: lerna.json ================================================ { "lerna": "2.0.0-beta.36", "packages": [ "packages/*" ], "version": "9.9.0" } ================================================ FILE: package.json ================================================ { "scripts": { "lint": "lerna run lint", "start": "lerna run start --scope vudash", "heroku-postbuild": "./node_modules/.bin/lerna bootstrap", "docs:preview": "docsify serve docs", "postinstall": "lerna bootstrap --hoist" }, "devDependencies": { "chance": "^1.0.4", "cheerio": "^0.22.0", "code": "^4.0.0", "docsify": "^4.6.10", "docsify-cli": "^4.2.1", "glob": "^7.0.6", "lerna": "2.0.0-beta.36", "marked": "^0.3.19", "mocha": "^4.0.1", "nock": "^9.0.2", "nodemon": "^1.9.2", "prismjs": "^1.14.0", "sinon": "^1.17.4", "sinon-as-promised": "^4.0.2", "standard": "^11.0.1" }, "engines": { "node": ">=9.x" }, "standard": { "globals": [ "describe", "context", "it", "before", "after", "beforeEach", "afterEach" ] }, "dependencies": { "app-module-path": "^2.2.0", "bluebird": "^3.5.2", "boom": "^6.0.0", "browser-sync": "^2.24.7", "buble": "^0.19.3", "catbox-memory": "^3.1.2", "chartist": "^0.11.0", "circleci": "^0.3.3", "cron": "^1.4.1", "export-dir": "^0.1.2", "fit-text": "^2.0.1", "fs-extra": "^0.30.0", "handlebars": "^4.0.12", "hapi": "^16.6.3", "hapi-api-secret-key": "^1.1.0", "inert": "^4.2.1", "izitoast": "^1.4.0", "joi": "^13.7.0", "jq.node": "^2.1.1", "json-to-css": "^0.1.0", "moment": "^2.22.2", "moment-timezone": "^0.5.21", "npm": "^5.10.0", "npm-programmatic": "0.0.8", "ora": "^1.4.0", "reorient": "^2.1.0", "rollup": "^0.43.1", "rollup-plugin-buble": "^0.19.2", "rollup-plugin-commonjs": "^8.4.1", "rollup-plugin-node-resolve": "^3.4.0", "rollup-plugin-postcss": "^0.4.3", "rollup-plugin-svelte": "^4.3.2", "rollup-plugin-svg": "^1.0.1", "rollup-plugin-uglify-es": "0.0.1", "rollup-plugin-virtual": "^1.0.1", "slash": "^1.0.0", "socket.io": "^2.1.1", "spreadsheet-to-json": "^1.3.1", "svelte": "^1.64.1", "travis-ci": "^2.2.0", "unhandled-rejection": "^1.0.0", "vision": "^4.1.1" } } ================================================ FILE: packages/core/README.md ================================================ [![Join the chat at https://gitter.im/vudash/vudash-core](https://badges.gitter.im/vudash/vudash-core.svg)](https://gitter.im/vudash/vudash-core?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Build Status](https://travis-ci.org/vudash/vudash.svg?branch=master)](https://travis-ci.org/vudash/vudash) [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg)](http://standardjs.com/) # Vudash A dashboard, like dashing, but written in NodeJS. ## Documentation Documentation has moved [here](http://vudash.github.io/vudash/) ## What does it look like? ![dashboard](https://cloud.githubusercontent.com/assets/218949/18632967/05d72ba6-7e72-11e6-964d-6de1f38135ac.png) ![graph](https://cloud.githubusercontent.com/assets/218949/18608448/68c9bf90-7ce1-11e6-95a9-15c864722271.png) ## Demo http://vudash.herokuapp.com/demo.dashboard ## Features * will happily run on a free heroku instance * es6 * all cross-origin requests done server side * websockets rather than polling * websocket fallback to long-poll when sockets aren't available * Custom widgets * Custom dashboards * Simple row-by-row layout * Dashboard arrangement is simply the config order (see below) * Super simple widget structure ================================================ FILE: packages/core/app.js ================================================ 'use strict' const { register, start, stop } = require('./src/server') let server register() .then(registered => { server = registered start(server) }) process.on('SIGUSR2', () => { stop(server) .then(() => { process.exit() }) }) ================================================ FILE: packages/core/bin/vudash.js ================================================ #!/usr/bin/env node const create = require('../src/cli/create') const help = require('../src/cli/help') const logo = require('../src/cli/logo') const [ , , arg ] = process.argv logo.run() if (!arg) { require('../app') } if (arg === 'create') { create.run() } if (['help', '--help'].includes(arg)) { help.run() } ================================================ FILE: packages/core/dashboards/simple.json ================================================ { "name": "simple-dashboard", "layout": { "columns": 5, "rows": 4 }, "datasources": { "ds-rnd": { "module": "../datasource-random", "schedule": 1000, "options": { "method": "string" } } }, "widgets": [ { "position": {"x": 3, "y": 0, "w": 2, "h": 1}, "widget": "../widget-statistic", "datasource": "ds-rnd" } ] } ================================================ FILE: packages/core/dashboards/template.json ================================================ { "name": "simple-dashboard", "layout": { "columns": 5, "rows": 4 }, "widgets": [ { "position": { "x": 1, "y": 0, "w": 3, "h":1 }, "widget": "vudash-widget-time" } ] } ================================================ FILE: packages/core/package.json ================================================ { "name": "vudash", "version": "9.9.0", "keywords": [ "vudash", "dashboard", "dashing", "analytics", "monitoring", "websockets", "geckoboard", "widget", "stats", "dash", "dashing-js", "statistics", "big screen", "display", "home automation", "automation", "ha" ], "repository": { "type": "git", "url": "https://github.com/vudash/vudash" }, "description": "Easy to use, flexible dashboard software for monitoring, analytics, and more.", "main": "app.js", "scripts": { "lint": "../../node_modules/.bin/standard", "watch": "BROWSER_SYNC=true ../../node_modules/.bin/nodemon -e html,js .", "start": "./bin/vudash.js", "test": "PORT=3418 NODE_PATH=src:test ../../node_modules/.bin/mocha ../../test/unit.lab.js", "link": "npm link" }, "bin": { "vudash": "bin/vudash.js" }, "engines": { "node": ">=9.x" }, "author": "Antony Jones", "license": "MIT", "dependencies": { "app-module-path": "^2.2.0", "bluebird": "^3.5.0", "boom": "^6.0.0", "browser-sync": "^2.12.9", "buble": "^0.19.3", "catbox-memory": "^3.0.0", "chalk": "^1.1.3", "figlet": "^1.2.0", "find-root": "^1.1.0", "fs-extra": "^0.30.0", "handlebars": "^4.0.10", "hapi": "^16.6.2", "hapi-api-secret-key": "^1.1.0", "hoek": "^4.1.1", "inert": "^4.2.0", "izitoast": "^1.2.0", "joi": "^10.5.2", "json-to-css": "^0.1.0", "lodash": "^4.16.4", "npm": "^5.0.4", "npm-programmatic": "0.0.8", "ora": "^1.3.0", "require-directory": "^2.1.1", "rollup": "^0.43.0", "rollup-plugin-buble": "^0.19.2", "rollup-plugin-commonjs": "^8.0.2", "rollup-plugin-node-resolve": "^3.3.0", "rollup-plugin-postcss": "^0.4.3", "rollup-plugin-svelte": "^4.1.0", "rollup-plugin-svg": "^1.0.1", "rollup-plugin-uglify-es": "0.0.1", "rollup-plugin-virtual": "^1.0.1", "slash": "^1.0.0", "socket.io": "^2.0.1", "svelte": "^1.60.2", "unhandled-rejection": "^1.0.0", "vision": "^4.1.1" }, "standard": { "globals": [ "describe", "context", "it", "before", "after", "beforeEach", "afterEach" ] } } ================================================ FILE: packages/core/src/cli/create.js ================================================ 'use strict' const npm = require('npm-programmatic') const ora = require('ora') const Path = require('path') const fs = require('fs-extra') const { green, yellow } = require('chalk') const dockerFileContents = ` FROM node:10-alpine COPY . /app WORKDIR /app RUN npm install EXPOSE 3300 ENV SERVER_URL http://localhost:3300 CMD npm start ` exports.run = function () { const dashboard = require('../../dashboards/template.json') const cwd = process.cwd() const spinner = ora().start('Creating dashboard layout') const configFile = Path.join(cwd, 'dashboards', 'default.json') const dockerFile = Path.join(cwd, 'Dockerfile') const packageJson = Path.join(cwd, 'package.json') const dashboardsDir = Path.join(cwd, 'dashboards') fs.ensureDirSync(dashboardsDir) fs.writeJsonSync(packageJson, { name: 'my-vudash-dashboard', main: 'vudash', scripts: { start: 'vudash' }, 'engines': { 'node': '>=9.x' } }) fs.writeJsonSync(configFile, dashboard) fs.outputFileSync(dockerFile, dockerFileContents) spinner.succeed('Created dashboard layout') spinner.start('Installing dependencies. This could take a minute or two...') npm.install([ 'vudash', 'vudash-widget-time' ], { cwd, save: true }) .then(() => { spinner.succeed('Installed dependencies.') console.log( green( 'Created sample dashboard. Run "vudash" or "npm start" to view' ) ) console.log( yellow( 'Dockerfile written. Use `docker build -t my-dashboard-name .` to build' ) ) }) .catch(e => { spinner.fail('Failed to install some dependencies.') console.log(e) }) } ================================================ FILE: packages/core/src/cli/help.js ================================================ 'use strict' const { bold } = require('chalk') exports.run = function () { console.log('Usage: vudash [action]') console.log('with no action, runs the dashboard configured in the current working directory.') console.log('\nactions:') console.log('\n', bold('create'), 'Create a new dashboard') } ================================================ FILE: packages/core/src/cli/logo.js ================================================ 'use strict' const { textSync } = require('figlet') const { yellow, blue } = require('chalk') const { version } = require('../../package.json') exports.run = function () { console.log( yellow( textSync('vudash', { font: 'Slant' }) ), blue( `v${version}` ) ) } ================================================ FILE: packages/core/src/config-validator/config-validator.spec.js ================================================ 'use strict' const { expect } = require('code') const { validate } = require('.') const Joi = require('joi') describe('config-validator', () => { it('returns validated values', () => { const result = validate('some-name', Joi.string().required(), 'hello') expect(result).to.equal('hello') }) it('throws validation errors', () => { expect(() => { validate('some-name', Joi.number().required(), 'hello') }).to.throw() }) it('with no json', () => { const result = validate('some-name', {}, undefined) expect(result).to.equal({}) }) it('with no rules', () => { const result = validate('some-name', null, 'hello') expect(result).to.equal('hello') }) it('with empty rules', () => { const result = validate('some-name', {}, 'hello') expect(result).to.equal('hello') }) it('with options', () => { const result = validate( 'some-name', Joi.object({ a: Joi.string() }), { a: 'x', b: 'y' }, { allowUnknown: true } ) expect(result).to.equal({a: 'x', b: 'y'}) }) }) ================================================ FILE: packages/core/src/config-validator/index.js ================================================ 'use strict' const Joi = require('joi') const { ConfigurationError } = require('../errors') exports.validate = function (name, rules = {}, json = {}, options = {}) { if (!rules || (typeof rules === 'object' && !Object.keys(rules).length)) { return json } const { error, value } = Joi.validate(json, rules, options) if (error) { throw new ConfigurationError( `Could not register ${name} due to invalid configuration: ${error}` ) } return value } ================================================ FILE: packages/core/src/dashboard/bundler/index.js ================================================ 'use strict' const base = ` import iziToast from 'izitoast' import 'izitoast/dist/css/iziToast.css' const VUDASH = window.VUDASH const socket = io(VUDASH.config.serverUrl) socket.on('error', function (e) { iziToast.show({ title: 'Socket Error', theme: 'dark', color: 'red', message: e.message, timeout: 5000, onOpen: function () { console.error(e) } }) }) socket.on('disconnect', function () { iziToast.show({ id: 'disconnect', title: 'Socket Disconnected', theme: 'light', color: 'red', message: 'Will reload soon to restore connection...', timeout: ${process.env.DISCONNECT_RELOAD_TIMEOUT} || 30000, onClosed: function () { window.location.reload() } }) }) socket.on('audio:play', function (data) { VUDASH.player.play(data.data) }) socket.on('view:current', function (data) { window.location.pathname = '/' + data.dashboard + '.dashboard' }) ` exports.build = function (widgets) { const model = widgets.reduce((curr, { name, markup, css, js, componentPath }) => { curr.imports.push(`import ${name} from '${componentPath}'`) curr.containers.push(markup) curr.css.push(css) curr.events.push(js) return curr }, { imports: [], containers: [], events: [], css: [] }) const imports = [ ...new Set(model.imports) ] const js = ` 'use strict' ${imports.join('\n')} ${base} ${model.events.join('\n')} ` const html = ` ${model.containers.join('\n')} ` return { js, html } } ================================================ FILE: packages/core/src/dashboard/compiler/compiler.spec.js ================================================ 'use strict' const compiler = require('.') const { stub } = require('sinon') const rollup = require('rollup') const { expect } = require('code') describe('dashboard/compiler', () => { context('Bundle', () => { const compiled = 'abc123' let js before(async () => { stub(rollup, 'rollup') const bundleStub = { generate: stub().returns(compiled) } rollup.rollup.resolves(bundleStub) js = await compiler.compile('zzz') }) after(() => { rollup.rollup.restore() }) it('Returns compiled js', () => { expect(js).to.exist().and.to.equal(compiled) }) }) }) ================================================ FILE: packages/core/src/dashboard/compiler/configuration-builder/configuration-builder.js ================================================ 'use strict' const svelte = require('rollup-plugin-svelte') const resolve = require('rollup-plugin-node-resolve') const commonjs = require('rollup-plugin-commonjs') const virtual = require('rollup-plugin-virtual') const css = require('rollup-plugin-postcss') const svg = require('rollup-plugin-svg') const buble = require('rollup-plugin-buble') const uglify = require('rollup-plugin-uglify-es') exports.build = function (source) { const inputConfig = { entry: '__input__', plugins: [ svg(), virtual({ '__input__': source }), commonjs(), resolve({ customResolveOptions: { moduleDirectory: 'node_modules' } }), css(), svelte(), buble(), uglify() ] } const outputConfig = { format: 'iife', file: 'bundle.js' } return { inputConfig, outputConfig } } ================================================ FILE: packages/core/src/dashboard/compiler/configuration-builder/configuration-builder.spec.js ================================================ 'use strict' const builder = require('.') const { reach } = require('hoek') const { expect } = require('code') describe('dashboard/compiler/configuration-builder', () => { context('Dynamic Contents', () => { it('Contents correctly set', () => { const expected = '__input__' const { inputConfig } = builder.build(expected) const actual = reach(inputConfig, 'entry') expect(actual).to.exist().and.to.equal(expected) }) }) }) ================================================ FILE: packages/core/src/dashboard/compiler/configuration-builder/index.js ================================================ 'use strict' module.exports = require('./configuration-builder') ================================================ FILE: packages/core/src/dashboard/compiler/index.js ================================================ 'use strict' const rollup = require('rollup') const { build } = require('./configuration-builder') exports.compile = function (source) { const { inputConfig, outputConfig } = build(source) return rollup .rollup(inputConfig) .then(bundle => { const js = bundle.generate(outputConfig) return js }) } ================================================ FILE: packages/core/src/dashboard/dashboard.js ================================================ 'use strict' const { reach } = require('hoek') const Emitter = require('./emitter') const id = require('../id-gen') const { schema } = require('./schema') const configValidator = require('../config-validator') const parser = require('./parser') const datasourceLoader = require('../datasource-loader') const widgetBinder = require('../widget-binder') const renderer = require('./renderer') function isWidgetEvent (eventId) { return eventId.endsWith(':update') } class Dashboard { constructor (json, io) { const preprocessed = parser.parse(json) const descriptor = configValidator.validate(preprocessed.name, schema, preprocessed, { allowUnknown: true }) const { name, layout, css } = descriptor this.id = id() this.name = name this.additionalCss = css || {} this.emitter = new Emitter(io, this.id) this.layout = layout this.descriptor = descriptor } emit (eventId, data, historical) { if (!isWidgetEvent(eventId)) { return this.emitter.emit(eventId, data, historical) } const widgetId = eventId.split(':')[0] const widget = this.widgets[widgetId] if (!historical && widget) { widget.history.insert(data) } const history = widget ? widget.history.fetch() : {} const update = Object.assign({ history }, data) this.emitter.emit(eventId, update, historical) } loadDatasources () { const datasources = reach(this, 'descriptor.datasources', { default: {} }) const hasDatasources = Object.keys(datasources).length this.datasources = hasDatasources ? datasourceLoader.load(datasources) : {} } loadWidgets () { const widgets = reach(this, 'descriptor.widgets', { default: [] }) const hasWidgets = Object.keys(widgets).length this.widgets = hasWidgets ? widgetBinder.load(this, widgets, this.datasources) : {} } destroy () { const datasources = Object.values(this.datasources) console.log(`Dashboard ${this.id} cleaning up ${datasources.length} datasources.`) datasources.forEach(datasource => { clearInterval(datasource.timer) }) const widgets = Object.values(this.widgets) console.log(`Dashboard ${this.id} attempting cleanup of ${widgets.length} widgets.`) widgets.forEach(widget => { widget.hasOwnProperty('destroy') && widget.destroy() }) } async toRenderModel () { const model = await renderer.buildRenderModel( this.name, this.widgets, this.layout ) model.css = renderer.compileAdditionalCss(this.additionalCss) return model } } exports.create = function (descriptor, io) { return new Dashboard(descriptor, io) } ================================================ FILE: packages/core/src/dashboard/dashboard.spec.js ================================================ 'use strict' const Emitter = require('./emitter') const widgetBinder = require('../widget-binder') const renderer = require('./renderer') const datasourceLoader = require('../datasource-loader') const parser = require('./parser') const configValidator = require('../config-validator') const { stub, useFakeTimers } = require('sinon') const { expect } = require('code') const { create } = require('.') describe('dashboard', () => { describe('constructor', () => { let dashboard const descriptor = { name: 'bar', layout: { columns: 4, rows: 6 } } beforeEach(() => { stub(parser, 'parse').returns(descriptor) stub(configValidator, 'validate').returns(descriptor) dashboard = create({}, { on: stub() }) }) afterEach(() => { parser.parse.restore() configValidator.validate.restore() }) it('generates a dashboard id', () => { expect(dashboard.id).to.exist() }) it('assigns dashboard name', () => { expect(dashboard.name).to.equal(descriptor.name) }) it('assigns dashboard layout', () => { expect(dashboard.layout).to.equal(descriptor.layout) }) it('assigns descriptor for future use', () => { expect(dashboard.descriptor).to.equal(descriptor) }) it('creates emitter', () => { expect(dashboard.emitter).to.be.an.instanceOf(Emitter) }) }) describe('#loadDatasources()', () => { let dashboard const emitter = { on: stub() } beforeEach(() => { stub(parser, 'parse') stub(configValidator, 'validate') }) afterEach(() => { parser.parse.restore() configValidator.validate.restore() }) context('empty datasource stanza', () => { it('empty datasources when none are specified', () => { parser.parse.returns({}) configValidator.validate.returns({}) dashboard = create({}, emitter) dashboard.loadDatasources() expect(dashboard.datasources).to.equal({}) }) }) context('list of datasources', () => { beforeEach(() => { const descriptor = { datasources: { foo: { foo: 'bar' } } } parser.parse.returns(descriptor) configValidator.validate.returns(descriptor) stub(datasourceLoader, 'load').returns('bar') dashboard = create({}, emitter) dashboard.loadDatasources() }) afterEach(() => { datasourceLoader.load.restore() }) it('calls loader to load datasources', () => { expect(datasourceLoader.load.callCount).to.equal(1) }) it('calls loader to load datasources', () => { expect(dashboard.datasources).to.equal('bar') }) }) }) describe('#loadWidgets()', () => { let dashboard const emitter = { on: stub() } beforeEach(() => { stub(parser, 'parse') stub(configValidator, 'validate') }) afterEach(() => { parser.parse.restore() configValidator.validate.restore() }) context('empty widget stanza', () => { it('empty widgets when none are specified', () => { parser.parse.returns({}) configValidator.validate.returns({}) dashboard = create({}, emitter) dashboard.loadWidgets() expect(dashboard.widgets).to.equal({}) }) }) context('list of widgets', () => { beforeEach(() => { const descriptor = { widgets: [ { foo: 'bar' } ] } parser.parse.returns(descriptor) configValidator.validate.returns(descriptor) stub(widgetBinder, 'load').returns('bar') dashboard = create({}, emitter) dashboard.loadWidgets() }) afterEach(() => { widgetBinder.load.restore() }) it('calls loader to load widgets', () => { expect(widgetBinder.load.callCount).to.equal(1) }) it('calls loader to load widgets', () => { expect(dashboard.widgets).to.equal('bar') }) }) }) describe('#destroy()', () => { let dashboard let clock beforeEach(() => { clock = useFakeTimers() stub(parser, 'parse').returns({}) stub(configValidator, 'validate').returns({}) dashboard = create({}, { on: stub() }) }) afterEach(() => { parser.parse.restore() configValidator.validate.restore() clock.restore() }) context('with list of datasources', () => { let stub1 = stub() let stub2 = stub() beforeEach(() => { const timer1 = setInterval(stub1, 1) const timer2 = setInterval(stub2, 1) dashboard.datasources = { foo: { timer: timer1 }, bar: { timer: timer2 } } dashboard.widgets = {} clock.tick(1) }) it('clears all timers', () => { dashboard.destroy() clock.tick(1) expect(stub1.callCount).to.equal(1) expect(stub1.callCount).to.equal(1) }) }) context('when no datasources exist', () => { it('succeeds silently', () => { dashboard.datasources = {} dashboard.widgets = {} expect(() => { dashboard.destroy() }).not.to.throw() }) }) context('with list of widgets', () => { const widgets = { abc: { destroy: stub() }, def: { } } beforeEach(() => { dashboard.widgets = widgets dashboard.datasources = {} }) it('calls destroy on widgets which support it', () => { dashboard.destroy() expect(widgets.abc.destroy.callCount).to.equal(1) }) }) context('when no widgets exist', () => { it('succeeds silently', () => { dashboard.datasources = {} dashboard.widgets = {} expect(() => { dashboard.destroy() }).not.to.throw() }) }) }) describe('#toRenderModel()', () => { context('no additiona css', () => { let dashboard const descriptor = { name: 'some-name', layout: 'some-layout' } beforeEach(() => { stub(parser, 'parse').returns(descriptor) stub(configValidator, 'validate').returns(descriptor) stub(renderer, 'buildRenderModel').returns({}) dashboard = create({}, { on: stub() }) dashboard.widgets = { abc: { foo: 'bar' } } dashboard.toRenderModel() }) afterEach(() => { parser.parse.restore() configValidator.validate.restore() renderer.buildRenderModel.restore() }) it('calls renderer with name', () => { expect(renderer.buildRenderModel.firstCall.args[0]).to.equal(dashboard.name) }) it('calls renderer with widgets', () => { expect(renderer.buildRenderModel.firstCall.args[1]).to.equal(dashboard.widgets) }) it('calls renderer with layout', () => { expect(renderer.buildRenderModel.firstCall.args[2]).to.equal(dashboard.layout) }) }) context('additional css', () => { let dashboard let renderModel const descriptor = { name: 'some-name', layout: 'some-layout' } beforeEach(async () => { stub(parser, 'parse').returns(descriptor) stub(configValidator, 'validate').returns({}) stub(renderer, 'buildRenderModel').returns({}) stub(renderer, 'compileAdditionalCss').returns('some: css') dashboard = create({}, { on: stub() }) dashboard.additionalCss = { some: 'css' } dashboard.widgets = {} renderModel = await dashboard.toRenderModel() }) afterEach(() => { renderer.compileAdditionalCss.restore() parser.parse.restore() configValidator.validate.restore() renderer.buildRenderModel.restore() }) it('calls css transpiler', () => { expect(renderer.compileAdditionalCss.callCount).to.equal(1) }) it('css is compiled', () => { expect(renderer.compileAdditionalCss.firstCall.args[0]).to.equal({ some: 'css' }) }) it('dashboard css contains additional css', () => { expect(renderModel.css).to.equal('some: css') }) }) }) describe('#emit()', () => { const exampleEvent = { some: 'data' } let dashboard const socketEmitter = { on: stub() } const dashboardEmitter = { emit: stub() } beforeEach(() => { stub(parser, 'parse').returns({}) stub(configValidator, 'validate').returns({}) dashboard = create({}, socketEmitter) dashboard.emitter = dashboardEmitter dashboard.widgets = { xyz: { history: { insert: stub(), fetch: stub().returns([{ foo: 'bar' }]) } } } }) afterEach(() => { parser.parse.restore() configValidator.validate.restore() dashboardEmitter.emit.reset() }) context('a widget event', () => { it('emits event', () => { dashboard.emit('xyz:update', exampleEvent) expect(dashboardEmitter.emit.callCount).to.equal(1) }) it('calls widget event history', () => { dashboard.emit('xyz:update', exampleEvent) expect(dashboard.widgets.xyz.history.insert.callCount).to.equal(1) }) it('returns existing history', () => { dashboard.emit('xyz:update', exampleEvent) expect( dashboardEmitter.emit.firstCall.args[1].history ).to.exist() .and.to.equal([{ foo: 'bar' }]) }) it('widget does not exist', () => { expect(() => { dashboard.emit('abc:update', exampleEvent) }).not.to.throw() }) it('stores non-historical event', () => { dashboard.emit('xyz:update', exampleEvent) expect( dashboard.widgets.xyz.history.insert.firstCall.args[0] ).to.equal(exampleEvent) }) it('does not store non-historical event', () => { dashboard.emit('xyz:update', exampleEvent, true) expect(dashboard.widgets.xyz.history.insert.callCount).to.equal(0) }) }) context('a non widget event', () => { it('emits event', () => { dashboard.emit('xyz:abc', exampleEvent) expect(dashboardEmitter.emit.callCount).to.equal(1) }) it('does not add event to history', () => { dashboard.emit('xyz:update', exampleEvent, true) expect( dashboard.widgets.xyz.history.insert.callCount ).to.equal(0) }) }) }) }) ================================================ FILE: packages/core/src/dashboard/emitter/emitter.js ================================================ 'use strict' const chalk = require('chalk') class Emitter { constructor (socketio, room) { this.io = socketio this.room = room this.recentEvents = {} this.io.on('connection', (socket) => { this.clientJoinHandler(socket) }) } clientJoinHandler (socket) { socket.join(this.room) const historicalEvents = this.recentEvents const eventIds = Object.keys(historicalEvents) console.log(`Client ${chalk.bold.green(socket.id)} connected to ${chalk.bold.red(this.room)}. Receives ${chalk.bold.yellow(eventIds.length)} historical events.`) eventIds.map(eventId => { this.emit(eventId, historicalEvents[eventId], true) }) } emit (event, data, historical) { this.io.to(this.room).emit(event, data) if (!historical) { this.recentEvents[event] = data } } } module.exports = Emitter ================================================ FILE: packages/core/src/dashboard/emitter/emitter.spec.js ================================================ 'use strict' const Emitter = require('.') const { expect } = require('code') const { stub, spy } = require('sinon') describe('dashboard/emitter', () => { const room = 'my-room' const broadcastStub = { emit: spy() } const socketSpy = { on: stub(), to: stub().withArgs(room).returns(broadcastStub) } const emitter = new Emitter(socketSpy, room) it('Instantiation of emitter binds handler', () => { expect(socketSpy.on.callCount).to.equal(1) expect(socketSpy.on.firstCall.args[0]).to.equal('connection') }) it('Emitted events are saved', () => { emitter.emit('abc', {id: 'abc'}) emitter.emit('def', {id: 'def'}) emitter.emit('ghi', {id: 'ghi'}) const recentEvents = emitter.recentEvents expect(Object.keys(recentEvents)).to.have.length(3) }) it('Repeated event updates previous event', () => { emitter.emit('def', {id: 'pqr'}) const recentEvents = emitter.recentEvents expect(Object.keys(recentEvents)).to.have.length(3) expect(recentEvents.def).to.equal({id: 'pqr'}) }) it('On connect, socket is joined to a room', () => { const mockSocket = { id: 'xyz', join: spy() } emitter.clientJoinHandler(mockSocket) expect(mockSocket.join.callCount).to.equal(1) expect(mockSocket.join.firstCall.args[0]).to.equal(room) }) it('On connect, socket is joined to a room', () => { const emit = broadcastStub.emit emit.reset() const mockSocket = { id: 'xyz', join: spy() } emitter.clientJoinHandler(mockSocket) expect(emit.callCount).to.equal(3) expect(emit.firstCall.args).to.equal(['abc', {id: 'abc'}]) expect(emit.secondCall.args).to.equal(['def', {id: 'pqr'}]) expect(emit.thirdCall.args).to.equal(['ghi', {id: 'ghi'}]) }) }) ================================================ FILE: packages/core/src/dashboard/emitter/index.js ================================================ 'use strict' module.exports = require('./emitter') ================================================ FILE: packages/core/src/dashboard/index.js ================================================ 'use strict' module.exports = require('./dashboard') ================================================ FILE: packages/core/src/dashboard/loader/index.js ================================================ 'use strict' module.exports = require('./loader') ================================================ FILE: packages/core/src/dashboard/loader/loader.js ================================================ 'use strict' const { NotFoundError } = require('../../errors') const Dashboard = require('..') const fs = require('fs') const { join } = require('path') function load (cache, name, io) { const path = join(process.cwd(), 'dashboards', `${name}.json`) if (!fs.existsSync(path)) { throw new NotFoundError(`Dashboard ${name} does not exist.`) } const descriptor = require(path) return add(cache, name, io, descriptor) } function add (cache, name, io, descriptor) { const dashboard = Dashboard.create(descriptor, io) dashboard.loadDatasources() dashboard.loadWidgets() cache[name] = dashboard return cache[name] } function find (cache, name, io) { return cache[name] || load(cache, name, io) } function has (cache, name) { return !!cache[name] } module.exports = { find, has, add, load } ================================================ FILE: packages/core/src/dashboard/loader/loader.spec.js ================================================ 'use strict' const { NotFoundError } = require('../../errors') const loader = require('.') const { expect } = require('code') describe('dashboard/loader', () => { it('Dashboard is not found', () => { expect(() => { return loader.load({}, 'xyz') }).to.throw(NotFoundError, 'Dashboard xyz does not exist.') }) context('#add()', () => { const cache = {} const emitter = { on: () => { } } const descriptor = { layout: { columns: 0, rows: 0 }, widgets: [] } beforeEach(() => { loader.add(cache, 'xyz', emitter, descriptor) }) it('Add dashboard to cache', () => { expect(cache.xyz.descriptor).to.equal(descriptor) }) it('Find dashboard from cache', () => { expect(loader.find(cache, 'xyz', emitter).descriptor).to.equal(descriptor) }) }) context('#has()', () => { const cache = { 'abc': {} } it('is contained in cache', () => { expect(loader.has(cache, 'abc')).to.be.true() }) it('is not contained in cache', () => { expect(loader.has(cache, 'xyz')).to.be.false() }) }) }) ================================================ FILE: packages/core/src/dashboard/parser/index.js ================================================ 'use strict' module.exports = require('./parser') ================================================ FILE: packages/core/src/dashboard/parser/parser.js ================================================ 'use strict' const { ConfigurationError } = require('../../errors') function get (directive) { if (process.env.hasOwnProperty(directive)) { return process.env[directive] } throw new ConfigurationError(`Environment variable ${directive} does not exist`) } function parse (json) { const root = Object.keys(json) root.forEach(key => { const child = json[key] if (typeof child !== 'object') { return } const directive = child['$env'] if (directive) { json[key] = get(directive) } else { parse(json[key]) } }) return json } exports.parse = parse ================================================ FILE: packages/core/src/dashboard/parser/parser.spec.js ================================================ 'use strict' const { expect } = require('code') const { parse } = require('.') const { ConfigurationError } = require('../../errors') describe('dashboard/parser', () => { beforeEach(() => { process.env.SOME_KEY = 'abcde' }) afterEach(() => { process.env.SOME_KEY = undefined }) it('replaces config element with environmental variable', () => { const config = { some: { value: { $env: 'SOME_KEY' } } } expect(parse(config)).to.equal({ some: { value: 'abcde' } }) }) it('replaces multiple elements', () => { const config = { some: { value: { $env: 'SOME_KEY' }, other: { value: { $env: 'SOME_KEY' } } } } expect(parse(config)).to.equal({ some: { value: 'abcde', other: { value: 'abcde' } } }) }) it('replaces entire element', () => { const config = { some: { value: { $env: 'SOME_KEY', invalid: 'entry' } } } expect(parse(config)).to.equal({ some: { value: 'abcde' } }) }) it('throws error if variable does not exist', () => { const config = { some: { value: { $env: 'NONEXISTENT_KEY' } } } expect(() => { parse(config) }).to.throw( ConfigurationError, 'Environment variable NONEXISTENT_KEY does not exist' ) }) }) ================================================ FILE: packages/core/src/dashboard/renderer/index.js ================================================ 'use strict' module.exports = require('./renderer') ================================================ FILE: packages/core/src/dashboard/renderer/renderer.js ================================================ 'use strict' const bundler = require('../bundler') const compiler = require('../compiler') const Css = require('json-to-css') function renderWidgets (widgets, layout) { const widgetModel = Object.values(widgets) return widgetModel.map(widget => { return widget.toRenderModel(layout) }) } exports.buildRenderModel = async function (name, widgets, layout) { const renderedWidgets = renderWidgets(widgets, layout) const { js, html } = bundler.build(renderedWidgets) const script = await compiler.compile(js) return { name, html, js: script } } exports.compileAdditionalCss = function (css) { return Css.of(css) } ================================================ FILE: packages/core/src/dashboard/renderer/renderer.spec.js ================================================ 'use strict' describe('dashboard/renderer', () => { }) ================================================ FILE: packages/core/src/dashboard/schema/index.js ================================================ 'use strict' module.exports = require('./schema') ================================================ FILE: packages/core/src/dashboard/schema/schema.js ================================================ 'use strict' const Joi = require('joi') const layoutSchema = Joi.object({ columns: Joi.number().required().description('Number of columns'), rows: Joi.number().required().description('Number of rows') }).required().description('Layout') const widgetPositionSchema = Joi.object({ x: Joi.number().required().description('Column for widget in dashboard, zero indexed'), y: Joi.number().required().description('Row for widget in dashboard, zero indexed'), w: Joi.number().required().description('Widget width in dashboard columns'), h: Joi.number().required().description('Widget height in dashboard rows') }).required().description('Widget position data') const widgetSchema = Joi.object({ position: widgetPositionSchema, widget: Joi.any().required().description('Path to widget, Node package name, or Class'), datasource: Joi.string().optional().description('Datasource name'), options: Joi.object().optional().description('Widget configuration'), background: Joi.string().optional().description('Optional background styling, used as css background') }).description('Widget Configuration') const widgetsSchema = Joi.array().required().items(widgetSchema).description('List of widgets') const datasourcesSchema = Joi.object().pattern(/.*/, Joi.object({ module: Joi.string().required().description('Datasource module name or directory path'), schedule: Joi.number().required().description('Update frequency, milliseconds'), options: Joi.object().optional().description('Datasource specific options') })).optional().description('Hash of datasources') const dashboardSchema = Joi.object({ name: Joi.string().optional().description('Dashboard name'), layout: layoutSchema, datasources: datasourcesSchema, widgets: widgetsSchema }).description('Dashboard Descriptor') exports.schema = dashboardSchema ================================================ FILE: packages/core/src/dashboard/schema/schema.spec.js ================================================ 'use strict' const { schema } = require('.') const fs = require('fs') const { join } = require('path') const { expect } = require('code') const Joi = require('joi') describe('dashboard/schema', () => { context('Parse', () => { it('Throws on invalid schema', () => { const { error } = Joi.validate({}, schema) expect(error.message).to.include('"layout" is required') }) const dashboardsDir = join(__dirname, '..', '..', '..', 'dashboards') const boards = fs.readdirSync(dashboardsDir) boards.forEach(board => { it(`Parses valid schema ${board}`, () => { const json = require(join(dashboardsDir, board)) const { error } = Joi.validate(json, schema) expect(error).not.to.exist() }) }) }) context('validation', () => { context('datasources', () => { it('are optional', () => { const descriptor = { layout: { columns: 1, rows: 1 }, widgets: [] } const { value } = Joi.validate(descriptor, schema) expect(value).to.equal(descriptor) }) it('must be datasources', () => { const descriptor = { layout: { columns: 1, rows: 1 }, widgets: [], datasources: { 'some-datasource': {} } } const { error } = Joi.validate(descriptor, schema) expect(error.message).to.include('fails because [child "module" fails') }) }) it('datasource options is optional', () => { const descriptor = { layout: { columns: 1, rows: 1 }, widgets: [], datasources: { 'some-datasource': { module: 'a', schedule: 30000 } } } const { value } = Joi.validate(descriptor, schema) expect(value).to.equal(descriptor) }) it('must include update schedule', () => { const descriptor = { layout: { columns: 1, rows: 1 }, widgets: [], datasources: { 'some-datasource': { module: 'a' } } } const { error } = Joi.validate(descriptor, schema) expect(error.message).to.include('"schedule" is required') }) it('update schedule must be in milliseconds', () => { const descriptor = { layout: { columns: 1, rows: 1 }, widgets: [], datasources: { 'some-datasource': { module: 'a', schedule: 'aaa' } } } const { error } = Joi.validate(descriptor, schema) expect(error.message).to.include('"schedule" must be a number') }) it('must include module name', () => { const descriptor = { layout: { columns: 1, rows: 1 }, widgets: [], datasources: { 'some-datasource': { schedule: 30000 } } } const { error } = Joi.validate(descriptor, schema) expect(error.message).to.include('"module" is required') }) }) }) ================================================ FILE: packages/core/src/dashboard-event/dashboard-event.js ================================================ 'use strict' exports.build = function (data) { return { meta: { updated: new Date() }, data } } ================================================ FILE: packages/core/src/dashboard-event/index.js ================================================ 'use strict' module.exports = require('./dashboard-event') ================================================ FILE: packages/core/src/datasource/datasource.spec.js ================================================ 'use strict' const loader = require('.') const locator = require('./locator') const DatasourceBuilder = require('util/datasource.builder') const validator = require('./validator') const { expect } = require('code') const { stub } = require('sinon') context('datasource.validator', () => { const widget = DatasourceBuilder.create().build() context('Datasource specified', () => { beforeEach(() => { stub(locator, 'locate') stub(validator, 'validate').returns({}) }) afterEach(() => { locator.locate.restore() validator.validate.restore() }) it('Registers widget data source', () => { locator.locate.returns({ Constructor: widget, options: {} }) loader.load('some-widget', {}, 'a-datasource') expect(locator.locate.callCount).to.equal(1) expect(locator.locate.firstCall.args[1]).to.equal('a-datasource') }) it('calls for widget validation on load', () => { const options = { foo: 'bar' } locator.locate.returns({ Constructor: widget, options, validation: {} }) loader.load('some-widget', {}, 'a-datasource') expect(validator.validate.callCount).to.equal(1) expect(validator.validate.firstCall.args[2]).to.equal(options) }) it('no validation specified', () => { locator.locate.returns({ Constructor: widget }) loader.load('some-widget', {}, 'a-datasource') expect(validator.validate.callCount).to.equal(0) }) }) context('no datasource specified', () => { it('will polyfill a fetch method which throws', () => { const loaded = loader.load('some-widget', {}, undefined) function fn () { return loaded.fetch() } expect(fn).to.throw('Widget some-widget requested data, but no datasource was configured. Check the widget configuration in your dashboard config.') }) }) }) ================================================ FILE: packages/core/src/datasource/dummy-datasource/dummy-datasource.spec.js ================================================ 'use strict' const DummyDatasource = require('.') const { expect } = require('code') describe('datasource.dummy-datasource', () => { it('can be constructed', () => { expect(DummyDatasource).to.be.a.function() }) it('Does not fetch', () => { const widgetName = 'abczys' const ds = new DummyDatasource({ widgetName }) expect(ds.fetch.bind(ds)).to.throw(Error, `Widget ${widgetName} requested data, but no datasource was configured. Check the widget configuration in your dashboard config.`) }) }) ================================================ FILE: packages/core/src/datasource/dummy-datasource/index.js ================================================ 'use strict' class DummyDatasource { constructor (options) { this.config = options } fetch () { throw new Error(`Widget ${this.config.widgetName} requested data, but no datasource was configured. Check the widget configuration in your dashboard config.`) } } module.exports = DummyDatasource ================================================ FILE: packages/core/src/datasource/index.js ================================================ 'use strict' const Joi = require('joi') const { applyToDefaults } = require('hoek') const DummyDatasource = require('./dummy-datasource') const validator = require('./validator') const locator = require('./locator') const datasourceValidation = { schedule: Joi.number().min(0).description('Datasource refresh schedule') } function extendValidation (validation) { return validation ? applyToDefaults(datasourceValidation, validation) : datasourceValidation } function loadValidOptions (widgetName, validation, options) { return options ? validator.validate(widgetName, validation, options) : {} } exports.load = function (widgetName, dashboard, datasourceName) { if (!datasourceName) { return new DummyDatasource({ widgetName }) } const { Constructor, validation, options } = locator.locate(dashboard.datasources, datasourceName) const datasourceValidation = extendValidation(validation) const config = loadValidOptions(widgetName, datasourceValidation, options) return new Constructor(config) } ================================================ FILE: packages/core/src/datasource/locator/index.js ================================================ 'use strict' const { WidgetRegistrationError } = require('../../errors') exports.locate = function (datasources, datasource) { const resolved = datasources[datasource] if (!resolved) { throw new WidgetRegistrationError(`Unable to use datasource ${datasource} as it does not exist`) } return resolved } ================================================ FILE: packages/core/src/datasource/locator/locator.spec.js ================================================ 'use strict' const { WidgetRegistrationError } = require('../../errors') const locator = require('.') const { expect } = require('code') describe('datasource.locator', () => { it('loads datasource', () => { const datasources = { abcde: { foo: 'bar' } } expect( locator.locate(datasources, 'abcde') ).to.equal(datasources.abcde) }) it('Cannot load datasource', () => { expect(() => { return locator.locate({}, 'non-existent') }).to.throw(WidgetRegistrationError, 'Unable to use datasource non-existent as it does not exist') }) }) ================================================ FILE: packages/core/src/datasource/validator/index.js ================================================ 'use strict' const configValidator = require('../../config-validator') exports.validate = function (widgetName, validation, options = {}) { return validation ? configValidator.validate(`widget:${widgetName}`, validation, options) : options } ================================================ FILE: packages/core/src/datasource/validator/validator.spec.js ================================================ 'use strict' const validator = require('.') const configValidator = require('../../config-validator') const { stub } = require('sinon') const { expect } = require('code') describe('datasource.validator', () => { context('No validation specified', () => { let options beforeEach(() => { stub(configValidator, 'validate') options = validator.validate('a', null, { a: 'b' }) }) afterEach(() => { configValidator.validate.restore() }) it('returns datasource options', () => { expect( options ).to.equal({ a: 'b' }) }) it('validation is not called as it does not exist', () => { expect(configValidator.validate.callCount).to.equal(0) }) }) context('Validation specified', () => { beforeEach(() => { stub(configValidator, 'validate') }) afterEach(() => { configValidator.validate.restore() }) it('validation is called if available', () => { validator.validate('a', {}, {}) expect(configValidator.validate.callCount).to.equal(1) }) }) }) ================================================ FILE: packages/core/src/datasource-binder/datasource-binder.js ================================================ 'use strict' const { createEmitter } = require('./datasource-emitter') const chalk = require('chalk') exports.bind = function (name, datasource, schedule = 30000) { const emitter = createEmitter() function fetchFunction () { datasource .fetch() .then(data => { emitter.emit('update', data) }) .catch(e => { console.error(chalk.red.bold(`Error updating datasource ${name}`), chalk.yellow(e.message)) console.error(chalk.red(e.stack)) }) } const timer = setInterval(fetchFunction, schedule) fetchFunction() return { timer, emitter } } ================================================ FILE: packages/core/src/datasource-binder/datasource-binder.spec.js ================================================ 'use strict' const { expect } = require('code') const binder = require('.') const { stub, useFakeTimers } = require('sinon') const datasourceEmitter = require('./datasource-emitter') describe('datasource-binder', () => { describe('timers', () => { let clock let timer const emitter = { emit: stub() } const datasource = { fetch: stub().resolves({ foo: 'bar' }) } beforeEach(() => { stub(datasourceEmitter, 'createEmitter').returns(emitter) clock = useFakeTimers() const result = binder.bind('xyz', datasource, 500) timer = result.timer }) afterEach(() => { datasourceEmitter.createEmitter.restore() clock.restore() datasource.fetch.reset() clearInterval(timer) }) it('fetch is called immediately', () => { expect(datasource.fetch.callCount).to.equal(1) }) it('fetch when interval is reached', () => { clock.tick(500) expect(datasource.fetch.callCount).to.equal(2) }) it('on each subsequent interval', () => { clock.tick(1000) expect(datasource.fetch.callCount).to.equal(3) }) }) context('missing schedule', () => { let clock let timer const emitter = { emit: stub() } const datasource = { fetch: stub().resolves({ foo: 'bar' }) } beforeEach(() => { stub(datasourceEmitter, 'createEmitter').returns(emitter) clock = useFakeTimers() const result = binder.bind('xyz', datasource) timer = result.timer }) afterEach(() => { datasourceEmitter.createEmitter.restore() clock.restore() datasource.fetch.reset() clearInterval(timer) }) it('default interval is set to 30 seconds', () => { clock.tick(30000) expect(datasource.fetch.callCount).to.equal(2) }) }) describe('event emitters', () => { let timer let emitter const expectedData = { foo: 'bar' } const datasource = { fetch: stub().resolves(expectedData) } beforeEach(() => { const result = binder.bind('xyz', datasource, 500) timer = result.timer emitter = result.emitter }) afterEach(() => { datasource.fetch.reset() clearInterval(timer) }) it('fetch emits data', done => { emitter.on('update', data => { expect(data).to.equal(expectedData) done() }) }) }) }) ================================================ FILE: packages/core/src/datasource-binder/datasource-emitter.js ================================================ 'use strict' const EventEmitter = require('events') class DatasourceEmitter extends EventEmitter {} exports.createEmitter = function () { return new DatasourceEmitter() } ================================================ FILE: packages/core/src/datasource-binder/index.js ================================================ 'use strict' module.exports = require('./datasource-binder') ================================================ FILE: packages/core/src/datasource-loader/datasource-loader.js ================================================ 'use strict' const resolver = require('../resolver') const configValidator = require('../config-validator') const datasourceBinder = require('../datasource-binder') const loadError = 'Cannot load datasource someDatasource because it does not look like a datasource' function resolveDatasource (path) { return resolver.resolve(path) } function parseConfiguration (datasourceName, validation, options) { return configValidator.validate(datasourceName, validation, options) } function register (registrationFn, configuration) { if (typeof registrationFn !== 'function') { throw new Error(`${loadError} (no registration function)`) } return registrationFn(configuration) } function initialise (name, registrationFn, configuration = {}, schedule) { const datasource = register(registrationFn, configuration) if (typeof datasource.fetch !== 'function') { throw new Error(`${loadError} (no fetch function)`) } return datasourceBinder.bind(name, datasource, schedule) } exports.load = function (descriptor) { const datasourceNames = Object.keys(descriptor) return datasourceNames.reduce((datasources, name) => { const { module: path, options, schedule } = descriptor[name] const { validation, register } = resolveDatasource(path) const configuration = parseConfiguration(name, validation, options) datasources[name] = initialise(name, register, configuration, schedule) return datasources }, {}) } ================================================ FILE: packages/core/src/datasource-loader/datasource-loader.spec.js ================================================ 'use strict' const { expect } = require('code') const { stub } = require('sinon') const datasourceLoader = require('.') const resolver = require('../resolver') const binder = require('../datasource-binder') const validator = require('../config-validator') describe('datasource-loader', () => { describe('datasource prototype', () => { const descriptor = { someDatasource: { module: '../some/path', schedule: 1000, options: { foo: 'bar' } } } context('missing fetch function', () => { const someDatasource = { register: options => { return {} } } beforeEach(() => { stub(resolver, 'resolve').returns(someDatasource) }) it('fails to load', () => { expect(() => { datasourceLoader.load(descriptor) }).to.throw( Error, 'Cannot load datasource someDatasource because it does not look like a datasource (no fetch function)' ) }) afterEach(() => { resolver.resolve.restore() }) }) context('missing registration function', () => { const someDatasource = {} beforeEach(() => { stub(resolver, 'resolve').returns(someDatasource) }) it('fails to load', () => { expect(() => { datasourceLoader.load(descriptor) }).to.throw( Error, 'Cannot load datasource someDatasource because it does not look like a datasource (no registration function)' ) }) afterEach(() => { resolver.resolve.restore() }) }) }) context('no datasource options specified', () => { const descriptor = { someDatasource: { module: '../some/path', schedule: 1000 } } const someDatasource = { register: stub().returns({ fetch: stub() }) } beforeEach(() => { stub(resolver, 'resolve').returns(someDatasource) stub(binder, 'bind') datasourceLoader.load(descriptor) }) it('should call register', () => { expect(someDatasource.register.callCount).to.equal(1) }) it('should pass empty options', () => { expect(someDatasource.register.firstCall.args[0]).to.equal({}) }) afterEach(() => { resolver.resolve.restore() binder.bind.restore() }) }) context('multiple datasources', () => { let datasources const descriptor = { 'plugin-a': { module: '../some/path' }, 'plugin-b': { module: '../some/path' } } const bound = { some: 'value' } const datasource = { register: () => { return { fetch: () => {} } } } beforeEach(() => { stub(resolver, 'resolve').returns(datasource) stub(validator, 'validate').returns({}) stub(binder, 'bind').returns(bound) datasources = datasourceLoader.load(descriptor) }) afterEach(() => { resolver.resolve.restore() validator.validate.restore() binder.bind.restore() }) it('contains two datasources', () => { const datasourceNames = Object.keys(datasources) expect(datasourceNames.length).to.equal(2) }) it('datasources are exposed', () => { expect(datasources['plugin-a']).to.equal(bound) }) }) }) ================================================ FILE: packages/core/src/datasource-loader/index.js ================================================ 'use strict' module.exports = require('./datasource-loader') ================================================ FILE: packages/core/src/errors/configuration.error.js ================================================ 'use strict' module.exports = class ConfigurationError extends Error { } ================================================ FILE: packages/core/src/errors/index.js ================================================ 'use strict' const { upperCamel } = require('../upper-camel') const requireDirectory = require('require-directory') const errors = requireDirectory(module, { rename: upperCamel }) module.exports = errors ================================================ FILE: packages/core/src/errors/not-found.error.js ================================================ 'use strict' module.exports = class NotFoundError extends Error { } ================================================ FILE: packages/core/src/errors/plugin-registration.error.js ================================================ 'use strict' module.exports = class PluginRegistrationError extends Error { } ================================================ FILE: packages/core/src/errors/widget-registration.error.js ================================================ 'use strict' module.exports = class WidgetRegistrati extends Error { } ================================================ FILE: packages/core/src/id-gen/id-gen.js ================================================ 'use strict' module.exports = () => { const random = Math.random() * 0xFFFFFFFFFFFF << 0 const positive = Math.abs(random) return positive.toString(16) } ================================================ FILE: packages/core/src/id-gen/id-gen.spec.js ================================================ 'use strict' const id = require('.') const { expect } = require('code') describe('id-gen', () => { it('generates a string id', () => { expect(id()) .to.exist() .and.to.be.a.string() }) }) ================================================ FILE: packages/core/src/id-gen/index.js ================================================ 'use strict' module.exports = require('./id-gen') ================================================ FILE: packages/core/src/plugins/api/api.js ================================================ 'use strict' const Joi = require('joi') const viewCurrentHandlers = require('./handlers/view/current') const dashboardHandlers = require('./handlers/dashboards') const ApiPlugin = { register: function (server, options, next) { server.route({ method: 'PUT', path: '/api/v1/view/current', config: { tags: ['api'], validate: { payload: { dashboard: Joi.string().required().description('Dashboard to switch to') } } }, handler: viewCurrentHandlers.put }) server.route({ method: 'PUT', path: '/api/v1/dashboards/{name}', config: { tags: ['api'], validate: { params: { name: Joi.string().required().description('Dashboard id') }, payload: { descriptor: Joi.object().required().description('Dashboard descriptor') } } }, handler: dashboardHandlers.put }) next() } } ApiPlugin.register.attributes = { name: 'api', version: '1.0.0', dependencies: ['hapi-api-secret-key'] } module.exports = ApiPlugin ================================================ FILE: packages/core/src/plugins/api/handlers/dashboards/index.js ================================================ 'use strict' exports.put = require('./put') ================================================ FILE: packages/core/src/plugins/api/handlers/dashboards/put.js ================================================ 'use strict' const loader = require('../../../../dashboard/loader') module.exports = function (request, reply) { const { dashboards } = request.server.plugins.ui const { io } = request.server.plugins.socket const { name } = request.params const { descriptor } = request.payload const isUpdate = loader.has(dashboards, name) loader.add(dashboards, name, io, descriptor) reply().code(isUpdate ? 200 : 201) } ================================================ FILE: packages/core/src/plugins/api/handlers/dashboards/put.spec.js ================================================ 'use strict' const { stub } = require('sinon') const { put } = require('.') const { expect } = require('code') describe('core/plugins/api', () => { describe('v1', () => { describe('dashboards', () => { const name = 'xyz' const descriptor = { layout: { columns: 0, rows: 0 }, widgets: [] } const dashboards = {} const server = { plugins: { ui: { dashboards }, socket: { io: { on: stub() } } } } const reply = stub() .returns({ code: stub() }) beforeEach(() => { const request = { server, params: { name }, payload: { descriptor } } put(request, reply) }) afterEach(() => { reply.reset() }) it('reply is called', () => { expect(reply.callCount).to.equal(1) }) it('dashboards contains new dashboard', () => { expect(Object.keys(dashboards)).to.include(name) }) }) }) }) ================================================ FILE: packages/core/src/plugins/api/handlers/view/current/index.js ================================================ 'use strict' exports.put = require('./put') ================================================ FILE: packages/core/src/plugins/api/handlers/view/current/put.js ================================================ 'use strict' module.exports = function (request, reply) { const { io } = request.server.plugins.socket const { dashboard } = request.payload io.emit('view:current', { dashboard }) reply() } ================================================ FILE: packages/core/src/plugins/api/handlers/view/current/put.spec.js ================================================ 'use strict' const { stub } = require('sinon') const { put } = require('.') const { expect } = require('code') describe('core/plugins/api', () => { describe('v1', () => { describe('view/current', () => { const dashboard = 'xyz' const server = { plugins: { socket: { io: { emit: stub() } } } } const reply = stub() beforeEach(() => { const request = { server, payload: { dashboard } } put(request, reply) }) afterEach(() => { server.plugins.socket.io.emit.reset() reply.reset() }) it('reply is called', () => { expect(reply.callCount).to.equal(1) }) it('emits a broadcast event', () => { expect(server.plugins.socket.io.emit.callCount).to.equal(1) }) it('broadcast event has dashboard switch event', () => { expect(server.plugins.socket.io.emit.firstCall.args[0]).to.equal('view:current') }) it('broadcast event has payload data', () => { expect(server.plugins.socket.io.emit.firstCall.args[1]).to.equal({ dashboard }) }) }) }) }) ================================================ FILE: packages/core/src/plugins/api/index.js ================================================ 'use strict' module.exports = require('./api') ================================================ FILE: packages/core/src/plugins/socket/index.js ================================================ 'use strict' module.exports = require('./socket') ================================================ FILE: packages/core/src/plugins/socket/socket.js ================================================ 'use strict' const socketio = require('socket.io') const SocketPlugin = { register: function (server, options, next) { server.expose('io', socketio(server.listener)) next() } } SocketPlugin.register.attributes = { name: 'socket', version: '1.0.0' } module.exports = SocketPlugin ================================================ FILE: packages/core/src/plugins/static/index.js ================================================ 'use strict' module.exports = require('./static') ================================================ FILE: packages/core/src/plugins/static/static.js ================================================ 'use strict' const { join } = require('path') const AssetsPlugin = { register: function (server, options, next) { server.route({ method: 'GET', path: '/assets/{param*}', handler: { directory: { path: join(__dirname, '..', '..', '..', 'src/public') } } }) next() } } AssetsPlugin.register.attributes = { name: 'assets', version: '1.0.0' } module.exports = AssetsPlugin ================================================ FILE: packages/core/src/plugins/ui/handlers/dashboard/index.js ================================================ 'use strict' const dashboardLoader = require('../../../../dashboard/loader') const { NotFoundError } = require('../../../../errors') const Boom = require('boom') async function buildViewModel (dashboard, server) { const serverUrl = server.settings.app.serverUrl const { name, html, js, css } = await dashboard.toRenderModel() return { serverUrl, html, name, bundle: js.code, map: js.map, css } } exports.handler = async function (request, reply) { const { board } = request.params const { server } = request const { io } = server.plugins.socket const { dashboards } = server.plugins.ui try { const dashboard = dashboardLoader.find(dashboards, board, io) const model = await buildViewModel(dashboard, server) return reply.view('dashboard', model) } catch (e) { console.error(e) if (e instanceof NotFoundError) { return reply.redirect('/') } return reply(Boom.boomify(e, { statusCode: 400 })) } } ================================================ FILE: packages/core/src/plugins/ui/handlers/index/index.js ================================================ 'use strict' const Path = require('path') const { readdirSync } = require('fs') const { internal } = require('boom') const { join } = require('path') function loadFromDisk (boards) { const path = Path.join(process.cwd(), 'dashboards') const files = readdirSync(path) return files.reduce((curr, d) => { const descriptor = require(join(path, d)) curr.add({ name: descriptor.name, path: '/' + d.replace('.json', '') + '.dashboard' }) return curr }, boards) } function loadFromCache (boards, request) { const { dashboards } = request.server.plugins.ui return Object.keys(dashboards).reduce((curr, d) => { curr.add(d) return curr }, boards) } exports.handler = function (request, reply) { const defaultDashboard = process.env.DEFAULT_DASHBOARD if (defaultDashboard) { return reply.redirect(`/${defaultDashboard}.dashboard`) } try { const boards = new Set() loadFromDisk(boards) loadFromCache(boards, request) reply.view('listing', { boards: Array.from(boards) }) } catch (e) { return reply(internal(e)) } } ================================================ FILE: packages/core/src/plugins/ui/handlers/index/index.spec.js ================================================ 'use strict' const { expect } = require('code') const { handler } = require('.') describe('plugins/ui/handlers/index', () => { context('Default Dashboard', () => { before(() => { process.env.DEFAULT_DASHBOARD = 'xxyyzz' }) after(() => { process.env.DEFAULT_DASHBOARD = undefined }) it('Index points to default dashboard', done => { const replyFunc = { redirect: (uri) => { expect(uri).to.equal('/xxyyzz.dashboard') done() } } handler({}, replyFunc) }) }) }) ================================================ FILE: packages/core/src/plugins/ui/index.js ================================================ 'use strict' module.exports = require('./ui') ================================================ FILE: packages/core/src/plugins/ui/ui.js ================================================ 'use strict' const Joi = require('joi') const { handler: indexHandler } = require('./handlers/index') const { handler: dashboardHandler } = require('./handlers/dashboard') const DashboardPlugin = { register: function (server, options, next) { server.route({ method: 'GET', path: '/', handler: indexHandler }) server.route({ method: 'GET', path: '/{board}.dashboard', config: { validate: { params: { board: Joi.string().required().description('Board name') } }, cache: { expiresIn: 15 * 60 * 1000, privacy: 'private' } }, handler: dashboardHandler }) server.route({ method: '*', path: '/{p*}', handler: indexHandler }) server.expose('dashboards', {}) next() } } DashboardPlugin.register.attributes = { name: 'ui', version: '1.0.0', dependencies: ['socket'] } module.exports = DashboardPlugin ================================================ FILE: packages/core/src/plugins/ui/ui.spec.js ================================================ 'use strict' const server = require('server') const { expect } = require('code') describe('core/plugins/ui', function () { this.timeout(10000) let app const dashboard = 'simple' before(async () => { app = await server.register() }) after(async () => { await server.stop(app) }) it('Loads dashboards into memory', () => { return app.inject({ url: `/${dashboard}.dashboard` }) .then(({ statusCode }) => { expect(statusCode).to.equal(200) }) }) it('Builds dashboard cache', () => { const cachedDashboards = Object.keys(app.plugins.ui.dashboards) expect(cachedDashboards).to.only.include('simple') }) }) ================================================ FILE: packages/core/src/public/css/listing.css ================================================ body { font-family: 'Ubuntu', sans-serif; color: #fff; background-color: #191919; text-align: center; } .logo { width: 25vw; margin: 5vh; } path { fill: #fff; stroke: transparent; } rect { fill: tomato; } .sub-page { font-size: 20px; } ul { list-style-type: none; padding: 0; } ul > li { margin: 5px 0; } a, a:visited, a:active { color: #fff; text-decoration: none; } a:hover { color: tomato; } ================================================ FILE: packages/core/src/public/css/style.css ================================================ @font-face { font-family: 'Ubuntu'; font-style: normal; font-weight: 400; src: local('Ubuntu Regular'), local('Ubuntu-Regular'), url('/assets/fonts/ubuntu-v11-latin-regular.woff2') format('woff2'), url('/assets/fonts/ubuntu-v11-latin-regular.woff') format('woff'); } body { font-family: 'Ubuntu', sans-serif; color: #fff; overflow: hidden; background-color: #000; } .widget-container { position: absolute; display: block; box-sizing: border-box; background-color: #191919; border: 3px solid #000; border-radius: 10px; } .widget-container > *:first-child { display: flex; flex-direction: column; align-items: center; justify-content: center; } .widget-container > *:first-child { height: 100%; width: 100%; } ================================================ FILE: packages/core/src/public/js/audio.js ================================================ 'use strict' var VUDASH = window.VUDASH var Player = function () { this.audio = new window.Audio() } Player.prototype.play = function (data) { this.audio.src = data this.audio.addEventListener('canplaythrough', function () { this.play() }) } VUDASH.player = new Player() ================================================ FILE: packages/core/src/public/js/object-assign.polyfill.js ================================================ 'use strict' if (typeof Object.assign !== 'function') { Object.assign = function (target, varArgs) { 'use strict' if (target == null) { throw new TypeError('Cannot convert undefined or null to object') } var to = Object(target) for (var index = 1; index < arguments.length; index++) { var nextSource = arguments[index] if (nextSource != null) { for (var nextKey in nextSource) { if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) { to[nextKey] = nextSource[nextKey] } } } } return to } } ================================================ FILE: packages/core/src/resolver/index.js ================================================ 'use strict' module.exports = require('./resolver') ================================================ FILE: packages/core/src/resolver/resolver.js ================================================ 'use strict' const fs = require('fs') const { join } = require('path') function discoverNpmModule (moduleName) { try { return require.resolve(moduleName) } catch (e) { return null } } function discoverLocalModule (moduleName) { const path = join(process.cwd(), moduleName) return fs.existsSync(path) ? path : null } function throwNotFound (moduleName) { throw new Error(`Module ${moduleName} could not be resolved as an NPM module or a local module`) } function discover (moduleName) { return [ discoverNpmModule, discoverLocalModule, throwNotFound ].find(method => { return method(moduleName) })(moduleName) } function resolve (moduleName) { return require(discover(moduleName)) } module.exports = { discover, resolve } ================================================ FILE: packages/core/src/resolver/resolver.spec.js ================================================ 'use strict' const { discover } = require('.') const { expect } = require('code') const { resolve } = require('path') describe('resolver', () => { context('An npm module', () => { const rootDir = resolve(process.cwd(), '../..') it('returns path', () => { expect( discover('code') ).to.equal( `${rootDir}/node_modules/code/lib/index.js` ) }) }) context('A local module', () => { it('returns path', () => { expect( discover('test/resources/widgets/example') ).to.equal( `${process.cwd()}/test/resources/widgets/example` ) }) }) }) ================================================ FILE: packages/core/src/server.js ================================================ 'use strict' const requirePaths = require('app-module-path') requirePaths.addPath(process.cwd()) requirePaths.addPath(`${process.cwd()}/node_modules`) const Hapi = require('hapi') const fs = require('fs') const Path = require('path') const chalk = require('chalk') const unhandled = require('unhandled-rejection') const id = require('./id-gen') const rejectionEmitter = unhandled({ timeout: 5 }) rejectionEmitter.on('unhandledRejection', (error, promise) => { console.error(error) }) function register () { const server = new Hapi.Server() server.connection({ port: process.env.PORT || 3300 }) server.settings.app = { serverUrl: process.env.SERVER_URL || server.info.uri } const apiKey = process.env['API_KEY'] || id() return server.register([ require('vision'), require('inert'), require('./plugins/socket'), require('./plugins/static'), require('./plugins/ui'), { register: require('hapi-api-secret-key').plugin, options: { secrets: [ apiKey ] } }, require('./plugins/api') ]) .then(() => { server.views({ engines: { html: require('handlebars') }, relativeTo: __dirname, path: './views' }) console.log(`Loading dashboards from ${chalk.blue(process.cwd())}`) console.log(`Server ${chalk.green.bold('running')}`) console.log(`Api key: ${chalk.magenta.bold(apiKey)}`) console.log('Dashboards available:') const dashboardDir = Path.join(process.cwd(), 'dashboards') const boards = fs.readdirSync(dashboardDir) for (let board of boards) { const loaded = require(Path.join(dashboardDir, board)) const boardUrl = `${Path.basename(board, '.json')}.dashboard` console.log(chalk.blue.bold(loaded.name), 'at', chalk.cyan.underline(`${server.settings.app.serverUrl}/${boardUrl}`)) } return Promise.resolve(server) }) } function start (server) { return server.start() .then(() => { if (process.env.BROWSER_SYNC) { const bs = require('browser-sync').create() bs.init({ open: false, proxy: server.info.uri, files: ['src/public/**/*.{js,css}'] }) } return Promise.resolve() }) } function cleanup (server) { const cache = Object.values(server.plugins.ui.dashboards) cache.forEach(dashboard => { dashboard.destroy() }) } function stop (server) { server.stop({ timeout: 60000 }, () => { cleanup(server) return Promise.resolve() }) } module.exports = { register, start, stop } ================================================ FILE: packages/core/src/transform-loader/index.js ================================================ 'use strict' module.exports = require('./transform-loader') ================================================ FILE: packages/core/src/transform-loader/transform-loader.js ================================================ 'use strict' const resolver = require('../resolver') const configValidator = require('../config-validator') const Joi = require('joi') function validate (widgetName, configuration) { const transformerSchema = Joi.object({ transformer: Joi.string().required().label('Transformer module name'), options: Joi.object().optional().label('Transform configuration') }) const schema = Joi.array().items( transformerSchema ).required() return configValidator.validate(widgetName, schema, configuration) } exports.load = function (widgetName, configuration) { validate(widgetName, configuration) return configuration.map(({ transformer, options }) => { const Constructor = resolver.resolve(transformer) return new Constructor(options) }) } ================================================ FILE: packages/core/src/transform-loader/transform-loader.spec.js ================================================ 'use strict' const { load } = require('.') const { expect } = require('code') const { stub } = require('sinon') const resolver = require('../resolver') const { ConfigurationError } = require('../errors') describe('transform-loader', () => { class SomeTransformer {} class OtherTransformer {} context('transformers with configuration', () => { let transformers const configuration = [ { transformer: 'some-transformer', options: { foo: 'bar' } }, { transformer: 'other-transformer', options: {} } ] beforeEach(() => { stub(resolver, 'resolve') resolver.resolve .withArgs('some-transformer') .returns(SomeTransformer) resolver.resolve .withArgs('other-transformer') .returns(OtherTransformer) transformers = load('x', configuration) }) afterEach(() => { resolver.resolve.restore() }) it('loads two transformers', () => { expect(transformers.length).to.equal(2) }) it('loads first transformer', () => { expect(transformers[0]).to.be.an.instanceof(SomeTransformer) }) it('loads second transformer', () => { expect(transformers[1]).to.be.an.instanceof(OtherTransformer) }) it('requests transformer modules', () => { expect(resolver.resolve.callCount).to.equal(2) }) }) context('empty transformer list', () => { const configuration = [] it('loads no transformers', () => { expect( load('x', configuration) ) .to.be.an.array() .and.to.have.length(0) }) }) describe('validation', () => { context('missing transformer name', () => { const configuration = [{}] beforeEach(() => { stub(resolver, 'resolve') }) afterEach(() => { resolver.resolve.restore() }) it('throws a validation error', () => { expect(() => { load('x', configuration) }).to.throw(ConfigurationError, /"Transformer module name" is required/) }) }) }) }) ================================================ FILE: packages/core/src/upper-camel/index.js ================================================ 'use strict' const { flow, camelCase, upperFirst } = require('lodash') exports.upperCamel = function (name) { const rename = flow([camelCase, upperFirst]) return rename(name) } ================================================ FILE: packages/core/src/upper-camel/upper-camel.spec.js ================================================ 'use strict' const { upperCamel } = require('.') const { expect } = require('code') describe('upper-camel', () => { const scenarios = [ { input: 'some-name', output: 'SomeName' }, { input: 'SomeName', output: 'SomeName' }, { input: '@scope/package-name', output: 'ScopePackageName' }, { input: '__some/name%&', output: 'SomeName' } ] scenarios.forEach(({ input, output }) => { it(`${input} becomes ${output}`, () => { expect(upperCamel(input)) .to.exist() .and.to.be.a.string() .and.to.equal(output) }) }) }) ================================================ FILE: packages/core/src/views/dashboard.html ================================================ {{name}} Dashboard {{{html}}} ================================================ FILE: packages/core/src/views/listing.html ================================================ Available Boards

Available Boards

================================================ FILE: packages/core/src/widget/history/history.js ================================================ 'use strict' class History { constructor (size = 10) { this.size = size this.items = [] } insert (entry) { this.items.push(entry) if (this.items.length > this.size) { this.items.shift() } } fetch () { return this.items } } exports.create = function (size) { return new History(size) } ================================================ FILE: packages/core/src/widget/history/history.spec.js ================================================ 'use strict' const { create } = require('.') const { times } = require('lodash') const { expect } = require('code') describe('widget-binder/history', () => { context('three items', () => { let contents beforeEach(() => { const history = create() history.insert('a') history.insert('b') history.insert('c') contents = history.fetch() }) it('has all entries', () => { expect(contents[2]).to.equal('c') }) it('is earliest first', () => { expect(contents[0]).to.equal('a') }) }) context('history overflow', () => { let contents beforeEach(() => { const history = create(2) history.insert('a') history.insert('b') history.insert('c') contents = history.fetch() }) it('has length of two', () => { expect(contents.length).to.equal(2) }) it('has all entries', () => { expect(contents[1]).to.equal('c') }) it('is earliest first', () => { expect(contents[0]).to.equal('b') }) }) context('size not specified', () => { let contents let history beforeEach(() => { history = create() times(11, i => { history.insert(i) }) contents = history.fetch() }) it('history size is 10', () => { expect(history.size).to.equal(10) }) it('is limited by size', () => { expect(contents).to.have.length(10) }) }) }) ================================================ FILE: packages/core/src/widget/history/index.js ================================================ 'use strict' module.exports = require('./history') ================================================ FILE: packages/core/src/widget/index.js ================================================ 'use strict' const id = require('../id-gen') const WidgetPosition = require('./widget-position') const loader = require('./loader') const renderer = require('./renderer') const History = require('./history') const validator = require('./validator') class Widget { constructor (widgetPath, config) { const { position, background, options = {}, history } = config this.id = id() this.widgetPath = widgetPath this.options = options this.background = background this.position = position this.history = History.create(history) } register (emitter) { const { widget, name, componentPath } = loader.load(this.widgetPath) this.componentPath = componentPath this.name = name this.options = validator.validate(name, widget.validation, this.options) this.widget = widget.register(this.options, emitter) } update (value) { return this.widget.update ? this.widget.update(value) : value } toRenderModel (dashboardLayout) { const { id, name, options, componentPath, background, position } = this const widgetPosition = new WidgetPosition(dashboardLayout, position) return { id, name, componentPath, markup: renderer.renderHtml(id), css: renderer.renderStyles(id, widgetPosition, background), js: renderer.renderScript(id, name, options) } } } exports.create = function (widgetPath, config) { return new Widget(widgetPath, config) } ================================================ FILE: packages/core/src/widget/loader/index.js ================================================ 'use strict' const { reach } = require('hoek') const { join } = require('path') const resolver = require('../../resolver') const { upperCamel } = require('../../upper-camel') const { ConfigurationError } = require('../../errors') const slash = require('slash') const findRoot = require('find-root') function discoverComponentPath (packagePath, packageJson) { const relativeComponentPath = readComponentStanza(packageJson) const absoluteComponentPath = join(packagePath, relativeComponentPath) const localPath = slash(absoluteComponentPath) if (!localPath) { const packageName = reach(packageJson, 'name') throw new ConfigurationError(`Cannot find component at ${localPath} for widget ${packageName}.`) } return localPath } function findPackageRoot (directory) { const moduleEntrypoint = resolver.discover(directory) return findRoot(moduleEntrypoint) } function loadPackageJson (packageRoot) { return require(join(packageRoot, 'package.json')) } function readPackage (directory) { const packageRoot = findPackageRoot(directory) const widget = require(packageRoot) const packageJson = loadPackageJson(packageRoot) const componentPath = discoverComponentPath(packageRoot, packageJson) const name = upperCamel(packageJson.name) return { widget, name, componentPath } } function readComponentStanza (packageJson) { const path = 'vudash.component' const componentPath = reach(packageJson, path) if (!componentPath) { const packageName = reach(packageJson, 'name') throw new ConfigurationError(`Widget ${packageName} is missing '${path}' in package.json`) } return componentPath } exports.load = function (pathOrDescriptor) { const isPreParsed = typeof pathOrDescriptor === 'object' if (isPreParsed) { return pathOrDescriptor } return readPackage(pathOrDescriptor) } ================================================ FILE: packages/core/src/widget/loader/loader.spec.js ================================================ 'use strict' const loader = require('.') const { ComponentCompilationError } = require('errors') const { expect } = require('code') describe('widget/loader', () => { context('Programmatic Config', () => { const pkg = { Module: { register: () => {} }, component: 'some-component', name: 'vudash-some-component' } it('Parses Component', () => { const resolved = loader.load(pkg) expect(resolved).to.equal(pkg) }) }) context('Vudash Metadata', () => { it('fails to read component metadata', () => { const message = "Widget vudash-widget-missing is missing 'vudash.component' in package.json" const fn = () => { loader.load('test/resources/widgets/missing') } expect(fn).to.throw(ComponentCompilationError, message) }) }) context('Valid Component', () => { let component beforeEach(() => { component = loader.load('test/resources/widgets/example') }) it('returns registration method', () => { expect(component.widget.register).to.be.a.function() }) it('returns markup path', () => { expect(component.componentPath).to.endWith('test/resources/widgets/example/markup.html') }) it('returns registration method', () => { expect(component.name).to.exist().and.to.equal('VudashWidgetExample') }) }) }) ================================================ FILE: packages/core/src/widget/renderer/index.js ================================================ 'use strict' exports.renderScript = function (id, name, config) { return ` const widget_${id} = new ${name}({ target: document.getElementById("widget-container-${id}"), data: { config: ${JSON.stringify(config)} } }); socket.on('${id}:update', ($data) => { if ($data.error) { console.error('Widget "${id}" encountered error: ' + $data.error.message) } widget_${id}.update($data) }) `.trim() } exports.renderHtml = function (id) { return `
` } exports.renderStyles = function (id, widgetPosition, background) { const { top, left, width, height } = widgetPosition const rules = [ `top:${top}%`, `left:${left}%`, `width:${width}%`, `height:${height}%` ] if (background) { rules.push(`background:${background}`) } return `#widget-container-${id}{${rules.join(';')}}` } ================================================ FILE: packages/core/src/widget/renderer/renderer.spec.js ================================================ 'use strict' const renderer = require('.') const WidgetPosition = require('../widget-position') const Cheerio = require('cheerio') const { expect } = require('code') describe('widget/renderer', () => { context('#renderScript()', () => { const id = 'abc' const config = { a: 'b' } let rendered before(() => { rendered = renderer.renderScript(id, 'AbcWidget', config) }) it('update method is rendered', () => { expect(rendered).to.include("socket.on('abc:update', ($data) => {") }) it('target is set correctly', () => { expect(rendered).to.include('target: document.getElementById("widget-container-abc")') }) it('error handling exists', () => { expect(rendered).to.include('Widget "abc" encountered error') }) it('default data contains config', () => { expect(rendered).to.include('data: { config: {"a":"b"} }') }) it('widget update method is called', () => { expect(rendered).to.include('widget_abc.update($data)') }) it('component is rendered', () => { expect(rendered).to.startWith('const widget_abc = new AbcWidget') }) }) context('#renderHtml()', () => { let $ before(() => { const widget = { id: 'xyz' } const markup = renderer.renderHtml(widget.id) $ = Cheerio.load(markup) }) it('Has correct id', () => { expect($('div').attr('id')).to.equal('widget-container-xyz') }) it('Has correct class', () => { expect($('div').hasClass('widget-container')).to.be.true() }) }) describe('#renderStyles()', () => { const widgetPosition = new WidgetPosition({ rows: 4, columns: 5 }, { x: 1, y: 2, w: 3, h: 4 }) const background = '#fff' context('Css', () => { let css before(() => { css = renderer.renderStyles('xyz', widgetPosition, background) }) it('Renders widget id', () => { expect(css).to.startWith('#widget-container-xyz{') }) it('Renders background correctly', () => { expect(css).to.contain('background:#fff') }) it('Renders position correctly', () => { expect(css).to.contain('left:20%;') expect(css).to.contain('top:50%;') expect(css).to.contain('width:60%;') expect(css).to.contain('height:100%;') }) }) context('No Background', () => { let css before(() => { css = renderer.renderStyles('abc', widgetPosition, undefined) }) it('Does not contain background rule', () => { expect(css).not.to.contain('background:') }) }) }) }) ================================================ FILE: packages/core/src/widget/validator/index.js ================================================ 'use strict' module.exports = require('./validator') ================================================ FILE: packages/core/src/widget/validator/validator.js ================================================ 'use strict' const configValidator = require('../../config-validator') const Joi = require('joi') const defaultSchema = { description: Joi.string().optional().description('Widget display label') } exports.validate = function (name, baseSchema, options) { if (!baseSchema) { return options } const schema = baseSchema.keys(defaultSchema) return configValidator.validate(name, schema, options) } ================================================ FILE: packages/core/src/widget/validator/validator.spec.js ================================================ 'use strict' const { expect } = require('code') const { validate } = require('.') describe('widget/validator', () => { it('has no validation', () => { const descriptor = { a: 'b' } expect( validate('xyz', null, descriptor) ).to.equal(descriptor) }) }) ================================================ FILE: packages/core/src/widget/widget-position/index.js ================================================ class WidgetPosition { constructor (dashboardLayout, position) { this.columns = dashboardLayout.columns this.rows = dashboardLayout.rows this.position = position } get rowHeight () { return 100 / this.rows } get columnWidth () { return 100 / this.columns } get height () { return this.position.h * this.rowHeight } get width () { return this.position.w * this.columnWidth } get left () { return this.position.x * this.columnWidth } get top () { return this.position.y * this.rowHeight } } module.exports = WidgetPosition ================================================ FILE: packages/core/src/widget/widget-position/widget-position.spec.js ================================================ 'use strict' const WidgetPosition = require('.') const { expect } = require('code') describe('css-builder/widget-position', () => { const dashboard = { columns: 5, rows: 4 } it('Calculates first widget dimensions', () => { const position = { x: 0, y: 0, w: 1, h: 1 } const widgetPosition = new WidgetPosition(dashboard, position) expect(widgetPosition.top).to.equal(0) expect(widgetPosition.left).to.equal(0) expect(widgetPosition.width).to.equal(20) expect(widgetPosition.height).to.equal(25) }) it('Calculates middle widget dimensions', () => { const position = { x: 2, y: 4, w: 2, h: 1 } const widgetPosition = new WidgetPosition(dashboard, position) expect(widgetPosition.top).to.equal(100) expect(widgetPosition.left).to.equal(40) expect(widgetPosition.width).to.equal(40) expect(widgetPosition.height).to.equal(25) }) }) ================================================ FILE: packages/core/src/widget/widget.spec.js ================================================ 'use strict' const { create } = require('.') const { stub } = require('sinon') const loader = require('./loader') const { expect } = require('code') const WidgetPosition = require('./widget-position') const renderer = require('./renderer') describe('widget', () => { describe('#create()', () => { it('has an auto-generated id', () => { const widget = create('xyz', {}) expect(widget.id).to.exist() }) it('has options', () => { const widget = create('xyz', {}) expect(widget.options).to.equal({}) }) it('has options', () => { const widget = create('xyz', {}) expect(widget.history).to.exist() }) }) describe('#register()', () => { let widget const register = stub() const options = { foo: 'bar' } const dashboardEmitter = { xyz: 'abc' } beforeEach(() => { stub(loader, 'load').returns({ widget: { register } }) widget = create('xyz', { options }) widget.register(dashboardEmitter) }) afterEach(() => { loader.load.restore() }) it('widget is registered', () => { expect(register.callCount).to.equal(1) }) it('registers options with widget', () => { expect(register.firstCall.args[0]).to.equal(options) }) it('passes the emitter to the register method', () => { expect(register.firstCall.args[1]).to.equal(dashboardEmitter) }) }) describe('update', () => { let widget const data = { foo: 'bar' } context('widget implements update function', () => { const modified = { foo: 'bar' } beforeEach(() => { widget = create('xyz', {}) widget.widget = { update: stub().returns(modified) } }) it('update calls widget update', () => { widget.update(data) expect(widget.widget.update.callCount).to.equal(1) }) it('calls with update data', () => { widget.update(data) expect(widget.widget.update.firstCall.args[0]).to.equal(data) }) it('returns modified data', () => { expect(widget.update(data)).to.equal(modified) }) }) context('widget does not implement update function', () => { it('returns update data', () => { widget = create('xyz', {}) widget.widget = {} expect(widget.update(data)).to.equal(data) }) }) }) describe('#toRenderModel()', () => { let widget let renderModel const dashboardLayout = { rows: 1, columns: 1 } const background = '#fff' const options = { foo: 'bar' } beforeEach(() => { stub(renderer, 'renderHtml').returns('html') stub(renderer, 'renderStyles').returns('css') stub(renderer, 'renderScript').returns('js') widget = create('xxx', { options, background }) widget.id = 'my-id' widget.name = 'some-widget' widget.componentPath = 'xyz' renderModel = widget.toRenderModel(dashboardLayout) }) afterEach(() => { renderer.renderHtml.restore() renderer.renderStyles.restore() renderer.renderScript.restore() }) describe('renders HTML', () => { it('renders html', () => { expect(renderer.renderHtml.callCount).to.equal(1) }) it('passes id to html renderer', () => { expect(renderer.renderHtml.firstCall.args[0]).to.equal(widget.id) }) }) describe('renders styles', () => { it('renders css', () => { expect(renderer.renderStyles.callCount).to.equal(1) }) it('passes id to style renderer', () => { expect(renderer.renderStyles.firstCall.args[0]).to.equal(widget.id) }) it('passes position to style renderer', () => { expect(renderer.renderStyles.firstCall.args[1]).to.be.an.instanceOf(WidgetPosition) }) it('passes background to style renderer', () => { expect(renderer.renderStyles.firstCall.args[2]).to.equal(background) }) }) describe('renders scripts', () => { it('renders js', () => { expect(renderer.renderScript.callCount).to.equal(1) }) it('passes id to style renderer', () => { expect(renderer.renderScript.firstCall.args[0]).to.equal(widget.id) }) it('passes position to style renderer', () => { expect(renderer.renderScript.firstCall.args[1]).to.equal(widget.name) }) it('passes background to style renderer', () => { expect(renderer.renderScript.firstCall.args[2]).to.equal(options) }) }) describe('rendered model', () => { it('outputs model for renderer', () => { expect(renderModel).to.equal({ id: widget.id, name: widget.name, componentPath: widget.componentPath, markup: 'html', css: 'css', js: 'js' }) }) }) }) }) ================================================ FILE: packages/core/src/widget-binder/index.js ================================================ 'use strict' module.exports = require('./widget-binder') ================================================ FILE: packages/core/src/widget-binder/widget-binder.js ================================================ 'use strict' const Widget = require('../widget') const EventEmitter = require('events') const transformLoader = require('../transform-loader') const widgetDatasourceBinding = require('../widget-datasource-binding') function fetchDatasource (datasources, datasourceId) { const loopbackDatasource = { emitter: new EventEmitter() } return datasources[datasourceId] || loopbackDatasource } exports.load = function (dashboard, widgets = [], datasources = {}) { return widgets.reduce((curr, descriptor) => { const { name, position, background, datasource: datasourceId, transformations, widget: widgetPath, options } = descriptor const widget = Widget.create(widgetPath, { position, background, options }) const datasource = fetchDatasource(datasources, datasourceId) widget.register(datasource.emitter) const transforms = transformations ? transformLoader.load(name, transformations) : [] widgetDatasourceBinding.bindEvent(dashboard, widget, datasource, transforms) datasource.emitter.on('plugin', (eventName, data) => { dashboard.emit(eventName, data) }) curr[widget.id] = widget return curr }, {}) } ================================================ FILE: packages/core/src/widget-binder/widget-binder.spec.js ================================================ 'use strict' const { load } = require('.') const { expect } = require('code') const { stub } = require('sinon') const Widget = require('../widget') const widgetDatasourceBinding = require('../widget-datasource-binding') const EventEmitter = require('events') const transformLoader = require('../transform-loader') describe('widget-binder', () => { context('no widgets specified', () => { it('has empty widget list', () => { const widgets = load({}, [], {}) expect(widgets).to.equal({}) }) }) context('widget specified without datasource', () => { let result let stubWidget beforeEach(() => { const widgets = [{}] stubWidget = { register: stub() } stub(Widget, 'create').returns(stubWidget) stub(widgetDatasourceBinding, 'bindEvent') result = load({}, widgets, {}) }) afterEach(() => { widgetDatasourceBinding.bindEvent.restore() Widget.create.restore() }) it('returns a list of initialised widgets', () => { const widgetIds = Object.keys(result) expect(widgetIds).to.have.length(1) }) it('widget is registered', () => { expect(stubWidget.register.callCount).to.equal(1) }) it('loopback datasource is wired up', () => { expect(stubWidget.register.firstCall.args[0]).to.be.an.instanceof(EventEmitter) }) }) context('widget and datasource specified', () => { const datasources = { xyz: { emitter: new EventEmitter() } } let stubWidget beforeEach(() => { const widgets = [{ datasource: 'xyz' }] stubWidget = { register: stub() } stub(Widget, 'create').returns(stubWidget) stub(widgetDatasourceBinding, 'bindEvent') load({}, widgets, datasources) }) afterEach(() => { widgetDatasourceBinding.bindEvent.restore() Widget.create.restore() }) it('widget is registered', () => { expect(stubWidget.register.callCount).to.equal(1) }) it('datasource is wired up', () => { expect(stubWidget.register.firstCall.args[0]).to.equal(datasources.xyz.emitter) }) }) context('loads transformations from configuration', () => { let stubWidget const widgets = [{ name: 'xyz', transformations: [{ transformer: 'xxx', options: { foo: 'bar' } }] }] beforeEach(() => { stubWidget = { register: stub() } stub(Widget, 'create').returns(stubWidget) stub(transformLoader, 'load').returns([{ baz: 'qux' }]) stub(widgetDatasourceBinding, 'bindEvent') load({}, widgets, {}) }) afterEach(() => { transformLoader.load.restore() widgetDatasourceBinding.bindEvent.restore() Widget.create.restore() }) it('widget is registered', () => { expect(stubWidget.register.callCount).to.equal(1) }) it('transformations are loaded', () => { expect(transformLoader.load.callCount).to.equal(1) }) it('transformation loader gets widget name', () => { expect(transformLoader.load.firstCall.args[0]).to.equal('xyz') }) it('transformation loader gets transformation configuration', () => { expect(transformLoader.load.firstCall.args[1]).to.equal(widgets[0].transformations) }) it('calls bindEvent with transformer map', () => { expect(widgetDatasourceBinding.bindEvent.firstCall.args[3]).to.equal([{ baz: 'qux' }]) }) }) context('plugin event fired', () => { const dashboard = { emit: null } beforeEach(() => { const widgets = [{ datasource: 'xyz' }] const datasources = { xyz: { emitter: new EventEmitter() } } stub(Widget, 'create').returns({ register: stub() }) dashboard.emit = stub() load(dashboard, widgets, datasources) datasources.xyz.emitter.emit('plugin', 'xyzzy', 'abcde') }) afterEach(() => { Widget.create.restore() }) it('plugin event from widget causes dashboard emit', () => { expect(dashboard.emit.callCount).to.equal(1) }) it('dashboard plugin event has correct name', () => { expect(dashboard.emit.firstCall.args[0]).to.equal('xyzzy') }) it('dashboard plugin event has correct data', () => { expect(dashboard.emit.firstCall.args[1]).to.equal('abcde') }) }) }) ================================================ FILE: packages/core/src/widget-datasource-binding/index.js ================================================ 'use strict' module.exports = require('./widget-datasource-binding') ================================================ FILE: packages/core/src/widget-datasource-binding/widget-datasource-binding.js ================================================ 'use strict' const dashboardEvent = require('../dashboard-event') function transform (data, transformers) { return transformers.reduce((current, next) => { return next.transform(current) }, data) } exports.bindEvent = function (dashboard, widget, datasource, transformers) { datasource.emitter.on('update', value => { const event = `${widget.id}:update` const hasTransformers = !!(transformers && transformers.length) const transformed = hasTransformers ? transform(value, transformers) : value const result = widget.update(transformed) const payload = dashboardEvent.build(result) dashboard.emit(event, payload) }) } ================================================ FILE: packages/core/src/widget-datasource-binding/widget-datasource-binding.spec.js ================================================ 'use strict' const widgetDatasourceBinder = require('.') const { stub } = require('sinon') const { expect } = require('code') const EventEmitter = require('events') describe('widget-datasource-binding', () => { context('datasource has emitter', () => { const widget = {} const dashboard = {} it('event is bound', () => { const datasource = { emitter: { on: stub() } } widgetDatasourceBinder.bindEvent(dashboard, widget, datasource, []) expect(datasource.emitter.on.callCount).to.equal(1) }) }) context('widget emits data', () => { const rawData = { user: { firstName: 'Alfred', lastName: 'Wilks' } } const widget = { id: 'abc', update: stub().returns(rawData) } const dashboard = { emit: stub() } before(() => { const emitter = new EventEmitter() const datasource = { emitter } widgetDatasourceBinder.bindEvent(dashboard, widget, datasource, []) dashboard.emit = stub() datasource.emitter.emit('update') }) it('dashboard event is emitted', () => { expect(dashboard.emit.callCount).to.equal(1) }) it('emitted event has widget id', () => { expect(dashboard.emit.firstCall.args[0]).to.equal('abc:update') }) it('emitted event has data', () => { expect(dashboard.emit.firstCall.args[1].data).to.equal(rawData) }) it('emitted event has metaData', () => { expect(dashboard.emit.firstCall.args[1].meta).to.include('updated') }) }) context('output is transformed', () => { const widgetOutput = { fullName: 'Alfred Wilks' } const transformedData = { foo: 'bar' } const transformers = [{ transform: stub().returns(transformedData) }] const widget = { update: stub() .withArgs(transformedData) .returns(widgetOutput) } const dashboard = { emit: stub() } const datasource = { emitter: new EventEmitter() } before(() => { widgetDatasourceBinder.bindEvent(dashboard, widget, datasource, transformers) dashboard.emit = stub() datasource.emitter.emit('update') }) it('dashboard event is emitted', () => { expect(dashboard.emit.callCount).to.equal(1) }) it('emitted event has transformed data', () => { expect( dashboard.emit.firstCall.args[1].data ).to.equal(widgetOutput) }) }) }) ================================================ FILE: packages/core/test/resources/widgets/broken/package.json ================================================ { "name": "vudash-widget-broken", "main": "widget.js" } ================================================ FILE: packages/core/test/resources/widgets/broken/widget.js ================================================ 'use strict' exports.register = () => { return {} } ================================================ FILE: packages/core/test/resources/widgets/configurable/component.html ================================================

hi

================================================ FILE: packages/core/test/resources/widgets/configurable/package.json ================================================ { "name": "vudash-widget-configurable", "main": "widget.js", "vudash": { "component": "./component.html" } } ================================================ FILE: packages/core/test/resources/widgets/configurable/widget.js ================================================ 'use strict' const { Promise } = require('bluebird') const defaults = { foo: 'bar', working: false } class ExampleWidget { constructor (options) { this.options = Object.assign({}, defaults, options) } update (data) { return Promise.resolve(this.options) } } exports.register = (options) => { return new ExampleWidget(options) } ================================================ FILE: packages/core/test/resources/widgets/example/markup.html ================================================

Hello

================================================ FILE: packages/core/test/resources/widgets/example/package.json ================================================ { "name": "vudash-widget-example", "main": "widget.js", "vudash": { "component": "./markup.html" } } ================================================ FILE: packages/core/test/resources/widgets/example/widget.js ================================================ 'use strict' const { Promise } = require('bluebird') class ExampleWidget { update (data) { return Promise.resolve({x: 'y'}) } } exports.register = () => { return new ExampleWidget() } ================================================ FILE: packages/core/test/resources/widgets/missing/package.json ================================================ { "name": "vudash-widget-missing", "main": "widget.js" } ================================================ FILE: packages/core/test/resources/widgets/missing/widget.js ================================================ 'use strict' exports.register = () => { return {} } ================================================ FILE: packages/core/test/util/dashboard.builder.js ================================================ 'use strict' const WidgetBuilder = require('./widget.builder') class DashboardBuilder { constructor () { this.overrides = { widgets: [] } } withName (name = 'Some Dashboard') { this.overrides.name = name return this } addDatasource (moduleName, options) { this.overrides.datasources = this.datasources || {} this.overrides.datasources[moduleName] = { module: moduleName, options } return this } addWidget (widget = WidgetBuilder.create().build()) { this.overrides.widgets.push(widget) return this } build () { return Object.assign({}, { layout: { rows: 4, columns: 5 } }, this.overrides) } } module.exports = { create: () => { return new DashboardBuilder() } } ================================================ FILE: packages/core/test/util/datasource.builder.js ================================================ 'use strict' class Datasource { constructor (options) { this.options = options } fetch () { return this.options } } class DatasourceBuilder { constructor () { this.ds = Datasource } build () { return this.ds } } module.exports = { create: () => { return new DatasourceBuilder() } } ================================================ FILE: packages/core/test/util/widget.builder.js ================================================ 'use strict' const { Promise } = require('bluebird') class WidgetBuilder { constructor () { this.widget = this._createWidgetModule() this.position = { x: 0, y: 0, w: 0, h: 0 } } _createWidgetModule (internals = { schedule: 1000, job: () => { return Promise.resolve({}) } }) { const Module = class MyWidget { register (options) { return Object.assign({}, internals, { config: options }) } } const component = './src/component.html' const name = 'VudashMyWidget' return { Module, component, name } } withJob (job = Promise.resolve({}), schedule = 1000) { this.widget = this._createWidgetModule({ job, schedule }) return this } withOptions (options = {}) { this.options = options return this } withWidget (widget) { this.widget = widget return this } build () { return { position: this.position, widget: this.widget, options: this.options } } } module.exports = { create: () => { return new WidgetBuilder() } } ================================================ FILE: packages/datasource-google-sheets/datasource.js ================================================ 'use strict' const GoogleSheetsTransport = require('./src/google-sheets-transport') const { validation } = require('./src/config-validator') exports.validation = validation exports.register = function (options) { return new GoogleSheetsTransport(options) } ================================================ FILE: packages/datasource-google-sheets/datasource.spec.js ================================================ 'use strict' const datasource = require('./datasource') const { expect } = require('code') describe('datasource-google-sheets/datasource', () => { it('has register method', () => { expect(datasource.register).to.exist().and.to.be.a.function() }) it('exports validation', () => { expect(datasource.validation).to.exist() }) }) ================================================ FILE: packages/datasource-google-sheets/package.json ================================================ { "name": "@vudash/datasource-google-sheets", "version": "9.9.0", "description": "Google Sheets datasource for Vudash", "main": "datasource.js", "scripts": { "lint": "../../node_modules/.bin/standard", "test": "NODE_PATH=src:test ../../node_modules/.bin/mocha ../../test/unit.lab.js", "link": "npm link" }, "author": "Antony Jones", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/vudash/vudash" }, "publishConfig": { "access": "public" }, "dependencies": { "bluebird": "^3.4.7", "hoek": "^4.1.0", "joi": "^10.2.1", "spreadsheet-to-json": "^1.0.5" }, "standard": { "globals": [ "describe", "context", "it", "before", "after", "beforeEach", "afterEach" ] } } ================================================ FILE: packages/datasource-google-sheets/src/config-validator/config-validator.spec.js ================================================ 'use strict' const Joi = require('joi') const configUtil = require('../../test/config.util') const sinon = require('sinon') const validator = require('.') const { expect } = require('code') describe('datasource-google-sheets.config-validator', () => { const sandbox = sinon.sandbox.create() afterEach(() => { sandbox.restore() }) it('With invalid config', () => { const { error } = Joi.validate({}, validator.validation) expect(error).to.be.an.error(/fails because/) }) it('With valid single-cell config', () => { const { error } = Joi.validate(configUtil.getSingleCellConfig(), validator.validation) expect(error).not.to.exist() }) it('With valid range config', () => { const { error } = Joi.validate(configUtil.getRangeConfig(), validator.validation) expect(error).not.to.exist() }) it('Invalid credentials file', () => { const credentials = 'xxx:yyy' const config = configUtil.getSingleCellConfig(credentials) const { error } = Joi.validate(config, validator.validation) expect(error).to.be.an.error() }) }) ================================================ FILE: packages/datasource-google-sheets/src/config-validator/index.js ================================================ 'use strict' const Joi = require('joi') class ConfigValidator { get inlineCredentialsValidation () { return Joi.object().required().keys({ type: Joi.string().required().only('service_account').description('Key type'), project_id: Joi.string().required().description('Project name'), private_key_id: Joi.string().required().description('Project name'), private_key: Joi.string().required().description('Project name'), client_email: Joi.string().email().required().description('Project name'), client_id: Joi.string().required().description('Project name'), auth_uri: Joi.string().uri().required().description('Auth Uri'), token_uri: Joi.string().uri().required().description('Token Uri'), auth_provider_x509_cert_url: Joi.string().uri().required().description('Auth provider x509 certificate url'), client_x509_cert_url: Joi.string().uri().required().description('Client x509 certificate url') }) } get fileCredentialsValidation () { return Joi.string().regex(/^file:.*/).required().description('Filesystem path to credentials json, prefixed with "file:"') } get validation () { return { sheet: Joi.string().required().description('Sheet id'), tab: Joi.string().required().description('Tab Name'), columns: this.columnSchema, rows: this.rowSchema, credentials: Joi.alternatives([ this.inlineCredentialsValidation, this.fileCredentialsValidation ]).required().description('Service account credentials') } } get columnSchema () { return Joi.alternatives([ Joi.string().required().description('Column heading'), Joi.array().required().description('Array of column headings') ]).required().description('Column or an array of Columns to retrieve') } get rowSchema () { return Joi.alternatives([ Joi.number().required().description('Row number'), Joi.object().keys({ from: Joi.number().required().description('First row in range'), to: Joi.number().required().description('Last row in range') }).required().description('Range selector') ]).required().description('Row number to retrieve, or object with "from" and "to" row numbers to select a range') } } module.exports = new ConfigValidator() ================================================ FILE: packages/datasource-google-sheets/src/google-sheets-transport/google-sheets-transport.spec.js ================================================ 'use strict' const GoogleSheetsTransport = require('.') const configUtil = require('../../test/config.util') const sinon = require('sinon') const { expect } = require('code') describe('google-sheets-transport', () => { const sandbox = sinon.sandbox.create() afterEach(() => { sandbox.restore() }) context('External credentials', () => { it('Loads credentials from disk', () => { const contents = require('../../test/example.credentials.test.json') const credentials = 'file:../../test/example.credentials.test.json' const config = configUtil.getSingleCellConfig(credentials) const transport = new GoogleSheetsTransport(config) expect(transport.credentials).to.equal(contents) }) it('File not found', () => { expect(() => { return new GoogleSheetsTransport(configUtil.getSingleCellConfig('file:some-nonexistent-file')) }).to.throw(Error, /some-nonexistent-file" as it could not be found/) }) it('Validates credentials loaded from disk', () => { expect(() => { return new GoogleSheetsTransport(configUtil.getSingleCellConfig('file:../../test/example.invalid-credentials.test.json')) }).to.throw(Error, /fails because/) }) it('Read credentials from file', () => { const credentials = 'file:../../test/example.credentials.test.json' const transport = new GoogleSheetsTransport(configUtil.getSingleCellConfig(credentials)) expect(transport).to.be.an.instanceOf(GoogleSheetsTransport) }) }) it('Fetches single cell sheet data', () => { const config = configUtil.getSingleCellConfig() const transport = new GoogleSheetsTransport(config) sinon.stub(transport, 'extract') .withArgs({ spreadsheetKey: config.sheet, credentials: config.credentials, sheetsToExtract: [config.tab] }).returns(Promise.resolve({ [config.tab]: [ { [config.columns]: 'myValue' } ] })) return transport.fetch().then((result) => { expect(transport.extract.callCount).to.equal(1) expect(result).to.equal('myValue') }) }) it('Fetches range of sheet data', () => { const config = configUtil.getRangeConfig() const transport = new GoogleSheetsTransport(config) sinon.stub(transport, 'extract') .withArgs({ spreadsheetKey: config.sheet, credentials: config.credentials, sheetsToExtract: [config.tab] }).returns(Promise.resolve({ [config.tab]: [ { [config.columns[0]]: 'cell0,0', [config.columns[1]]: 'cell0,1' }, { [config.columns[0]]: 'cell1,0', [config.columns[1]]: 'cell1,1' }, { [config.columns[0]]: 'cell2,0', [config.columns[1]]: 'cell2,1' } ] })) return transport.fetch().then((result) => { expect(transport.extract.callCount).to.equal(1) expect(result).to.equal([ ['cell0,0', 'cell0,1'], ['cell1,0', 'cell1,1'], ['cell2,0', 'cell2,1'] ]) }) }) it('Fetches single column of sheet data', () => { const config = configUtil.getSingleColumnConfig() const transport = new GoogleSheetsTransport(config) sinon.stub(transport, 'extract') .withArgs({ spreadsheetKey: config.sheet, credentials: config.credentials, sheetsToExtract: [config.tab] }).returns(Promise.resolve({ [config.tab]: [ { [config.columns[0]]: 'cell0,0', unused: 'cell0,1' }, { [config.columns[0]]: 'cell1,0', unused: 'cell1,1' }, { [config.columns[0]]: 'cell2,0', unused: 'cell2,1' } ] })) return transport.fetch().then((result) => { expect(transport.extract.callCount).to.equal(1) expect(result).to.equal([ 'cell0,0', 'cell1,0', 'cell2,0' ]) }) }) }) ================================================ FILE: packages/datasource-google-sheets/src/google-sheets-transport/index.js ================================================ 'use strict' const path = require('path') const fs = require('fs') const { Promise } = require('bluebird') const spreadsheetToJson = require('spreadsheet-to-json') const configValidator = require('../config-validator') class GoogleSheetsTransport { constructor (options) { this.config = options this.extract = Promise.promisify(spreadsheetToJson.extractSheets) this.credentials = options.credentials if (typeof this.credentials === 'string') { this.credentials = this.loadCredentialsFromDisk(this.credentials) } } static get widgetValidation () { return configValidator.widgetValidation } loadCredentialsFromDisk () { if (this.credentials.indexOf('file:') !== 0) { throw new Error('File credentials must be prefixed with "file:" and reference a local json service account credentials file') } const fileName = this.credentials.split(':')[1] const resolvedFile = path.join(__dirname, fileName) if (!fs.existsSync(resolvedFile)) { throw new Error(`Credentials could not be loaded from "${resolvedFile}" as it could not be found`) } const credentials = require(resolvedFile) this.validate(configValidator.inlineCredentialsValidation, credentials) return credentials } validate (schema, credentials) { schema.validate(credentials, (err) => { if (err) { throw err } }) } fetch () { const conf = this.config return this.extract({ spreadsheetKey: conf.sheet, credentials: conf.credentials, sheetsToExtract: [conf.tab] }).then((data) => { return this._extractCellData(data) }) } _extractCellData (data) { const conf = this.config const tab = data[conf.tab] const multiCell = (typeof conf.rows === 'object' || Array.isArray(conf.columns)) return multiCell ? this._toMatrix(tab) : this._extractCellValue(tab) } _toMatrix (tab) { const conf = this.config return tab.map((row) => { const columns = Object.keys(row).filter((col) => { return conf.columns.includes(col) }) const values = columns.map((column) => { return row[column] }) return values.length > 1 ? values : values[0] }) } _extractCellValue (tab) { const conf = this.config return tab[conf.rows - 1][conf.columns] } } module.exports = GoogleSheetsTransport ================================================ FILE: packages/datasource-google-sheets/test/config.util.js ================================================ 'use strict' function getConfig (credentialsOverride) { const uri = 'http://a.b' const credentials = credentialsOverride || { type: 'service_account', project_id: 'd', private_key_id: 'a', private_key: 'b', client_email: 'p@x.y', client_id: '123', auth_uri: uri, token_uri: uri, auth_provider_x509_cert_url: uri, client_x509_cert_url: uri } return { sheet: 'x', tab: 'y', credentials } } exports.getRangeConfig = function (credentialsOverride) { const baseConfig = getConfig(credentialsOverride) return Object.assign({ columns: ['a', 'b'], rows: { from: 1, to: 3 } }, baseConfig) } exports.getSingleColumnConfig = function (credentialsOverride) { const baseConfig = getConfig(credentialsOverride) return Object.assign({ columns: ['a'], rows: { from: 1, to: 3 } }, baseConfig) } exports.getSingleCellConfig = function (credentialsOverride) { const baseConfig = getConfig(credentialsOverride) return Object.assign({ columns: 'z', rows: 1 }, baseConfig) } ================================================ FILE: packages/datasource-google-sheets/test/example.credentials.test.json ================================================ {"type":"service_account","project_id":"d","private_key_id":"a","private_key":"b","client_email":"p@x.y","client_id":"123","auth_uri":"http://x.y","token_uri":"http://x.y","auth_provider_x509_cert_url":"http://x.y","client_x509_cert_url":"http://x.y"} ================================================ FILE: packages/datasource-google-sheets/test/example.invalid-credentials.test.json ================================================ {} ================================================ FILE: packages/datasource-random/datasource.js ================================================ 'use strict' const RandomTransport = require('./src/random-transport') const { validation } = require('./src/datasource-validation') exports.validation = validation exports.register = function (options) { return new RandomTransport(options) } ================================================ FILE: packages/datasource-random/datasource.spec.js ================================================ 'use strict' const RandomTransport = require('./src/random-transport') const { expect } = require('code') describe('plugin', () => { const MT_SEED = 'a' const AN_EMAIL = 'urijihed@ocekode.lr' it('Allows method to be specified', () => { const transport = new RandomTransport({ method: 'email' }, MT_SEED) return transport.fetch().then((email) => { expect(email).to.equal(AN_EMAIL) }) }) it('Unknown method returns an error', () => { const transport = new RandomTransport({ method: 'abcdefg' }, MT_SEED) expect(transport.fetch.bind(transport)).to.throw(Error, /is not a known chance method/) }) it('Passes options to method', () => { const options = { domain: 'xyz.com' } const transport = new RandomTransport({ method: 'email', options }, MT_SEED) return transport.fetch().then((email) => { expect(email).to.endWith(options.domain) }) }) it('If method is not defaulted, options are not defaulted', () => { const transport = new RandomTransport({ method: 'email' }, MT_SEED) return transport.fetch().then((email) => { expect(email).to.equal(AN_EMAIL) }) }) it('Allow multiple arguments to options', () => { const options = ['chance.integer', 12, { min: 15, max: 32 }] const transport = new RandomTransport({ method: 'n', options }, MT_SEED) return transport.fetch().then((numbers) => { expect(numbers).to.be.an.array() expect(numbers.length).to.equal(12) for (const val of numbers) { expect(val, val).to.be.between(14, 33) } }) }) it('Validate for unknown method references', () => { const options = ['chance.abcde'] const transport = new RandomTransport({ method: 'n', options }, MT_SEED) expect(transport.fetch.bind(transport)).to.throw(Error, /is not a known chance method/) }) it('Allow shorthand chance method names', () => { const options = ['integer', 12, { min: 0, max: 1 }] const transport = new RandomTransport({ method: 'n', options }, MT_SEED) expect(transport.fetch.bind(transport)).not.to.throw() }) }) ================================================ FILE: packages/datasource-random/package.json ================================================ { "name": "@vudash/datasource-random", "version": "9.9.0", "description": "Random Datasource for Vudash", "main": "datasource.js", "scripts": { "test": "NODE_PATH=src:test ../../node_modules/.bin/mocha ../../test/unit.lab.js", "link": "npm link", "lint": "../../node_modules/.bin/standard" }, "keywords": [ "vudash", "transport", "datasource", "chance", "random", "values" ], "repository": { "type": "git", "url": "https://github.com/vudash/vudash" }, "author": "Antony Jones", "license": "MIT", "publishConfig": { "access": "public" }, "dependencies": { "bluebird": "^3.4.7", "chance": "^1.0.4", "joi": "^13.0.2" }, "standard": { "globals": [ "describe", "context", "it", "before", "after", "beforeEach", "afterEach" ] } } ================================================ FILE: packages/datasource-random/src/datasource-validation/index.js ================================================ 'use strict' const Joi = require('joi') module.exports.validation = { method: Joi.string().optional().description('Chance method name'), options: Joi.alternatives([ Joi.object().optional().description('Chance method options'), Joi.array().optional().description('Chance method arguments') ]) } ================================================ FILE: packages/datasource-random/src/random-transport/index.js ================================================ 'use strict' const Chance = require('chance') const Promise = require('bluebird').Promise class RandomTransport { constructor (options, seed = new Date().getTime()) { this.config = options this.chance = new Chance(seed) } prepareOptions () { const options = this.config.method ? this.config.options : { min: 0, max: 999 } const args = Array.isArray(options) ? options : [options] if (this.config.method === 'n') { const functionReference = options[0] const referenceParts = functionReference.split('.') const functionName = referenceParts.length > 1 ? referenceParts[1] : referenceParts[0] this.validateMethod(functionName) args[0] = this.chance[functionName] } return { method: this.config.method || 'natural', options: args } } validateMethod (method) { if (typeof this.chance[method] !== 'function') { throw new Error(`${method} is not a known chance method`) } } fetch () { const conf = this.prepareOptions() this.validateMethod(conf.method) const result = this.chance[conf.method].apply(this.chance, conf.options) return Promise.resolve(result) } } module.exports = RandomTransport ================================================ FILE: packages/datasource-random/src/random-transport/index.spec.js ================================================ 'use strict' const { expect } = require('code') const RandomTransport = require('.') describe('random-transport', () => { const MT_SEED = 'a' it('Returns a random number', () => { const transport = new RandomTransport({ config: {} }, MT_SEED) return transport.fetch().then((number) => { expect(number).to.be.above(0).and.below(1000) }) }) }) ================================================ FILE: packages/datasource-rest/datasource.js ================================================ 'use strict' const RestTransport = require('./src/rest-transport') const { validation } = require('./src/datasource-validation') exports.validation = validation exports.register = function (options) { return new RestTransport(options) } ================================================ FILE: packages/datasource-rest/package.json ================================================ { "name": "@vudash/datasource-rest", "version": "9.9.0", "description": "REST Datasource for Vudash", "main": "datasource.js", "scripts": { "test": "NODE_PATH=src:test ../../node_modules/.bin/mocha ../../test/unit.lab.js", "link": "npm link", "lint": "../../node_modules/.bin/standard" }, "author": "Antony Jones", "license": "MIT", "publishConfig": { "access": "public" }, "dependencies": { "got": "^6.7.1", "hoek": "^4.1.0", "joi": "^10.6.0" }, "standard": { "globals": [ "describe", "context", "it", "before", "after", "beforeEach", "afterEach" ] } } ================================================ FILE: packages/datasource-rest/src/datasource-validation/datasource-validation.js ================================================ 'use strict' const Joi = require('joi') exports.validation = { url: Joi.string().description('Url to call'), method: Joi.string().optional().default('get').only('get', 'post', 'put', 'options', 'delete', 'head').description('Http Method'), headers: Joi.object().optional().description('additional headers'), body: Joi.object().optional().description('request body'), graph: Joi.string().optional().description('Graph expression (json path) to reach json values') } ================================================ FILE: packages/datasource-rest/src/datasource-validation/datasource-validation.spec.js ================================================ 'use strict' const Joi = require('joi') const { validation } = require('.') const { expect } = require('code') describe('datasource-rest/datasource-validation', () => { it('defaults method to get', () => { const { value } = Joi.validate({}, validation) expect(value.method).to.equal('get') }) }) ================================================ FILE: packages/datasource-rest/src/datasource-validation/index.js ================================================ 'use strict' module.exports = require('./datasource-validation') ================================================ FILE: packages/datasource-rest/src/rest-transport/index.js ================================================ 'use strict' const got = require('got') const { applyToDefaults } = require('hoek') const pkg = require('../../package.json') const internals = { prepareRequest (config) { const options = applyToDefaults({ json: true, headers: { 'user-agent': `vudash/${pkg.version} (https://github.com/vudash/vudash)`, 'content-type': 'application/json' } }, config) if (config.body) { options.body = JSON.stringify(config.body) } return options } } class RestTransport { constructor (options) { this.config = options } fetch () { const options = internals.prepareRequest(this.config) return got[this.config.method](this.config.url, options) .then(({ body }) => body) } } module.exports = RestTransport ================================================ FILE: packages/datasource-rest/src/rest-transport/rest-transport.spec.js ================================================ 'use strict' const nock = require('nock') const RestTransport = require('.') const { expect } = require('code') describe('transports.rest', () => { const host = 'http://example.net' const scenarios = [ { method: 'get', host, path: '/' }, { method: 'post', host, path: '/some/path' }, { method: 'post', host, path: '/some/path', body: { a: 'b', one: 2, true: false } }, { method: 'get', host, path: '/lala', query: { foot: 'bart' } }, { method: 'post', host, path: '/some/path', body: { a: 'b', one: 2, true: false }, query: { foot: 'bath' } } ] afterEach(() => { nock.cleanAll() }) scenarios.forEach(scenario => { it(`#fetch() with ${scenario.method} ${scenario.host}`, async () => { const config = Object.assign({}, scenario) config.url = `${config.host}${config.path}` delete config.host delete config.path const transport = new RestTransport(config) nock(scenario.host)[scenario.method](scenario.path, scenario.body) .query(scenario.query) .reply(200, { a: 'b' }) const body = await transport.fetch() expect(nock.isDone(), nock.pendingMocks()).to.equal(true) expect(body.a).to.equal('b') }) }) context('Json body', () => { const body = { i: { love: { animals: 'dogs' } } } const options = { method: 'get', url: 'http://example.com/some/stuff' } beforeEach(() => { nock('http://example.com') .get('/some/stuff') .reply(200, body) }) afterEach(() => { nock.cleanAll() }) it('returns full body', async () => { const transport = new RestTransport(options) const value = await transport.fetch() expect(value).to.equal(body) }) }) }) ================================================ FILE: packages/datasource-value/datasource.js ================================================ 'use strict' const ValueTransport = require('./src/value-transport') const { validation } = require('./src/datasource-validation') exports.validation = validation exports.register = function (options) { return new ValueTransport(options) } ================================================ FILE: packages/datasource-value/package.json ================================================ { "name": "@vudash/datasource-value", "version": "9.9.0", "description": "Value datasource for Vudash dashboards", "scripts": { "test": "NODE_PATH=src:test ../../node_modules/.bin/mocha ../../test/unit.lab.js", "link": "npm link", "lint": "../../node_modules/.bin/standard" }, "main": "datasource.js", "repository": { "type": "git", "url": "https://github.com/vudash/vudash" }, "author": "Antony Jones", "license": "MIT", "publishConfig": { "access": "public" }, "dependencies": { "bluebird": "^3.4.7", "joi": "^10.2.2" }, "standard": { "globals": [ "describe", "context", "it", "before", "after", "beforeEach", "afterEach" ] } } ================================================ FILE: packages/datasource-value/src/datasource-validation/datasource-validation.spec.js ================================================ 'use strict' const Joi = require('joi') const { validation } = require('.') const { expect } = require('code') describe('value', () => { context('Widget Validation', () => { it('Requires a value to be passed in config', () => { const { error } = Joi.validate({}, validation) expect(error).to.be.an.error(/"value" is required/) }) const valueTypes = [ 'string', 123, Date.now(), { a: 'x' }, () => {} ] valueTypes.forEach((value) => { it(`Allows value ${typeof value} to be passed in`, () => { const { error } = Joi.validate({ value }, validation) expect(error).not.to.exist() }) }) }) }) ================================================ FILE: packages/datasource-value/src/datasource-validation/index.js ================================================ 'use strict' const Joi = require('joi') module.exports.validation = { value: Joi.any().required().description('Value to return') } ================================================ FILE: packages/datasource-value/src/value-transport/index.js ================================================ 'use strict' const Promise = require('bluebird').Promise class ValueTransport { constructor (options) { this.config = options } fetch () { return Promise.resolve(this.config.value) } } module.exports = ValueTransport ================================================ FILE: packages/datasource-value/src/value-transport/index.spec.js ================================================ 'use strict' const ValueTransport = require('.') const { expect } = require('code') describe('value', () => { it('Returns value passed in config', () => { const config = { value: 'abc' } const transport = new ValueTransport(config) return transport.fetch().then((value) => { expect(value).to.equal('abc') }) }) }) ================================================ FILE: packages/transformer-jq/index.js ================================================ 'use strict' module.exports = require('./lib/transformer') ================================================ FILE: packages/transformer-jq/lib/transformer.js ================================================ 'use strict' const { jq } = require('jq.node') const { Promise } = require('bluebird') class JqTransformer { constructor (transformation) { this.transformation = transformation } transform (data) { const input = JSON.stringify(data) return new Promise((resolve, reject) => { jq(input, this.transformation.value, {}, function (err, result) { if (err) { return reject(err) } const json = JSON.parse(result) return resolve(json) }) }) } } module.exports = JqTransformer ================================================ FILE: packages/transformer-jq/lib/transformer.spec.js ================================================ 'use strict' const JqTransformer = require('./transformer') const { expect } = require('code') describe('transformer', () => { const usersIn = { jeff: { email: 'jeff@example.net' }, joe: { phone: '+447721981546' }, emma: { email: 'emma@example.com' }, grayson: { email: 'wailo@example.net' } } const groupsOut = { 'example.net': [ { email: 'jeff@example.net' }, { email: 'wailo@example.net' } ], 'example.com': [ { email: 'emma@example.com' } ] } it('transforms value', async () => { const value = 'filter(has("email")) | groupBy(flow(get("email"), split("@"), get(1)))' const transformer = new JqTransformer({ value }) const out = await transformer.transform(usersIn) expect(out).to.equal(groupsOut) }) }) ================================================ FILE: packages/transformer-jq/package.json ================================================ { "name": "@vudash/transformer-jq", "version": "9.9.0", "description": "Vudash data transformer which uses jq to make complex json transformations", "main": "transformer.js", "author": "Antony Jones", "license": "MIT", "scripts": { "lint": "../../node_modules/.bin/standard", "test": "../../node_modules/.bin/mocha ../../test/unit.lab.js" }, "dependencies": { "bluebird": "^3.5.1", "jq.node": "^2.1.1" }, "publishConfig": { "access": "public" } } ================================================ FILE: packages/transformer-map/package.json ================================================ { "name": "@vudash/transformer-map", "version": "9.9.0", "description": "Vudash data transformer which maps from one structure to another", "main": "transformer.js", "author": "Antony Jones", "license": "MIT", "scripts": { "lint": "../../node_modules/.bin/standard" }, "dependencies": { "reorient": "^2.1.0" }, "publishConfig": { "access": "public" } } ================================================ FILE: packages/transformer-map/transformer.js ================================================ 'use strict' const { transform } = require('reorient') class MapTransformer { constructor (schema) { this.schema = schema } transform (data) { const { value } = transform(data, this.schema) return value } } module.exports = MapTransformer ================================================ FILE: packages/widget-chart/package.json ================================================ { "name": "@vudash/widget-chart", "version": "9.9.0", "description": "Chart widget for Vudash", "main": "./src/server", "author": "Antony Jones", "license": "MIT", "dependencies": { "bluebird": "^3.5.0", "chartist": "^0.11.0" }, "vudash": { "component": "./src/client/markup.html" }, "scripts": { "test": "NODE_PATH=src:test ../../node_modules/.bin/mocha ../../test/unit.lab.js", "link": "npm link", "lint": "../../node_modules/.bin/standard" }, "repository": { "type": "git", "url": "https://github.com/vudash/vudash" }, "keywords": [ "vudash", "travis", "travis-ci", "travisci", "widget", "dashboard", "dashing", "build" ], "standard": { "globals": [ "describe", "context", "it", "before", "after", "beforeEach", "afterEach" ] } } ================================================ FILE: packages/widget-chart/src/client/chart-options.js ================================================ 'use strict' const ChartTypes = { 'line': { constructorName: 'Line', options: { chartPadding: { right: 60 } } }, 'pie': { constructorName: 'Pie' }, 'bar': { constructorName: 'Bar' }, 'donut': { constructorName: 'Pie', options: { donut: true, donutWidth: 60, donutSolid: true } } } exports.get = function (chartType) { return ChartTypes[chartType] || ChartTypes.line } ================================================ FILE: packages/widget-chart/src/client/markup.html ================================================
{{ config.description }}
================================================ FILE: packages/widget-chart/src/server/index.js ================================================ 'use strict' module.exports = require('./widget') ================================================ FILE: packages/widget-chart/src/server/widget.js ================================================ 'use strict' const defaults = { description: '', labels: [], type: 'line' } class ChartWidget { constructor (options) { this.config = Object.assign({}, defaults, options) } update (data) { const names = Object.keys(data) const series = names.map(names => { return data[names] }) return { series } } } exports.register = function (options) { return new ChartWidget(options) } ================================================ FILE: packages/widget-chart/src/server/widget.spec.js ================================================ 'use strict' const { register } = require('.') const { expect } = require('code') describe('widget', () => { context('Default configuration', () => { const scenarios = [ { attribute: 'description', defaultValue: '', override: 'Time taken' }, { attribute: 'labels', defaultValue: [], override: ['a', 'b', 'c'] }, { attribute: 'type', defaultValue: 'line', override: 'bar' } ] scenarios.forEach(({ attribute, defaultValue, override }) => { it(`Default ${attribute}`, () => { const model = register({}) expect(model.config[attribute]).to.equal(defaultValue) }) it(`Override ${attribute}`, () => { const model = register({ [attribute]: override }) expect(model.config[attribute]).to.equal(override) }) }) }) context('Renders data series', () => { let model before(() => { model = register({}) }) it('Renders multiple datasets', () => { const data = { incremental: [1, 2, 3, 4, 5, 6], exponential: [1, 4, 9, 16, 25, 36], incidental: [1, 2, 6, 12, 20, 30] } const result = model.update(data) expect(result.series).to.equal([ data.incremental, data.exponential, data.incidental ]) }) it('Renders simple array dataset', () => { const data = [1, 4, 9, 16, 25, 36] const result = model.update(data) expect(result.series).to.equal(data) }) }) }) ================================================ FILE: packages/widget-ci/.gitignore ================================================ node_modules *.log ================================================ FILE: packages/widget-ci/README.MD ================================================ # Vudash CI Widget Shows the status of CI builds on a [Vudash](https://npmjs.org/vudash) Dashboard Indicates project build status for a number of CI providers. Documentation has moved [to github](https://vudash.com#ci-widget) ================================================ FILE: packages/widget-ci/package.json ================================================ { "name": "vudash-widget-ci", "version": "9.9.0", "description": "A CI Widget for Vudash", "main": "./src/server", "scripts": { "test": "NODE_PATH=src:test ../../node_modules/.bin/mocha ../../test/unit.lab.js", "link": "npm link", "lint": "../../node_modules/.bin/standard" }, "repository": { "type": "git", "url": "https://github.com/vudash/vudash" }, "keywords": [ "vudash", "travis", "travis-ci", "travisci", "widget", "dashboard", "dashing", "build" ], "vudash": { "component": "./src/client/markup.html" }, "author": "Antony Jones", "license": "MIT", "dependencies": { "bluebird": "^3.4.6", "circleci": "^0.3.2", "hoek": "^4.1.0", "joi": "^9.2.0", "moment": "^2.22.0", "travis-ci": "^2.1.1" }, "standard": { "globals": [ "describe", "context", "it", "before", "after", "beforeEach", "afterEach" ] } } ================================================ FILE: packages/widget-ci/src/build-status.enum.js ================================================ class BuildStatus { static get passed () { return 'passed' } static get failed () { return 'failed' } static get unknown () { return 'unknown' } static get queued () { return 'queued' } static get running () { return 'running' } } module.exports = BuildStatus ================================================ FILE: packages/widget-ci/src/client/markup.html ================================================
{{#if icon === 'running'}} {{/if}} {{#if icon === 'passed'}} {{/if}} {{#if icon === 'failed'}} {{/if}} {{#if icon === 'queued'}} {{/if}} {{#if ['error', 'unknown'].includes(icon)}} {{/if}}
{{#if !config.hideOwner}}{{ config.user }} / {{/if}}{{ config.repo }}
================================================ FILE: packages/widget-ci/src/engines/circleci/circleci.spec.js ================================================ 'use strict' const { expect } = require('code') const CircleCI = require('circleci') const BuildStatus = require('../../build-status.enum') const Engine = require('.') const { stub } = require('sinon') describe('engines.circleci', () => { let getBranchBuildsStub context('Build Passed', () => { let status const options = { repo: 'repo', user: 'user', branch: 'branch', options: { auth: 'x' } } before(() => { const engine = new Engine(options) const circleci = new CircleCI({ auth: 'x' }) engine.circleci = circleci getBranchBuildsStub = stub(circleci, 'getBranchBuilds') getBranchBuildsStub.resolves([{ status: 'success' }]) return engine.fetchBuildStatus() .then((result) => { status = result }) }) after(() => { getBranchBuildsStub.restore() }) it('has correct repo', () => { expect(getBranchBuildsStub.firstCall.args[0].project).to.equal(options.repo) }) it('has correct user', () => { expect(getBranchBuildsStub.firstCall.args[0].username).to.equal(options.user) }) it('has correct branch', () => { expect(getBranchBuildsStub.firstCall.args[0].branch).to.equal(options.branch) }) it('fetches correct status', () => { expect(status).to.equal(BuildStatus.passed) }) }) }) ================================================ FILE: packages/widget-ci/src/engines/circleci/index.js ================================================ 'use strict' const CircleCI = require('circleci') const BuildStatus = require('../../build-status.enum') class CircleCIEngine { constructor (options) { this.circleci = new CircleCI({ auth: options.options.auth }) this.user = options.user this.repo = options.repo this.branch = options.branch this.mappings = { success: BuildStatus.passed, failed: BuildStatus.failed, fixed: BuildStatus.passed, canceled: BuildStatus.failed, infrastructure_fail: BuildStatus.failed, timedout: BuildStatus.failed, running: BuildStatus.running, queued: BuildStatus.queued, scheduled: BuildStatus.queued } } fetchBuildStatus () { return this.circleci.getBranchBuilds({ username: this.user, project: this.repo, branch: this.branch, limit: 1 }) .then((builds) => { if (builds.length < 1) { throw new Error('No builds found') } const latestBuild = builds[0] return this.mappings[latestBuild.status] || BuildStatus.unknown }) } } module.exports = CircleCIEngine ================================================ FILE: packages/widget-ci/src/engines/factory.js ================================================ const Travis = require('./travis') const CircleCI = require('./circleci') class Factory { constructor () { this.engines = { travis: Travis, circleci: CircleCI } } getEngine (engine) { return this.engines[engine] } get availableEngines () { return Object.keys(this.engines) } } module.exports = new Factory() ================================================ FILE: packages/widget-ci/src/engines/travis/index.js ================================================ 'use strict' const Travis = require('travis-ci') const Promise = require('bluebird').Promise const BuildStatus = require('../../build-status.enum') class TravisEngine { constructor (options) { this.travis = new Travis({ version: '2.0.0' }) this.user = options.user this.repo = options.repo this.branch = options.branch this.mappings = { started: BuildStatus.running, created: BuildStatus.queued, passed: BuildStatus.passed, failed: BuildStatus.failed } } fetchBuildStatus () { return new Promise((resolve, reject) => { this.travis.repos(this.user, this.repo).builds().get((err, res) => { if (err) { throw err } if (!res.builds || !res.builds.length) { reject(new Error('No builds found')) } const latestBuild = res.builds[0] resolve(this.mappings[latestBuild.state] || BuildStatus.unknown) }) }) } } module.exports = TravisEngine ================================================ FILE: packages/widget-ci/src/engines/travis/travis.spec.js ================================================ 'use strict' const { expect } = require('code') const sinon = require('sinon') const BuildStatus = require('../../build-status.enum') describe('engines.travis', () => { const Engine = require('.') it('Expects state to be returned', () => { const engine = new Engine({}) engine.travis = { repos: sinon.stub().returns({ builds: sinon.stub().returns({ get: sinon.stub().yields(null, { builds: [ { state: 'passed' } ] }) }) }) } return engine.fetchBuildStatus() .then((result) => { expect(result).to.equal(BuildStatus.passed) }) }) }) ================================================ FILE: packages/widget-ci/src/server/index.js ================================================ 'use strict' module.exports = require('./widget') ================================================ FILE: packages/widget-ci/src/server/validation.js ================================================ 'use strict' const engineFactory = require('../engines/factory') const Joi = require('joi') module.exports = { repo: Joi.string().required().description('Repository Name'), user: Joi.string().required().description('Account/Organisation Name'), branch: Joi.string().optional().description('Branch name'), schedule: Joi.number().optional().default(60000).description('Update frequency (ms)'), provider: Joi.string().required().only(engineFactory.availableEngines).description('CI Provider name'), hideOwner: Joi.boolean().optional().default(false).description('Hide repo owner from display'), sounds: Joi.object({ passed: Joi.string().optional().description('Sound to play on build pass'), failed: Joi.string().optional().description('Sound to play on build fail'), unknown: Joi.string().optional().description('Sound to play on unknown state') }).optional().description('Sounds to play when build status changes'), options: Joi.when('provider', { is: 'circleci', then: Joi.object({ auth: Joi.string().required().description('CircleCI auth token') }).required(), otherwise: Joi.forbidden() }) } ================================================ FILE: packages/widget-ci/src/server/widget.js ================================================ 'use strict' const Joi = require('joi') const Hoek = require('hoek') const engineFactory = require('../engines/factory') const validation = require('./validation') class CiWidget { constructor (config, emitter) { const { error, value: options } = Joi.validate(config, validation) if (error) { throw new Error(`Could not load CI widget, ${error.message}`) } this.emitter = emitter this.config = Object.assign({ branch: 'master' }, options) const Provider = engineFactory.getEngine(this.config.provider) this.provider = new Provider(this.config) this.timer = setInterval(function () { this.run() }.bind(this), this.config.schedule) this.run() } run () { return this.provider .fetchBuildStatus() .then((status) => { const sound = Hoek.reach(this.config, `sounds.${status}`) if (sound && this.previousState !== status) { this.emitter.emit('plugin', 'audio:play', { data: sound }) } this.previousState = status this.emitter.emit('update', { status }) }) } destroy () { clearInterval(this.timer) } } exports.register = function (options, emitter) { return new CiWidget(options, emitter) } ================================================ FILE: packages/widget-ci/src/server/widget.spec.js ================================================ 'use strict' const { register } = require('.') const Travis = require('../engines/travis') const BuildStatus = require('../build-status.enum') const engineFactory = require('../engines/factory') const { expect } = require('code') const sinon = require('sinon') describe('widget-ci/server', () => { context('branch configuration', () => { it('defaults to master branch', () => { const config = { provider: 'travis', user: 'x', repo: 'y' } const configuration = register(config) expect(configuration.config.branch).to.equal('master') }) it('can override branch', () => { const config = { provider: 'travis', user: 'x', repo: 'y', branch: 'feature/xyz' } const configuration = register(config) expect(configuration.config.branch).to.equal('feature/xyz') }) }) context('show owner', () => { it('do not show repo owner', () => { const config = { provider: 'travis', user: 'x', repo: 'y', hideOwner: true } const configuration = register(config) expect(configuration.config.hideOwner).to.be.true() }) it('show repo owner', () => { const config = { provider: 'travis', user: 'x', repo: 'y' } const configuration = register(config) expect(configuration.config.hideOwner).to.be.false() }) }) context('provider is travis', () => { it('No config for travis', () => { const config = { provider: 'travis', user: 'x', repo: 'y' } const configuration = register(config) expect(configuration.config.provider).to.equal(config.provider) }) }) context('provider is circleci', () => { it('Auth for circleci', () => { const config = { provider: 'circleci', user: 'x', repo: 'y', options: { auth: 'aaa' } } const configuration = register(config) expect(configuration.config.provider).to.equal(config.provider) }) }) context.skip('Sound configuration', () => { let instance let sandbox let emitStub before(() => { sandbox = sinon.sandbox.create() const travisStub = sinon.createStubInstance(Travis) travisStub.fetchBuildStatus.resolves(BuildStatus.passed) const TravisClassStub = sandbox.stub(Travis.prototype, 'constructor').returns(travisStub) sandbox.stub(engineFactory, 'getEngine').returns(TravisClassStub) emitStub = sandbox.stub() instance = register({ provider: 'travis', user: 'x', repo: 'y', sounds: { passed: 'recovery-sound' } }, emitStub) return instance.update() }) after(() => { sandbox.restore() }) it('Calls stub', () => { expect(emitStub.callCount).to.equal(1) }) it('Emits sound event', () => { expect(emitStub.firstCall.args[0]).to.equal('audio:play') }) it('Delivers sound payload', () => { expect(emitStub.firstCall.args[1]).to.equal({ data: 'recovery-sound' }) }) it('Sound only plays on state change', () => { return instance.update() .then(() => { expect(emitStub.callCount).to.equal(1) }) }) const scenarios = [ { scenario: 'all specified', sounds: { passed: 'x', failed: 'y', unknown: 'z' } }, { scenario: 'passed specified', sounds: { passed: 'x' } }, { scenario: 'failed specified', sounds: { failed: 'y' } }, { scenario: 'unknown specified', sounds: { unknown: 'z' } } ] scenarios.forEach(({ scenario, sounds }) => { it(`Sound config ${scenario}`, () => { const config = { provider: 'travis', user: 'x', repo: 'y', sounds } const configuration = register(config) expect(configuration.config.provider).to.equal(config.provider) }) }) }) }) ================================================ FILE: packages/widget-gauge/.gitignore ================================================ node_modules *.log ================================================ FILE: packages/widget-gauge/README.MD ================================================ # Vudash Gauge Widget Displays a gauge, like a vu-meter, on a [Vudash](https://npmjs.org/vudash) Dashboard Documentation has moved [to github](https://vudash.com#gauge-widget) ================================================ FILE: packages/widget-gauge/package.json ================================================ { "name": "vudash-widget-gauge", "version": "9.9.0", "main": "./src/server", "scripts": { "test": "NODE_PATH=src:test ../../node_modules/.bin/mocha ../../test/unit.lab.js", "link": "npm link", "lint": "../../node_modules/.bin/standard" }, "vudash": { "component": "./src/client/markup.html" }, "dependencies": { "bluebird": "^3.4.7", "hoek": "^4.1.0", "joi": "^13.1.2" }, "standard": { "globals": [ "describe", "context", "it", "before", "after", "beforeEach", "afterEach" ] } } ================================================ FILE: packages/widget-gauge/src/client/markup.html ================================================
{{ config.description }}
================================================ FILE: packages/widget-gauge/src/server/index.js ================================================ 'use strict' module.exports = require('./widget') ================================================ FILE: packages/widget-gauge/src/server/widget.js ================================================ 'use strict' const Joi = require('joi') const { applyToDefaults } = require('hoek') const defaults = { maximum: 100 } class GaugeWidget { constructor (options) { this.config = applyToDefaults(defaults, options, false) } update (value) { const config = this.config return { value, config } } } exports.validation = Joi.object({ maximum: Joi.number().required().min(0).description('Maximum value') }) exports.register = function (options) { return new GaugeWidget(options) } ================================================ FILE: packages/widget-gauge/src/server/widget.spec.js ================================================ 'use strict' const { register, validation } = require('.') const { expect } = require('code') const Joi = require('joi') describe('widget-gauge/widget', () => { context('Maximum', () => { it('is passed', () => { const config = { maximum: 46 } const widget = register(config) expect(widget.config.maximum).to.equal(config.maximum) }) }) describe('Validation', () => { context('Maximum', () => { it('is valid', () => { const config = { maximum: 46 } const { value } = Joi.validate(config, validation) expect(value.maximum).to.equal(config.maximum) }) it('is a number', () => { const config = { maximum: 'abc' } const { error } = Joi.validate(config, validation) expect(error.message).to.include('"maximum" must be a number') }) it('is required', () => { const config = {} const { error } = Joi.validate(config, validation) expect(error.message).to.include('"maximum" is required') }) }) }) }) ================================================ FILE: packages/widget-health/README.MD ================================================ # Vudash Health Widget Displays [Vudash](https://npmjs.org/vudash) Dashboard health Documentation has moved [to github](https://vudash.com#health-widget) ================================================ FILE: packages/widget-health/package.json ================================================ { "name": "vudash-widget-health", "main": "./src/server", "scripts": { "test": "NODE_PATH=src:test ../../node_modules/.bin/mocha ../../test/unit.lab.js", "link": "npm link", "lint": "../../node_modules/.bin/standard" }, "vudash": { "component": "./src/client/component.html" }, "keywords": [ "vudash", "dashboard", "dashing", "health", "monitoring" ], "repository": { "type": "git", "url": "https://github.com/vudash/vudash" }, "version": "9.9.0", "dependencies": { "bluebird": "^3.4.7" }, "standard": { "globals": [ "describe", "context", "it", "before", "after", "beforeEach", "afterEach" ] } } ================================================ FILE: packages/widget-health/src/client/component.html ================================================
heart {{ heart }}
Health
================================================ FILE: packages/widget-health/src/server/index.js ================================================ 'use strict' module.exports = require('./widget') ================================================ FILE: packages/widget-health/src/server/widget.js ================================================ 'use strict' class HealthWidget { constructor (options, emitter) { this.emitter = emitter this.on = false this.timer = setInterval(function () { this.run() }.bind(this), options.schedule || 1000) this.run() } run () { this.on = !this.on this.emitter.emit('update', { on: this.on }) } destroy () { clearInterval(this.timer) } } exports.register = function (options, emitter) { return new HealthWidget(options, emitter) } ================================================ FILE: packages/widget-progress/README.MD ================================================ # Vudash Progress Widget Displays a progress bar widget on a [Vudash](https://npmjs.org/vudash) dashboard Documentation has moved [to github](https://vudash.com#gauge-widget) ================================================ FILE: packages/widget-progress/package.json ================================================ { "name": "vudash-widget-progress", "main": "./src/server", "version": "9.8.0", "description": "Vudash Progress Widget", "scripts": { "test": "NODE_PATH=src:test ../../node_modules/.bin/mocha ../../test/unit.lab.js", "link": "npm link", "lint": "../../node_modules/.bin/standard" }, "repository": { "type": "git", "url": "https://github.com/vudash/vudash" }, "keywords": [ "vudash", "widget", "progress", "progress bar", "completion", "percentage" ], "vudash": { "component": "./src/client/component.html" }, "author": "Antony Jones", "license": "MIT", "standard": { "globals": [ "describe", "context", "it", "before", "after", "beforeEach", "afterEach" ] } } ================================================ FILE: packages/widget-progress/src/client/component.html ================================================
{{ config.description }}
================================================ FILE: packages/widget-progress/src/server/index.js ================================================ 'use strict' module.exports = require('./widget') ================================================ FILE: packages/widget-progress/src/server/widget.js ================================================ 'use strict' class ProgressWidget { constructor (options) { this.config = options } update (data) { let percentage = data if (data < 0) { percentage = 0 } if (data > 100) { percentage = 100 } return { percentage } } } exports.register = function (options) { return new ProgressWidget(options) } ================================================ FILE: packages/widget-statistic/.gitignore ================================================ node_modules *.log ================================================ FILE: packages/widget-statistic/README.MD ================================================ # Vudash Statistic Widget Displays a statistic, such as a visitor count, on a [Vudash](https://npmjs.org/vudash) Dashboard ![stats widget](https://cloud.githubusercontent.com/assets/218949/20489789/adb964ca-b003-11e6-917b-c07218625bd3.png) Documentation has moved [to github](https://vudash.com#statistic-widget) ================================================ FILE: packages/widget-statistic/package.json ================================================ { "name": "vudash-widget-statistic", "version": "9.9.0", "main": "./src/server", "scripts": { "test": "NODE_PATH=src:test ../../node_modules/.bin/mocha ../../test/unit.lab.js", "link": "npm link", "lint": "../../node_modules/.bin/standard" }, "dependencies": { "chartist": "^0.11.0", "fit-text": "^2.0.0", "joi": "^13.1.2", "sprintf-js": "^1.0.3" }, "repository": { "type": "git", "url": "https://github.com/vudash/vudash" }, "vudash": { "component": "./src/client/markup.html" }, "standard": { "globals": [ "describe", "context", "it", "before", "after", "beforeEach", "afterEach" ] } } ================================================ FILE: packages/widget-statistic/src/client/markup.html ================================================
{{ displayValue }}
{{#if config.historyView === 'ticker'}}
{{ ticker.difference }} ({{ ticker.percent }}%)
{{/if}}
{{ config.description }}
{{#if config.historyView === 'chart'}}
{{/if}}
================================================ FILE: packages/widget-statistic/src/server/index.js ================================================ 'use strict' module.exports = require('./widget') ================================================ FILE: packages/widget-statistic/src/server/widget.js ================================================ 'use strict' const { sprintf } = require('sprintf-js') const Joi = require('joi') const defaults = { description: 'Statistics' } function format (format, value) { return sprintf(format, value) } class StatisticWidget { constructor (options) { this.config = Object.assign({}, defaults, options) } update (value) { return { value, displayValue: format(this.config.format, value) } } } exports.validation = Joi.object({ format: Joi.string().optional().default('%s').description('Display format'), colour: Joi.string().optional().default('#fff').description('Colour'), description: Joi.string().optional().description('Description'), 'font-ratio': Joi.number().default(4).description('Font ratio for display value'), historyView: Joi.string().only('chart', 'ticker').default('chart').description('History display format') }) exports.register = function (options) { return new StatisticWidget(options) } ================================================ FILE: packages/widget-statistic/src/server/widget.spec.js ================================================ 'use strict' const { expect } = require('code') const { register, validation } = require('./widget') const Joi = require('joi') describe('widget', () => { context('#register()', () => { it('Uses config', () => { const config = { foo: 'bar' } const widget = register(config, validation) expect(widget.config.foo).to.equal(config.foo) }) it('Has default config values', () => { const config = {} const widget = register(config) expect(widget.config.description).to.equal('Statistics') }) it('Merges config with default values', () => { const config = { description: 'hello' } const widget = register(config) expect(widget.config.description).to.equal(config.description) }) }) context('Updates', () => { let output beforeEach(() => { const configuration = register({ format: '%d%%' }) output = configuration.update(34) }) it('Will convert given value to string', () => { const widget = register({ format: '%s' }) const { displayValue } = widget.update(2) expect(displayValue).to.equal('2') }) it('Will format according to format config', () => { expect(output.displayValue).to.equal('34%') }) it('Will retain original value for history', () => { expect(output.value).to.equal(34) }) }) context('Configuration', () => { it('With provided colour', () => { const conf = { colour: '#f00' } const config = Joi.attempt(conf, validation) expect(config.colour).to.equal('#f00') }) it('No colour passed', () => { const config = Joi.attempt({}, validation) expect(config.colour).to.equal('#fff') }) it('default format is set', () => { const config = Joi.attempt({}, validation) expect(config.format).to.equal('%s') }) }) }) ================================================ FILE: packages/widget-status/README.MD ================================================ # Vudash Status Widget Displays status for APIs, such as a Statuspage IO status page, on a [Vudash](https://npmjs.org/vudash) Dashboard Documentation has moved [to github](https://vudash.com#status-widget) ================================================ FILE: packages/widget-status/package.json ================================================ { "name": "vudash-widget-status", "main": "./src/server", "scripts": { "test": "NODE_PATH=src:test ../../node_modules/.bin/mocha ../../test/unit.lab.js", "link": "npm link", "lint": "../../node_modules/.bin/standard" }, "vudash": { "component": "./src/client/markup.html" }, "keywords": [ "vudash", "statuspage", "status", "monitoring", "github", "api", "widget" ], "repository": { "type": "git", "url": "https://github.com/vudash/vudash" }, "dependencies": { "bluebird": "^3.4.7", "export-dir": "^0.1.2", "got": "^6.7.1", "hoek": "^4.1.0", "joi": "^13.0.1", "moment": "^2.18.1" }, "version": "9.9.0", "standard": { "globals": [ "describe", "context", "it", "before", "after", "beforeEach", "afterEach" ] } } ================================================ FILE: packages/widget-status/src/client/markup.html ================================================
{{ when }}
    {{#each components as cmp}}
  • {{ cmp.name }}
  • {{/each}}
{{ description }}
================================================ FILE: packages/widget-status/src/health-status.js ================================================ 'use strict' module.exports = { HEALTHY: { ligature: 'good' }, PARTIAL_OUTAGE: { ligature: 'minor' }, MAJOR_OUTAGE: { ligature: 'major' }, DEGRADED: { ligature: 'minor' }, UNKNOWN: { ligature: 'waiting' } } ================================================ FILE: packages/widget-status/src/providers/github/github.spec.js ================================================ 'use strict' const Provider = require('.') const HealthStatus = require('../../health-status') const nock = require('nock') const { expect } = require('code') describe('providers.github', () => { context('#configValidation', () => { it('Returns an empty block', () => { expect(Provider.configValidation).to.equal({}) }) }) context('#fetch()', () => { const scenarios = [ { status: 'good', health: HealthStatus.HEALTHY }, { status: 'minor', health: HealthStatus.PARTIAL_OUTAGE }, { status: 'major', health: HealthStatus.MAJOR_OUTAGE }, { status: 'xyz', health: HealthStatus.UNKNOWN } ] afterEach(() => { nock.cleanAll() }) scenarios.forEach(({ status, health }) => { it(`Returns ${health} when status is ${status}`, () => { nock('https://status.github.com') .get('/api/status.json') .reply(200, { status }) const provider = new Provider() return provider.fetch() .then((output) => { expect(output.description).to.equal('Github') expect(output.components[0].ligature).to.equal(health) expect(output.components[0].name).to.equal('github') }) }) }) }) }) ================================================ FILE: packages/widget-status/src/providers/github/index.js ================================================ 'use strict' const got = require('got') const HealthStatus = require('../../health-status') const { reach } = require('hoek') class Github { static get configValidation () { return {} } mapHealth (body) { const mappings = { good: HealthStatus.HEALTHY, major: HealthStatus.MAJOR_OUTAGE, minor: HealthStatus.PARTIAL_OUTAGE } const status = reach(body, 'status') return mappings[status] || HealthStatus.UNKNOWN } fetch () { return got('https://status.github.com/api/status.json', { json: true }) .then((response) => { const body = response.body return { components: [ { name: 'github', ligature: this.mapHealth(body) } ], description: 'Github' } }) } } module.exports = Github ================================================ FILE: packages/widget-status/src/providers/index.js ================================================ 'use strict' const exportDir = require('export-dir') module.exports = exportDir(__dirname) ================================================ FILE: packages/widget-status/src/providers/statuspageio/index.js ================================================ 'use strict' const got = require('got') const HealthStatus = require('../../health-status') const { reach } = require('hoek') const { assign } = Object const Joi = require('joi') function mapHealthStatus (status) { const mappings = { 'operational': HealthStatus.HEALTHY, 'partial_outage': HealthStatus.PARTIAL_OUTAGE, 'major_outage': HealthStatus.MAJOR_OUTAGE, 'degraded_performance': HealthStatus.DEGRADED } return mappings[status] || HealthStatus.UNKNOWN } class StatuspageIo { constructor (config) { this.url = config.url this.selectedComponents = config.components } static get configValidation () { return { url: Joi.string().uri().required().description('Status page url'), components: Joi.array().items(Joi.string()).required().description('Component names to monitor') } } filterComponentList (all, component) { if (this.selectedComponents.includes(component.name)) { const styles = mapHealthStatus(component.status) const state = assign({ name: component.name }, styles) all.push(state) } return all } fetch () { return got(this.url, { json: true }) .then((response) => { const body = response.body const filteredComponents = body.components.reduce(this.filterComponentList.bind(this), []) return { description: reach(body, 'page.name'), components: filteredComponents } }) } } module.exports = StatuspageIo ================================================ FILE: packages/widget-status/src/providers/statuspageio/statuspageio.spec.js ================================================ 'use strict' const Provider = require('.') const HealthStatus = require('../../health-status') const nock = require('nock') const { assign } = Object const Joi = require('joi') const { expect } = require('code') describe('providers.statuspageio', () => { context('#configValidation', () => { it('Requires component list', () => { Joi.validate({ url: 'http://www.example.com' }, Provider.configValidation, (err) => { expect(err).to.exist() }) }) it('Requires statuspage url', () => { Joi.validate({ components: [] }, Provider.configValidation, (err) => { expect(err).to.exist() }) }) it('Statuspage url must be an url', () => { Joi.validate({ url: 'xxx', components: [] }, Provider.configValidation, (err) => { expect(err).to.exist() }) }) }) context('#fetch()', () => { const url = 'http://example.org/' const statuspageJson = { page: { name: 'Some Page' }, status: { indicator: 'none' }, components: [ { name: 'status', indicator: 'none' }, { name: 'Component A', status: 'operational' }, { name: 'Component B', status: 'major_outage' }, { name: 'Component C', status: 'partial_outage' }, { name: 'Component D', status: 'degraded_performance' }, { name: 'Component E', status: 'xxx' }, { name: 'Component F', status: 'operational' } ] } const config = { url, components: [ 'Component A', 'Component B', 'Component C', 'Component D', 'Component E' ] } let results beforeEach(() => { nock(url, { reqheaders: { accept: 'application/json' } }) .get('/') .reply(200, statuspageJson) const provider = new Provider(config) return provider.fetch() .then((output) => { results = output }) }) it('Has status page name', () => { expect(results.description).to.equal('Some Page') }) it('Fetches components', () => { expect(results.components).to.equal([ assign({ name: 'Component A' }, HealthStatus.HEALTHY), assign({ name: 'Component B' }, HealthStatus.MAJOR_OUTAGE), assign({ name: 'Component C' }, HealthStatus.PARTIAL_OUTAGE), assign({ name: 'Component D' }, HealthStatus.DEGRADED), assign({ name: 'Component E' }, HealthStatus.UNKNOWN) ]) }) }) }) ================================================ FILE: packages/widget-status/src/server/index.js ================================================ 'use strict' module.exports = require('./widget') ================================================ FILE: packages/widget-status/src/server/validator.js ================================================ 'use strict' const Joi = require('joi') const providers = require('../providers') function getBasicValidation () { const availableProviders = Object.keys(providers) return { type: Joi.string().required().only(availableProviders).description('Status page type'), schedule: Joi.number().optional().default(60000).description('CI Refresh schedule'), config: Joi.object().optional().default({}).description('Provider configuration') } } function validate (schema, config) { const { error, value } = Joi.validate(config, schema) if (error) { throw new Error(`Unable to configure status widget: ${error.message}`) } return value } exports.validateConfig = function (options) { return validate(getBasicValidation(), options) } exports.validateProvider = function (ProviderClass, config) { const { configValidation } = ProviderClass return validate(configValidation, config) } ================================================ FILE: packages/widget-status/src/server/widget.js ================================================ 'use strict' const providers = require('../providers') const { validateConfig, validateProvider } = require('./validator') class StatusWidget { constructor (options, emitter) { this.emitter = emitter const { type, schedule, config } = validateConfig(options) const ProviderClass = providers[type] const providerConfig = validateProvider(ProviderClass, config) this.provider = new ProviderClass(providerConfig) this.timer = setInterval(function () { this.run() }.bind(this), schedule) this.run() } run () { return this .provider .fetch() .then(data => { this.emitter.emit('update', data) }) } destroy () { clearInterval(this.timer) } } exports.register = function (options, emitter) { return new StatusWidget(options, emitter) } ================================================ FILE: packages/widget-time/.gitignore ================================================ node_modules *.log ================================================ FILE: packages/widget-time/README.md ================================================ # Vudash Time Widget Shows the current time and date in your [Vudash](https://npmjs.org/vudash) Dashboard. Also plays custom sounds as alarms. Documentation has moved [to vudash.com](https://vudash.com#time-widget) ================================================ FILE: packages/widget-time/package.json ================================================ { "name": "vudash-widget-time", "version": "9.9.0", "description": "Vudash time widget", "main": "./src/server", "scripts": { "test": "NODE_PATH=src:test ../../node_modules/.bin/mocha ../../test/unit.lab.js", "link": "npm link", "lint": "../../node_modules/.bin/standard" }, "keywords": [ "vudash", "dashboard", "dashing", "time", "alarm", "cron", "schedule" ], "repository": { "type": "git", "url": "https://github.com/vudash/vudash" }, "author": "Antony Jones", "license": "MIT", "dependencies": { "bluebird": "^3.5.1", "cron": "^1.3.0", "hoek": "^4.2.1", "joi": "^9.2.0", "moment": "^2.19.1", "moment-timezone": "^0.5.13" }, "vudash": { "component": "./src/client/markup.html" }, "standard": { "globals": [ "describe", "context", "it", "before", "after", "beforeEach", "afterEach" ] } } ================================================ FILE: packages/widget-time/src/client/markup.html ================================================
{{ time }}
{{ date }}
================================================ FILE: packages/widget-time/src/server/alarms.js ================================================ 'use strict' const { reach } = require('hoek') const { CronJob } = require('cron') exports.parseAlarms = function (config, emitter) { const alarms = reach(config, 'alarms', { default: [] }) return alarms.map(alarm => { return alarm.actions.map(action => { const context = { options: action.options, emitter } return new CronJob({ cronTime: alarm.expression, onTick: function () { this.emitter.emit('plugin', 'audio:play', { data: this.options.data }) }, context, start: true, timeZone: config.timezone, runOnInit: false }) }) }) } ================================================ FILE: packages/widget-time/src/server/index.js ================================================ 'use strict' module.exports = require('./widget') ================================================ FILE: packages/widget-time/src/server/validation.js ================================================ 'use strict' const Joi = require('joi') const moment = require('moment') const action = Joi.object({ action: Joi.string().only('sound').description('Action name'), options: Joi.object({ data: Joi.string().description('Data uri for sound file') }).description('Action configuration') }).description('Action') const alarm = Joi.object({ expression: Joi.string().description('Cron Expression'), actions: Joi.array().items(action).description('Actions') }).required().description('Alarm entry') exports.schema = Joi.object({ timezone: Joi.string().only(moment.tz.names()).optional().default('UTC').description('A momentjs timezone'), alarms: Joi.array().items(alarm).optional().description('List of alarms') }).optional() ================================================ FILE: packages/widget-time/src/server/widget.js ================================================ 'use strict' const { time } = require('../time') const { schema } = require('./validation') const { parseAlarms } = require('./alarms') class TimeWidget { constructor (options, emitter) { this.config = options this.emitter = emitter this.alarms = parseAlarms(this.config, this.emitter) this.timer = setInterval(function () { this.run() }.bind(this), 1000) this.run() } run () { const data = time(this.config.timezone) this.emitter.emit('update', data) } destroy () { clearInterval(this.timer) this.alarms.forEach(actions => { actions.forEach(action => action.stop()) }) } } exports.validation = schema exports.register = function (options, emitter) { return new TimeWidget(options, emitter) } ================================================ FILE: packages/widget-time/src/server/widget.spec.js ================================================ 'use strict' const { register } = require('.') const { expect } = require('code') const { stub } = require('sinon') describe('widget', () => { context('Alarms', () => { let widget const config = { alarms: [ { expression: '* * * * * *', actions: [ { action: 'sound', options: { data: 'abcde' } } ] } ], timezone: 'UTC' } it('Allows alarm config', () => { expect(() => { widget = register(config, { emit: stub() }) }).not.to.throw() }) afterEach(() => { widget.destroy() }) }) context('No alarms', () => { let widget it('Allows config', () => { expect(() => { widget = register({ timezone: 'UTC' }, { emit: stub() }) }).not.to.throw() }) afterEach(() => { widget.destroy() }) }) context('#destroy()', () => { let widget const action1 = { stop: stub() } const action2 = { stop: stub() } beforeEach(() => { widget = register({ timezone: 'UTC' }, { emit: stub() }) widget.alarms = [ [ action1, action2 ] ] }) it('calls stop on all actions', () => { widget.destroy() expect(action1.stop.callCount).to.equal(1) expect(action2.stop.callCount).to.equal(1) }) afterEach(() => { widget.destroy() }) }) }) ================================================ FILE: packages/widget-time/src/time/index.js ================================================ 'use strict' const moment = require('moment-timezone') exports.time = locale => { const now = moment().tz(locale) return { time: now.format('HH:mm:ss'), date: now.format('MMMM Do YYYY') } } ================================================ FILE: packages/widget-time/src/time/time.spec.js ================================================ 'use strict' const moment = require('moment-timezone') const service = require('.') const { expect } = require('code') describe('time', () => { it('with UTC locale', async () => { const timezone = 'UTC' const current = moment().tz(timezone).format('HH:mm:ss') const { time } = await service.time(timezone) expect(time).to.equal(current) }) it('with locale', async () => { const timezone = 'America/Los_Angeles' const current = moment().tz(timezone).format('HH:mm:ss') const { time } = await service.time(timezone) expect(time).to.equal(current) }) }) ================================================ FILE: test/unit.lab.js ================================================ 'use strict' const path = require('path') const glob = require('glob') require('sinon-as-promised') const tests = glob.sync(path.join(process.cwd(), './{,!(node_modules)/**/}*.spec.js')) tests.forEach(fullPath => require(fullPath))