├── .circleci └── config.yml ├── .editorconfig ├── .gitignore ├── .prettierignore ├── .tool-versions ├── LICENSE ├── README.md ├── docs ├── customize-build-process.md ├── customize-layout.md ├── deployment-docker.md ├── deployment-heroku.md ├── event-reducer.md ├── getstarted.md ├── running-multiple-dashboards.md └── sharing-widgets.md ├── example.jpg ├── example ├── .dockerignore ├── .gitignore ├── Dashboard.js ├── Dockerfile ├── dashbling.config.js ├── index.html ├── index.js ├── jobs │ ├── circleBuildStatus.js │ └── githubStars.js ├── package.json ├── styles │ └── main.scss └── widgets │ ├── HelloWidget.js │ ├── circleCi │ ├── CircleCiStatus.js │ └── circleci.svg │ └── gitHubStars │ ├── GitHubStars.js │ └── github.svg ├── hooks └── pre-commit ├── lerna.json ├── package.json ├── packages ├── build-support │ ├── .babelrc │ ├── .gitignore │ ├── .npmignore │ ├── assets.ts │ ├── package.json │ ├── postcss.config.js │ ├── test │ │ ├── assets.test.ts │ │ └── fixture │ │ │ └── dashbling.config.js │ ├── tsconfig.json │ ├── webpack.config.js │ └── webpackDevMiddleware.ts ├── client │ ├── .babelrc │ ├── .gitignore │ ├── Widget.js │ ├── Widget.scss │ ├── components │ │ ├── Dashboard.js │ │ ├── Flex.js │ │ └── index.js │ ├── dashbling.js │ ├── index.js │ ├── layouts │ │ └── FlexLayout.scss │ ├── package.json │ ├── store.js │ ├── test │ │ ├── Dashboard.test.js │ │ ├── Widget.test.js │ │ ├── dashbling.test.js │ │ ├── react.setup.js │ │ └── store.test.js │ └── widgets │ │ ├── Clock.js │ │ ├── DashblingConnected.js │ │ ├── DashblingConnected.scss │ │ ├── Image.js │ │ ├── LastUpdatedAt.js │ │ └── index.js ├── core │ ├── .gitignore │ ├── .npmignore │ ├── history.js │ ├── nodemon.json │ ├── package.json │ ├── src │ │ ├── cli.ts │ │ ├── lib │ │ │ ├── Event.ts │ │ │ ├── EventHistory.ts │ │ │ ├── FileEventHistory.ts │ │ │ ├── InMemoryEventHistory.ts │ │ │ ├── authToken.ts │ │ │ ├── clientConfig.ts │ │ │ ├── constants.ts │ │ │ ├── eventBus.ts │ │ │ ├── jobs.ts │ │ │ ├── logger.ts │ │ │ └── sendEvent.ts │ │ ├── server.ts │ │ └── server │ │ │ ├── basicAuth.ts │ │ │ ├── compiledAssets.ts │ │ │ ├── forceHttps.ts │ │ │ ├── logging.ts │ │ │ └── routes.ts │ ├── test │ │ ├── FileEventHistory.test.ts │ │ ├── InMemoryEventHistory.test.ts │ │ ├── clientConfig.test.ts │ │ ├── eventBus.test.ts │ │ ├── fixture │ │ │ └── dashbling.config.js │ │ ├── integration │ │ │ └── server.integration.test.ts │ │ ├── jobs.test.ts │ │ └── utils.ts │ └── tsconfig.json ├── create-dashbling-app │ ├── .gitignore │ ├── .npmignore │ ├── create-dashboard.js │ ├── index.js │ ├── package.json │ ├── script │ │ └── prepare.sh │ └── test.sh ├── create-widget │ ├── .gitignore │ ├── create-widget.js │ ├── index.js │ ├── package.json │ └── templates │ │ ├── MyWidget.js │ │ ├── README.md │ │ ├── gitignore │ │ ├── job.js │ │ └── styles.css ├── dashbling-widget-weather │ ├── Climacon.js │ ├── README.md │ ├── WeatherWidget.js │ ├── climacons │ │ ├── cloud moon.svg │ │ ├── cloud sun.svg │ │ ├── cloud.svg │ │ ├── drizzle.svg │ │ ├── hail.svg │ │ ├── haze.svg │ │ ├── lightning.svg │ │ ├── moon.svg │ │ ├── rain.svg │ │ ├── sleet.svg │ │ ├── snow.svg │ │ ├── sun.svg │ │ ├── thermometer full.svg │ │ ├── thermometer low.svg │ │ ├── tornado.svg │ │ └── wind.svg │ ├── job.js │ ├── logo_OpenWeatherMap.svg │ ├── package.json │ └── styles.css └── mongodb-history │ ├── README.md │ ├── mongodb-history.js │ └── package.json ├── script ├── e2e-tests.sh └── setup.sh ├── tsconfig.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | shared: &shared 4 | working_directory: ~/repo 5 | 6 | steps: 7 | - checkout 8 | 9 | - restore_cache: 10 | keys: 11 | - v1-dependencies-{{ checksum "package.json" }} 12 | - v1-dependencies- 13 | 14 | - run: npm config set unsafe-perm=true 15 | - run: yarn install 16 | 17 | - save_cache: 18 | paths: 19 | - node_modules 20 | key: v1-dependencies-{{ checksum "package.json" }} 21 | 22 | - run: yarn prettier --list-different 23 | - run: yarn test 24 | - run: yarn test:e2e 25 | 26 | 27 | jobs: 28 | "node-10": 29 | <<: *shared 30 | docker: 31 | - image: node:10 32 | "node-12": 33 | <<: *shared 34 | docker: 35 | - image: node:12 36 | "node-13": 37 | <<: *shared 38 | docker: 39 | - image: node:13 40 | 41 | workflows: 42 | version: 2 43 | build: 44 | jobs: 45 | - "node-10" 46 | - "node-12" 47 | - "node-13" 48 | nightly: 49 | jobs: 50 | - "node-10" 51 | - "node-12" 52 | - "node-13" 53 | triggers: 54 | - schedule: 55 | cron: "0 0 * * *" 56 | filters: 57 | branches: 58 | only: 59 | - master 60 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{js,ts}] 4 | indent_style = space 5 | indent_size = 2 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | *.log 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | *.d.ts 3 | packages/core/lib/* 4 | packages/build-support/*.js 5 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 12.13.1 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Pascal Widdershoven 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 💎 Dashbling - hackable React based dashboards for developers 2 | 3 | [![CircleCI](https://circleci.com/gh/pascalw/dashbling.svg?style=svg)](https://circleci.com/gh/pascalw/dashbling) 4 | 5 | Dashbling is a React based tool for creating beautiful dashboards. 6 | 7 | Features: 8 | 9 | * Widgets are React components that automatically update when data changes. 10 | * Data can be pushed into your dashboard via a REST API, or fetch data using small NodeJS modules. 11 | * Looks great out of the box, or fully adapt to your taste. 12 | * Great developer experience with hot reloading, ES2015+ and Sass. 13 | * Extensible build system powered by Webpack. 14 | * Can easily be hosted on Heroku, or any platform supporting Docker. 15 | * Widgets can easily be shared via NPM packages. 16 | 17 | Follow [this guide](./docs/getstarted.md) to get started with your first Dashbling dashboard. 18 | Read the full docs [here](./docs/). 19 | 20 | There's also an [example](https://github.com/pascalw/dashbling/tree/master/example) available in the repo and a running [demo](https://dashbling.fly.dev). 21 | 22 | ![example](https://raw.githubusercontent.com/pascalw/dashbling/master/example.jpg) 23 | -------------------------------------------------------------------------------- /docs/customize-build-process.md: -------------------------------------------------------------------------------- 1 | # Customize Dashbling build process 2 | 3 | Dashbling uses Webpack under the hood to build your dashboard. 4 | 5 | To customize the Webpack configuration, specify a `webpackConfig` property in your `dashbling.config.js` as follows: 6 | 7 | ```js 8 | module.exports = { 9 | webpackConfig: config => { 10 | // return modified config 11 | // or even completely custom config. 12 | // 13 | // Example: 14 | // config.module.rules.push({ 15 | // test: /\.jpg$/, 16 | // loader: "file-loader" 17 | // }); 18 | return config; 19 | } 20 | }; 21 | ``` 22 | 23 | The default Webpack configuration can be found [here](https://github.com/pascalw/dashbling/blob/master/packages/build-support/webpack.config.js). 24 | -------------------------------------------------------------------------------- /docs/customize-layout.md: -------------------------------------------------------------------------------- 1 | # Customize the global layout 2 | 3 | By default, Dashbling uses a [Flexbox based layout](https://github.com/pascalw/dashbling/blob/master/packages/client/layouts/FlexLayout.scss). 4 | 5 | However, if this doesn't suit your needs it's possible to use a different layout by passing it to your `Dashboard` instance: 6 | 7 | ```js 8 | export default props => { 9 | return ( 10 | 11 | [...] 12 | 13 | ); 14 | }; 15 | ``` 16 | 17 | A layout is an object that specifies which class names are applied to certain DOM elements. The following keys should be present in your layout: 18 | 19 | * `dashboard` - this is the outer-most component in the Dashbling DOM. 20 | * `metaContainer` - this is the container holding the `last updated` information. 21 | * `widgetContainer` - this is the container containing the widgets. 22 | * `widget` - this class is applied to each widget. 23 | 24 | The suggested approach is to create a custom layout using a CSS module. Create a (S)CSS file somewhere with: 25 | 26 | ```scss 27 | // myCustomLayout.scss 28 | 29 | .dashboard { 30 | // dashboard styles here 31 | } 32 | 33 | .metaContainer { 34 | // metaContainer styles here 35 | } 36 | 37 | .widgetContainer { 38 | // widgetContainer styles here 39 | } 40 | 41 | .widget { 42 | // widget styles here 43 | } 44 | ``` 45 | 46 | Import it and pass it to your dashboard: 47 | 48 | ```js 49 | import React from "react"; 50 | 51 | import { Dashboard } from "@dashbling/client/components"; 52 | import myCustomLayout from "./myCustomLayout.scss"; 53 | 54 | export default props => { 55 | return ( 56 | 57 | [...] 58 | 59 | ); 60 | }; 61 | ``` 62 | 63 | For example a three column grid layout could look like this: 64 | 65 | ```scss 66 | $base-widget-size: 300px; 67 | 68 | .dashboard { 69 | width: 100vw; 70 | min-height: 100vh; 71 | overflow-x: hidden; 72 | } 73 | 74 | .metaContainer { 75 | margin: auto 1em 1em; 76 | } 77 | 78 | .widgetContainer { 79 | display: grid; 80 | grid-template-columns: repeat(3, 1fr); 81 | grid-gap: 0.5em; 82 | } 83 | 84 | .widget { 85 | flex: 1 1 $base-widget-size; 86 | min-width: $base-widget-size; 87 | min-height: $base-widget-size; 88 | 89 | padding: 0.75em; 90 | margin: 0.5em; 91 | color: #fff; 92 | word-wrap: break-word; 93 | hyphens: auto; 94 | 95 | position: relative; 96 | } 97 | ``` 98 | -------------------------------------------------------------------------------- /docs/deployment-docker.md: -------------------------------------------------------------------------------- 1 | # Deployment on Docker 2 | 3 | Dashbling dashboards ship with a Dockerfile. 4 | 5 | 1. Build a Docker image: 6 | 7 | ```sh 8 | docker build -t my-dashboard . 9 | ``` 10 | 11 | 2. And run it: 12 | 13 | ```sh 14 | docker run -p 3000:3000 -it my-dashboard 15 | ``` 16 | 17 | Now open http://localhost:3000/ in your browser to see your dashboard. 18 | 19 | -------------------------------------------------------------------------------- /docs/deployment-heroku.md: -------------------------------------------------------------------------------- 1 | # Deploying on Heroku 2 | 3 | Deploying a Dashbling app on Heroku is easy. 4 | 5 | 1. Add a `Procfile` in the root of your project with the following contents: 6 | 7 | ``` 8 | web: yarn start 9 | ``` 10 | 11 | 2. Add a `heroku-postbuild` script to your `package.json`: 12 | 13 | ```diff 14 | "scripts": { 15 | "start": "NODE_ENV=${NODE_ENV:-development} dashbling start", 16 | - "build": "dashbling compile" 17 | + "build": "dashbling compile", 18 | + "heroku-postbuild": "yarn build" 19 | }, 20 | ``` 21 | 22 | 3. Commit your changes to and push to Heroku. 23 | 4. You're done! 24 | 25 | ## Persistent history 26 | 27 | This is not required but you might want to enable the MongoDB history adapter on Heroku. Dashbling stores event history on the filesystem by default, but the filesystem is not persistent on Heroku. 28 | 29 | If you're only pulling data into your dashboard this is not really an issue, because Dashbling will just pull in all data again when it's restarted. However if you're pushing data into Dashbling you definitely want to store it persistently. 30 | 31 | In Heroku, enable the `mLab MongoDB` add-on. Then follow the steps outlined in the [MongoDB history adapter docs](../packages/mongodb-history/README.md). 32 | 33 | -------------------------------------------------------------------------------- /docs/event-reducer.md: -------------------------------------------------------------------------------- 1 | # Using an Event Reducer 2 | 3 | By default, Dashbling keeps track of the last event data per event type. For advanced use cases you can customize this behaviour by providing an `EventReducer`. 4 | 5 | An `EventReducer` looks like this: 6 | 7 | ```typescript 8 | interface Reducer { 9 | (eventId: string, previousState: any | undefined, eventData: any): any; 10 | } 11 | ``` 12 | 13 | Event data that flows into Dashbling, either from jobs or from data pushed in via the REST API, is passed to the reducer allow it to modify the data before it gets stored in the history and pushed to clients. 14 | 15 | An `EventReducer` is a function that takes an `eventId`, the previous state that was stored for this event (or `null` if it's the first time an event is triggered) and the `eventData` that's coming in. The default implementation simply returns the new data like this: 16 | 17 | ```javascript 18 | const defaultReducer = (_eventId, _previousState, eventData) => { 19 | return eventData; 20 | }; 21 | ``` 22 | 23 | You can create a custom reducer in case you want to override this behaviour. 24 | 25 | For example say you have events with the following data: 26 | 27 | ```javascript 28 | const event = { 29 | name: "Alice" 30 | }; 31 | ``` 32 | 33 | And you want to aggregate all events, you might define a reducer like this: 34 | 35 | ```javascript 36 | eventReducer: (id, eventState = {}, event) => { 37 | switch (id) { 38 | case "hello": 39 | if (eventState.names) { 40 | // existing state, append the incoming name to the list of names. 41 | return { names: [...eventState.names, event.name] }; 42 | } else { 43 | // existing state doesn't match our expectation, create a new state. 44 | return { names: [event.name] }; 45 | } 46 | default: 47 | return event; 48 | } 49 | } 50 | ``` 51 | 52 | This means that the following data will be stored for the `hello` event: 53 | 54 | ```json 55 | { 56 | "names": ["Alice", "Bob", "John"] 57 | } 58 | ``` 59 | -------------------------------------------------------------------------------- /docs/getstarted.md: -------------------------------------------------------------------------------- 1 | # Getting started with Dashbling 2 | 3 | *Dashbling requires NodeJS 8.9.0 or higher.* 4 | 5 | ```shell 6 | yarn create @dashbling/dashboard my-dashboard # or `npx @dashbling/create-dashboard my-dashboard` if you're not using Yarn 7 | cd my-dashboard 8 | yarn start # or `npm start` 9 | ``` 10 | 11 | **Note: current versions of Yarn do not support `yarn create` with scoped packages. Use `npx` instead if `yarn create` doesn't work. Dependencies will still be managed by Yarn if `yarn` is in your `$PATH`.** 12 | 13 | Now open http://localhost:3000/ in your browser to see your dashboard. 14 | 15 | ## Files and directories 16 | 17 | * **dashbling.config.js** - main configuration file. 18 | * **Dashboard.js** - main dashboard definition. 19 | * This is a React component that defines which Widgets are rendered. 20 | * **index.html** - HTML boilerplate 21 | * **index.js** - javascript boilerplate. 22 | * **jobs** - directory where jobs can be defined. 23 | * **styles** - where global (S)CSS lives. 24 | * **widgets** - directory where widget components can be defined. 25 | 26 | Note that Dashbling does not care about the directory structure at all, so adapt to your own taste! 27 | 28 | The guide below describes the process of developing and running your Dashbling dashboard. 29 | There's also an [example](https://github.com/pascalw/dashbling/tree/master/example) available in the repo, and a live [demo](https://dashbling.fly.dev/). 30 | 31 | ## Deployment 32 | 33 | For production use it's recommended to precompile your dashboard - you can do this by running `yarn build` from the root of your project. 34 | Running the server with `NODE_ENV=production yarn start` will make use of the compiled dashboard and disable all dev tools. 35 | 36 | ## Writing a custom widget 37 | 38 | Widgets in Dashbling are just React components. By convention, widgets make use of the `@dashbling/client/Widget` component to ensure consistent looking widgets and provide some often needed functionality out of the box. This however is not required - a widget can return any HTML! 39 | 40 | Most widgets need data to display. In Dashbling, widget data is provided by React component props. A simple `Hello` widget might look like this: 41 | 42 | ```js 43 | import React from "react"; 44 | import { Widget } from "@dashbling/client/Widget"; 45 | 46 | export const HelloWidget = (props) => { 47 | return ( 48 | 49 | hello ${props.name}! 50 | 51 | ) 52 | }; 53 | ``` 54 | 55 | Notice that the widget doesn't care where the data is coming from. All it knows is that it should receive a `name` prop and display that. 56 | 57 | When using widgets in your dashboard you bind data to the widgets using the `connect` function: 58 | 59 | ```js 60 | import { connect } from "@dashbling/client/dashbling"; 61 | 62 | const HelloWorld = connect("hello-world")(HelloWidget); 63 | 64 | export default props => { 65 | return ( 66 | 67 | 68 | 69 | ); 70 | }; 71 | ``` 72 | 73 | Here we define that our `HelloWorld` widget should be fed with data from the `hello-world` event. This means that any time a `hello-world` event is sent by the Dashboard backend, the `HelloWorld` widget will automatically be updated with the event data. 74 | 75 | As mentioned before, Widgets are simply React components. This means that you can use any React component inside your widgets, for graphs, charts, progress bars, etc. 76 | 77 | ## Getting data into your dashboard 78 | 79 | There are a number of ways to get data in your dashboard: 80 | 81 | 1. Push data into your dashboard via HTTP 82 | 2. Pull data into your dashboard by using jobs 83 | 3. Pull data into your dashboard by using streams 84 | 85 | ### Pushing data into your dashboard 86 | 87 | Pushing data requires a token for security reasons. Dashbling uses the configured `authToken`, or generates a random token (if none is configured). 88 | 89 | ```sh 90 | curl -XPOST -H "Content-Type: application/json" \ 91 | -H "Authorization: bearer YOUR_AUTH_TOKEN" \ 92 | -d '{"any json data": "here"}' 93 | http://localhost:3000/events/EVENT_ID_HERE 94 | ``` 95 | 96 | Following the `HelloWorld` example above we could push a name into the dashboard like this: 97 | 98 | ```sh 99 | curl -XPOST -H "Content-Type: application/json" \ 100 | -H "Authorization: bearer YOUR_AUTH_TOKEN" \ 101 | -d '{"name": "world"}' \ 102 | http://localhost:3000/events/hello-world 103 | ``` 104 | 105 | ### Pulling data into your dashboard using jobs 106 | 107 | Dashbling supports `jobs`: functions that are invoked at specified intervals and are expected to publish events. 108 | 109 | A typical job looks something like this: 110 | 111 | ```js 112 | module.exports = async function myJob(sendEvent) { 113 | const response = await fetch(`https://ipv4.icanhazip.com/`); 114 | const ipAddress = await response.text(); 115 | 116 | const event = { ipAddress }; 117 | sendEvent("myEventId", event); 118 | }; 119 | 120 | ``` 121 | 122 | To have Dashbling run this function periodically we configure it in the `dashbling.config.js`: 123 | 124 | ```js 125 | module.exports = { 126 | jobs: [ 127 | { 128 | schedule: "*/5 * * * *", 129 | fn: require("./jobs/myJob") 130 | } 131 | ] 132 | }; 133 | ``` 134 | 135 | Each job should have a schedule (cron expression) and a function to run. Functions can also be defined inline: 136 | 137 | ```js 138 | module.exports = { 139 | jobs: [ 140 | { 141 | schedule: "*/5 * * * *", 142 | fn: sendEvent => sendEvent("myEvent", { message: "hello world! " }) 143 | } 144 | ] 145 | }; 146 | ``` 147 | 148 | Of course, you're free to do anything you want inside these functions! 149 | A single job can also publish multiple events. 150 | 151 | ## Pulling data into your dashboard using streams 152 | 153 | Besides running scheduled jobs like above it's also possible to hold a reference to the `sendEvent` function from the start. 154 | This allows you to build any custom logic that pulls in data into your dashboard, for example using streams. 155 | 156 | To get access to the `sendEvent` function you can register an `onStart` function in the `dashbling.config.js`: 157 | 158 | ```js 159 | module.exports = { 160 | onStart: (sendEvent) => { 161 | initializeStreamListener(sendEvent); 162 | } 163 | }; 164 | ``` 165 | -------------------------------------------------------------------------------- /docs/running-multiple-dashboards.md: -------------------------------------------------------------------------------- 1 | # Running multiple dashboards 2 | 3 | A single Dashbling instance can run multiple dashboards. 4 | 5 | This can be achieved in several ways, but the gist of it is that you need to render a certain dashboard based on a URL or location hash. 6 | 7 | By default Dashbling projects render the only available dashboard like this (see the `index.js` file in your dashboard project): 8 | 9 | ```js 10 | import Dashboard from "./Dashboard"; 11 | dashbling.start(document.getElementById("root"), Dashboard); 12 | ``` 13 | 14 | Choosing a specific dashboard to render based on the location hash: 15 | 16 | ```js 17 | import Dashboard from "./Dashboard"; 18 | import Dashboard2 from "./Dashboard2"; 19 | 20 | const root = document.getElementById("root"); 21 | 22 | const dashboards = { 23 | "#dasboard1": Dashboard, 24 | "#dashboard2": Dashboard2 25 | }; 26 | 27 | const dashboard = () => dashboards[window.location.hash] || Dashboard; 28 | dashbling.start(root, dashboard()); 29 | 30 | window.addEventListener("hashchange", () => { 31 | dashbling.render(root, dashboard()); 32 | }, false); 33 | ``` 34 | 35 | Beware though that the Dashbling server does not know (or care) about your frontend dashboards, so all data published by the server will be sent to all clients even if they might be serving a dashboard that doesn't use that data. 36 | -------------------------------------------------------------------------------- /docs/sharing-widgets.md: -------------------------------------------------------------------------------- 1 | # Sharing widgets 2 | 3 | Widgets can be shared via NPM packages. 4 | 5 | Dashbling provides a generator to create a new widget package. Run it with: 6 | 7 | ```sh 8 | yarn create @dashbling/widget ~/Code/my-widget 9 | # or 10 | npx @dashbling/create-widget my-widget 11 | ``` 12 | 13 | A widget package looks like this: 14 | 15 | ```Sh 16 | ├── MyWidget.js 17 | ├── README.md 18 | ├── job.js 19 | ├── package.json 20 | ├── styles.css 21 | └── yarn.lock 22 | ``` 23 | 24 | * `MyWidget` - is where you define your widget frontend component. 25 | * `job.js` - is where you define a job to fetch data for your widget (if applicable). 26 | * `styles.css` - is where you write your css. It's a CSS module, so it's scoped to your widget. 27 | 28 | Creating a widget module is conceptually the same as creating a widget in your dashboard project. See the [get started](./getstarted.md) docs for more details. 29 | -------------------------------------------------------------------------------- /example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pascalw/dashbling/64859ef6b63f473bada69ce79b36aaebaf1fef78/example.jpg -------------------------------------------------------------------------------- /example/.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /Dockerfile -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dashbling-events 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /example/Dashboard.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { connect } from "@dashbling/client/dashbling"; 4 | import { Dashboard } from "@dashbling/client/components"; 5 | import { Clock } from "@dashbling/client/widgets"; 6 | import { HelloWidget } from "./widgets/HelloWidget"; 7 | import { GitHubStars } from "./widgets/gitHubStars/GitHubStars"; 8 | import { CircleCiStatus } from "./widgets/circleCi/CircleCiStatus"; 9 | import { WeatherWidget } from "dashbling-widget-weather"; 10 | 11 | const DashblingGitHubStars = connect("github-stars-dashbling")(GitHubStars); 12 | const DashblingCiStatus = connect("dashbling-ci-status")(CircleCiStatus); 13 | const WeatherInAmsterdam = connect("weather-amsterdam")(WeatherWidget); 14 | const BoundHelloWidget = connect("hello")(HelloWidget); 15 | 16 | export default props => { 17 | return ( 18 | 19 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /example/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.9-alpine 2 | WORKDIR /app/ 3 | 4 | ADD package.json yarn.lock /app/ 5 | RUN yarn install 6 | 7 | ADD . /app 8 | RUN yarn build 9 | 10 | FROM node:8.9-alpine 11 | WORKDIR /app/ 12 | 13 | ADD package.json yarn.lock /app/ 14 | RUN yarn install --production 15 | 16 | ADD . /app/ 17 | COPY --from=0 /app/dist /app/dist 18 | 19 | ENV NODE_ENV=production 20 | CMD ["./node_modules/.bin/dashbling", "start"] -------------------------------------------------------------------------------- /example/dashbling.config.js: -------------------------------------------------------------------------------- 1 | const { createFileHistory } = require("@dashbling/core/history"); 2 | const eventHistoryPath = require("path").join( 3 | process.cwd(), 4 | "dashbling-events" 5 | ); 6 | 7 | module.exports = { 8 | webpackConfig: config => { 9 | // return modified config 10 | // or even completely custom config. 11 | // 12 | // Example: 13 | // config.module.rules.push({ 14 | // test: /\.jpg$/, 15 | // loader: "file-loader" 16 | // }); 17 | return config; 18 | }, 19 | onStart: sendEvent => { 20 | // start custom code that sends events here, 21 | // for example listen to streams etc. 22 | }, 23 | configureServer: async hapiServer => { 24 | // Configure the Hapi server here. 25 | // See https://hapijs.com/api/17.1.1 docs for details. 26 | // This is only needed for more advanced use cases. 27 | hapiServer.route({ 28 | method: "GET", 29 | path: "/ping", 30 | handler: (_request, _h) => { 31 | return "pong"; 32 | } 33 | }); 34 | }, 35 | eventHistory: createFileHistory(eventHistoryPath), 36 | forceHttps: false, 37 | jobs: [ 38 | { 39 | schedule: "*/5 * * * *", 40 | fn: require("./jobs/githubStars")( 41 | "pascalw/dashbling", 42 | "github-stars-dashbling" 43 | ) 44 | }, 45 | { 46 | schedule: "*/5 * * * *", 47 | fn: require("./jobs/circleBuildStatus")( 48 | "github/pascalw/dashbling", 49 | "dashbling-ci-status" 50 | ) 51 | }, 52 | { 53 | schedule: "*/30 * * * *", 54 | fn: require("dashbling-widget-weather/job")( 55 | "weather-amsterdam", 56 | process.env.OPEN_WEATHER_MAP_APP_ID, 57 | "2759794" 58 | ) 59 | } 60 | ] 61 | }; 62 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | My Dashboard 6 | 7 | 8 | 9 |
10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | import * as dashbling from "@dashbling/client"; 2 | import "./styles/main.scss"; 3 | 4 | import Dashboard from "./Dashboard"; 5 | dashbling.start(document.getElementById("root"), Dashboard); 6 | 7 | if (module.hot) { 8 | module.hot.accept(); 9 | } 10 | -------------------------------------------------------------------------------- /example/jobs/circleBuildStatus.js: -------------------------------------------------------------------------------- 1 | const fetch = require("node-fetch"); 2 | 3 | module.exports = (repo, eventId) => 4 | async function circleBuildStatus(sendEvent) { 5 | try { 6 | const headers = { Accept: "application/json" }; 7 | const response = await fetch( 8 | `https://circleci.com/api/v1.1/project/${repo}?filter=completed&limit=1`, 9 | { headers: headers } 10 | ); 11 | const json = await response.json(); 12 | const buildStatus = json[0]; 13 | 14 | const event = { 15 | repo: buildStatus.reponame, 16 | outcome: buildStatus.outcome, 17 | buildUrl: buildStatus.build_url 18 | }; 19 | 20 | sendEvent(eventId, event); 21 | } catch (e) { 22 | console.warn(`Failed to fetch Circle CI build status for ${repo}`, e); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /example/jobs/githubStars.js: -------------------------------------------------------------------------------- 1 | const fetch = require("node-fetch"); 2 | 3 | module.exports = (repo, eventId) => 4 | async function gitHubStars(sendEvent) { 5 | try { 6 | const response = await fetch(`https://api.github.com/repos/${repo}`); 7 | const json = await response.json(); 8 | 9 | sendEvent(eventId, json); 10 | } catch (e) { 11 | console.warn("Failed to fetch GitHub stars", e); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "@dashbling/example", 4 | "version": "1.0.0", 5 | "author": "Pascal Widdershoven", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": 9 | "NODE_ENV=development ../node_modules/.bin/ts-node ../src/cli.ts start" 10 | }, 11 | "browserslist": "last 2 versions" 12 | } 13 | -------------------------------------------------------------------------------- /example/styles/main.scss: -------------------------------------------------------------------------------- 1 | html, body, #root { 2 | width: 100vw; 3 | min-height: 100vh; 4 | overflow-x: hidden; 5 | } 6 | 7 | * { 8 | margin: 0; 9 | padding: 0; 10 | box-sizing: border-box; 11 | } 12 | 13 | ::-webkit-scrollbar { 14 | display: none; 15 | } 16 | 17 | body { 18 | color: #212121; 19 | background: #222222; 20 | font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Open Sans","Helvetica Neue",sans-serif; 21 | font-weight: 400; 22 | -moz-osx-font-smoothing: grayscale; 23 | -webkit-font-smoothing: antialiased; 24 | text-rendering: optimizeLegibility; 25 | } 26 | 27 | img { 28 | user-select: none; 29 | } 30 | -------------------------------------------------------------------------------- /example/widgets/HelloWidget.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Widget, MediumLabel } from "@dashbling/client/Widget"; 3 | 4 | const truncate = value => 5 | value.length < 30 ? value : value.substring(0, 30) + "..."; 6 | 7 | export const HelloWidget = props => ( 8 | 9 | Hello {truncate(props.name || "world")} 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /example/widgets/circleCi/CircleCiStatus.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Widget, LargeLabel, MediumLabel } from "@dashbling/client/Widget"; 3 | 4 | const bgColor = props => { 5 | return props.outcome === "success" ? "#429c6a" : "#dd1506"; 6 | }; 7 | 8 | export const CircleCiStatus = props => { 9 | return ( 10 | 15 | {props.repo} 16 | {props.outcome} 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /example/widgets/circleCi/circleci.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/widgets/gitHubStars/GitHubStars.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Widget, 4 | SmallLabel, 5 | MediumLabel, 6 | LargeLabel 7 | } from "@dashbling/client/Widget"; 8 | 9 | export const GitHubStars = ({ full_name, stargazers_count }) => { 10 | if (stargazers_count === undefined) return null; 11 | 12 | return ( 13 | 18 | {stargazers_count} stars 19 | {full_name} 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /example/widgets/gitHubStars/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | jsfiles=$(git diff --cached --name-only --diff-filter=ACM "*.js" "*.jsx" "*.ts" "*.json" | tr '\n' ' ') 3 | [ -z "$jsfiles" ] && exit 0 4 | 5 | # Prettify all staged .js files 6 | echo "$jsfiles" | xargs ./node_modules/.bin/prettier --write 7 | 8 | # Add back the modified/prettified files to staging 9 | echo "$jsfiles" | xargs git add 10 | 11 | exit 0 12 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "npmClient": "npm", 3 | "useWorkspaces": true, 4 | "version": "independent", 5 | "registry": "https://registry.npmjs.org/", 6 | "command": { 7 | "publish": { 8 | "ignoreChanges": ["@dashbling/example", "*.md"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "packages/*", 5 | "example/" 6 | ], 7 | "scripts": { 8 | "dev": "NODE_ENV=development AUTH_TOKEN=development nodemon --config ./packages/core/nodemon.json --watch packages/core/src/ --watch example/ --exec 'bash -c' 'cd example && ts-node ../packages/core/src/cli.ts start'", 9 | "prettier": "prettier '**/*.@(js|ts|tsx|jsx|json)'", 10 | "test": "lerna run test", 11 | "build": "lerna run build", 12 | "test:e2e": "./script/e2e-tests.sh", 13 | "lerna": "lerna" 14 | }, 15 | "devDependencies": { 16 | "lerna": "^3.19.0", 17 | "prettier": "^1.9.2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/build-support/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/react", 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "modules": false, 8 | "useBuiltIns": "usage", 9 | "corejs": 3 10 | } 11 | ] 12 | ], 13 | "plugins": [ 14 | "@babel/plugin-proposal-object-rest-spread" 15 | ], 16 | "env": { 17 | "test": { 18 | "presets": [ 19 | "@babel/react", 20 | [ 21 | "@babel/preset-env", 22 | { 23 | "modules": "commonjs", 24 | "useBuiltIns": "usage", 25 | "corejs": 3, 26 | "targets": { 27 | "node": "current" 28 | } 29 | } 30 | ] 31 | ], 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/build-support/.gitignore: -------------------------------------------------------------------------------- 1 | /*.d.ts 2 | /*.js 3 | /*.js.map 4 | !postcss.config.js 5 | !webpack.config.js 6 | README.md 7 | -------------------------------------------------------------------------------- /packages/build-support/.npmignore: -------------------------------------------------------------------------------- 1 | !*.js 2 | !*.d.ts 3 | !postcss.config.js 4 | !webpack.config.js 5 | !*.js.map 6 | -------------------------------------------------------------------------------- /packages/build-support/assets.ts: -------------------------------------------------------------------------------- 1 | const Webpack = require("webpack"); 2 | import { ClientConfig } from "@dashbling/core/src/lib/clientConfig"; 3 | 4 | const buildCompiler = (clientConfig: ClientConfig, webpack: any) => { 5 | const baseConfig = require("./webpack.config")(clientConfig.projectPath); 6 | 7 | let processedConfig = Object.assign({}, baseConfig); 8 | processedConfig = clientConfig.webpackConfig(processedConfig); 9 | 10 | return webpack(processedConfig); 11 | }; 12 | 13 | export const compile = (clientConfig: ClientConfig, webpack: any = Webpack) => { 14 | return new Promise((resolve, reject) => { 15 | buildCompiler(clientConfig, webpack).run((err: Error, stats: any) => { 16 | if (err) { 17 | reject(err); 18 | } else { 19 | resolve(stats); 20 | } 21 | }); 22 | }); 23 | }; 24 | 25 | export const devMiddlewares = ( 26 | clientConfig: ClientConfig, 27 | webpack: any = Webpack 28 | ) => { 29 | const compiler = buildCompiler(clientConfig, webpack); 30 | 31 | const devMiddleware = require("webpack-dev-middleware")(compiler); 32 | const hotMiddleware = require("webpack-hot-middleware")(compiler); 33 | 34 | return [devMiddleware, hotMiddleware]; 35 | }; 36 | -------------------------------------------------------------------------------- /packages/build-support/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dashbling/build-support", 3 | "version": "0.4.1", 4 | "author": "Pascal Widdershoven", 5 | "description": "Hackable React based dashboards for developers", 6 | "license": "MIT", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/pascalw/dashbling.git" 13 | }, 14 | "scripts": { 15 | "build": "./node_modules/.bin/tsc -p .", 16 | "prepare": "yarn run --silent build && cp ../../README.md .", 17 | "test": "yarn run --silent build && jest", 18 | "test:watch": "jest --watch" 19 | }, 20 | "dependencies": { 21 | "@babel/core": "^7.7.7", 22 | "@babel/plugin-proposal-object-rest-spread": "^7.7.7", 23 | "@babel/preset-env": "^7.7.7", 24 | "@babel/preset-react": "^7.7.4", 25 | "@dashbling/core": "^0.4.1", 26 | "autoprefixer": "^9.7.3", 27 | "babel-loader": "^8.0.6", 28 | "clean-webpack-plugin": "^0.1.17", 29 | "core-js": "^3", 30 | "css-loader": "^3.4.0", 31 | "file-loader": "^3.0.1", 32 | "html-webpack-plugin": "^3.2.0", 33 | "node-sass": "^4.13.0", 34 | "postcss-loader": "^2.0.9", 35 | "sass-loader": "^6.0.6", 36 | "style-loader": "^0.19.0", 37 | "webpack": "^4.29.0", 38 | "webpack-dev-middleware": "^3.5.1", 39 | "webpack-hot-middleware": "^2.24.3" 40 | }, 41 | "devDependencies": { 42 | "@types/jest": "^21.1.8", 43 | "@types/node": "^8.0.57", 44 | "jest": "^24", 45 | "ts-jest": "^24", 46 | "ts-node": "^3.3.0", 47 | "typescript": "^3.3.1" 48 | }, 49 | "jest": { 50 | "transform": { 51 | "^.+\\.ts?$": "ts-jest" 52 | }, 53 | "transformIgnorePatterns": [ 54 | "node_modules/(?!(@dashbling)/)" 55 | ], 56 | "testRegex": "((\\.|/)test)\\.(js|ts)$", 57 | "moduleFileExtensions": [ 58 | "ts", 59 | "js" 60 | ] 61 | }, 62 | "engines": { 63 | "node": ">= 10" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/build-support/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require("autoprefixer")] 3 | }; 4 | -------------------------------------------------------------------------------- /packages/build-support/test/assets.test.ts: -------------------------------------------------------------------------------- 1 | import * as assets from "../assets"; 2 | import * as path from "path"; 3 | import { 4 | ClientConfig, 5 | load as loadConfig 6 | } from "@dashbling/core/src/lib/clientConfig"; 7 | 8 | const projectPath = path.join(__dirname, "fixture"); 9 | 10 | test("resolves with webpack stats if compilation succeeds", () => { 11 | const stats = {}; 12 | const webpackMock = jest.fn(() => { 13 | return { 14 | run: (callback: (error: Error, stats: any) => void) => { 15 | callback(null!, stats); 16 | } 17 | }; 18 | }); 19 | 20 | const config: ClientConfig = loadConfig(projectPath); 21 | 22 | const promise = assets.compile(config, webpackMock); 23 | return expect(promise).resolves.toEqual(stats); 24 | }); 25 | 26 | test("rejects promise if compilation fails", () => { 27 | const webpackMock = jest.fn(() => { 28 | return { 29 | run: (callback: (error: Error, stats: any) => void) => { 30 | callback(new Error("Compilation failed"), {}); 31 | } 32 | }; 33 | }); 34 | 35 | const config: ClientConfig = loadConfig(projectPath); 36 | 37 | const promise = assets.compile(config, webpackMock); 38 | return expect(promise).rejects.toEqual(new Error("Compilation failed")); 39 | }); 40 | 41 | test("passes webpack config to client configuration", () => { 42 | const webpackMock = jest.fn(() => { 43 | return { 44 | run: () => {} 45 | }; 46 | }); 47 | 48 | const config: ClientConfig = loadConfig(projectPath); 49 | let modifiedConfig = {}; 50 | 51 | (config as any).webpackConfig = (baseConfig: any) => { 52 | expect(baseConfig).toHaveProperty("entry"); 53 | return modifiedConfig; 54 | }; 55 | 56 | assets.compile(config, webpackMock); 57 | expect(webpackMock).toBeCalledWith(modifiedConfig); 58 | }); 59 | -------------------------------------------------------------------------------- /packages/build-support/test/fixture/dashbling.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | jobs: [], 3 | eventHistory: Promise.resolve() 4 | }; 5 | -------------------------------------------------------------------------------- /packages/build-support/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["./*.ts"], 4 | "exclude": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/build-support/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const HTML = require("html-webpack-plugin"); 4 | const Clean = require("clean-webpack-plugin"); 5 | 6 | const exclude = modulePath => { 7 | return ( 8 | /node_modules/.test(modulePath) && 9 | !/node_modules[\\/]@dashbling[\\/](?!node_modules)/.test(modulePath) && 10 | !/node_modules[\\/].*?dashbling-widget.*/.test(modulePath) 11 | ); 12 | }; 13 | 14 | module.exports = projectPath => { 15 | const env = process.env.NODE_ENV || "development"; 16 | const isProd = env === "production"; 17 | const out = path.join(projectPath, "./dist"); 18 | 19 | const optimization = { 20 | splitChunks: isProd && { chunks: "all" }, 21 | minimize: isProd, 22 | // prints more readable module names in the browser console on HMR updates, in dev 23 | namedModules: !isProd, 24 | // prevent emitting assets with errors, in dev 25 | noEmitOnErrors: !isProd 26 | }; 27 | 28 | const plugins = [ 29 | new webpack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify(env) }), 30 | new Clean([out], { root: projectPath }), 31 | new HTML({ 32 | template: path.join(projectPath, "./index.html"), 33 | inject: true, 34 | minify: isProd 35 | ? { 36 | removeComments: true, 37 | collapseWhitespace: true 38 | } 39 | : false 40 | }) 41 | ]; 42 | 43 | if (!isProd) { 44 | // dev only 45 | plugins.push( 46 | new webpack.HotModuleReplacementPlugin(), 47 | new webpack.NamedModulesPlugin(), 48 | new webpack.NoEmitOnErrorsPlugin() 49 | ); 50 | } 51 | 52 | return { 53 | mode: isProd ? "production" : "development", 54 | entry: { 55 | app: [ 56 | path.join(projectPath, "./index.js"), 57 | ...(isProd ? [] : ["webpack-hot-middleware/client"]) 58 | ] 59 | }, 60 | output: { 61 | path: out, 62 | filename: "[name].[hash].js", 63 | publicPath: "./" 64 | }, 65 | module: { 66 | rules: [ 67 | { 68 | test: /\.jsx?$/, 69 | exclude: exclude, 70 | loader: "babel-loader", 71 | options: { 72 | extends: path.join(__dirname, ".babelrc") 73 | } 74 | }, 75 | { 76 | test: /\.s?css$/, 77 | exclude: exclude, 78 | use: [ 79 | { loader: "style-loader" }, 80 | { 81 | loader: "css-loader", 82 | options: { 83 | modules: { 84 | localIdentName: "[path][name]__[local]--[hash:base64:5]" 85 | } 86 | } 87 | }, 88 | { 89 | loader: "postcss-loader", 90 | options: { 91 | config: { 92 | path: path.join(__dirname, "postcss.config.js") 93 | } 94 | } 95 | }, 96 | { loader: "sass-loader" } 97 | ] 98 | }, 99 | { 100 | test: /\.(png|svg|jpg|jpeg|gif)$/, 101 | loader: "file-loader", 102 | exclude: exclude 103 | } 104 | ] 105 | }, 106 | plugins, 107 | optimization, 108 | devtool: !isProd && "cheap-module-source-map" 109 | }; 110 | }; 111 | -------------------------------------------------------------------------------- /packages/build-support/webpackDevMiddleware.ts: -------------------------------------------------------------------------------- 1 | import * as assets from "./assets"; 2 | import { ClientConfig } from "@dashbling/core/src/lib/clientConfig"; 3 | 4 | const addMiddleware = (server: any, middleware: any) => { 5 | server.ext("onRequest", (request: any, h: any) => { 6 | const req = request.raw.req; 7 | const res = request.raw.res; 8 | 9 | return new Promise((resolve, reject) => { 10 | middleware(req, res, (err: any) => { 11 | if (err) { 12 | return reject(err); 13 | } 14 | 15 | resolve(h.continue); 16 | }); 17 | }); 18 | }); 19 | }; 20 | 21 | export const install = (server: any, clientConfig: ClientConfig) => { 22 | assets.devMiddlewares(clientConfig).forEach((middleware: any) => { 23 | addMiddleware(server, middleware); 24 | }); 25 | 26 | return Promise.resolve(); 27 | }; 28 | -------------------------------------------------------------------------------- /packages/client/.babelrc: -------------------------------------------------------------------------------- 1 | ../build-support/.babelrc -------------------------------------------------------------------------------- /packages/client/.gitignore: -------------------------------------------------------------------------------- 1 | README.md 2 | -------------------------------------------------------------------------------- /packages/client/Widget.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import styles from "./Widget.scss"; 4 | 5 | const classes = classNames => classNames.filter(Boolean).join(" "); 6 | 7 | const bgImage = image => { 8 | return ( 9 |
13 | ); 14 | }; 15 | 16 | export const Widget = (props, context) => { 17 | const className = classes([ 18 | styles.widget, 19 | context.layout.widget, 20 | props.className 21 | ]); 22 | 23 | let children = props.title ? ( 24 | 25 | {props.title} 26 |
{props.children}
27 |
28 | ) : ( 29 | props.children 30 | ); 31 | 32 | if (props.href) { 33 | children = ( 34 | 35 | {children} 36 | 37 | ); 38 | } 39 | 40 | return ( 41 |
42 | {children} 43 | 44 | {props.bgImage && bgImage(props.bgImage)} 45 |
46 | ); 47 | }; 48 | Widget.contextTypes = { layout: PropTypes.object }; 49 | 50 | export const SmallLabel = props => { 51 | return Label(props, styles.labelSmall); 52 | }; 53 | 54 | export const MediumLabel = props => { 55 | return Label(props, styles.labelMedium); 56 | }; 57 | 58 | export const LargeLabel = props => { 59 | return Label(props, styles.labelLarge); 60 | }; 61 | 62 | export const Label = (props, className) => { 63 | const labelClassName = classes([styles.label, className]); 64 | 65 | return ( 66 | 67 | {props.children} 68 | 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /packages/client/Widget.scss: -------------------------------------------------------------------------------- 1 | .widget { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | 6 | font-size: calc(100% + 0.5vw); 7 | text-align: center; 8 | 9 | position: relative; 10 | } 11 | 12 | .bgImage { 13 | background-position: center; 14 | background-size: contain; 15 | background-repeat: no-repeat; 16 | position: absolute; 17 | top: 1em; 18 | bottom: 1em; 19 | right: 1em; 20 | left: 1em; 21 | opacity: 0.15; 22 | pointer-events: none; 23 | } 24 | 25 | .inner { 26 | margin-top: auto; 27 | margin-bottom: auto; 28 | display: flex; 29 | flex-direction: column; 30 | max-height: 100%; 31 | } 32 | 33 | .label { 34 | display: block; 35 | width: 100%; 36 | text-align: center; 37 | } 38 | 39 | .labelLarge { 40 | font-size: 4em; 41 | } 42 | 43 | .labelMedium { 44 | font-size: 2em; 45 | } 46 | 47 | .labelSmall { 48 | font-size: 1.5em; 49 | } 50 | 51 | .link { 52 | display: flex; 53 | justify-content: center; 54 | flex-grow: 1; 55 | flex-direction: column; 56 | color: inherit; 57 | text-decoration: none; 58 | } 59 | -------------------------------------------------------------------------------- /packages/client/components/Dashboard.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import flexLayout from "../layouts/FlexLayout.scss"; 4 | import LastUpdatedAt from "../widgets/LastUpdatedAt"; 5 | import DashblingConnected from "../widgets/DashblingConnected"; 6 | 7 | const MetaContainer = props => { 8 | return ( 9 |
10 | 11 | 12 |
13 | ); 14 | }; 15 | 16 | const WidgetContainer = props => { 17 | return
{props.children}
; 18 | }; 19 | 20 | export class Dashboard extends React.Component { 21 | getChildContext() { 22 | return { layout: this.props.layout }; 23 | } 24 | 25 | render() { 26 | const { layout } = this.props; 27 | 28 | return ( 29 |
30 | {this.props.children} 31 | 32 | 33 |
34 | ); 35 | } 36 | } 37 | 38 | Dashboard.defaultProps = { 39 | layout: flexLayout 40 | }; 41 | 42 | Dashboard.childContextTypes = { 43 | layout: PropTypes.object 44 | }; 45 | -------------------------------------------------------------------------------- /packages/client/components/Flex.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | const style = { 3 | display: "flex", 4 | width: "100%", 5 | flexWrap: "wrap" 6 | }; 7 | 8 | export const Flex = props => { 9 | return
{props.children}
; 10 | }; 11 | -------------------------------------------------------------------------------- /packages/client/components/index.js: -------------------------------------------------------------------------------- 1 | export { Dashboard } from "./Dashboard"; 2 | export { Flex } from "./Flex"; 3 | -------------------------------------------------------------------------------- /packages/client/dashbling.js: -------------------------------------------------------------------------------- 1 | import { connect as connectRedux } from "react-redux"; 2 | import { eventReceived, dashblingConnected } from "./store"; 3 | import { heartbeat } from "../core/src/lib/constants.ts"; 4 | 5 | window.__dashblingEventSource = window.__dashblingEventSource || null; 6 | 7 | export const connectStoreToDashbling = (store, eventSource) => { 8 | // during development with HMR, this will get called multiple 9 | // times, so make sure to cleanup to prevent breaking HMR. 10 | if (window.__dashblingEventSource != null) { 11 | window.__dashblingEventSource.close(); 12 | } 13 | 14 | if (eventSource == null) { 15 | eventSource = new EventSource("/events"); 16 | } 17 | 18 | window.__dashblingEventSource = eventSource; 19 | 20 | let wasConnected = false; 21 | let reconnectTimer = null; 22 | 23 | eventSource.onopen = function() { 24 | // Reload if we were connected before. 25 | // This will happen during deploys 26 | // so this will make sure that after a deploy new widgets are loaded. 27 | if (wasConnected) location.reload(true); 28 | 29 | wasConnected = true; 30 | store.dispatch(dashblingConnected(true)); 31 | console.info("Connected to Dashbling ✅"); 32 | }; 33 | 34 | eventSource.onmessage = function(e) { 35 | if (isHeartbeat(e.data)) { 36 | return; 37 | } 38 | 39 | const eventData = JSON.parse(e.data); 40 | const id = eventData.id; 41 | const data = eventData.data; 42 | const updatedAt = eventData.updatedAt; 43 | 44 | store.dispatch(eventReceived(id, data, updatedAt)); 45 | }; 46 | 47 | eventSource.onerror = function() { 48 | store.dispatch(dashblingConnected(false)); 49 | 50 | reconnectTimer && clearTimeout(reconnectTimer); 51 | reconnectTimer = setTimeout(function() { 52 | // onerror the browser will try to reconnect in most cases, but not all. 53 | // When the browser succesfully reconnects it will call onopen again, causing the 54 | // page to reload. If the browser doesn't reconnect within 30s we're going to manually 55 | // try reconnecting to the event stream. This is to prevent the frontend getting 56 | // into a state where the connection is lost and will never reconnect again. 57 | console.warn("Trying to reconnect to Dashbling after connection failure"); 58 | eventSource.close(); 59 | connectStoreToDashbling(store); 60 | }, 30000); 61 | }; 62 | }; 63 | 64 | const isHeartbeat = message => message === heartbeat; 65 | 66 | export const connect = id => 67 | connectRedux((state, ownProps) => (state.data && state.data[id]) || {}); 68 | -------------------------------------------------------------------------------- /packages/client/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Provider } from "react-redux"; 4 | import store from "./store"; 5 | import { connectStoreToDashbling } from "./dashbling"; 6 | 7 | export const start = (root, DashboardComponent) => { 8 | connectStoreToDashbling(store); 9 | render(root, DashboardComponent); 10 | }; 11 | 12 | export const render = (rootElement, DashboardComponent) => { 13 | ReactDOM.render( 14 | 15 | 16 | , 17 | rootElement 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /packages/client/layouts/FlexLayout.scss: -------------------------------------------------------------------------------- 1 | $base-widget-size: 300px; 2 | 3 | .dashboard { 4 | display: flex; 5 | flex-direction: column; 6 | 7 | width: 100vw; 8 | min-height: 100vh; 9 | overflow-x: hidden; 10 | } 11 | 12 | .metaContainer { 13 | margin: auto 1em 1em; 14 | } 15 | 16 | .widgetContainer { 17 | display: flex; 18 | flex-wrap: wrap; 19 | justify-content: center; 20 | margin: 0.5em; 21 | } 22 | 23 | .widget { 24 | flex: 1 1 $base-widget-size; 25 | min-width: $base-widget-size; 26 | min-height: $base-widget-size; 27 | 28 | padding: 0.75em; 29 | margin: 0.5em; 30 | color: #fff; 31 | word-wrap: break-word; 32 | hyphens: auto; 33 | 34 | position: relative; 35 | } 36 | -------------------------------------------------------------------------------- /packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dashbling/client", 3 | "version": "0.4.1", 4 | "author": "Pascal Widdershoven", 5 | "description": "Hackable React based dashboards for developers", 6 | "license": "MIT", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/pascalw/dashbling.git" 13 | }, 14 | "scripts": { 15 | "test": "jest", 16 | "test:watch": "jest --watch", 17 | "prepare": "cp ../../README.md ." 18 | }, 19 | "dependencies": { 20 | "core-js": "^3", 21 | "date-fns": "^1.29.0", 22 | "prop-types": "^15.6.0", 23 | "react-redux": "^5.0.6", 24 | "redux": "^3.7.2", 25 | "timezone": "^1.0.13" 26 | }, 27 | "peerDependencies": { 28 | "react": "^16.2.0", 29 | "react-dom": "^16.2.0" 30 | }, 31 | "engines": { 32 | "node": ">= 10" 33 | }, 34 | "devDependencies": { 35 | "@babel/core": "^7.0.0", 36 | "@dashbling/build-support": "^0.4.1", 37 | "babel-core": "^7.0.0-bridge.0", 38 | "babel-jest": "^23", 39 | "enzyme": "^3.3.0", 40 | "enzyme-adapter-react-16": "^1.1.1", 41 | "identity-obj-proxy": "^3.0.0", 42 | "jest": "^24", 43 | "raf": "^3.4.0", 44 | "react": "^16.2.0", 45 | "react-dom": "^16.2.0" 46 | }, 47 | "jest": { 48 | "transform": { 49 | "^.+\\.jsx?$": "babel-jest" 50 | }, 51 | "moduleNameMapper": { 52 | "\\.(css|scss)$": "identity-obj-proxy" 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/client/store.js: -------------------------------------------------------------------------------- 1 | import { createStore } from "redux"; 2 | 3 | export const EVENT_RECEIVED = "EVENT_RECEIVED"; 4 | export const DASHBLING_CONNECTED = "DASHBLING_CONNECTED"; 5 | 6 | const dashbling = (state = {}, action) => { 7 | switch (action.type) { 8 | case EVENT_RECEIVED: 9 | return Object.assign({}, state, { 10 | lastUpdatedAt: new Date(action.updatedAt), 11 | data: Object.assign({}, state.data, { [action.id]: action.data }) 12 | }); 13 | case DASHBLING_CONNECTED: 14 | return Object.assign({}, state, { 15 | connected: action.connected 16 | }); 17 | default: 18 | return state; 19 | } 20 | }; 21 | 22 | export default createStore( 23 | dashbling, 24 | {}, 25 | window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() 26 | ); 27 | 28 | export const dashblingConnected = connected => { 29 | return { type: DASHBLING_CONNECTED, connected }; 30 | }; 31 | 32 | export const eventReceived = (id, data, updatedAt) => { 33 | return { type: EVENT_RECEIVED, id, data, updatedAt }; 34 | }; 35 | -------------------------------------------------------------------------------- /packages/client/test/Dashboard.test.js: -------------------------------------------------------------------------------- 1 | import "./react.setup"; 2 | import React, { Component } from "react"; 3 | import PropTypes from "prop-types"; 4 | import { createStore } from "redux"; 5 | import { Provider } from "react-redux"; 6 | 7 | import { mount } from "enzyme"; 8 | import { Dashboard } from "../components/Dashboard"; 9 | import flexLayout from "../layouts/FlexLayout.scss"; 10 | 11 | const store = createStore(state => state, {}); 12 | 13 | const createContextSpy = spy => { 14 | const component = (props, context) => { 15 | spy(context); 16 | return
; 17 | }; 18 | component.contextTypes = { layout: PropTypes.object }; 19 | return component; 20 | }; 21 | 22 | test("passes custom layout to children", () => { 23 | let layout = { 24 | widget: "myWidgetClass" 25 | }; 26 | 27 | const contextReceiver = jest.fn(); 28 | const ContextSpy = createContextSpy(contextReceiver); 29 | 30 | mount( 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | 38 | const receivedContext = contextReceiver.mock.calls[0][0]; 39 | expect(receivedContext.layout).toEqual(layout); 40 | }); 41 | 42 | test("uses flex layout by default", () => { 43 | const contextReceiver = jest.fn(); 44 | const ContextSpy = createContextSpy(contextReceiver); 45 | 46 | mount( 47 | 48 | 49 | 50 | 51 | 52 | ); 53 | 54 | const receivedContext = contextReceiver.mock.calls[0][0]; 55 | 56 | // JSON.stringify because mocked css module cannot be compared using toEqual :( 57 | expect(JSON.stringify(receivedContext.layout)).toEqual( 58 | JSON.stringify(flexLayout) 59 | ); 60 | }); 61 | -------------------------------------------------------------------------------- /packages/client/test/Widget.test.js: -------------------------------------------------------------------------------- 1 | import "./react.setup"; 2 | import React from "react"; 3 | import { shallow } from "enzyme"; 4 | 5 | import { Widget } from "../Widget"; 6 | 7 | describe("class name", () => { 8 | test("builds className from styles and context", () => { 9 | const wrapper = shallow(, { 10 | context: { layout: { widget: "widgetContextClass" } } 11 | }); 12 | 13 | expect(wrapper.prop("className")).toEqual("widget widgetContextClass"); 14 | }); 15 | 16 | test("appends className from prop", () => { 17 | const wrapper = shallow(, { 18 | context: { layout: { widget: "widgetContextClass" } } 19 | }); 20 | 21 | expect(wrapper.prop("className")).toEqual( 22 | "widget widgetContextClass propClassName" 23 | ); 24 | }); 25 | }); 26 | 27 | describe("href", () => { 28 | test("wraps children in anchor if href provided", () => { 29 | const wrapper = shallow( 30 | , 34 | { 35 | context: { layout: { widget: "widgetContextClass" } } 36 | } 37 | ); 38 | 39 | const anchor = wrapper.find("a"); 40 | expect(anchor.prop("href")).toEqual("https://github.com/pascalw/dashbling"); 41 | expect(anchor.prop("target")).toEqual("_blank"); 42 | }); 43 | 44 | test("does not wrap children if no href provided", () => { 45 | const wrapper = shallow(, { 46 | context: { layout: { widget: "widgetContextClass" } } 47 | }); 48 | 49 | expect(wrapper.find("a").length).toEqual(0); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /packages/client/test/dashbling.test.js: -------------------------------------------------------------------------------- 1 | import * as dashbling from "../dashbling"; 2 | import { heartbeat } from "../../core/src/lib/constants.ts"; 3 | import { dashblingConnected, eventReceived } from "../store"; 4 | 5 | const storeMock = () => { 6 | return { 7 | dispatch: jest.fn() 8 | }; 9 | }; 10 | 11 | const eventSourceMock = () => { 12 | return { 13 | close: jest.fn() 14 | }; 15 | }; 16 | 17 | test("dispatches connected message when event source is opened", () => { 18 | const store = storeMock(); 19 | const eventSource = eventSourceMock(); 20 | 21 | dashbling.connectStoreToDashbling(store, eventSource); 22 | eventSource.onopen(); 23 | 24 | expect(store.dispatch).toHaveBeenCalledWith(dashblingConnected(true)); 25 | }); 26 | 27 | test("dispatches disconnected message on error", () => { 28 | const store = storeMock(); 29 | const eventSource = eventSourceMock(); 30 | 31 | dashbling.connectStoreToDashbling(store, eventSource); 32 | eventSource.onerror(); 33 | 34 | expect(store.dispatch).toHaveBeenCalledWith(dashblingConnected(false)); 35 | }); 36 | 37 | test("dispatches received messages", () => { 38 | const store = storeMock(); 39 | const eventSource = eventSourceMock(); 40 | 41 | dashbling.connectStoreToDashbling(store, eventSource); 42 | 43 | const updatedAt = Date.now(); 44 | const event = { 45 | data: JSON.stringify({ 46 | id: "myEvent", 47 | data: { some: "arg" }, 48 | updatedAt: updatedAt 49 | }) 50 | }; 51 | eventSource.onmessage(event); 52 | 53 | expect(store.dispatch).toHaveBeenCalledWith( 54 | eventReceived("myEvent", { some: "arg" }, updatedAt) 55 | ); 56 | }); 57 | 58 | test("ignores heartbeat messages", () => { 59 | const store = storeMock(); 60 | const eventSource = eventSourceMock(); 61 | 62 | dashbling.connectStoreToDashbling(store, eventSource); 63 | 64 | const event = { data: heartbeat }; 65 | eventSource.onmessage(event); 66 | 67 | expect(store.dispatch).not.toHaveBeenCalled(); 68 | }); 69 | 70 | test("closes existing eventSources", () => { 71 | const store = storeMock(); 72 | const eventSource1 = eventSourceMock(); 73 | const eventSource2 = eventSourceMock(); 74 | 75 | dashbling.connectStoreToDashbling(store, eventSource1); 76 | dashbling.connectStoreToDashbling(store, eventSource2); 77 | 78 | expect(eventSource1.close).toHaveBeenCalled(); 79 | }); 80 | -------------------------------------------------------------------------------- /packages/client/test/react.setup.js: -------------------------------------------------------------------------------- 1 | import "raf/polyfill"; 2 | import Enzyme from "enzyme"; 3 | import Adapter from "enzyme-adapter-react-16"; 4 | 5 | Enzyme.configure({ adapter: new Adapter() }); 6 | -------------------------------------------------------------------------------- /packages/client/test/store.test.js: -------------------------------------------------------------------------------- 1 | import store, { dashblingConnected, eventReceived } from "../store"; 2 | const NOW = new Date(); 3 | 4 | test("sets connected state", () => { 5 | store.dispatch(dashblingConnected(true)); 6 | expect(store.getState()).toHaveProperty("connected", true); 7 | 8 | store.dispatch(dashblingConnected(false)); 9 | expect(store.getState()).toHaveProperty("connected", false); 10 | }); 11 | 12 | test("stores latest events per type", () => { 13 | store.dispatch(eventReceived("myEvent", { some: "data" }, NOW)); 14 | store.dispatch(eventReceived("myEvent", { some: "data2" }, NOW)); 15 | 16 | expect(store.getState().data).toHaveProperty("myEvent", { some: "data2" }); 17 | expect(store.getState()).toHaveProperty("lastUpdatedAt", NOW); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/client/widgets/Clock.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Widget, MediumLabel, LargeLabel } from "../Widget"; 3 | import tz from "timezone"; 4 | 5 | const dateFormatter = (tzdata, timezone, format) => date => { 6 | return tz(tzdata)(date, format, "en_US", timezone); 7 | }; 8 | 9 | export const Clock = class Clock extends React.Component { 10 | constructor(props) { 11 | super(props); 12 | this.state = { time: new Date() }; 13 | 14 | this.formatDate = dateFormatter( 15 | props.tzdata, 16 | props.timezone, 17 | "%a %b %e %Y" 18 | ); 19 | this.formatTime = dateFormatter(props.tzdata, props.timezone, "%H:%M"); 20 | } 21 | 22 | componentDidMount() { 23 | this.timerID = setInterval(() => this.setState({ time: new Date() }), 1000); 24 | } 25 | 26 | componentWillUnmount() { 27 | clearInterval(this.timerID); 28 | } 29 | 30 | render() { 31 | return ( 32 | 33 | {this.formatDate(this.state.time)} 34 | 35 | 36 | {this.formatTime(this.state.time)} 37 | 38 | 39 | {this.props.title && {this.props.title}} 40 | 41 | ); 42 | } 43 | }; 44 | 45 | Clock.defaultProps = { 46 | backgroundColor: "#359c94" 47 | }; 48 | -------------------------------------------------------------------------------- /packages/client/widgets/DashblingConnected.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import styles from "./DashblingConnected.scss"; 4 | 5 | const DashblingConnected = function(props) { 6 | if (props.connected == null || props.connected) return null; 7 | 8 | return ( 9 |
10 | Connection lost 11 |
12 | ); 13 | }; 14 | 15 | export default connect(function(state) { 16 | return { connected: state.connected }; 17 | })(DashblingConnected); 18 | -------------------------------------------------------------------------------- /packages/client/widgets/DashblingConnected.scss: -------------------------------------------------------------------------------- 1 | $background-color-1: #e82711; 2 | $background-color-2: #9b2d23; 3 | 4 | @keyframes pulse-connection-lost { 5 | 0% { background-color: $background-color-1; } 6 | 50% { background-color: $background-color-2; } 7 | 100% { background-color: $background-color-1; } 8 | } 9 | 10 | .connectionLost { 11 | animation: pulse-connection-lost 2s infinite; 12 | display: inline; 13 | padding: 0.5em 1em; 14 | background: $background-color-1; 15 | } 16 | -------------------------------------------------------------------------------- /packages/client/widgets/Image.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Widget } from "../Widget"; 3 | 4 | const styles = { 5 | padding: "1.5em", 6 | background: "transparent", 7 | textAlign: "center" 8 | }; 9 | 10 | export const Image = props => { 11 | return ( 12 | 13 | 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /packages/client/widgets/LastUpdatedAt.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import format from "date-fns/format"; 4 | 5 | function formatDate(date) { 6 | if (!date) return "never"; 7 | 8 | return format(date, "ddd MMM DD YYYY HH:mm"); 9 | } 10 | 11 | const LastUpdatedAt = function(props) { 12 | return ( 13 |
14 | Last updated: {formatDate(props.lastUpdatedAt)} 15 |
16 | ); 17 | }; 18 | 19 | export default connect(state => { 20 | return { lastUpdatedAt: state.lastUpdatedAt }; 21 | })(LastUpdatedAt); 22 | -------------------------------------------------------------------------------- /packages/client/widgets/index.js: -------------------------------------------------------------------------------- 1 | export { Clock } from "./Clock"; 2 | export { Image } from "./Image"; 3 | -------------------------------------------------------------------------------- /packages/core/.gitignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | node_modules/ 3 | example/dist/ 4 | .DS_Store 5 | README.md 6 | -------------------------------------------------------------------------------- /packages/core/.npmignore: -------------------------------------------------------------------------------- 1 | example/dist/ 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /packages/core/history.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | createFileHistory: require("./lib/lib/FileEventHistory").createHistory, 3 | createInMemoryHistory: require("./lib/lib/InMemoryEventHistory").createHistory 4 | }; 5 | -------------------------------------------------------------------------------- /packages/core/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["example", "test", "client"], 3 | "ext": "js json ts" 4 | } 5 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dashbling/core", 3 | "version": "0.4.1", 4 | "author": "Pascal Widdershoven", 5 | "description": "Hackable React based dashboards for developers", 6 | "license": "MIT", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/pascalw/dashbling.git" 13 | }, 14 | "bin": { 15 | "dashbling": "./lib/cli.js" 16 | }, 17 | "scripts": { 18 | "dev": "NODE_ENV=development nodemon --config ./nodemon.json --watch src/ --exec 'bash -c' 'cd example && ts-node ../src/cli.ts start'", 19 | "test": "yarn run --silent build && jest", 20 | "test:watch": "jest --watch", 21 | "test:e2e": "./script/e2e-tests.sh", 22 | "build": "./node_modules/.bin/tsc -p .", 23 | "prepare": "yarn run --silent build && chmod +x lib/cli.js && cp ../../README.md ." 24 | }, 25 | "files": [ 26 | "/client/", 27 | "/example/", 28 | "/lib/", 29 | "/src/" 30 | ], 31 | "dependencies": { 32 | "@hapi/basic": "^5.1.1", 33 | "@hapi/hapi": "^18.4.0", 34 | "@hapi/inert": "^5.2.2", 35 | "commander": "^2.12.2", 36 | "node-cron": "^1.2.1", 37 | "node-fetch": "^1.7.3", 38 | "tracer": "^0.8.11" 39 | }, 40 | "engines": { 41 | "node": ">= 10" 42 | }, 43 | "devDependencies": { 44 | "@types/hapi__hapi": "^18.2.6", 45 | "@types/jest": "^21.1.8", 46 | "@types/node": "^8.0.57", 47 | "@types/node-fetch": "^2.1.4", 48 | "jest": "^24", 49 | "nodemon": "^1.18.9", 50 | "ts-jest": "^24", 51 | "ts-node": "^3.3.0", 52 | "typescript": "^3.3.1" 53 | }, 54 | "jest": { 55 | "transform": { 56 | "^.+\\.tsx?$": "ts-jest" 57 | }, 58 | "testRegex": "((\\.|/)test)\\.(jsx?|tsx?)$", 59 | "moduleFileExtensions": [ 60 | "ts", 61 | "tsx", 62 | "js", 63 | "jsx" 64 | ] 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/core/src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import * as server from "./server"; 3 | import logger from "./lib/logger"; 4 | import * as program from "commander"; 5 | import { load } from "./lib/clientConfig"; 6 | program.version(require("../package.json").version); 7 | 8 | const projectPath = process.cwd(); 9 | 10 | program.command("start").action(async () => { 11 | process.env.NODE_ENV = process.env.NODE_ENV || "development"; 12 | 13 | try { 14 | const serverInstance = await server.start(projectPath); 15 | const gracefulShutdown = async () => { 16 | logger.info("Stopping server..."); 17 | await serverInstance.stop(); 18 | process.exit(0); 19 | }; 20 | 21 | process.once("SIGINT", gracefulShutdown); 22 | process.once("SIGTERM", gracefulShutdown); 23 | } catch (e) { 24 | logger.error(e); 25 | process.exit(1); 26 | } 27 | }); 28 | 29 | program.command("compile").action(async () => { 30 | process.env.NODE_ENV = process.env.NODE_ENV || "production"; 31 | 32 | try { 33 | const assets = await import("@dashbling/build-support/assets"); 34 | const clientConfig = load(projectPath); 35 | 36 | const stats: any = await assets.compile(clientConfig); 37 | console.log(stats.toString({ colors: true })); 38 | 39 | if (stats.hasErrors()) { 40 | process.exit(1); 41 | } else { 42 | process.exit(0); 43 | } 44 | } catch (e) { 45 | console.error(e); 46 | process.exit(1); 47 | } 48 | }); 49 | 50 | program.on("--help", () => process.exit(1)); 51 | 52 | program.on("command:*", action => { 53 | console.log(`Unknown command '${action}'`); 54 | return program.help(); 55 | }); 56 | 57 | program.parse(process.argv); 58 | 59 | if (!process.argv.slice(2).length) { 60 | program.help(); 61 | } 62 | -------------------------------------------------------------------------------- /packages/core/src/lib/Event.ts: -------------------------------------------------------------------------------- 1 | export interface Event { 2 | id: string; 3 | data: any; 4 | updatedAt: Date; 5 | } 6 | -------------------------------------------------------------------------------- /packages/core/src/lib/EventHistory.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "./Event"; 2 | 3 | export interface EventHistory { 4 | put(id: string, event: Event): Promise; 5 | getAll(): Promise; 6 | get(id: string): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/src/lib/FileEventHistory.ts: -------------------------------------------------------------------------------- 1 | import { EventHistory } from "./EventHistory"; 2 | import { createHistory as createInMemoryHistory } from "./InMemoryEventHistory"; 3 | import { Event } from "./Event"; 4 | import * as fs from "fs"; 5 | import * as util from "util"; 6 | 7 | const writeToFile = util.promisify(fs.writeFile); 8 | const readFile = util.promisify(fs.readFile); 9 | 10 | class FileEventHistory implements EventHistory { 11 | private inMemoryHistory: EventHistory; 12 | private historyFile: string; 13 | 14 | static async create(historyFile: string): Promise { 15 | const inMemoryHistory = await createInMemoryHistory(); 16 | 17 | const history = new FileEventHistory(historyFile, inMemoryHistory); 18 | await history.loadHistory(); 19 | 20 | return history; 21 | } 22 | 23 | private constructor(historyFile: string, inMemoryHistory: EventHistory) { 24 | this.historyFile = historyFile; 25 | this.inMemoryHistory = inMemoryHistory; 26 | } 27 | 28 | async put(id: string, event: Event) { 29 | await this.inMemoryHistory.put(id, event); 30 | return this.saveHistory(); 31 | } 32 | 33 | async get(id: string) { 34 | return this.inMemoryHistory.get(id); 35 | } 36 | 37 | async getAll(): Promise { 38 | return this.inMemoryHistory.getAll(); 39 | } 40 | 41 | private async saveHistory() { 42 | const events = await this.getAll(); 43 | return writeToFile(this.historyFile, JSON.stringify(events)); 44 | } 45 | 46 | private async loadHistory() { 47 | let fileContents; 48 | 49 | try { 50 | fileContents = await readFile(this.historyFile); 51 | } catch (e) { 52 | if (e.code === "ENOENT") { 53 | return this.saveHistory(); 54 | } else { 55 | throw e; 56 | } 57 | } 58 | 59 | if (fileContents.byteLength == 0) return; 60 | 61 | const serializedEvents = JSON.parse(fileContents.toString()); 62 | 63 | serializedEvents.forEach((event: any) => { 64 | event.updatedAt = new Date(event.updatedAt); 65 | this.put(event.id, event); 66 | }); 67 | } 68 | } 69 | 70 | export const createHistory = async ( 71 | historyFile: string 72 | ): Promise => { 73 | return FileEventHistory.create(historyFile); 74 | }; 75 | -------------------------------------------------------------------------------- /packages/core/src/lib/InMemoryEventHistory.ts: -------------------------------------------------------------------------------- 1 | import { EventHistory } from "./EventHistory"; 2 | import { Event } from "./Event"; 3 | 4 | class InMemoryEventHistory implements EventHistory { 5 | private history: { [key: string]: Event } = {}; 6 | 7 | async put(id: string, event: Event) { 8 | this.history[id] = event; 9 | } 10 | 11 | async get(id: string) { 12 | return this.history[id]; 13 | } 14 | 15 | async getAll(): Promise { 16 | return Object.values(this.history); 17 | } 18 | } 19 | 20 | export const createHistory = async (): Promise => { 21 | return new InMemoryEventHistory(); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/core/src/lib/authToken.ts: -------------------------------------------------------------------------------- 1 | export const generate = () => { 2 | return require("crypto") 3 | .randomBytes(20) 4 | .toString("base64") 5 | .replace(/\W/g, ""); 6 | }; 7 | -------------------------------------------------------------------------------- /packages/core/src/lib/clientConfig.ts: -------------------------------------------------------------------------------- 1 | const cron = require("node-cron"); 2 | import { Server } from "@hapi/hapi"; 3 | import * as path from "path"; 4 | import logger from "./logger"; 5 | import { generate as generateAuthToken } from "./authToken"; 6 | import { SendEvent } from "./sendEvent"; 7 | import { EventHistory } from "./EventHistory"; 8 | import { Reducer, defaultReducer } from "./eventBus"; 9 | 10 | interface ConfigSource { 11 | get: (option: string) => any; 12 | } 13 | 14 | interface Parser { 15 | (input: any): any; 16 | expected?: string; 17 | } 18 | 19 | export class JobConfig { 20 | public readonly id?: string; 21 | public readonly schedule: string; 22 | public readonly fn: () => void; 23 | 24 | constructor(schedule: string, fn: () => void) { 25 | this.schedule = schedule; 26 | this.fn = fn; 27 | } 28 | } 29 | 30 | export interface ClientConfig { 31 | readonly projectPath: string; 32 | readonly jobs: JobConfig[]; 33 | readonly onStart: (sendEvent: SendEvent) => void; 34 | readonly configureServer: (server: Server) => Promise; 35 | readonly webpackConfig: (defaultConfig: any) => any; 36 | readonly eventHistory: Promise; 37 | readonly eventReducer: Reducer; 38 | 39 | readonly forceHttps: boolean; 40 | readonly port: number; 41 | readonly authToken: string; 42 | readonly basicAuth: string | null; 43 | } 44 | 45 | const DEFAULTS: { [key: string]: any } = { 46 | port: 3000, 47 | forceHttps: false, 48 | onStart: () => {}, 49 | configureServer: () => Promise.resolve(), 50 | webpackConfig: (config: any) => config, 51 | authToken: () => { 52 | const token = generateAuthToken(); 53 | 54 | logger.warn( 55 | "No authToken was specified, using random token %s for authentication.", 56 | token 57 | ); 58 | 59 | return token; 60 | }, 61 | eventReducer: defaultReducer 62 | }; 63 | 64 | const error = (name: string, expectation: string, actualValue: any): Error => { 65 | return new Error( 66 | `Invalid '${name}' configuration. Expected '${name}' to be ${expectation}, but was '${actualValue}'.` 67 | ); 68 | }; 69 | 70 | const isFunction = (val: any): boolean => { 71 | return typeof val === "function"; 72 | }; 73 | 74 | const envify = (option: string) => { 75 | return option 76 | .split(/(?=[A-Z])/) 77 | .join("_") 78 | .toUpperCase(); 79 | }; 80 | 81 | const tryParseBool: Parser = (input: any) => { 82 | if (typeof input === "boolean") return input; 83 | 84 | if (typeof input === "string") { 85 | if (input.toLocaleLowerCase() === "true") return true; 86 | if (input.toLocaleLowerCase() === "false") return false; 87 | } 88 | 89 | return null; 90 | }; 91 | tryParseBool.expected = "a boolean"; 92 | 93 | const tryParseNumber: Parser = (input: any) => { 94 | const parsed = Number(input); 95 | return isNaN(parsed) ? null : parsed; 96 | }; 97 | tryParseNumber.expected = "a number"; 98 | 99 | const tryParseString: Parser = (input: any) => { 100 | return typeof input === "string" ? input : null; 101 | }; 102 | tryParseString.expected = "a string"; 103 | 104 | const getConfigOption = (configSources: ConfigSource[]) => ( 105 | option: string, 106 | parse: Parser 107 | ) => { 108 | for (const configSource of configSources) { 109 | const value = configSource.get(option); 110 | 111 | if (value != null) { 112 | const parsedValue = parse(value); 113 | 114 | if (parsedValue == null) { 115 | throw error(option, parse.expected!, value); 116 | } 117 | 118 | return parsedValue; 119 | } 120 | } 121 | 122 | return null; 123 | }; 124 | 125 | const envConfigSource = (env: NodeJS.ProcessEnv) => { 126 | return { 127 | get(option) { 128 | return env[envify(option)]; 129 | } 130 | }; 131 | }; 132 | 133 | const objectConfigSource = (object: any) => { 134 | return { 135 | get(option) { 136 | let value = object[option]; 137 | if (typeof value === "function") value = value(); 138 | 139 | return value; 140 | } 141 | }; 142 | }; 143 | 144 | const valideJobs = (config: any) => { 145 | if (!(config.jobs instanceof Array)) 146 | throw error("jobs", "an array", config.jobs); 147 | 148 | config.jobs.forEach((job: any) => { 149 | if (!isFunction(job.fn)) { 150 | throw error("job.fn", "a function", job.fn); 151 | } 152 | 153 | if (!cron.validate(job.schedule)) { 154 | throw error("job.schedule", "a valid cron expression", job.schedule); 155 | } 156 | }); 157 | }; 158 | 159 | export const load = (projectPath: string): ClientConfig => { 160 | const configPath = path.join(projectPath, "dashbling.config.js"); 161 | 162 | const rawConfig = require(configPath); 163 | return parse(rawConfig, projectPath); 164 | }; 165 | 166 | export const parse = ( 167 | input: any, 168 | projectPath: string, 169 | env = process.env 170 | ): ClientConfig => { 171 | valideJobs(input); 172 | 173 | if (input.onStart != null && !isFunction(input.onStart)) { 174 | throw error("onStart", "a function", input.onStart); 175 | } 176 | 177 | if (input.configureServer != null && !isFunction(input.configureServer)) { 178 | throw error("configureServer", "a function", input.configureServer); 179 | } 180 | 181 | if (input.webpackConfig != null && !isFunction(input.webpackConfig)) { 182 | throw error("webpackConfig", "a function", input.webpackConfig); 183 | } 184 | 185 | if (!(input.eventHistory instanceof Promise)) { 186 | throw error("eventHistory", "a Promise", input.eventHistory); 187 | } 188 | 189 | if (input.eventReducer != null && !isFunction(input.eventReducer)) { 190 | throw error("eventReducer", "a function", input.eventReducer); 191 | } 192 | 193 | const loadConfigOption = getConfigOption([ 194 | envConfigSource(env), 195 | objectConfigSource(input), 196 | objectConfigSource(DEFAULTS) 197 | ]); 198 | 199 | return { 200 | projectPath: projectPath, 201 | onStart: input.onStart || DEFAULTS.onStart, 202 | configureServer: input.configureServer || DEFAULTS.configureServer, 203 | webpackConfig: input.webpackConfig || DEFAULTS.webpackConfig, 204 | eventReducer: input.eventReducer || DEFAULTS.eventReducer, 205 | jobs: input.jobs, 206 | forceHttps: loadConfigOption("forceHttps", tryParseBool), 207 | port: loadConfigOption("port", tryParseNumber), 208 | authToken: loadConfigOption("authToken", tryParseString), 209 | basicAuth: loadConfigOption("basicAuth", tryParseString), 210 | eventHistory: input.eventHistory || DEFAULTS.eventHistory 211 | }; 212 | }; 213 | -------------------------------------------------------------------------------- /packages/core/src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | module.exports.heartbeat = "\uD83D\uDC93"; 2 | -------------------------------------------------------------------------------- /packages/core/src/lib/eventBus.ts: -------------------------------------------------------------------------------- 1 | import { EventHistory } from "./EventHistory"; 2 | import { Event } from "./Event"; 3 | 4 | export interface Subscriber { 5 | (event: Event): void; 6 | } 7 | 8 | export interface Reducer { 9 | (eventId: string, previousState: any | undefined, eventData: any): any; 10 | } 11 | 12 | export const defaultReducer: Reducer = ( 13 | _id: string, 14 | _previousState: any | undefined, 15 | event: any 16 | ): any => { 17 | return event; 18 | }; 19 | 20 | export class EventBus { 21 | private subscribers: Subscriber[]; 22 | private history: EventHistory; 23 | private reducer: Reducer; 24 | 25 | static withDefaultReducer(history: EventHistory) { 26 | return new EventBus(history, defaultReducer); 27 | } 28 | 29 | constructor(history: EventHistory, reducer: Reducer) { 30 | this.subscribers = []; 31 | this.history = history; 32 | this.reducer = reducer; 33 | } 34 | 35 | subscribe(subscriber: Subscriber) { 36 | this.subscribers.push(subscriber); 37 | } 38 | 39 | unsubscribe(subscriber: Subscriber) { 40 | const idx = this.subscribers.indexOf(subscriber); 41 | this.subscribers.splice(idx, 1); 42 | } 43 | 44 | async publish(id: string, data: any) { 45 | const previousState = await this.history.get(id); 46 | const previousData = previousState ? previousState.data : undefined; 47 | 48 | const reducedData = this.reducer(id, previousData, data); 49 | const event: Event = { 50 | id, 51 | data: reducedData, 52 | updatedAt: new Date(Date.now()) 53 | }; 54 | 55 | this.subscribers.forEach(subscriber => subscriber(event)); 56 | return this.history.put(id, event); 57 | } 58 | 59 | async replayHistory(subscriber: Subscriber) { 60 | const events = await this.history.getAll(); 61 | events.forEach(event => subscriber(event)); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/core/src/lib/jobs.ts: -------------------------------------------------------------------------------- 1 | const cron = require("node-cron"); 2 | import logger from "../lib/logger"; 3 | import { SendEvent } from "../lib/sendEvent"; 4 | import { ClientConfig } from "./clientConfig"; 5 | 6 | interface Job { 7 | id?: string; 8 | schedule: string; 9 | fn: (sendEvent: SendEvent) => void; 10 | } 11 | 12 | let jobs: Job[] = []; 13 | 14 | const executeJob = (sendEvent: SendEvent) => async (job: Job) => { 15 | const jobId = job.id || job.fn.name || "unknown"; 16 | logger.debug("Executing job: %s", jobId); 17 | 18 | try { 19 | await job.fn(sendEvent); 20 | } catch (e) { 21 | console.warn(`Failed to execute job ${jobId}: ${e}`); 22 | } 23 | }; 24 | 25 | const scheduleJob = (sendEvent: SendEvent) => (job: Job) => { 26 | const boundExecuteJob = executeJob(sendEvent); 27 | cron.schedule(job.schedule, () => boundExecuteJob(job)); 28 | boundExecuteJob(job); 29 | }; 30 | 31 | export const start = (clientConfig: ClientConfig, sendEvent: SendEvent) => { 32 | clientConfig.jobs.forEach(scheduleJob(sendEvent)); 33 | }; 34 | -------------------------------------------------------------------------------- /packages/core/src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | const tracer = require("tracer"); 2 | 3 | export type Level = "debug" | "info" | "warn" | "error"; 4 | 5 | export interface Logger { 6 | debug(message: string, ...args: any[]): void; 7 | info(message: string, ...args: any[]): void; 8 | warn(message: string, ...args: any[]): void; 9 | error(message: string, ...args: any[]): void; 10 | error(error: Error): void; 11 | 12 | setLevel(level: Level): void; 13 | close(): void; 14 | } 15 | 16 | export const defaultLevel: Level = (process.env.LOG_LEVEL as Level) || "info"; 17 | 18 | const logger: Logger = tracer.colorConsole({ 19 | level: defaultLevel, 20 | format: "{{timestamp}} [{{title}}] {{message}}" 21 | }); 22 | 23 | logger.setLevel = (level: Level) => { 24 | tracer.setLevel(level); 25 | }; 26 | 27 | export default logger; 28 | -------------------------------------------------------------------------------- /packages/core/src/lib/sendEvent.ts: -------------------------------------------------------------------------------- 1 | export interface SendEvent { 2 | (id: string, data: any): void; 3 | } 4 | -------------------------------------------------------------------------------- /packages/core/src/server.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "@hapi/hapi"; 2 | import { EventBus } from "./lib/eventBus"; 3 | import { createHistory as createInMemoryHistory } from "./lib/InMemoryEventHistory"; 4 | import * as jobs from "./lib/jobs"; 5 | import logger from "./lib/logger"; 6 | import { ClientConfig, load } from "./lib/clientConfig"; 7 | import { install as installHttpsEnforcement } from "./server/forceHttps"; 8 | import { install as installRoutes } from "./server/routes"; 9 | import { install as installLogging } from "./server/logging"; 10 | import { install as installBasicAuth } from "./server/basicAuth"; 11 | 12 | const installAssetHandling = async ( 13 | environment: string, 14 | server: Server, 15 | clientConfig: ClientConfig 16 | ) => { 17 | if (environment === "development") { 18 | const devMiddleware = await import("@dashbling/build-support/webpackDevMiddleware"); 19 | return devMiddleware.install(server, clientConfig); 20 | } else { 21 | const compiledAssets = await import("./server/compiledAssets"); 22 | return compiledAssets.install(server, clientConfig.projectPath); 23 | } 24 | }; 25 | 26 | const createEventHistory = (config: ClientConfig) => { 27 | if (config.eventHistory instanceof Promise) { 28 | return config.eventHistory; 29 | } 30 | 31 | if (config.eventHistory === false) { 32 | return createInMemoryHistory(); 33 | } 34 | 35 | throw new Error(`Invalid eventHistory: ${config.eventHistory}`); 36 | }; 37 | 38 | const installUnhandledRejectionHandler = (environment: string) => { 39 | if (environment === "production") { 40 | process.on("unhandledRejection", (reason, p) => { 41 | console.warn("Unhandled Rejection at:", p, "reason:", reason); 42 | }); 43 | } 44 | }; 45 | 46 | export const start = async (projectPath: string, eventBus?: EventBus) => { 47 | const clientConfig: ClientConfig = load(projectPath); 48 | const environment = process.env.NODE_ENV || "production"; 49 | 50 | const history = await createEventHistory(clientConfig); 51 | eventBus = eventBus || new EventBus(history, clientConfig.eventReducer); 52 | 53 | const server = new Server({ 54 | port: clientConfig.port 55 | }); 56 | 57 | installLogging(server); 58 | installHttpsEnforcement(server, clientConfig); 59 | await installBasicAuth(server, clientConfig); 60 | await installAssetHandling(environment, server, clientConfig); 61 | installRoutes(server, eventBus, clientConfig); 62 | 63 | const sendEvent = eventBus.publish.bind(eventBus); 64 | jobs.start(clientConfig, sendEvent); 65 | 66 | clientConfig.onStart(sendEvent); 67 | await clientConfig.configureServer(server); 68 | 69 | await server.initialize(); 70 | await server.start(); 71 | 72 | installUnhandledRejectionHandler(environment); 73 | 74 | logger.info("Server running at: %s", server.info.uri); 75 | return server; 76 | }; 77 | -------------------------------------------------------------------------------- /packages/core/src/server/basicAuth.ts: -------------------------------------------------------------------------------- 1 | import { ClientConfig } from "../lib/clientConfig"; 2 | import { Server, Request } from "@hapi/hapi"; 3 | 4 | class Credentials { 5 | readonly username: string; 6 | readonly password: string; 7 | 8 | constructor(basicAuth: string) { 9 | const parts = basicAuth.split(":"); 10 | if (parts.length != 2) { 11 | throw new Error( 12 | `Invalid basicAuth configuration provided: '${basicAuth}'.` 13 | ); 14 | } 15 | 16 | this.username = parts[0]; 17 | this.password = parts[1]; 18 | } 19 | } 20 | 21 | const validate = (credentials: Credentials) => async ( 22 | _request: Request, 23 | username: string, 24 | password: string 25 | ) => { 26 | const isValid = 27 | username === credentials.username && password === credentials.password; 28 | return { isValid, credentials }; 29 | }; 30 | 31 | export const install = async (server: Server, clientConfig: ClientConfig) => { 32 | if (!clientConfig.basicAuth) return; 33 | 34 | const credentials = new Credentials(clientConfig.basicAuth); 35 | await server.register(require("@hapi/basic")); 36 | 37 | server.auth.strategy("simple", "basic", { validate: validate(credentials) }); 38 | server.auth.default("simple"); 39 | }; 40 | -------------------------------------------------------------------------------- /packages/core/src/server/compiledAssets.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | 3 | export const install = async (server: any, projectPath: string) => { 4 | await server.register(require("@hapi/inert")); 5 | 6 | server.route({ 7 | method: "GET", 8 | path: "/{param*}", 9 | handler: { 10 | directory: { 11 | path: path.join(projectPath, "dist/") 12 | } 13 | } 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /packages/core/src/server/forceHttps.ts: -------------------------------------------------------------------------------- 1 | import { ClientConfig } from "../lib/clientConfig"; 2 | import { Server, Request, ResponseToolkit } from "@hapi/hapi"; 3 | 4 | const isHttpRequest = (request: Request) => { 5 | return request.headers["x-forwarded-proto"] !== "https"; 6 | }; 7 | 8 | const extractHost = (request: Request) => { 9 | return request.headers["x-forwarded-host"] || request.headers.host; 10 | }; 11 | 12 | export const install = (server: Server, clientConfig: ClientConfig) => { 13 | if (!clientConfig.forceHttps) return; 14 | 15 | server.ext({ 16 | type: "onRequest", 17 | method: (request: Request, h: ResponseToolkit) => { 18 | if (isHttpRequest(request)) { 19 | return h 20 | .redirect("https://" + extractHost(request) + request.path) 21 | .permanent(true) 22 | .takeover(); 23 | } 24 | 25 | return h.continue; 26 | } 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/core/src/server/logging.ts: -------------------------------------------------------------------------------- 1 | import logger from "../lib/logger"; 2 | import { Server, Request, ResponseObject } from "@hapi/hapi"; 3 | 4 | export const install = (server: Server) => { 5 | server.events.on("response", (request: Request) => { 6 | logger.info( 7 | `${request.info.remoteAddress}: ${request.method.toUpperCase()} ${ 8 | request.path 9 | } ${(request.response as ResponseObject).statusCode}` 10 | ); 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/core/src/server/routes.ts: -------------------------------------------------------------------------------- 1 | import { PassThrough } from "stream"; 2 | import { EventBus } from "../lib/eventBus"; 3 | import { Event } from "../lib/Event"; 4 | import { ClientConfig } from "../lib/clientConfig"; 5 | const { heartbeat } = require("../lib/constants"); 6 | import { Server, Request, ResponseToolkit } from "@hapi/hapi"; 7 | 8 | interface PassThroughWithHeaders extends PassThrough { 9 | headers: { [key: string]: string }; 10 | } 11 | 12 | export const install = ( 13 | server: Server, 14 | eventBus: EventBus, 15 | clientConfig: ClientConfig 16 | ) => { 17 | server.route({ 18 | method: "GET", 19 | path: "/events", 20 | handler: streamEventsHandler(eventBus) 21 | }); 22 | 23 | server.route({ 24 | method: "POST", 25 | path: "/events/{id}", 26 | options: { 27 | payload: { allow: "application/json" }, 28 | auth: false // disable basic auth, uses custom authToken 29 | }, 30 | handler: postEventHandler(eventBus, clientConfig.authToken) 31 | }); 32 | }; 33 | 34 | const streamEventsHandler = (eventBus: EventBus) => ( 35 | _req: Request, 36 | _h: ResponseToolkit 37 | ) => { 38 | const stream = new PassThrough() as PassThroughWithHeaders; 39 | stream.headers = { 40 | "content-type": "text/event-stream", 41 | "content-encoding": "identity" 42 | }; 43 | 44 | const subscriber = (event: Event) => { 45 | const outEvent = { ...event } as any; 46 | outEvent.updatedAt = event.updatedAt.getTime(); 47 | 48 | stream.write(`data: ${JSON.stringify(outEvent)}\n\n`); 49 | }; 50 | 51 | const sendHeartBeat = setInterval(() => { 52 | stream.write(`data: ${heartbeat}\n\n`); 53 | }, 20 * 1000); 54 | 55 | eventBus.subscribe(subscriber); 56 | eventBus.replayHistory(subscriber); 57 | 58 | stream.once("close", () => { 59 | eventBus.unsubscribe(subscriber); 60 | clearInterval(sendHeartBeat); 61 | }); 62 | 63 | stream.write("\n\n"); 64 | return stream; 65 | }; 66 | 67 | const postEventHandler = (eventBus: EventBus, token: string) => ( 68 | req: Request, 69 | h: ResponseToolkit 70 | ) => { 71 | const validAuthHeaders = [`Bearer ${token}`, `bearer ${token}`]; 72 | if (!validAuthHeaders.includes(req.headers.authorization)) { 73 | return h.response("Unauthorized").code(401); 74 | } 75 | 76 | eventBus.publish(req.params.id, req.payload); 77 | return "OK"; 78 | }; 79 | -------------------------------------------------------------------------------- /packages/core/test/FileEventHistory.test.ts: -------------------------------------------------------------------------------- 1 | import { createHistory } from "../src/lib/FileEventHistory"; 2 | import { mkTempFile, mkTempDir } from "./utils"; 3 | 4 | import * as fs from "fs"; 5 | import * as path from "path"; 6 | import { promisify } from "util"; 7 | 8 | test("stores latest event per id", async () => { 9 | const historyPath = await mkTempFile("history"); 10 | const history = await createHistory(historyPath); 11 | 12 | const myEvent1 = { 13 | id: "myEvent", 14 | data: { first: "event" }, 15 | updatedAt: new Date() 16 | }; 17 | await history.put("myEvent", myEvent1); 18 | let events = await history.getAll(); 19 | 20 | expect(events).toEqual([myEvent1]); 21 | 22 | const myEvent2 = { 23 | id: "myEvent", 24 | data: { second: "event" }, 25 | updatedAt: new Date() 26 | }; 27 | await history.put("myEvent", myEvent2); 28 | 29 | events = await history.getAll(); 30 | expect(events).toEqual([myEvent2]); 31 | 32 | const anotherEvent = { id: "anotherEvent", data: {}, updatedAt: new Date() }; 33 | await history.put("anotherEvent", anotherEvent); 34 | 35 | events = await history.getAll(); 36 | expect(events).toEqual([myEvent2, anotherEvent]); 37 | }); 38 | 39 | test("get event data by id", async () => { 40 | const historyPath = await mkTempFile("history"); 41 | const history = await createHistory(historyPath); 42 | 43 | const myEvent = { 44 | id: "myEvent", 45 | data: { first: "event" }, 46 | updatedAt: new Date() 47 | }; 48 | 49 | await history.put("myEvent", myEvent); 50 | const eventFromHistory = await history.get("myEvent"); 51 | 52 | expect(eventFromHistory).toEqual(myEvent); 53 | }); 54 | 55 | test("writes events to file", async () => { 56 | const historyPath = await mkTempFile("history"); 57 | const history = await createHistory(historyPath); 58 | 59 | const myEvent1 = { 60 | id: "myEvent", 61 | data: { first: "event" }, 62 | updatedAt: new Date() 63 | }; 64 | await history.put("myEvent", myEvent1); 65 | 66 | const contents = await promisify(fs.readFile)(historyPath); 67 | expect(contents.toString()).toEqual(JSON.stringify([myEvent1])); 68 | }); 69 | 70 | test("reads initial history from file", async () => { 71 | const historyPath = await mkTempFile("history"); 72 | const history1 = await createHistory(historyPath); 73 | 74 | const myEvent1 = { 75 | id: "myEvent", 76 | data: { first: "event" }, 77 | updatedAt: new Date() 78 | }; 79 | await history1.put("myEvent", myEvent1); 80 | 81 | const history2 = await createHistory(historyPath); 82 | let events = await history2.getAll(); 83 | 84 | expect(events).toEqual([myEvent1]); 85 | }); 86 | 87 | test("creates history file if it doesnt exist", async () => { 88 | const tmpDir = await mkTempDir(); 89 | const historyPath = path.join(tmpDir, "doesntexist"); 90 | const _ = await createHistory(historyPath); 91 | 92 | expect(fs.accessSync(historyPath)).toBeTruthy; 93 | }); 94 | -------------------------------------------------------------------------------- /packages/core/test/InMemoryEventHistory.test.ts: -------------------------------------------------------------------------------- 1 | import { createHistory } from "../src/lib/InMemoryEventHistory"; 2 | 3 | test("stores latest event per id", async () => { 4 | const history = await createHistory(); 5 | 6 | const myEvent1 = { 7 | id: "myEvent", 8 | data: { first: "event" }, 9 | updatedAt: new Date() 10 | }; 11 | await history.put("myEvent", myEvent1); 12 | 13 | let events = await history.getAll(); 14 | expect(events).toEqual([myEvent1]); 15 | 16 | const myEvent2 = { 17 | id: "myEvent", 18 | data: { second: "event" }, 19 | updatedAt: new Date() 20 | }; 21 | await history.put("myEvent", myEvent2); 22 | 23 | events = await history.getAll(); 24 | expect(events).toEqual([myEvent2]); 25 | 26 | const anotherEvent = { id: "anotherEvent", data: {}, updatedAt: new Date() }; 27 | await history.put("anotherEvent", anotherEvent); 28 | 29 | events = await history.getAll(); 30 | expect(events).toEqual([myEvent2, anotherEvent]); 31 | }); 32 | 33 | test("get event data by id", async () => { 34 | const history = await createHistory(); 35 | 36 | const myEvent = { 37 | id: "myEvent", 38 | data: { first: "event" }, 39 | updatedAt: new Date() 40 | }; 41 | 42 | history.put("myEvent", myEvent); 43 | const eventFromHistory = await history.get("myEvent"); 44 | 45 | expect(eventFromHistory).toEqual(myEvent); 46 | }); 47 | -------------------------------------------------------------------------------- /packages/core/test/clientConfig.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { ClientConfig, parse, load } from "../src/lib/clientConfig"; 3 | import { defaultReducer } from "../src/lib/eventBus"; 4 | 5 | const projectPath = "/fake"; 6 | const basicValidConfig = { 7 | jobs: [], 8 | eventHistory: Promise.resolve() 9 | }; 10 | 11 | const parseAndExtractError = (config: any, env?: any) => { 12 | try { 13 | parse(config, projectPath, env); 14 | } catch (e) { 15 | return e.message; 16 | } 17 | }; 18 | 19 | test("returns typed configuration if valid", () => { 20 | const raw = { 21 | ...basicValidConfig, 22 | jobs: [{ id: "myJob", schedule: "*/5 * * * *", fn: () => {} }] 23 | }; 24 | 25 | const config: ClientConfig = parse(raw, projectPath); 26 | expect(config.projectPath).toEqual(projectPath); 27 | expect(config.jobs).toEqual(raw.jobs); 28 | }); 29 | 30 | test("throws if configuration invalid", () => { 31 | const raw = {}; 32 | 33 | expect(() => { 34 | parse(raw, projectPath); 35 | }).toThrow(); 36 | }); 37 | 38 | test("validates onStart", () => { 39 | const config = { 40 | ...basicValidConfig, 41 | onStart: "wrong type" 42 | }; 43 | 44 | const error = parseAndExtractError(config); 45 | expect(error).toMatch(/Invalid 'onStart'/); 46 | }); 47 | 48 | test("validates configureServer", () => { 49 | const config = { 50 | ...basicValidConfig, 51 | configureServer: "wrong type" 52 | }; 53 | 54 | const error = parseAndExtractError(config); 55 | expect(error).toMatch(/Invalid 'configureServer'/); 56 | }); 57 | 58 | test("throws if passed invalid cron expression", () => { 59 | const raw = { 60 | ...basicValidConfig, 61 | jobs: [{ schedule: "not a cron exp", fn: () => {} }] 62 | }; 63 | 64 | const error = parseAndExtractError(raw); 65 | 66 | expect(error).toEqual( 67 | "Invalid 'job.schedule' configuration. Expected 'job.schedule' to be a valid cron expression, but was 'not a cron exp'." 68 | ); 69 | }); 70 | 71 | describe("forceHttps", () => { 72 | test("throws if invalid forceHttps", () => { 73 | const rawConfig = { 74 | ...basicValidConfig, 75 | forceHttps: "not a bool" 76 | }; 77 | 78 | const error = parseAndExtractError(rawConfig); 79 | expect(error).toMatch(/forceHttps/); 80 | }); 81 | 82 | test("takes env var over config.js", () => { 83 | const rawConfig = { 84 | ...basicValidConfig, 85 | forceHttps: false 86 | }; 87 | 88 | const env = { FORCE_HTTPS: "true" }; 89 | 90 | const config: ClientConfig = parse(rawConfig, projectPath, env); 91 | expect(config.forceHttps).toBe(true); 92 | }); 93 | }); 94 | 95 | describe("port", () => { 96 | test("defaults to 3000", () => { 97 | const config: ClientConfig = parse(basicValidConfig, projectPath); 98 | expect(config.port).toEqual(3000); 99 | }); 100 | 101 | test("supports env var", () => { 102 | const env = { PORT: "1234" }; 103 | 104 | const config: ClientConfig = parse(basicValidConfig, projectPath, env); 105 | expect(config.port).toEqual(1234); 106 | }); 107 | 108 | test("throws if not a number", () => { 109 | const env = { PORT: "foobar" }; 110 | 111 | const error = parseAndExtractError(basicValidConfig, env); 112 | expect(error).toMatch(/port/); 113 | }); 114 | }); 115 | 116 | describe("authToken", () => { 117 | test("sets default", () => { 118 | const config: ClientConfig = parse(basicValidConfig, projectPath); 119 | expect(typeof config.authToken).toBe("string"); 120 | }); 121 | 122 | test("supports env var", () => { 123 | const env = { AUTH_TOKEN: "s3cr3t" }; 124 | 125 | const config: ClientConfig = parse(basicValidConfig, projectPath, env); 126 | expect(config.authToken).toEqual("s3cr3t"); 127 | }); 128 | }); 129 | 130 | describe("basicAuth", () => { 131 | test("no default", () => { 132 | const config: ClientConfig = parse(basicValidConfig, projectPath); 133 | expect(config.basicAuth).toBeNull(); 134 | }); 135 | 136 | test("supports env var", () => { 137 | const env = { BASIC_AUTH: "username:password" }; 138 | 139 | const config: ClientConfig = parse(basicValidConfig, projectPath, env); 140 | expect(config.basicAuth).toEqual("username:password"); 141 | }); 142 | }); 143 | 144 | describe("webpackConfig", () => { 145 | test("default no-op", () => { 146 | const config: ClientConfig = parse(basicValidConfig, projectPath); 147 | 148 | const webpackConfig = {}; 149 | expect(config.webpackConfig(webpackConfig)).toBe(webpackConfig); 150 | }); 151 | 152 | test("validates is a function", () => { 153 | const rawConfig = { 154 | ...basicValidConfig, 155 | webpackConfig: 123 156 | }; 157 | 158 | const error = parseAndExtractError(rawConfig); 159 | expect(error).toMatch(/webpackConfig/); 160 | }); 161 | }); 162 | 163 | describe("eventReducer", () => { 164 | test("defaults to defaultReducer", () => { 165 | const config: ClientConfig = parse(basicValidConfig, projectPath); 166 | expect(config.eventReducer).toEqual(defaultReducer); 167 | }); 168 | 169 | test("validates is a function", () => { 170 | const rawConfig = { 171 | ...basicValidConfig, 172 | eventReducer: 123 173 | }; 174 | 175 | const error = parseAndExtractError(rawConfig); 176 | expect(error).toMatch(/eventReducer/); 177 | }); 178 | }); 179 | 180 | test("loads and validates config from file", () => { 181 | const projectPath = path.join(__dirname, "fixture"); 182 | const config: ClientConfig = load(projectPath); 183 | 184 | expect(config.projectPath).toEqual(projectPath); 185 | }); 186 | 187 | test("throws when config cannot be loaded", () => { 188 | const projectPath = path.join("/tmp/bogus"); 189 | 190 | expect(() => { 191 | load(projectPath); 192 | }).toThrowError(/Cannot find module/); 193 | }); 194 | 195 | describe("eventHistory", () => { 196 | test("supports promise", () => { 197 | const rawConfig = { 198 | ...basicValidConfig, 199 | eventHistory: Promise.resolve("this should yield an EventHistory, really") 200 | }; 201 | 202 | const config: ClientConfig = parse(rawConfig, projectPath); 203 | expect(config.eventHistory).toEqual(rawConfig.eventHistory); 204 | }); 205 | 206 | test("throws if invalid", () => { 207 | const assertThrowsEventHistoryError = (input: any) => { 208 | const rawConfig = { 209 | ...basicValidConfig, 210 | eventHistory: input 211 | }; 212 | 213 | const error = parseAndExtractError(rawConfig); 214 | expect(error).toMatch(/eventHistory/); 215 | }; 216 | 217 | assertThrowsEventHistoryError(true); 218 | assertThrowsEventHistoryError(false); 219 | assertThrowsEventHistoryError(0); 220 | assertThrowsEventHistoryError({}); 221 | assertThrowsEventHistoryError(() => ""); 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /packages/core/test/eventBus.test.ts: -------------------------------------------------------------------------------- 1 | import { EventBus, defaultReducer } from "../src/lib/eventBus"; 2 | import { mockDate, restoreDate } from "./utils"; 3 | import { createHistory } from "../src/lib/InMemoryEventHistory"; 4 | 5 | const NOW = new Date(); 6 | 7 | beforeEach(() => { 8 | mockDate(NOW); 9 | }); 10 | 11 | afterEach(() => { 12 | restoreDate(); 13 | }); 14 | 15 | test("sends events to all subscribers", async () => { 16 | const subscriber1 = jest.fn(); 17 | const subscriber2 = jest.fn(); 18 | 19 | const history = await createHistory(); 20 | const eventBus = new EventBus(history, defaultReducer); 21 | 22 | eventBus.subscribe(subscriber1); 23 | eventBus.subscribe(subscriber2); 24 | 25 | await eventBus.publish("myEvent", { arg: "1" }); 26 | 27 | expect(subscriber1).toBeCalledWith({ 28 | id: "myEvent", 29 | data: { arg: "1" }, 30 | updatedAt: NOW 31 | }); 32 | expect(subscriber2).toBeCalledWith({ 33 | id: "myEvent", 34 | data: { arg: "1" }, 35 | updatedAt: NOW 36 | }); 37 | }); 38 | 39 | describe("event reducing", () => { 40 | test("passes the event data to the reducer", async () => { 41 | const subscriber1 = jest.fn(); 42 | const history = await createHistory(); 43 | 44 | const reducer = (_id: string, _previousState: any, eventData: any) => { 45 | return { wrapped: eventData }; 46 | }; 47 | 48 | const eventBus = new EventBus(history, reducer); 49 | 50 | eventBus.subscribe(subscriber1); 51 | await eventBus.publish("myEvent", { arg: "1" }); 52 | 53 | expect(subscriber1).toBeCalledWith({ 54 | id: "myEvent", 55 | data: { wrapped: { arg: "1" } }, 56 | updatedAt: NOW 57 | }); 58 | }); 59 | 60 | test("previousState is undefined if no previousState was stored", async () => { 61 | const previousStateSpy = jest.fn(); 62 | const history = await createHistory(); 63 | 64 | const reducer = (_id: string, previousState: any, eventData: any) => { 65 | previousStateSpy(previousState); 66 | return eventData; 67 | }; 68 | 69 | const eventBus = new EventBus(history, reducer); 70 | await eventBus.publish("myEvent", { arg: "1" }); 71 | 72 | expect(previousStateSpy).toBeCalledWith(undefined); 73 | }); 74 | }); 75 | 76 | test("replays previously received events", async () => { 77 | const subscriber = jest.fn(); 78 | 79 | const history = await createHistory(); 80 | const eventBus = new EventBus(history, defaultReducer); 81 | 82 | await eventBus.publish("myEvent", { arg: "1" }); 83 | await eventBus.publish("myEvent", { arg: "2" }); // last per id is replayed 84 | await eventBus.publish("myOtherEvent", { arg: "3" }); 85 | 86 | eventBus.subscribe(subscriber); 87 | await eventBus.replayHistory(subscriber); 88 | 89 | expect(subscriber).toBeCalledWith({ 90 | id: "myEvent", 91 | data: { arg: "2" }, 92 | updatedAt: NOW 93 | }); 94 | expect(subscriber).toBeCalledWith({ 95 | id: "myOtherEvent", 96 | data: { arg: "3" }, 97 | updatedAt: NOW 98 | }); 99 | }); 100 | 101 | test("stops sending events after unsubscribe", async () => { 102 | const subscriber1 = jest.fn(); 103 | const subscriber2 = jest.fn(); 104 | 105 | const history = await createHistory(); 106 | const eventBus = new EventBus(history, defaultReducer); 107 | 108 | eventBus.subscribe(subscriber1); 109 | eventBus.subscribe(subscriber2); 110 | eventBus.unsubscribe(subscriber1); 111 | 112 | await eventBus.publish("myEvent", { arg: "1" }); 113 | 114 | expect(subscriber2).toBeCalledWith({ 115 | id: "myEvent", 116 | data: { arg: "1" }, 117 | updatedAt: NOW 118 | }); 119 | expect(subscriber1).not.toBeCalled(); 120 | }); 121 | -------------------------------------------------------------------------------- /packages/core/test/fixture/dashbling.config.js: -------------------------------------------------------------------------------- 1 | const { createHistory } = require("../../src/lib/InMemoryEventHistory"); 2 | 3 | module.exports = { 4 | jobs: [], 5 | eventHistory: createHistory() 6 | }; 7 | -------------------------------------------------------------------------------- /packages/core/test/integration/server.integration.test.ts: -------------------------------------------------------------------------------- 1 | import * as server from "../../src/server"; 2 | import { EventBus } from "../../src/lib/eventBus"; 3 | import { SendEvent } from "../../src/lib/sendEvent"; 4 | import * as path from "path"; 5 | import * as http from "http"; 6 | import { mockDate, restoreDate, mkTempFile } from "../utils"; 7 | import { createHistory } from "../../src/lib/InMemoryEventHistory"; 8 | import fetch from "node-fetch"; 9 | 10 | const dashblingConfig = require("../fixture/dashbling.config"); 11 | const originalConfig = Object.assign({}, dashblingConfig); 12 | 13 | let serverInstance: any; 14 | 15 | const extractEvents = (onEvent: ((event: any) => void)) => ( 16 | response: http.IncomingMessage 17 | ) => { 18 | response.on("data", data => { 19 | const stringData = data.toString(); 20 | if (stringData.startsWith("data:")) { 21 | const eventData = JSON.parse(stringData.substring(6)); 22 | onEvent(eventData); 23 | } 24 | }); 25 | }; 26 | 27 | const createEventBus = async () => { 28 | const history = await createHistory(); 29 | return EventBus.withDefaultReducer(history); 30 | }; 31 | 32 | const NOW = new Date(); 33 | 34 | beforeEach(async () => { 35 | mockDate(NOW); 36 | 37 | Object.keys(dashblingConfig).forEach(key => { 38 | delete dashblingConfig[key]; 39 | }); 40 | Object.assign(dashblingConfig, originalConfig); 41 | 42 | process.env.PORT = "12345"; 43 | process.env.AUTH_TOKEN = "foobar"; 44 | process.env.EVENT_STORAGE_PATH = await mkTempFile("test-events"); 45 | serverInstance = null; 46 | }); 47 | 48 | afterEach(() => { 49 | restoreDate(); 50 | delete process.env.PORT; 51 | delete process.env.AUTH_TOKEN; 52 | delete process.env.EVENT_STORAGE_PATH; 53 | serverInstance && serverInstance.stop(); 54 | }); 55 | 56 | test("sends events over /events stream", async () => { 57 | const eventBus = await createEventBus(); 58 | serverInstance = await server.start( 59 | path.join(__dirname, "..", "fixture"), 60 | eventBus 61 | ); 62 | eventBus.publish("myEvent", { some: "arg" }); 63 | 64 | return new Promise((resolve, _reject) => { 65 | const req = http.get( 66 | "http://127.0.0.1:12345/events", 67 | extractEvents(event => { 68 | expect(event).toEqual({ 69 | id: "myEvent", 70 | data: { some: "arg" }, 71 | updatedAt: NOW.getTime() 72 | }); 73 | 74 | req.abort(); 75 | resolve(); 76 | }) 77 | ); 78 | }); 79 | }); 80 | 81 | describe("receiving events over HTTP", () => { 82 | test("requires AUTH_TOKEN", async () => { 83 | const eventBus = await createEventBus(); 84 | serverInstance = await server.start( 85 | path.join(__dirname, "..", "fixture"), 86 | eventBus 87 | ); 88 | 89 | return new Promise((resolve, _reject) => { 90 | eventBus.subscribe(event => { 91 | expect(event).toEqual({ 92 | id: "myEvent", 93 | data: { some: "arg" }, 94 | updatedAt: NOW 95 | }); 96 | resolve(); 97 | }); 98 | 99 | const request = http.request({ 100 | hostname: "127.0.0.1", 101 | port: 12345, 102 | path: "/events/myEvent", 103 | method: "POST", 104 | headers: { 105 | Authorization: `bearer ${process.env.AUTH_TOKEN}` 106 | } 107 | }); 108 | 109 | request.write(JSON.stringify({ some: "arg" })); 110 | request.end(); 111 | }); 112 | }); 113 | 114 | test("header specifying AUTH_TOKEN is case insensitive", async () => { 115 | const eventBus = await createEventBus(); 116 | serverInstance = await server.start( 117 | path.join(__dirname, "..", "fixture"), 118 | eventBus 119 | ); 120 | 121 | return new Promise((resolve, _reject) => { 122 | eventBus.subscribe(event => { 123 | expect(event).toEqual({ 124 | id: "myEvent", 125 | data: { some: "arg" }, 126 | updatedAt: NOW 127 | }); 128 | resolve(); 129 | }); 130 | 131 | const request = http.request({ 132 | hostname: "127.0.0.1", 133 | port: 12345, 134 | path: "/events/myEvent", 135 | method: "POST", 136 | headers: { 137 | Authorization: `Bearer ${process.env.AUTH_TOKEN}` 138 | } 139 | }); 140 | 141 | request.write(JSON.stringify({ some: "arg" })); 142 | request.end(); 143 | }); 144 | }); 145 | 146 | test("with basicAuth enabled, still requires AUTH_TOKEN", async () => { 147 | dashblingConfig.basicAuth = "username:password"; 148 | 149 | const eventBus = await createEventBus(); 150 | serverInstance = await server.start( 151 | path.join(__dirname, "..", "fixture"), 152 | eventBus 153 | ); 154 | 155 | return new Promise((resolve, _reject) => { 156 | eventBus.subscribe(event => { 157 | expect(event).toEqual({ 158 | id: "myEvent", 159 | data: { some: "arg" }, 160 | updatedAt: NOW 161 | }); 162 | resolve(); 163 | }); 164 | 165 | const request = http.request({ 166 | hostname: "127.0.0.1", 167 | port: 12345, 168 | path: "/events/myEvent", 169 | method: "POST", 170 | headers: { 171 | Authorization: `bearer ${process.env.AUTH_TOKEN}` 172 | } 173 | }); 174 | 175 | request.write(JSON.stringify({ some: "arg" })); 176 | request.end(); 177 | }); 178 | }); 179 | }); 180 | 181 | test("executes jobs on start", async () => { 182 | const eventBus = await createEventBus(); 183 | const publishSpy = jest.spyOn(eventBus, "publish"); 184 | 185 | const jobFn = (sendEvent: SendEvent) => { 186 | sendEvent("myJob", {}); 187 | }; 188 | 189 | dashblingConfig.jobs.push({ 190 | schedule: "*/5 * * * *", 191 | fn: jobFn 192 | }); 193 | 194 | serverInstance = await server.start( 195 | path.join(__dirname, "..", "fixture"), 196 | eventBus 197 | ); 198 | expect(publishSpy).toHaveBeenCalledWith("myJob", {}); 199 | }); 200 | 201 | test("calls config.onStart", async () => { 202 | const eventBus = await createEventBus(); 203 | const publishSpy = jest.spyOn(eventBus, "publish"); 204 | 205 | dashblingConfig.onStart = jest.fn((sendEvent: SendEvent) => { 206 | sendEvent("myEvent", {}); 207 | }); 208 | 209 | serverInstance = await server.start( 210 | path.join(__dirname, "..", "fixture"), 211 | eventBus 212 | ); 213 | 214 | expect(dashblingConfig.onStart).toHaveBeenCalled(); // can't compare bound functions :( 215 | expect(publishSpy).toHaveBeenCalledWith("myEvent", {}); 216 | }); 217 | 218 | test("calls config.configureServer", async () => { 219 | const eventBus = await createEventBus(); 220 | 221 | dashblingConfig.configureServer = jest.fn((server: any) => { 222 | expect(server.route).not.toBeUndefined(); 223 | return Promise.resolve(); 224 | }); 225 | 226 | serverInstance = await server.start( 227 | path.join(__dirname, "..", "fixture"), 228 | eventBus 229 | ); 230 | 231 | expect(dashblingConfig.configureServer).toHaveBeenCalled(); 232 | }); 233 | 234 | describe("forcing https", () => { 235 | test("supports forcing https", async () => { 236 | dashblingConfig.forceHttps = true; 237 | const eventBus = await createEventBus(); 238 | 239 | serverInstance = await server.start( 240 | path.join(__dirname, "..", "fixture"), 241 | eventBus 242 | ); 243 | 244 | const response = await fetch(`http://localhost:${process.env.PORT}/`, { 245 | redirect: "manual" 246 | }); 247 | 248 | expect(response.status).toEqual(301); 249 | expect(response.headers.get("location")).toEqual( 250 | `https://localhost:${process.env.PORT}/` 251 | ); 252 | }); 253 | 254 | test("redirects to x-forwarded-host", async () => { 255 | dashblingConfig.forceHttps = true; 256 | const eventBus = await createEventBus(); 257 | 258 | serverInstance = await server.start( 259 | path.join(__dirname, "..", "fixture"), 260 | eventBus 261 | ); 262 | 263 | const response = await fetch(`http://localhost:${process.env.PORT}/`, { 264 | redirect: "manual", 265 | headers: { 266 | "X-Forwarded-Host": "example.org" 267 | } 268 | }); 269 | 270 | expect(response.status).toEqual(301); 271 | expect(response.headers.get("location")).toEqual(`https://example.org/`); 272 | }); 273 | 274 | test("does not redirect if x-forwarded-proto is https", async () => { 275 | dashblingConfig.forceHttps = true; 276 | const eventBus = await createEventBus(); 277 | 278 | serverInstance = await server.start( 279 | path.join(__dirname, "..", "fixture"), 280 | eventBus 281 | ); 282 | 283 | const response = await fetch(`http://localhost:${process.env.PORT}/`, { 284 | redirect: "manual", 285 | headers: { 286 | "X-Forwarded-Proto": "https" 287 | } 288 | }); 289 | 290 | expect(response.status).not.toEqual(301); 291 | }); 292 | }); 293 | 294 | describe("basic auth", () => { 295 | test("401 if no auth provided", async () => { 296 | dashblingConfig.basicAuth = "username:password"; 297 | const eventBus = await createEventBus(); 298 | 299 | serverInstance = await server.start( 300 | path.join(__dirname, "..", "fixture"), 301 | eventBus 302 | ); 303 | 304 | const response = await fetch(`http://localhost:${process.env.PORT}/`); 305 | expect(response.status).toEqual(401); 306 | }); 307 | 308 | test("401 if invalid auth provided", async () => { 309 | dashblingConfig.basicAuth = "username:password"; 310 | const eventBus = await createEventBus(); 311 | 312 | serverInstance = await server.start( 313 | path.join(__dirname, "..", "fixture"), 314 | eventBus 315 | ); 316 | 317 | const response = await fetch(`http://localhost:${process.env.PORT}/`, { 318 | headers: { 319 | Authorization: "Basic " + Buffer.from("foo:bar").toString("base64") 320 | } 321 | }); 322 | 323 | expect(response.status).toEqual(401); 324 | }); 325 | 326 | test("Pass if valid auth provided", async () => { 327 | dashblingConfig.basicAuth = "username:password"; 328 | const eventBus = await createEventBus(); 329 | 330 | serverInstance = await server.start( 331 | path.join(__dirname, "..", "fixture"), 332 | eventBus 333 | ); 334 | 335 | const response = await fetch(`http://localhost:${process.env.PORT}/`, { 336 | headers: { 337 | Authorization: 338 | "Basic " + Buffer.from(dashblingConfig.basicAuth).toString("base64") 339 | } 340 | }); 341 | expect(response.status).not.toEqual(401); 342 | }); 343 | }); 344 | -------------------------------------------------------------------------------- /packages/core/test/jobs.test.ts: -------------------------------------------------------------------------------- 1 | import * as jobs from "../src/lib/jobs"; 2 | import { parse as parseConfig } from "../src/lib/clientConfig"; 3 | 4 | const basicValidClientConfig = parseConfig( 5 | { 6 | jobs: [], 7 | eventHistory: Promise.resolve() 8 | }, 9 | "/fake" 10 | ); 11 | 12 | test("catches job errors", async () => { 13 | const throwingJob = { 14 | schedule: "59 22 11 11 *", 15 | fn: async () => { 16 | throw "boom"; 17 | } 18 | }; 19 | 20 | const clientConfig = { 21 | ...basicValidClientConfig, 22 | jobs: [throwingJob] 23 | }; 24 | 25 | const sendEvent = jest.fn(); 26 | 27 | expect(() => { 28 | jobs.start(clientConfig, sendEvent); 29 | }).not.toThrow(); 30 | }); 31 | -------------------------------------------------------------------------------- /packages/core/test/utils.ts: -------------------------------------------------------------------------------- 1 | import { EventHistory } from "../src/lib/EventHistory"; 2 | import * as fs from "fs"; 3 | import * as os from "os"; 4 | import * as path from "path"; 5 | import * as util from "util"; 6 | 7 | export const mockDate = (date: Date) => { 8 | const originalNow = Date.now; 9 | const mockedDateFn = () => date.getTime(); 10 | (mockedDateFn as any).restore = () => (Date.now = originalNow); 11 | 12 | Date.now = mockedDateFn; 13 | }; 14 | 15 | export const restoreDate = () => { 16 | (Date.now as any).restore(); 17 | }; 18 | 19 | export const mkTempDir = async () => { 20 | const mkdtemp = util.promisify(fs.mkdtemp); 21 | return await mkdtemp(path.join(os.tmpdir(), "dashbling-tests-")); 22 | }; 23 | 24 | export const mkTempFile = async (filename: string) => { 25 | const writeFile = util.promisify(fs.writeFile); 26 | 27 | const tmpDir = await mkTempDir(); 28 | const pathToFile = path.join(tmpDir, filename); 29 | await writeFile(pathToFile, ""); 30 | 31 | return pathToFile; 32 | }; 33 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "lib" 5 | }, 6 | "include": ["src/**/*.ts"], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/create-dashbling-app/.gitignore: -------------------------------------------------------------------------------- 1 | /templates 2 | README.md 3 | -------------------------------------------------------------------------------- /packages/create-dashbling-app/.npmignore: -------------------------------------------------------------------------------- 1 | !/templates/ 2 | -------------------------------------------------------------------------------- /packages/create-dashbling-app/create-dashboard.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const yargs = require("yargs"); 4 | const { createEnv } = require("yeoman-environment"); 5 | const { resolve, basename } = require("path"); 6 | 7 | const env = createEnv(); 8 | const done = exitCode => process.exit(exitCode || 0); 9 | const dashboardGenerator = resolve(__dirname); 10 | env.register(require.resolve(dashboardGenerator), "create-dashboard"); 11 | 12 | const cli = yargs 13 | .command("") 14 | .demandCommand(1) 15 | .help() 16 | .wrap(null).argv; 17 | 18 | const directory = resolve(cli._[0]); 19 | const name = basename(directory); 20 | 21 | env.run( 22 | "create-dashboard", 23 | { 24 | directory, 25 | name 26 | }, 27 | done 28 | ); 29 | -------------------------------------------------------------------------------- /packages/create-dashbling-app/index.js: -------------------------------------------------------------------------------- 1 | const { ensureDirSync, readJsonSync, writeJsonSync } = require("fs-extra"); 2 | const { resolve, dirname } = require("path"); 3 | const { sync: commandExistsSync } = require("command-exists"); 4 | const Generator = require("yeoman-generator"); 5 | 6 | const supportsGit = commandExistsSync("git"); 7 | const supportsYarn = commandExistsSync("yarnpkg"); 8 | const installer = supportsYarn ? "yarn" : "npm"; 9 | 10 | const dashblingVersion = "^0"; 11 | 12 | module.exports = class extends Generator { 13 | constructor(args, opts) { 14 | super(args, opts); 15 | this.installDependencies = supportsYarn 16 | ? this.yarnInstall 17 | : this.npmInstall; 18 | } 19 | 20 | paths() { 21 | this.destinationRoot(this.options.directory); 22 | } 23 | 24 | _copy(path) { 25 | this.fs.copy(this.templatePath(path), this.destinationPath(path)); 26 | } 27 | 28 | writing() { 29 | ensureDirSync(this.destinationPath()); 30 | 31 | this.spawnCommandSync(installer, ["init", "--yes"]); 32 | 33 | const jsonPath = this.destinationPath("package.json"); 34 | const json = readJsonSync(jsonPath); 35 | const packageJson = Object.assign(json, { 36 | private: true, 37 | scripts: { 38 | start: "dashbling start", 39 | build: "dashbling compile" 40 | }, 41 | browserslist: "last 2 versions", 42 | devDependencies: { 43 | "@dashbling/build-support": 44 | process.env.DASHBLING_BUILD_SUPPORT_PACKAGE || dashblingVersion 45 | }, 46 | dependencies: { 47 | react: "^16.2", 48 | "react-dom": "^16.2", 49 | "@dashbling/core": 50 | process.env.DASHBLING_CORE_PACKAGE || dashblingVersion, 51 | "@dashbling/client": 52 | process.env.DASHBLING_CLIENT_PACKAGE || dashblingVersion, 53 | "dashbling-widget-weather": "^2.0.0" 54 | } 55 | }); 56 | 57 | delete packageJson.license; 58 | 59 | writeJsonSync(jsonPath, packageJson, { spaces: 2 }); 60 | 61 | [ 62 | "dashbling.config.js", 63 | "index.html", 64 | "index.js", 65 | "Dockerfile", 66 | "Dashboard.js", 67 | "widgets/", 68 | "styles/", 69 | "jobs/" 70 | ].forEach(this._copy.bind(this)); 71 | 72 | this.fs.copy( 73 | this.templatePath("gitignore"), 74 | this.destinationPath(".gitignore") 75 | ); 76 | 77 | this.installDependencies(); 78 | } 79 | 80 | end() { 81 | if (supportsGit) { 82 | this.spawnCommandSync("git", ["init", "--quiet"]); 83 | this.spawnCommandSync("git", ["add", "--all"]); 84 | this.spawnCommandSync("git", [ 85 | "commit", 86 | "-m", 87 | "Initial commit - new dashbling project.", 88 | "--quiet" 89 | ]); 90 | } 91 | } 92 | }; 93 | -------------------------------------------------------------------------------- /packages/create-dashbling-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dashbling/create-dashboard", 3 | "version": "0.4.1", 4 | "author": "Pascal Widdershoven", 5 | "description": "Hackable React based dashboards for developers", 6 | "license": "MIT", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/pascalw/dashbling.git" 13 | }, 14 | "bin": { 15 | "create-dashbling-dashboard": "./create-dashboard.js", 16 | "create-dashboard": "./create-dashboard.js" 17 | }, 18 | "scripts": { 19 | "prepare": "./script/prepare.sh", 20 | "test:e2e": "./test.sh" 21 | }, 22 | "dependencies": { 23 | "command-exists": "^1.2.2", 24 | "fs-extra": "^5.0.0", 25 | "yargs": "^10.0.3", 26 | "yeoman-environment": "^2.0.5", 27 | "yeoman-generator": "^2.0.1" 28 | }, 29 | "engines": { 30 | "node": ">= 10" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/create-dashbling-app/script/prepare.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | cp ../../README.md . 3 | 4 | TEMPLATES_DIR=$(pwd)/templates 5 | [ -e "$TEMPLATES_DIR" ] && rm -r "$TEMPLATES_DIR" 6 | mkdir "$TEMPLATES_DIR" 7 | 8 | cd ../../example && \ 9 | tar cf - \ 10 | --exclude=node_modules/ \ 11 | --exclude=package.json \ 12 | --exclude=yarn.lock \ 13 | . | (cd "$TEMPLATES_DIR" && tar xvf - ) 14 | 15 | # npm packages don't support .gitignore files :( 16 | mv "$TEMPLATES_DIR/.gitignore" "$TEMPLATES_DIR/gitignore" -------------------------------------------------------------------------------- /packages/create-dashbling-app/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e ; [[ $TRACE ]] && set -x 3 | SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" 4 | TMP_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t dashbling-tests) 5 | 6 | cleanup() { 7 | status=$? 8 | rm -rf "$TMP_DIR" 9 | rm -rf "$packagePath" 10 | exit $status 11 | } 12 | 13 | packageVersion() { 14 | node -e "console.log(require('./package.json').version)" 15 | } 16 | 17 | packagePath() { 18 | echo "$SCRIPTPATH/dashbling-create-dashboard-$(packageVersion).tgz" 19 | } 20 | 21 | trap "cleanup" INT TERM EXIT 22 | 23 | npm pack 24 | packagePath=$(packagePath) 25 | 26 | cd "$TMP_DIR" 27 | yarn add "file:$packagePath" 28 | 29 | ./node_modules/.bin/create-dashboard . 30 | yarn build -------------------------------------------------------------------------------- /packages/create-widget/.gitignore: -------------------------------------------------------------------------------- 1 | README.md 2 | -------------------------------------------------------------------------------- /packages/create-widget/create-widget.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const yargs = require("yargs"); 4 | const { createEnv } = require("yeoman-environment"); 5 | const { resolve, basename } = require("path"); 6 | 7 | const env = createEnv(); 8 | const done = exitCode => process.exit(exitCode || 0); 9 | const dashboardGenerator = resolve(__dirname); 10 | env.register(require.resolve(dashboardGenerator), "create-widget"); 11 | 12 | const cli = yargs 13 | .command("") 14 | .demandCommand(1) 15 | .help() 16 | .wrap(null).argv; 17 | 18 | const directory = resolve(cli._[0]); 19 | const name = basename(directory); 20 | 21 | env.run( 22 | "create-widget", 23 | { 24 | directory, 25 | name 26 | }, 27 | done 28 | ); 29 | -------------------------------------------------------------------------------- /packages/create-widget/index.js: -------------------------------------------------------------------------------- 1 | const { ensureDirSync, readJsonSync, writeJsonSync } = require("fs-extra"); 2 | const { resolve, dirname } = require("path"); 3 | const { sync: commandExistsSync } = require("command-exists"); 4 | const Generator = require("yeoman-generator"); 5 | 6 | const supportsGit = commandExistsSync("git"); 7 | const supportsYarn = commandExistsSync("yarnpkg"); 8 | const installer = supportsYarn ? "yarn" : "npm"; 9 | const dashblingClientPackage = process.env.DASHBLING_CLIENT_PACKAGE || "^0"; 10 | 11 | module.exports = class extends Generator { 12 | constructor(args, opts) { 13 | super(args, opts); 14 | this.installDependencies = supportsYarn 15 | ? this.yarnInstall 16 | : this.npmInstall; 17 | } 18 | 19 | paths() { 20 | this.destinationRoot(this.options.directory); 21 | } 22 | 23 | _copy(path) { 24 | this.fs.copy(this.templatePath(path), this.destinationPath(path)); 25 | } 26 | 27 | writing() { 28 | ensureDirSync(this.destinationPath()); 29 | 30 | this.spawnCommandSync(installer, ["init", "--yes"]); 31 | 32 | const jsonPath = this.destinationPath("package.json"); 33 | const json = readJsonSync(jsonPath); 34 | const packageJson = Object.assign(json, { 35 | main: "MyWidget.js", 36 | dependencies: { 37 | "node-fetch": "^1.7.3" 38 | }, 39 | devDependencies: { 40 | "@dashbling/client": dashblingClientPackage 41 | }, 42 | peerDependencies: { 43 | "@dashbling/client": dashblingClientPackage 44 | } 45 | }); 46 | 47 | writeJsonSync(jsonPath, packageJson, { spaces: 2 }); 48 | 49 | ["job.js", "MyWidget.js", "styles.css"].forEach(this._copy.bind(this)); 50 | 51 | this.fs.copy( 52 | this.templatePath("gitignore"), 53 | this.destinationPath(".gitignore") 54 | ); 55 | 56 | this.fs.copyTpl( 57 | this.templatePath("README.md"), 58 | this.destinationPath("README.md"), 59 | { name: this.options.name } 60 | ); 61 | 62 | this.installDependencies(); 63 | } 64 | 65 | end() { 66 | if (supportsGit) { 67 | this.spawnCommandSync("git", ["init", "--quiet"]); 68 | this.spawnCommandSync("git", ["add", "--all"]); 69 | this.spawnCommandSync("git", [ 70 | "commit", 71 | "-m", 72 | "Initial commit - new dashbling widget.", 73 | "--quiet" 74 | ]); 75 | } 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /packages/create-widget/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dashbling/create-widget", 3 | "version": "0.0.6", 4 | "author": "Pascal Widdershoven", 5 | "description": "Hackable React based dashboards for developers", 6 | "license": "MIT", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/pascalw/dashbling.git" 13 | }, 14 | "bin": { 15 | "create-dashbling-widget": "./create-widget.js", 16 | "create-widget": "./create-widget.js" 17 | }, 18 | "scripts": { 19 | "prepare": "cp ../../README.md ." 20 | }, 21 | "dependencies": { 22 | "command-exists": "^1.2.2", 23 | "fs-extra": "^5.0.0", 24 | "yargs": "^10.0.3", 25 | "yeoman-environment": "^2.0.5", 26 | "yeoman-generator": "^2.0.1" 27 | }, 28 | "engines": { 29 | "node": ">= 10" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/create-widget/templates/MyWidget.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Widget, SmallLabel, MediumLabel } from "@dashbling/client/Widget"; 3 | 4 | import styles from "./styles.css"; 5 | 6 | export const MyWidget = props => { 7 | const { ipAddress, ...restProps } = props; 8 | 9 | return ( 10 | 11 | Your IP: 12 | {ipAddress || "--"} 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /packages/create-widget/templates/README.md: -------------------------------------------------------------------------------- 1 | # Dashbling widget 2 | 3 | This is a Dashbling widget. For local development: 4 | 5 | 1. `yarn link` (or `npm link`). 6 | 2. `cd` to a Dashbling dashboard and execute `yarn link <%= name %>` (or `npm link <%= name %>`). 7 | 3. Include the widget and job in your dashboard and make changes. 8 | 9 | For more information about widget development visit the Dashbling docs: https://github.com/pascalw/dashbling/blob/master/docs/. -------------------------------------------------------------------------------- /packages/create-widget/templates/gitignore: -------------------------------------------------------------------------------- 1 | /node_modules -------------------------------------------------------------------------------- /packages/create-widget/templates/job.js: -------------------------------------------------------------------------------- 1 | const fetch = require("node-fetch"); 2 | 3 | module.exports = eventId => async sendEvent => { 4 | const response = await fetch("https://httpbin.org/get"); 5 | const json = await response.json(); 6 | 7 | sendEvent(eventId, { 8 | ipAddress: json.origin 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /packages/create-widget/templates/styles.css: -------------------------------------------------------------------------------- 1 | .widget { 2 | background-color: #226865; 3 | } -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/Climacon.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const ctx = require.context("./climacons/", false, /\.svg$/); 4 | const ICONS = ctx.keys().reduce(function(acc, file) { 5 | const id = file.match(/.\/(.*)\.svg$/)[1]; 6 | acc[id] = ctx(file); 7 | return acc; 8 | }, {}); 9 | 10 | export const Climacon = props => { 11 | return ; 12 | }; 13 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/README.md: -------------------------------------------------------------------------------- 1 | # Dashbling weather widget 2 | 3 | This is a widget for Dashbling, displaying local Weather information. 4 | 5 | Weather information is provided by [OpenWeatherMap](https://openweathermap.org/). 6 | This widget requires a (free) OpenWeatherMap API key. Register at https://openweathermap.org/appid. 7 | 8 | ## Usage 9 | 10 | Add to your project: 11 | 12 | ```sh 13 | yarn add dashbling-widget-weather 14 | ``` 15 | 16 | Add a job to fetch weather data: 17 | 18 | ```js 19 | // dashbling.config.js 20 | module.exports = { 21 | jobs: [ 22 | { 23 | schedule: "*/30 * * * *", 24 | fn: require("dashbling-widget-weather/job")( 25 | "weather-amsterdam", // event id 26 | "YOUR OPENWEATHERMAP APPID HERE", 27 | "2759794" // city id 28 | ) 29 | } 30 | ] 31 | } 32 | ``` 33 | 34 | And add the widget to your dashboard: 35 | 36 | ```js 37 | import { WeatherWidget } from "dashbling-widget-weather"; 38 | const WeatherInAmsterdam = connect("weather-amsterdam")(WeatherWidget); 39 | 40 | export default props => { 41 | return ( 42 | 43 | 44 | 45 | ); 46 | }; 47 | ``` 48 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/WeatherWidget.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Widget, SmallLabel, MediumLabel } from "@dashbling/client/Widget"; 3 | import { Climacon } from "./Climacon"; 4 | 5 | import styles from "./styles.css"; 6 | 7 | const formatTemp = (temp, unit) => { 8 | return (temp && `${temp} ${unit}`) || "--"; 9 | }; 10 | 11 | export const WeatherWidget = props => { 12 | const { title, temp, unit, climacon, text, url, ...restProps } = props; 13 | 14 | return ( 15 | 16 | {title} 17 | 18 |
19 | 20 | 21 | {formatTemp(temp, unit)} 22 | 23 | {text} 24 |
25 | 26 | 27 |
28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/climacons/cloud moon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 16 | 17 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/climacons/cloud sun.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 20 | 21 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/climacons/cloud.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/climacons/drizzle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 18 | 19 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/climacons/hail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 17 | 18 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/climacons/haze.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 14 | 15 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/climacons/lightning.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/climacons/moon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/climacons/rain.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 14 | 15 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/climacons/sleet.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 14 | 15 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/climacons/snow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 17 | 18 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/climacons/sun.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 19 | 20 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/climacons/thermometer full.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/climacons/thermometer low.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/climacons/tornado.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 13 | 14 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/climacons/wind.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 14 | 15 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/job.js: -------------------------------------------------------------------------------- 1 | const fetch = require("node-fetch"); 2 | 3 | const climacon = iconCode => { 4 | switch (iconCode) { 5 | case "01d": 6 | return "sun"; 7 | case "02d": 8 | return "cloud sun"; 9 | case "03d": 10 | case "03n": 11 | case "04d": 12 | return "cloud"; 13 | case "09d": 14 | case "10d": 15 | case "10d": 16 | return "rain"; 17 | case "11d": 18 | case "11n": 19 | return "lightning"; 20 | case "13d": 21 | case "13n": 22 | return "snow"; 23 | case "50d": 24 | case "50n": 25 | return "haze"; 26 | case "01n": 27 | return "moon"; 28 | case "02n": 29 | case "04n": 30 | return "cloud moon"; 31 | default: 32 | return null; 33 | } 34 | }; 35 | 36 | const unitSign = unit => { 37 | switch (unit) { 38 | case "metric": 39 | return "°C"; 40 | case "imperial": 41 | return "°F"; 42 | default: 43 | return "K"; 44 | } 45 | }; 46 | 47 | module.exports = ( 48 | eventId, 49 | appId, 50 | cityId, 51 | unit = "metric" 52 | ) => async sendEvent => { 53 | if (!appId) { 54 | console.warn( 55 | "No OpenWeatherMap APP_ID provided, register at https://openweathermap.org/appid" 56 | ); 57 | return; 58 | } 59 | 60 | const response = await fetch( 61 | `http://api.openweathermap.org/data/2.5/weather?id=${cityId}&APPID=${appId}&units=${unit}` 62 | ); 63 | const json = await response.json(); 64 | 65 | const eventData = { 66 | temp: Math.round(json.main.temp), 67 | unit: unitSign(unit), 68 | text: json.weather[0].main, 69 | climacon: climacon(json.weather[0].icon), 70 | url: `https://openweathermap.org/city/${cityId}` 71 | }; 72 | 73 | sendEvent(eventId, eventData); 74 | }; 75 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/logo_OpenWeatherMap.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dashbling-widget-weather", 3 | "version": "2.0.4", 4 | "main": "WeatherWidget.js", 5 | "author": "Pascal Widdershoven", 6 | "license": "MIT", 7 | "devDependencies": { 8 | "@dashbling/client": "^0.4.1" 9 | }, 10 | "dependencies": { 11 | "node-fetch": "^1.7.3" 12 | }, 13 | "peerDependencies": { 14 | "@dashbling/client": "^0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/dashbling-widget-weather/styles.css: -------------------------------------------------------------------------------- 1 | .widget { 2 | background-color: #2f2268; 3 | } 4 | 5 | .logo { 6 | width: 134px; 7 | height: 29px; 8 | align-self: center; 9 | margin-top: 20px; 10 | } 11 | 12 | .inner { 13 | margin: auto 0; 14 | } 15 | -------------------------------------------------------------------------------- /packages/mongodb-history/README.md: -------------------------------------------------------------------------------- 1 | # Dashbling MongoDB history adapter 2 | 3 | This is a History adapter for Dashbling, storing data in MongoDB. 4 | 5 | By default, Dashbling stores history data on the filesystem. 6 | This adapter is especially well suited if you run on environments where the filesystem is not persistent, for example on Heroku. 7 | 8 | ## Usage 9 | 10 | Add the dependency to your project: 11 | 12 | ```sh 13 | yarn add @dashbling/mongodb-history # or npm install --save @dashbling/mongodb-history 14 | ``` 15 | 16 | And plug it into your `dashbling.config.js`: 17 | 18 | ```js 19 | const { 20 | createHistory: createMongoHistory 21 | } = require("@dashbling/mongodb-history"); 22 | 23 | module.exports = { 24 | /* ... */ 25 | eventHistory: createMongoHistory(process.env.MONGODB_URI) 26 | /* ... */ 27 | }; 28 | ``` 29 | 30 | If you don't want to run MongoDB in development you can also conditionally use MongoDB like so: 31 | 32 | ```js 33 | /* dashbling.config.js */ 34 | 35 | const { 36 | createHistory: createMongoHistory 37 | } = require("@dashbling/mongodb-history"); 38 | 39 | const { createFileHistory } = require("@dashbling/core/history"); 40 | 41 | const history = () => { 42 | if (process.env.MONGODB_URI) { 43 | return createMongoHistory(process.env.MONGODB_URI); 44 | } else { 45 | const eventHistoryPath = require("path").join( 46 | process.cwd(), 47 | "dashbling-events" 48 | ); 49 | return createFileHistory(eventHistoryPath); 50 | } 51 | }; 52 | 53 | module.exports = { 54 | /* ... */ 55 | eventHistory: history() 56 | /* ... */ 57 | }; 58 | ``` 59 | 60 | -------------------------------------------------------------------------------- /packages/mongodb-history/mongodb-history.js: -------------------------------------------------------------------------------- 1 | const MongoClient = require("mongodb").MongoClient; 2 | 3 | class MongoDbHistory { 4 | constructor(mongoClient) { 5 | this.mongo = mongoClient; 6 | this.db = this.mongo.db(); 7 | } 8 | 9 | put(id, eventData) { 10 | return this._collection().replaceOne( 11 | { _id: id }, 12 | { 13 | _id: id, 14 | data: eventData 15 | }, 16 | { upsert: true } 17 | ); 18 | } 19 | 20 | getAll() { 21 | return this._collection() 22 | .find({}) 23 | .sort([["data.updatedAt", 1]]) 24 | .limit(1000) 25 | .map(this._toEvent) 26 | .toArray(); 27 | } 28 | 29 | async get(id) { 30 | const doc = await this._collection().findOne({ _id: id }); 31 | return doc == null ? null : this._toEvent(doc); 32 | } 33 | 34 | _collection() { 35 | return this.db.collection("events"); 36 | } 37 | 38 | _toEvent(storedEvent) { 39 | return storedEvent.data; 40 | } 41 | } 42 | 43 | module.exports.createHistory = async mongoUrl => { 44 | const client = new MongoClient(mongoUrl, { 45 | useNewUrlParser: true, 46 | w: "majority" 47 | }); 48 | 49 | await client.connect(); 50 | process.on("exit", client.close.bind(client)); 51 | 52 | return new MongoDbHistory(client); 53 | }; 54 | -------------------------------------------------------------------------------- /packages/mongodb-history/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dashbling/mongodb-history", 3 | "version": "1.0.2", 4 | "main": "mongodb-history.js", 5 | "author": "Pascal Widdershoven", 6 | "description": "Hackable React based dashboards for developers", 7 | "license": "MIT", 8 | "publishConfig": { 9 | "access": "public" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/pascalw/dashbling.git" 14 | }, 15 | "dependencies": { 16 | "mongodb": "^3.1.13" 17 | }, 18 | "peerDependencies": { 19 | "@dashbling/core": ">= 0.3.0-0" 20 | }, 21 | "devDependencies": { 22 | "@dashbling/core": "^0.4.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /script/e2e-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" 4 | 5 | packageVersion() { 6 | node -e "console.log(require('./packages/$1/package.json').version)" 7 | } 8 | 9 | packagePath() { 10 | echo "file:$SCRIPTPATH/../packages/$1/dashbling-$1-$(packageVersion "$1").tgz" 11 | } 12 | 13 | yarn lerna exec npm pack 14 | 15 | export DASHBLING_CORE_PACKAGE=$(packagePath "core") 16 | export DASHBLING_BUILD_SUPPORT_PACKAGE=$(packagePath "build-support") 17 | export DASHBLING_CLIENT_PACKAGE=$(packagePath "client") 18 | 19 | pushd ./packages/create-dashbling-app/ 20 | yarn install 21 | yarn test:e2e 22 | popd 23 | -------------------------------------------------------------------------------- /script/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Setting up git hooks..." 3 | for h in hooks/*; do ln -sf ../../$h .git/hooks/$(basename $h); done 4 | 5 | echo "Installing dependencies..." 6 | yarn install 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "module": "commonjs", 5 | "sourceMap": true, 6 | "declaration": true, 7 | "strict": true, 8 | "experimentalDecorators": true, 9 | "emitDecoratorMetadata": true 10 | } 11 | } 12 | --------------------------------------------------------------------------------