├── .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 | [](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 | 
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 |
17 |
--------------------------------------------------------------------------------
/packages/dashbling-widget-weather/climacons/cloud sun.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
21 |
--------------------------------------------------------------------------------
/packages/dashbling-widget-weather/climacons/cloud.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/packages/dashbling-widget-weather/climacons/drizzle.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
19 |
--------------------------------------------------------------------------------
/packages/dashbling-widget-weather/climacons/hail.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
18 |
--------------------------------------------------------------------------------
/packages/dashbling-widget-weather/climacons/haze.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
15 |
--------------------------------------------------------------------------------
/packages/dashbling-widget-weather/climacons/lightning.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
13 |
--------------------------------------------------------------------------------
/packages/dashbling-widget-weather/climacons/moon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/packages/dashbling-widget-weather/climacons/rain.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
15 |
--------------------------------------------------------------------------------
/packages/dashbling-widget-weather/climacons/sleet.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
15 |
--------------------------------------------------------------------------------
/packages/dashbling-widget-weather/climacons/snow.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
18 |
--------------------------------------------------------------------------------
/packages/dashbling-widget-weather/climacons/sun.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
20 |
--------------------------------------------------------------------------------
/packages/dashbling-widget-weather/climacons/thermometer full.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/packages/dashbling-widget-weather/climacons/thermometer low.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/packages/dashbling-widget-weather/climacons/tornado.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
14 |
--------------------------------------------------------------------------------
/packages/dashbling-widget-weather/climacons/wind.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
--------------------------------------------------------------------------------