├── .eslintignore
├── .eslintrc
├── .gitignore
├── .npmignore
├── .nycrc
├── .travis.yml
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LAYOUTS.md
├── LICENSE
├── METRIC-AGGREGATION.md
├── README.md
├── bin
└── nodejs-dashboard.js
├── images
├── aggregation-visual.png
├── average-equivalence-equation.png
├── time-index-equation-example.png
└── time-index-equation.png
├── index.js
├── lib
├── config.js
├── constants.js
├── dashboard-agent.js
├── dashboard.js
├── default-layout-config.js
├── generate-layouts.js
├── layout-config-schema.json
├── parse-settings.js
├── providers
│ ├── log-provider.js
│ └── metrics-provider.js
├── time.js
├── utils.js
└── views
│ ├── base-details-view.js
│ ├── base-line-graph.js
│ ├── base-view.js
│ ├── cpu-details-view.js
│ ├── cpu-view.js
│ ├── env-details-view.js
│ ├── eventloop-view.js
│ ├── goto-time-view.js
│ ├── help.js
│ ├── index.js
│ ├── memory-gauge-view.js
│ ├── memory-graph-view.js
│ ├── node-details-view.js
│ ├── panel.js
│ ├── stream-view.js
│ ├── system-details-view.js
│ └── user-details-view.js
├── package.json
├── test
├── .eslintrc
├── app
│ ├── index.js
│ └── layouts.js
├── lib
│ ├── dashboard-agent.spec.js
│ ├── generate-layouts.spec.js
│ ├── parse-settings.spec.js
│ ├── providers
│ │ ├── log-provider.spec.js
│ │ └── metrics-provider.spec.js
│ └── views
│ │ ├── base-line-graph.spec.js
│ │ ├── base-view.spec.js
│ │ ├── cpu-details-view.spec.js
│ │ ├── cpu-view.spec.js
│ │ ├── env-details-view.spec.js
│ │ ├── eventloop-view.spec.js
│ │ ├── goto-time-view.spec.js
│ │ ├── help.spec.js
│ │ ├── memory-gauge-view.spec.js
│ │ ├── node-details-view.spec.js
│ │ ├── panel.spec.js
│ │ ├── stream-view.spec.js
│ │ ├── system-details-view.spec.js
│ │ └── user-details-view.spec.js
├── setup.js
└── utils.js
└── yarn.lock
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | ---
2 | extends:
3 | - "formidable/configurations/es6-node"
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | npm-debug.log
3 | coverage
4 | .vscode
5 | .nyc_output
6 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 |
2 | /*
3 | !/bin
4 | !/lib
5 | !LICENSE
6 | !*.md
7 | !package.json
8 | !index.js
--------------------------------------------------------------------------------
/.nycrc:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "*.js",
4 | "bin/**/*.js",
5 | "lib/**/*.js"
6 | ],
7 | "reporter": [
8 | "lcov",
9 | "text"
10 | ],
11 | "all": true
12 | }
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | sudo: false
4 |
5 | branches:
6 | only:
7 | - master
8 |
9 | node_js:
10 | - "8"
11 | - "10"
12 | - "12"
13 | - "13"
14 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change log
2 |
3 | ## [v0.5.1] - 2019-11-22
4 |
5 | - **Fixed**: Support global installs of `nodejs-dashboard`. Add the `node_modules` directory from wherever `nodejs-dashboard` is installed to `NODE_PATH` to support `node -r nodejs-dashboard` required for full usage. [\#90]
6 | - *Internal*: Use SIGINT for Ctrl-c. [\#93]
7 |
8 | ## [v0.5.0] - 2019-11-21
9 |
10 | - **Breaking**: Update node engines to `8`+
11 | - *Internal*: Upgrade all prod + dev dependencies
12 | - *Internal*: Update to es-next code from at least auto-fixes.
13 |
14 | ## [v0.4.3] - 2017-12-01
15 | - **Fixed:** Environment variable names on Linux [\#76]
16 | - **Added:** Goto User-Defined Time Index [\#68]
17 | - **Added:** Pause, Rewind and Fast Forward Graphs [\#67]
18 | - **Added:** Longer history for graphs [\#64]
19 |
20 | ## v0.4.2 - 2017-12-01
21 | - **Not Published**: Bad release.
22 |
23 | ## [v0.4.1] - 2017-03-21
24 | - **Added:** Historical memory usage graph [\#63]
25 | - **Added:** Support for log filtering - see README [\#62]
26 |
27 | ## [v0.4.0] - 2017-02-18
28 |
29 | - **Added:** Support multiple customizable layouts [\#53], [\#59] - see README
30 | - **Removed**: `-i, --interleave` option - now available through layouts
31 | - **Changed**: Line graphs in default layout show 30s instead of 10s of data [\#59]
32 | - **Changed**: Improve views positioning, rerender on screen resize [\#51]
33 | - **Fixed**: Properly disable autoscroll if user has scrolled up [\#56]
34 | - *Internal*: Add description and keywords to package.json [\#49]
35 | - *Internal*: Test coverage [\#52], set up travis [\#54], add tests for views [\#57]
36 |
37 | ## [v0.3.0] - 2016-12-20
38 |
39 | - **Added**: interleave mode for stdout/stderr [\#47]
40 |
41 | ## [v0.2.1] - 2016-12-01
42 |
43 | - **Fixed**: Memory leak bug [\#45]
44 |
45 | ## [v0.2.0] - 2016-11-03
46 |
47 | - **Added**: Support older versions of node (0.10+) by converting to es5 syntax [\#43], [\#44]
48 |
49 | ## [v0.1.2] - 2016-10-20
50 |
51 | - **Changed**: Round cpu percentage to nearest decimal [\#35]
52 | - **Docs**: Add examples to README [\#28], describe global install usage [\#37]
53 | - *Internal*: Better tests [\#34]
54 |
55 | ## [v0.1.1] - 2016-10-14
56 |
57 | - **Changed**: Limit node version in package.json [\#18]
58 | - **Docs**: Update README [\#4], include exit keybindings [\#11]
59 |
60 | ## v0.1.0 - 2016-10-11
61 | - **Docs**: Update readme styling [\#1]
62 | - *Internal*: Remove dependency on root-require, update repo url in package.json [\#2]
63 | - *Internal*: Test scaffolding and basic reporter integration test [\#3]
64 |
65 | [v0.5.1]: https://github.com/FormidableLabs/nodejs-dashboard/compare/v0.5.0...v0.5.1
66 | [v0.5.0]: https://github.com/FormidableLabs/nodejs-dashboard/compare/v0.4.3...v0.5.0
67 | [v0.4.3]: https://github.com/FormidableLabs/nodejs-dashboard/compare/v0.4.1...v0.4.3
68 | [v0.4.1]: https://github.com/FormidableLabs/nodejs-dashboard/compare/v0.4.0...v0.4.1
69 | [v0.4.0]: https://github.com/FormidableLabs/nodejs-dashboard/compare/v0.3.0...v0.4.0
70 | [v0.3.0]: https://github.com/FormidableLabs/nodejs-dashboard/compare/v0.2.1...v0.3.0
71 | [v0.2.1]: https://github.com/FormidableLabs/nodejs-dashboard/compare/v0.2.0...v0.2.1
72 | [v0.2.0]: https://github.com/FormidableLabs/nodejs-dashboard/compare/v0.1.2...v0.2.0
73 | [v0.1.2]: https://github.com/FormidableLabs/nodejs-dashboard/compare/v0.1.1...v0.1.2
74 | [v0.1.1]: https://github.com/FormidableLabs/nodejs-dashboard/compare/v0.1.0...v0.1.1
75 |
76 | [\#93]: https://github.com/FormidableLabs/nodejs-dashboard/pull/93
77 | [\#90]: https://github.com/FormidableLabs/nodejs-dashboard/issues/90
78 | [\#76]: https://github.com/FormidableLabs/nodejs-dashboard/pull/76
79 | [\#68]: https://github.com/FormidableLabs/nodejs-dashboard/pull/72
80 | [\#67]: https://github.com/FormidableLabs/nodejs-dashboard/pull/70
81 | [\#64]: https://github.com/FormidableLabs/nodejs-dashboard/pull/66
82 | [\#63]: https://github.com/FormidableLabs/nodejs-dashboard/pull/63
83 | [\#62]: https://github.com/FormidableLabs/nodejs-dashboard/pull/62
84 | [\#59]: https://github.com/FormidableLabs/nodejs-dashboard/pull/59
85 | [\#57]: https://github.com/FormidableLabs/nodejs-dashboard/pull/57
86 | [\#56]: https://github.com/FormidableLabs/nodejs-dashboard/pull/56
87 | [\#54]: https://github.com/FormidableLabs/nodejs-dashboard/pull/54
88 | [\#53]: https://github.com/FormidableLabs/nodejs-dashboard/pull/53
89 | [\#52]: https://github.com/FormidableLabs/nodejs-dashboard/pull/52
90 | [\#51]: https://github.com/FormidableLabs/nodejs-dashboard/pull/51
91 | [\#50]: https://github.com/FormidableLabs/nodejs-dashboard/pull/50
92 | [\#49]: https://github.com/FormidableLabs/nodejs-dashboard/pull/49
93 | [\#47]: https://github.com/FormidableLabs/nodejs-dashboard/pull/47
94 | [\#45]: https://github.com/FormidableLabs/nodejs-dashboard/pull/45
95 | [\#44]: https://github.com/FormidableLabs/nodejs-dashboard/pull/44
96 | [\#43]: https://github.com/FormidableLabs/nodejs-dashboard/pull/43
97 | [\#37]: https://github.com/FormidableLabs/nodejs-dashboard/pull/37
98 | [\#35]: https://github.com/FormidableLabs/nodejs-dashboard/pull/35
99 | [\#34]: https://github.com/FormidableLabs/nodejs-dashboard/pull/34
100 | [\#28]: https://github.com/FormidableLabs/nodejs-dashboard/pull/28
101 | [\#25]: https://github.com/FormidableLabs/nodejs-dashboard/pull/25
102 | [\#18]: https://github.com/FormidableLabs/nodejs-dashboard/pull/18
103 | [\#11]: https://github.com/FormidableLabs/nodejs-dashboard/pull/11
104 | [\#4]: https://github.com/FormidableLabs/nodejs-dashboard/pull/4
105 | [\#3]: https://github.com/FormidableLabs/nodejs-dashboard/pull/3
106 | [\#2]: https://github.com/FormidableLabs/nodejs-dashboard/pull/2
107 | [\#1]: https://github.com/FormidableLabs/nodejs-dashboard/pull/1
108 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to nodejs-dashboard
2 |
3 | ## Contributor Covenant Code of Conduct
4 |
5 | ### Our Pledge
6 |
7 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
8 |
9 | ### Our Standards
10 |
11 | Examples of behavior that contributes to creating a positive environment include:
12 |
13 | - Using welcoming and inclusive language
14 | - Being respectful of differing viewpoints and experiences
15 | - Gracefully accepting constructive criticism
16 | - Focusing on what is best for the community
17 | - Showing empathy towards other community members
18 |
19 | Examples of unacceptable behavior by participants include:
20 |
21 | - The use of sexualized language or imagery and unwelcome sexual attention or advances
22 | - Trolling, insulting/derogatory comments, and personal or political attacks
23 | - Public or private harassment
24 | - Publishing others' private information, such as a physical or electronic address, without explicit permission
25 | - Other conduct which could reasonably be considered inappropriate in a professional setting
26 |
27 | ### Our Responsibilities
28 |
29 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
30 |
31 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
32 |
33 | ### Scope
34 |
35 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
36 |
37 | ### Enforcement
38 |
39 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at lauren.eastridge@formidable.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
40 |
41 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
42 |
43 | ### Attribution
44 |
45 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at http://contributor-covenant.org/version/1/4
46 |
--------------------------------------------------------------------------------
/LAYOUTS.md:
--------------------------------------------------------------------------------
1 | # Customizing layouts
2 |
3 | See [`lib/default-layout-config.js`](./lib/default-layout-config.js) and [`test/app/layouts.js`](./test/app/layouts.js) for examples.
4 |
5 | A layouts config file should export an array of layouts:
6 | - Each layout is an array of panels
7 | - A panel is an object representing a vertical section of the screen (i.e. a column). Its properties are:
8 | - `position`: optional, see below
9 | - `views`: array of views
10 | - A view is an object identifying one of the existing `___View` classes to be displayed. Its properties are:
11 | - `type`: one of `log`, `cpu`, `memory`, `memoryGraph`, `eventloop`
12 | - `title`: optional view title (default value depends on view type)
13 | - `borderColor`: view border color
14 | - `position`: optional, see below
15 | - type-specific settings, see below
16 | - `module`: defines module with factory function for custom view, see below
17 |
18 | #### position
19 |
20 | `position` defines the item's height (for views) or width (for panels). It can have one of:
21 | - `size`: fixed value (rows/cols)
22 | - `grow`: proportional to the container
23 |
24 | `position` is optional - it defaults to `{ grow: 1 }` if not specified
25 |
26 | For example, if a panel has 3 views with these positions:
27 | - A: size 15
28 | - B: grow 1
29 | - C: grow 3
30 |
31 | A will have a height of 15. B and C will split the remaining area proportionally (B gets 25%, C gets 75%).
32 |
33 | #### `log` view properties
34 |
35 | - `streams`: array of streams that view will listen to. Acceptable values are `stdout` and `stderr`
36 | - `fgColor`: text color
37 | - `bgColor`: background color
38 | - `scrollback`: specifies the maximum number of lines that log will buffer in order to scroll backwards and see the history. The default is 1000 lines
39 | - `exclude`: optional pattern - matching lines will be excluded from log
40 | - `include`: optional pattern - matching lines will be included in log. If pattern has a capturing group, only a content matching that group will be logged.
41 |
42 | #### `cpu` / `eventLoop` view properties
43 | - `limit`: line graph views accept this option indicating how many data points to display
44 |
45 | ### Custom views
46 |
47 | To define your own view, use `module` property. Module should export function,
48 | that receives `BaseView` and returns custom view, inherited from `BaseView`. Your view constructor will be called with `options`, that have some useful properties:
49 | - `logProvider` - use `logProvider.getLog(streams)` to get log history for `stdout` / `stderr` streams, or subscribe to stream events with `logProvider.on(stream, callback)`
50 | - `metricsProvider` - in the same way, use `metricsProvider.getMetrics(limit)` or `metricsProvider.on("metrics", callback)`
51 |
52 | `BaseView` will also provide some properties and methods:
53 | - `this.parent` - parent node. View should define `this.node` and attach it to `this.parent`
54 | - `this.layoutConfig` - config from layout, with pre-filled default values
55 | - `this.recalculatePosition()` - call it to apply position after defining `this.node`
56 |
57 | View can override these methods:
58 | - `BaseView.prototype.getDefaultLayoutConfig()` - returns default layout parameters for your view
59 | - `BaseView.prototype.destroy()` - use it to unsubscribe from events, destroy data etc.
60 |
61 | #### Custom view example
62 |
63 | ```js
64 | var blessed = require("blessed");
65 |
66 | module.exports = function (BaseView) {
67 | var HelloWorldView = function HelloWorldView(options) {
68 | BaseView.call(this, options);
69 |
70 | this.node = blessed.box({
71 | label: " " + this.layoutConfig.title + " ",
72 | content: "Hello {bold}world{/bold}!",
73 | border: "line",
74 | style: {
75 | border: {
76 | fg: this.layoutConfig.borderColor
77 | }
78 | }
79 | });
80 |
81 | this.recalculatePosition();
82 |
83 | this.parent.append(this.node);
84 | };
85 |
86 | HelloWorldView.prototype = Object.create(BaseView.prototype);
87 |
88 | HelloWorldView.prototype.getDefaultLayoutConfig = function () {
89 | return {
90 | title: "hello world view"
91 | };
92 | };
93 |
94 | return HelloWorldView;
95 | };
96 | ```
97 |
98 | #### nodejs-dashboard-progress-layout
99 |
100 | Another example is [nodejs-dashboard-progress-layout](https://github.com/alexkuz/nodejs-dashboard-layout-progress), that [uses log view](https://github.com/alexkuz/nodejs-dashboard-layout-progress/blob/master/status-layout.js#L23) to display status and [defines custom ProgressView](https://github.com/alexkuz/nodejs-dashboard-layout-progress/blob/master/progress-view.js) to track progress updates:
101 |
102 | 
103 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2013
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | 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, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/METRIC-AGGREGATION.md:
--------------------------------------------------------------------------------
1 | # Metric Aggregation
2 |
3 | The MetricsProvider class provides metric data for various data points.
4 |
5 | The data provided is streamed in two modes:
6 | * Real Time
7 | * A slice of the data that has been collected over time.
8 | * Aggregate
9 | * Data that has been rolled-up to various time increments.
10 |
11 | ## Time Indexes
12 |
13 | The strategy for aggregation centers around time indexes. Imagine an array
14 | of values and each element of that array being a data point. If the data
15 | is real-time, then each index is just one logical second. When the data
16 | is aggregated, each data point is the average of the data, for one logical
17 | grouping of time.
18 |
19 | To determine which data elements from the real-time array are to be grouped,
20 | the difference in time from the start of the process and the moment a data
21 | point was captured is passed through this formula:
22 |
23 |
24 |
25 | Where
26 |
27 | * `i`
28 | * Time Index
29 | * `t[k]`
30 | * Time of element `k`
31 | * `t[s]`
32 | * Start time of process
33 | * `u`
34 | * Time unit of measure (e.g. 5000ms=5s)
35 |
36 | All Times are expressed in milliseconds (ms) and are obtained from Date.now().
37 |
38 | **Example**
39 |
40 |
41 |
42 | The above example demonstrates that for a 5000ms (5s) aggregate, the time index
43 | for the data point is one. This formula is applied to all data points, so
44 | when many data points share the same logical time index, they can be averaged.
45 | When two or more array elements have a common time index, they form a time band.
46 |
47 |
48 |
49 | In the above visual, the start time `t[s]` is `7581298`. The unit of time is `5000ms`. By applying
50 | the time index equation against each data points' moment in time, the time index values are
51 | derived. For those that match, a time band is formed. These are colorized similarly.
52 |
53 | ## Averaging
54 |
55 | To efficiently perform the average, care is taken to reduce the number of
56 | CPU cycles required to analyze the data. To accomplish this, the program
57 | calculates the average inline with the originating transaction.
58 |
59 | Also, to prevent re-aggregating the data needlessly, each aggregate level
60 | maintains its position in the base array, thereby keeping track of where it
61 | left off. This is done using two simple numbers: the last array element
62 | examined and the start of the current time band.
63 |
64 | So that data is still streamed on an event-basis, an aggregate data point is
65 | only captured and emitted when it is complete. To detect when an aggregate
66 | is complete, the algorithm traverses the base real-time array of data, beginning
67 | where it left off for any given aggregate. Walking each element from there
68 | forward, a simplistic level-break algorithm is implemented. As and when a
69 | logical time index changes, this indicates that the previous set of data is
70 | complete.
71 |
72 | Once this occurs, the program then averages the data from the starting index
73 | through the ending index that spans a logical time unit, regardless of aggregate
74 | level (using the time index formula above).
75 |
76 | To ensure no overflows (and therefore `NaN`) are produced, the average is done
77 | using the formula on the left-side of this equivalence:
78 |
79 |
80 |
81 | The sum of memory usage even over a 10s aggregate is enough to produce `NaN`.
82 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
nodejs-dashboard
2 |
3 |
4 | Telemetry dashboard for node.js apps from the terminal!
5 |
6 |
7 | [](https://travis-ci.com/FormidableLabs/nodejs-dashboard)
8 |
9 | 
10 |
11 | Determine in realtime what's happening inside your node process from the terminal. No need to instrument code to get the deets. Also splits stderr/stdout to help spot errors sooner.
12 |
13 | [Install](#install) | [Setup](#setup) | [Usage](#usage) | [Using with other programs](#launching-your-app-with-something-other-than-node) | [CLI options](#cli-options) | [Customizing layouts](#customizing-layouts)
14 | ---------------|---------------------|-----------------|-----------------|------------------------------------------|----------------------
15 |
16 | NOTE: This module isn't designed for production use and should be limited to development environments.
17 |
18 | ### Install
19 |
20 | The preferred method is global install. `npm install -g nodejs-dashboard`
21 |
22 | Local install works also; just use `./node_modules/.bin/nodejs-dashboard` instead of `nodejs-dashboard` to execute.
23 |
24 | ### Setup
25 |
26 | The dashboard agent needs to be required by your app. There are two ways to do this:
27 |
28 | #### Including via code
29 |
30 | From within a `dev.index.js` script or other dev entry point simply require the `nodejs-dashboard` module.
31 |
32 | ```js
33 | // dev.index.js
34 | require("nodejs-dashboard");
35 | require("./index");
36 | ```
37 |
38 | To launch: `nodejs-dashboard node dev.index.js`
39 |
40 | #### Including via preload argument
41 |
42 | This method utilizes Node's `-r` flag to introduce the `nodejs-dashboard` module. In this setup no code modifications are required. This is functionally equivalent to the above example.
43 |
44 | To launch: `nodejs-dashboard -- node -r nodejs-dashboard index.js`
45 |
46 | #### Fonts
47 |
48 | `nodejs-dashboard` uses the [Braille Unicode character set](https://en.wikipedia.org/wiki/Braille_Patterns#Chart) to show graphs via the [node-drawille](https://github.com/madbence/node-drawille) dependancy. Ensure your terminal program\'s font supports this character set.
49 |
50 | #### Environment variables
51 |
52 | `nodejs-dashboard` uses several environment variables to modify its behavior. These include some required values to prevent mangled output.
53 |
54 | Variable | Required | Source | Description |
55 | --- | --- | --- | --- |
56 | TERM | required | [blessed](https://github.com/chjj/blessed) | Terminal value matching terminal program |
57 | LANG | required | [blessed](https://github.com/chjj/blessed) | Matches encoding of terminal program to display font correctly |
58 | FORCE_COLOR | optional | [chalk](https://github.com/chalk/chalk) | Used to force color output by the subprocess |
59 |
60 | ### Usage
61 |
62 | Press `?` to see a list of keybindings. Use arrow keys to change the layout.
63 |
64 | You may want to add an npm script to to your `package.json` to launch your app using nodejs-dashboard using one of the options above. Example:
65 |
66 | ```js
67 | "scripts": {
68 | "dev": "nodejs-dashboard -- node -r nodejs-dashboard index.js"
69 | }
70 | ```
71 |
72 | #### Passing arguments
73 |
74 | If your app requires additional arguments you'll need to use the `--` flag to separate them from `nodejs-dashboard` options. For example:
75 |
76 | `nodejs-dashboard --port=8002 -- node -m=false --bar=true index.js`
77 |
78 | #### Launching your app with something other than `node`
79 |
80 | Most CLI interfaces provide a mechanism for launching other tools. If you're looking to use something like [nodemon](https://github.com/remy/nodemon) or [babel](https://github.com/babel/babel/tree/master/packages/babel-cli) checkout the exec options provided by the CLI.
81 |
82 | ```bash
83 | % nodemon --exec "nodejs-dashboard babel-node" src/index.js
84 | ```
85 |
86 | #### Docker and Docker Compose support
87 |
88 | `nodejs-dashboard` can run inside a container if that container has a [TTY](https://en.wikipedia.org/wiki/Pseudoterminal) allocated to it. The [Docker documentation](https://docs.docker.com/engine/reference/run/#foreground) shows how to run a container with an interactive terminal session. Additional the [Docker Compose documentation](https://docs.docker.com/compose/reference/run/) indicates that `docker-compose run` defaults to allocating a TTY and `docker-compose up` defaults to not allocating one.
89 |
90 | #### CLI options
91 |
92 | Usage: `nodejs-dashboard [options] -- [node] [script] [arguments]`
93 |
94 | ```
95 | Options:
96 | -h, --help output usage information
97 | -e, --eventdelay [ms] Minimum threshold for event loop reporting, default 10ms
98 | -l, --layouts [file] Path to file or npm module with layouts
99 | -p, --port [port] Socket listener port
100 | -r, --refreshinterval [ms] Metrics refresh interval, default 1000ms
101 | -s, --settings [settings] Overrides layout settings for given view types
102 | -V, --version output the version number
103 | ```
104 |
105 | ##### `--eventdelay`
106 | This tunes the minimum threshold for reporting event loop delays. The default value is `10ms`. Any delay below this value will be reported at `0`.
107 |
108 | ##### `--layouts`
109 | Optionally supply a custom layout configuration (for details, see [Customizing Layouts](/LAYOUTS.md)). Default: [`lib/default-layout-config.js`](./lib/default-layout-config.js)
110 |
111 | ##### `--port`
112 | Under the hood the dashboard utilizes SocketIO with a default port of `9838`. If this conflicts with an existing service you can optionally change this value.
113 |
114 | ##### `--refreshinterval`
115 | Specifies the interval in milliseconds that the metrics should be refreshed. The default is 1000 ms (1 second).
116 |
117 | ##### `--settings`
118 | Overrides default or layout settings for views. Option value `settings` should have a format `=,...`. For example `--settings log.scrollback=100` will override `scrollback` setting for any view of `log` type (nested paths can be used if needed). For details about layouts, see [Customizing Layouts](/LAYOUTS.md)).
119 |
120 | ## Maintenance Status
121 |
122 | **Archived:** This project is no longer maintained by Formidable. We are no longer responding to issues or pull requests unless they relate to security concerns. We encourage interested developers to fork this project and make it their own!
123 |
--------------------------------------------------------------------------------
/bin/nodejs-dashboard.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | "use strict";
4 |
5 | const SocketIO = require("socket.io");
6 | const spawn = require("cross-spawn");
7 | const commander = require("commander");
8 | const path = require("path");
9 |
10 | const Dashboard = require("../lib/dashboard");
11 | const config = require("../lib/config");
12 | const appPkg = require(path.resolve("package.json"));
13 | const pkg = require("../package.json");
14 | const parseSettings = require("../lib/parse-settings");
15 |
16 | const appName = appPkg.name || "node";
17 | const program = new commander.Command(pkg.name);
18 |
19 | // Mimic commander syntax errors (with offsets) for consistency
20 | /* eslint-disable no-console */
21 | const exitWithError = function () {
22 | const args = Array.prototype.slice.call(arguments);
23 | console.error();
24 | console.error(...[" "].concat(args));
25 | console.error();
26 | process.exit(1); // eslint-disable-line no-process-exit
27 | };
28 | /* eslint-enable no-console */
29 |
30 | program.option("-e, --eventdelay [ms]",
31 | "Minimum threshold for event loop reporting, default 10ms",
32 | config.BLOCKED_THRESHOLD);
33 |
34 | program.option("-l, --layouts [file]",
35 | "Path to file with layouts",
36 | config.LAYOUTS);
37 |
38 | program.option("-p, --port [port]",
39 | "Socket listener port",
40 | config.PORT);
41 |
42 | program.option("-r, --refreshinterval [ms]",
43 | "Metrics refresh interval, default 1000ms",
44 | config.REFRESH_INTERVAL);
45 |
46 | program.option("-s, --settings [settings]",
47 | "Overrides layout settings for given view types",
48 | (settings) => {
49 | const res = parseSettings(settings);
50 |
51 | if (res.error) {
52 | exitWithError(res.error);
53 | }
54 |
55 | return res.result;
56 | },
57 | {}
58 | );
59 |
60 | program.version(pkg.version);
61 | program.usage("[options] -- [node] [script] [arguments]");
62 | program.parse(process.argv);
63 |
64 | if (!program.args.length) {
65 | program.outputHelp();
66 | return;
67 | }
68 |
69 | const command = program.args[0];
70 | const args = program.args.slice(1);
71 |
72 | const port = program.port;
73 |
74 | process.env[config.PORT_KEY] = port;
75 | process.env[config.REFRESH_INTERVAL_KEY] = program.refreshinterval;
76 | process.env[config.BLOCKED_THRESHOLD_KEY] = program.eventdelay;
77 |
78 | // Enhance `NODE_PATH` to include the dashboard such that `require("nodejs-dashboard")`
79 | // works even if globally installed.
80 | // See: https://github.com/FormidableLabs/nodejs-dashboard/issues/90
81 | const IS_WIN = process.platform.indexOf("win") === 0;
82 | const DELIM = IS_WIN ? ";" : ":";
83 | const DASHBOARD_PATH = path.resolve(__dirname, "../..");
84 | const NODE_PATH = (process.env.NODE_PATH || "")
85 | .split(DELIM)
86 | .filter(Boolean)
87 | .concat(DASHBOARD_PATH)
88 | .join(DELIM);
89 |
90 | const child = spawn(command, args, {
91 | env: {
92 | ...process.env,
93 | NODE_PATH
94 | },
95 | stdio: [null, null, null, null],
96 | detached: true
97 | });
98 |
99 | console.log("Waiting for client connection on %d...", port); //eslint-disable-line
100 |
101 | const server = new SocketIO(port);
102 |
103 | const dashboard = new Dashboard({
104 | appName,
105 | program,
106 | layoutsFile: program.layouts,
107 | settings: program.settings
108 | });
109 |
110 | server.on("connection", (socket) => {
111 | socket.on("metrics", (data) => {
112 | dashboard.onEvent({ type: "metrics",
113 | data: JSON.parse(data) });
114 | });
115 |
116 | socket.on("error", (err) => {
117 | exitWithError("Received error from agent, exiting: ", err);
118 | });
119 | });
120 |
121 | child.stdout.on("data", (data) => {
122 | dashboard.onEvent({ type: "stdout",
123 | data: data.toString("utf8") });
124 | });
125 |
126 | child.stderr.on("data", (data) => {
127 | dashboard.onEvent({ type: "stderr",
128 | data: data.toString("utf8") });
129 | });
130 |
131 | process.on("exit", () => {
132 | process.kill(process.platform === "win32" ? child.pid : -child.pid);
133 | });
134 |
--------------------------------------------------------------------------------
/images/aggregation-visual.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FormidableLabs/nodejs-dashboard/885fc96fec262b668da9282f57374966f7512b76/images/aggregation-visual.png
--------------------------------------------------------------------------------
/images/average-equivalence-equation.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FormidableLabs/nodejs-dashboard/885fc96fec262b668da9282f57374966f7512b76/images/average-equivalence-equation.png
--------------------------------------------------------------------------------
/images/time-index-equation-example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FormidableLabs/nodejs-dashboard/885fc96fec262b668da9282f57374966f7512b76/images/time-index-equation-example.png
--------------------------------------------------------------------------------
/images/time-index-equation.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FormidableLabs/nodejs-dashboard/885fc96fec262b668da9282f57374966f7512b76/images/time-index-equation.png
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const dashboardAgent = require("./lib/dashboard-agent");
4 |
5 | module.exports = dashboardAgent();
6 |
--------------------------------------------------------------------------------
/lib/config.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const pkg = require("../package.json");
4 | // Env var names must comply with:
5 | // http://pubs.opengroup.org/onlinepubs/000095399/basedefs/xbd_chap08.html
6 | // See: https://github.com/FormidableLabs/nodejs-dashboard/issues/75
7 | const name = pkg.name.replace("-", "_");
8 |
9 | module.exports = {
10 | PORT: 9838,
11 | PORT_KEY: `${name}_PORT`,
12 | REFRESH_INTERVAL: 1000,
13 | REFRESH_INTERVAL_KEY: `${name}_REFRESH_INTERVAL`,
14 | BLOCKED_THRESHOLD: 10,
15 | BLOCKED_THRESHOLD_KEY: `${name}_BLOCKED_THRESHOLD`,
16 | LAYOUTS: ""
17 | };
18 |
--------------------------------------------------------------------------------
/lib/constants.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | // these define the time levels that data will be aggregated into
4 | // each number is in milliseconds
5 | //
6 | // since these are used as object keys, they are strings
7 | //
8 | // For example, 5000 = 5s and means that one of the aggregate levels
9 | // will be at 5s increments of time
10 | //
11 | // 300000 = 30s and the corresponding zoom will show 30s aggregates
12 | const AGGREGATE_TIME_LEVELS = [
13 | "1000",
14 | "5000",
15 | "10000",
16 | "15000",
17 | "30000",
18 | "60000",
19 | "300000",
20 | "600000",
21 | "900000",
22 | "1800000",
23 | "3600000"
24 | ];
25 |
26 | const MILLISECONDS_PER_SECOND = 1000;
27 |
28 | // this array object is used to reduce ms to its highest human-readable form
29 | // see lib/providers/metrics-provider.js::getTimeIndexLabel
30 | const TIME_SCALES = [
31 | {
32 | units: "ms",
33 | divisor: 1
34 | }, {
35 | units: "s",
36 | divisor: 1000
37 | }, {
38 | units: "m",
39 | divisor: 60
40 | }, {
41 | units: "h",
42 | divisor: 60
43 | }, {
44 | units: "d",
45 | divisor: 24
46 | }, {
47 | units: "y",
48 | divisor: 365.24
49 | }
50 | ];
51 |
52 | module.exports = {
53 | AGGREGATE_TIME_LEVELS,
54 | MILLISECONDS_PER_SECOND,
55 | TIME_SCALES
56 | };
57 |
--------------------------------------------------------------------------------
/lib/dashboard-agent.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const SocketIO = require("socket.io-client");
4 | const blocked = require("blocked");
5 | const pusage = require("pidusage");
6 | const os = require("os");
7 | const _ = require("lodash");
8 | const config = require("./config");
9 |
10 | const dashboardAgent = function () {
11 | const options = {
12 | port: process.env[config.PORT_KEY],
13 | refreshInterval: process.env[config.REFRESH_INTERVAL_KEY],
14 | blockedThreshold: process.env[config.BLOCKED_THRESHOLD_KEY]
15 | };
16 |
17 | // check if the app was launched w/o the dashboard
18 | // if so, don't start any of the monitoring
19 | const enabled = options.port && options.refreshInterval && options.blockedThreshold;
20 |
21 | let socket;
22 |
23 | const metrics = {
24 | eventLoop: {
25 | delay: 0,
26 | high: 0
27 | },
28 | mem: {
29 | systemTotal: os.totalmem()
30 | },
31 | cpu: {
32 | utilization: 0
33 | }
34 | };
35 |
36 | const _delayed = function (delay) {
37 | metrics.eventLoop.high = Math.max(metrics.eventLoop.high, delay);
38 | metrics.eventLoop.delay = delay;
39 | };
40 |
41 | const _getStats = function (cb) {
42 | _.merge(metrics.mem, process.memoryUsage());
43 |
44 | pusage(process.pid, (err, stat) => {
45 | if (err) {
46 | return cb(err);
47 | }
48 |
49 | metrics.cpu.utilization = stat.cpu;
50 | return cb(null, metrics);
51 | });
52 | };
53 |
54 | const resetEventMetrics = function () {
55 | metrics.eventLoop.delay = 0;
56 | };
57 |
58 | const _emitStats = function () {
59 | _getStats((err, newMetrics) => {
60 | if (err) {
61 | console.error("Failed to load metrics: ", err); //eslint-disable-line
62 | if (socket && socket.connected) {
63 | socket.emit("error", JSON.stringify(err));
64 | }
65 | } else if (socket && socket.connected) {
66 | socket.emit("metrics", JSON.stringify(newMetrics));
67 | }
68 |
69 | resetEventMetrics();
70 | });
71 | };
72 |
73 | const startPump = function () {
74 | if (enabled) {
75 | socket = new SocketIO(`http://localhost:${options.port}`);
76 | blocked(_delayed, { threshold: options.blockedThreshold });
77 | options.intervalId = setInterval(_emitStats, options.refreshInterval);
78 | }
79 | };
80 |
81 | const destroy = function () {
82 | if (socket) {
83 | socket.close();
84 | socket = null;
85 | }
86 | if (options.intervalId) {
87 | clearInterval(options.intervalId);
88 | options.intervalId = null;
89 | }
90 | };
91 |
92 | startPump();
93 |
94 | return {
95 | _delayed,
96 | _getStats,
97 | _emitStats,
98 | destroy
99 | };
100 | };
101 |
102 | module.exports = dashboardAgent;
103 |
--------------------------------------------------------------------------------
/lib/dashboard.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const _ = require("lodash");
4 | const blessed = require("blessed");
5 | const HelpView = require("./views/help");
6 | const generateLayouts = require("./generate-layouts");
7 | const LogProvider = require("./providers/log-provider");
8 | const MetricsProvider = require("./providers/metrics-provider");
9 | const GotoTimeView = require("./views/goto-time-view");
10 | const views = require("./views");
11 |
12 | const THROTTLE_TIMEOUT = 150;
13 |
14 | const Dashboard = function Dashboard(options) {
15 | this.options = options || {};
16 | this.settings = this.options.settings;
17 |
18 | this.screen = blessed.screen({
19 | smartCSR: true,
20 | title: options.appName
21 | });
22 |
23 | this.logProvider = new LogProvider(this.screen);
24 | this.metricsProvider = new MetricsProvider(this.screen);
25 |
26 | this._createViews();
27 | this._configureKeys();
28 | this.screen.render();
29 | };
30 |
31 | Dashboard.prototype._createViews = function () {
32 | this.layouts = generateLayouts(this.options.layoutsFile);
33 |
34 | // container prevents stream view scrolling from interfering with side views
35 | this.container = blessed.box();
36 | this.screen.append(this.container);
37 | this.viewOptions = {
38 | screen: this.screen,
39 | parent: this.container,
40 | logProvider: this.logProvider,
41 | metricsProvider: this.metricsProvider
42 | };
43 |
44 | this.helpView = new HelpView(this.viewOptions);
45 | this.gotoTimeView = new GotoTimeView(this.viewOptions);
46 |
47 | this._showLayout(0);
48 | };
49 |
50 | Dashboard.prototype._configureKeys = function () {
51 | // ignore locked works like a global key handler regardless of input
52 | // this key will be watched on the global screen
53 | this.screen.ignoreLocked = ["C-c"];
54 | this.screen.key("C-c", () => {
55 | process.kill(process.pid, "SIGINT");
56 | });
57 |
58 | // watch for key events on the main container; not the screen
59 | // this allows for more granular key bindings in other views
60 | this.container.key(["left", "right"], _.throttle((ch, key) => {
61 | const delta = key.name === "left" ? -1 : 1;
62 | const target = (this.currentLayout + delta + this.layouts.length) % this.layouts.length;
63 | this._showLayout(target);
64 | }, THROTTLE_TIMEOUT));
65 |
66 | this.container.key(["?", "h", "S-h"], () => {
67 | this.gotoTimeView.hide();
68 | this.helpView.toggle();
69 | this.screen.render();
70 | });
71 |
72 | this.container.key(["g", "S-g"], () => {
73 | this.helpView.hide();
74 | this.gotoTimeView.toggle();
75 | this.screen.render();
76 | });
77 |
78 | this.container.key("escape", () => {
79 | if (this.helpView.isVisible() || this.gotoTimeView.isVisible()) {
80 | this.helpView.hide();
81 | this.gotoTimeView.hide();
82 | this.screen.render();
83 | } else {
84 | this.screen.emit("resetGraphs");
85 | this._showLayout(0);
86 | }
87 | });
88 |
89 | this.container.key(["q", "S-q"], () => {
90 | process.exit(0); // eslint-disable-line no-process-exit
91 | });
92 |
93 | this.container.key(["w", "S-w", "s", "S-s"], (ch, key) => {
94 | const zoom = key.name === "s" ? -1 : 1;
95 | this.screen.emit("zoomGraphs", zoom);
96 | this.screen.render();
97 | });
98 |
99 | this.container.key(["a", "S-a", "d", "S-d"], (ch, key) => {
100 | const scroll = key.name === "a" ? -1 : 1;
101 | this.screen.emit("scrollGraphs", scroll);
102 | this.screen.render();
103 | });
104 |
105 | this.container.key(["z", "S-z", "x", "S-x"], (ch, key) => {
106 | const goto = key.name === "z" ? -1 : 1;
107 | this.screen.emit("startGraphs", goto);
108 | this.screen.render();
109 | });
110 | };
111 |
112 | Dashboard.prototype.onEvent = function (event) {
113 | this.screen.emit(event.type, event.data);
114 | // avoid double screen render for stream events (Element calls screen.render on scroll)
115 | // TODO dashboard shouldn't know which events are used by which widgets
116 | if (event.type === "metrics") {
117 | this.screen.render();
118 | }
119 | };
120 |
121 | Dashboard.prototype._showLayout = function (id) {
122 | if (this.currentLayout === id) {
123 | return;
124 | }
125 |
126 | // Remove current layout
127 | if (this.panel) {
128 | this.panel.destroy();
129 | delete this.panel;
130 | }
131 |
132 | // create new layout
133 | this.panel = views.create(this.layouts[id], this.viewOptions, this.settings);
134 |
135 | this.currentLayout = id;
136 | this.helpView.node.setFront();
137 | this.screen.render();
138 | };
139 |
140 | module.exports = Dashboard;
141 |
--------------------------------------------------------------------------------
/lib/default-layout-config.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | module.exports = [
4 | [
5 | {
6 | position: {
7 | grow: 3
8 | },
9 | views: [
10 | {
11 | type: "log",
12 | title: "stdout",
13 | borderColor: "green",
14 | streams: ["stdout"]
15 | },
16 | {
17 | type: "log",
18 | title: "stderr",
19 | borderColor: "red",
20 | streams: ["stderr"]
21 | }
22 | ]
23 | },
24 | {
25 | views: [
26 | {
27 | type: "cpu",
28 | limit: 30
29 | },
30 | {
31 | type: "eventLoop",
32 | limit: 30
33 | },
34 | {
35 | type: "memory",
36 | position: {
37 | size: 15
38 | }
39 | }
40 | ]
41 | }
42 | ],
43 | [
44 | {
45 | position: {
46 | grow: 3
47 | },
48 | views: [
49 | {
50 | type: "log",
51 | title: "log",
52 | borderColor: "light-blue",
53 | streams: ["stdout", "stderr"]
54 | }
55 | ]
56 | },
57 | {
58 | views: [
59 | {
60 | type: "cpu",
61 | limit: 30
62 | },
63 | {
64 | type: "eventLoop",
65 | title: "event loop",
66 | limit: 30
67 | },
68 | {
69 | type: "memory",
70 | position: {
71 | size: 15
72 | }
73 | }
74 | ]
75 | }
76 | ],
77 | [
78 | {
79 | views: [
80 | {
81 | type: "cpu",
82 | limit: 30
83 | },
84 | {
85 | type: "eventLoop",
86 | limit: 30
87 | },
88 | {
89 | type: "memoryGraph"
90 | }
91 | ]
92 | }
93 | ],
94 | [
95 | {
96 | views: [
97 | {
98 | type: "log",
99 | title: "stdout",
100 | borderColor: "green",
101 | streams: ["stdout"]
102 | },
103 | {
104 | type: "log",
105 | title: "stdout",
106 | borderColor: "red",
107 | streams: ["stderr"]
108 | }
109 | ]
110 | }
111 | ],
112 | [
113 | {
114 | views: [
115 | {
116 | type: "log",
117 | title: "log",
118 | borderColor: "light-blue",
119 | streams: ["stdout", "stderr"]
120 | }
121 | ]
122 | }
123 | ],
124 | [
125 | {
126 | views: [
127 | {
128 | position: {
129 | grow: 2
130 | },
131 | type: "panel",
132 | views: [
133 | {
134 | type: "panel",
135 | views: [
136 | {
137 | type: "nodeDetails"
138 | },
139 | {
140 | type: "systemDetails"
141 | }
142 | ]
143 | },
144 | {
145 | type: "panel",
146 | views: [
147 | {
148 | type: "cpuDetails"
149 | },
150 | {
151 | type: "userDetails"
152 | }
153 | ]
154 | }
155 | ]
156 | },
157 | {
158 | position: {
159 | grow: 5
160 | },
161 | type: "envDetails"
162 | }
163 | ]
164 | }
165 | ]
166 | ];
167 |
--------------------------------------------------------------------------------
/lib/generate-layouts.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const _ = require("lodash");
4 | const assert = require("assert");
5 | const path = require("path");
6 | const defaultLayoutConfig = require("./default-layout-config");
7 | const validate = require("jsonschema").validate;
8 | const layoutConfigSchema = require("./layout-config-schema.json");
9 |
10 | module.exports = function generateLayouts(layoutsFile) {
11 | let layoutConfig = defaultLayoutConfig;
12 | if (layoutsFile) {
13 | /* eslint-disable global-require */
14 | try {
15 | layoutConfig = require(layoutsFile);
16 | } catch (err1) {
17 | layoutConfig = require(path.resolve(process.cwd(), layoutsFile));
18 | }
19 | /* eslint-enable global-require */
20 | const validationResult = validate(layoutConfig, layoutConfigSchema);
21 | assert(
22 | validationResult.valid,
23 | `Layout config is invalid:\n\n * ${validationResult.errors.join("\n * ")}\n`
24 | );
25 | }
26 |
27 | return layoutConfig.map((layouts) => ({
28 | view: {
29 | type: "panel",
30 | views: layouts.map((config) => _.merge(config, { type: "panel" }))
31 | },
32 | getPosition: _.identity
33 | }));
34 | };
35 |
--------------------------------------------------------------------------------
/lib/layout-config-schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "array",
3 | "items": {
4 | "$ref": "#/definitions/layout"
5 | },
6 | "definitions": {
7 | "layout": {
8 | "type": "array",
9 | "items": {
10 | "$ref": "#/definitions/panel"
11 | }
12 | },
13 | "panel": {
14 | "type": "object",
15 | "properties": {
16 | "position": {
17 | "$ref": "#/definitions/position"
18 | },
19 | "views": {
20 | "type": "array",
21 | "items": {
22 | "oneOf": [{
23 | "$ref": "#/definitions/panel"
24 | }, {
25 | "$ref": "#/definitions/streamView"
26 | }, {
27 | "$ref": "#/definitions/memoryView"
28 | }, {
29 | "$ref": "#/definitions/lineGraphView"
30 | }, {
31 | "$ref": "#/definitions/customView"
32 | }, {
33 | "$ref": "#/definitions/cpuDetailsView"
34 | }, {
35 | "$ref": "#/definitions/envDetailsView"
36 | }, {
37 | "$ref": "#/definitions/nodeDetailsView"
38 | }, {
39 | "$ref": "#/definitions/systemDetailsView"
40 | }, {
41 | "$ref": "#/definitions/userDetailsView"
42 | }]
43 | }
44 | }
45 | },
46 | "required": ["views"]
47 | },
48 | "streamView": {
49 | "type": "object",
50 | "properties": {
51 | "title": {
52 | "type": "string"
53 | },
54 | "type": {
55 | "enum": ["log"]
56 | },
57 | "borderColor": {
58 | "type": "string"
59 | },
60 | "fgColor": {
61 | "type": "string"
62 | },
63 | "bgColor": {
64 | "type": "string"
65 | },
66 | "scrollback": {
67 | "type": "number",
68 | "minimum": 0
69 | },
70 | "streams": {
71 | "type": "array",
72 | "items": {
73 | "enum": ["stdout", "stderr"]
74 | }
75 | },
76 | "exclude": {
77 | "type": "string"
78 | },
79 | "include": {
80 | "type": "string"
81 | },
82 | "position": {
83 | "$ref": "#/definitions/position"
84 | }
85 | },
86 | "required": ["type"]
87 | },
88 | "memoryView": {
89 | "type": "object",
90 | "properties": {
91 | "title": {
92 | "type": "string"
93 | },
94 | "borderColor": {
95 | "type": "string"
96 | },
97 | "type": {
98 | "enum": ["memory"]
99 | },
100 | "position": {
101 | "$ref": "#/definitions/position"
102 | }
103 | },
104 | "required": ["type"]
105 | },
106 | "lineGraphView": {
107 | "type": "object",
108 | "properties": {
109 | "title": {
110 | "type": "string"
111 | },
112 | "borderColor": {
113 | "type": "string"
114 | },
115 | "type": {
116 | "enum": ["cpu", "eventLoop", "memoryGraph"]
117 | },
118 | "position": {
119 | "$ref": "#/definitions/position"
120 | },
121 | "limit": {
122 | "type": "integer",
123 | "minimum": 0
124 | }
125 | },
126 | "required": ["type"]
127 | },
128 | "customView": {
129 | "type": "object",
130 | "properties": {
131 | "module": {
132 | "type": "string"
133 | },
134 | "position": {
135 | "$ref": "#/definitions/position"
136 | }
137 | },
138 | "required": ["module"]
139 | },
140 | "cpuDetailsView": {
141 | "type": "object",
142 | "properties": {
143 | "title": {
144 | "type": "string"
145 | },
146 | "borderColor": {
147 | "type": "string"
148 | },
149 | "type": {
150 | "enum": ["cpuDetails"]
151 | },
152 | "position": {
153 | "$ref": "#/definitions/position"
154 | }
155 | },
156 | "required": ["type"]
157 | },
158 | "envDetailsView": {
159 | "type": "object",
160 | "properties": {
161 | "title": {
162 | "type": "string"
163 | },
164 | "borderColor": {
165 | "type": "string"
166 | },
167 | "type": {
168 | "enum": ["envDetails"]
169 | },
170 | "position": {
171 | "$ref": "#/definitions/position"
172 | }
173 | },
174 | "required": ["type"]
175 | },
176 | "nodeDetailsView": {
177 | "type": "object",
178 | "properties": {
179 | "title": {
180 | "type": "string"
181 | },
182 | "borderColor": {
183 | "type": "string"
184 | },
185 | "type": {
186 | "enum": ["nodeDetails"]
187 | },
188 | "position": {
189 | "$ref": "#/definitions/position"
190 | }
191 | },
192 | "required": ["type"]
193 | },
194 | "systemDetailsView": {
195 | "type": "object",
196 | "properties": {
197 | "title": {
198 | "type": "string"
199 | },
200 | "borderColor": {
201 | "type": "string"
202 | },
203 | "type": {
204 | "enum": ["systemDetails"]
205 | },
206 | "position": {
207 | "$ref": "#/definitions/position"
208 | }
209 | },
210 | "required": ["type"]
211 | },
212 | "userDetailsView": {
213 | "type": "object",
214 | "properties": {
215 | "title": {
216 | "type": "string"
217 | },
218 | "borderColor": {
219 | "type": "string"
220 | },
221 | "type": {
222 | "enum": ["userDetails"]
223 | },
224 | "position": {
225 | "$ref": "#/definitions/position"
226 | }
227 | },
228 | "required": ["type"]
229 | },
230 | "position": {
231 | "oneOf": [{
232 | "type": "object",
233 | "properties": {
234 | "size": {
235 | "type": "integer",
236 | "minimum": 0
237 | }
238 | },
239 | "required": ["size"],
240 | "additionalProperties": false
241 | }, {
242 | "type": "object",
243 | "properties": {
244 | "grow": {
245 | "type": "number",
246 | "minimum": 0
247 | }
248 | },
249 | "required": ["grow"],
250 | "additionalProperties": false
251 | }]
252 | }
253 | }
254 | }
255 |
--------------------------------------------------------------------------------
/lib/parse-settings.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const _ = require("lodash");
4 |
5 | const parseSettings = function (settings) {
6 | const settingsList = settings.split(",");
7 | const parseResult = {};
8 |
9 | for (let i = 0; i < settingsList.length; i++) {
10 | const keyValue = settingsList[i].split("=");
11 | if (keyValue.length !== 2) { // eslint-disable-line no-magic-numbers
12 | return {
13 | error: `error: settings should have format .=: ${
14 | settingsList[i]}`
15 | };
16 | }
17 | const key = keyValue[0].trim();
18 | const value = keyValue[1].trim();
19 | if (!(/^[\w\d\[\]_-]+(\.[\w\d\[\]_-]+)+$/).test(key)) {
20 | return {
21 | error: `error: invalid path '${key}' for setting: ${settingsList[i]}`
22 | };
23 | }
24 |
25 | if ((/^\d+(\.\d*)?$/).test(value)) {
26 | _.set(parseResult, key, parseFloat(value));
27 | } else if ((/^(true|false)$/).test(value)) {
28 | _.set(parseResult, key, value === "true");
29 | } else {
30 | _.set(parseResult, key, value);
31 | }
32 | }
33 |
34 | return { result: parseResult };
35 | };
36 |
37 | module.exports = parseSettings;
38 |
--------------------------------------------------------------------------------
/lib/providers/log-provider.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const { EventEmitter } = require("events");
4 |
5 | const LogProvider = function LogProvider(screen) {
6 | EventEmitter.call(this);
7 |
8 | this._log = [];
9 | this.limit = 10000;
10 |
11 | screen.on("stdout", this._onLog.bind(this, "stdout"));
12 | screen.on("stderr", this._onLog.bind(this, "stderr"));
13 | };
14 |
15 | LogProvider.prototype = Object.create(EventEmitter.prototype);
16 |
17 | LogProvider.setLimit = function (limit) {
18 | this.limit = Math.max(this.limit, limit);
19 | };
20 |
21 | LogProvider.prototype._onLog = function (source, data) {
22 | this._log.push([source, data]);
23 | if (this._log.length > this.limit) {
24 | this._log = this._log.slice(this.limit - this._log.length);
25 | }
26 |
27 | this.emit(source, data);
28 | };
29 |
30 | LogProvider.prototype.getLog = function (sources, limit) {
31 | return this._log
32 | .filter((entry) => sources.indexOf(entry[0]) !== -1)
33 | .slice(0, limit || this.limit)
34 | .map((entry) => entry[1])
35 | .join("")
36 | .replace(/\n$/, "");
37 | };
38 |
39 | module.exports = LogProvider;
40 |
--------------------------------------------------------------------------------
/lib/providers/metrics-provider.js:
--------------------------------------------------------------------------------
1 | /**
2 | * For detail information on aggregation in this module, see ../../METRIC-AGGREGATION.md
3 | */
4 |
5 | "use strict";
6 |
7 | const { EventEmitter } = require("events");
8 | const _ = require("lodash");
9 | const constants = require("../constants");
10 | const time = require("../time");
11 |
12 | // get the defined aggregation levels
13 | const AGGREGATE_TIME_LEVELS = constants.AGGREGATE_TIME_LEVELS;
14 |
15 | // what a valid time offset looks like
16 | const TIME_LABEL_PATTERN = /^(\d+y)?\s*(\d{1,3}d)?\s*(\d{1,2})?(:\d{1,2})?(:\d{2})?$/i;
17 |
18 | /**
19 | * This is the constructor for the MetricsProvider
20 | *
21 | * @param {Object} screen
22 | * The blessed screen object.
23 | *
24 | * @returns {void}
25 | */
26 | const MetricsProvider
27 | = function MetricsProvider(screen) {
28 | /**
29 | * Setup the process to aggregate the data as and when it is necessary.
30 | *
31 | * @returns {void}
32 | */
33 | const setupAggregation
34 | = function setupAggregation() {
35 | // construct the aggregation container
36 | this._aggregation = _.reduce(AGGREGATE_TIME_LEVELS, (prev, timeLevel) => {
37 | prev[timeLevel] = {
38 | data: [],
39 | lastTimeIndex: undefined,
40 | lastAggregateIndex: 0,
41 | scrollOffset: 0
42 | };
43 |
44 | return prev;
45 | }, {});
46 |
47 | // an array of all available aggregation levels and metadata
48 | this.aggregationLevels = _.keys(this._aggregation);
49 | this.lowestAggregateTimeUnits = Number(this.aggregationLevels[0]);
50 | this.highestAggregationKey = _.last(this.aggregationLevels);
51 |
52 | // remember when all this started
53 | this._startTime = Date.now();
54 |
55 | // this is where we stopped aggregating
56 | this._lastAggregationIndex = 0;
57 | }.bind(this);
58 |
59 | EventEmitter.call(this);
60 |
61 | // the low-level container of all metrics provided
62 | this._metrics = [];
63 |
64 | // setup for aggregation
65 | setupAggregation();
66 |
67 | // initialize the zoom level to the lowest level
68 | this.setZoomLevel(0);
69 |
70 | // callback handlers
71 | screen.on("metrics", this._onMetrics.bind(this));
72 | screen.on("zoomGraphs", this.adjustZoomLevel.bind(this));
73 | screen.on("scrollGraphs", this.adjustScrollOffset.bind(this));
74 | screen.on("startGraphs", this.startGraphs.bind(this));
75 | screen.on("resetGraphs", this.resetGraphs.bind(this));
76 | };
77 |
78 | // MetricsProvider inherits from EventEmitter
79 | MetricsProvider.prototype = Object.create(EventEmitter.prototype);
80 |
81 | /**
82 | * Given a moment in time, the start time, and time units, produce the
83 | * correct time index.
84 | *
85 | * @param {Number} currentTime
86 | * The current time.
87 | *
88 | * @param {Number} startTime
89 | * The start time, to derived elapsed time.
90 | *
91 | * @param {Number} aggregateTimeUnits
92 | * The time units to derive the index.
93 | *
94 | * @returns {Number}
95 | * The time index for the elapsed time is returned.
96 | */
97 | const convertElapsedTimeToTimeIndex
98 | = function convertElapsedTimeToTimeIndex(
99 | currentTime,
100 | startTime,
101 | aggregateTimeUnits
102 | ) {
103 | return Math.floor((currentTime - startTime) / aggregateTimeUnits);
104 | };
105 |
106 | /**
107 | * Get the reference to the current aggregation.
108 | *
109 | * @returns {Object}
110 | * The object reference is returned.
111 | */
112 | MetricsProvider.prototype.getCurrentAggregation = function getCurrentAggregation() {
113 | return this._aggregation[this.zoomLevelKey];
114 | };
115 |
116 | /**
117 | * Set the zoom level desired.
118 | *
119 | * @param {Number} zoom
120 | * The desired zoom level. It may be clamped.
121 | *
122 | * @returns {void}
123 | */
124 | MetricsProvider.prototype.setZoomLevel = function setZoomLevel(zoom) {
125 | this.zoomLevel = _.clamp(zoom, 0, this.aggregationLevels.length - 1);
126 | this.zoomLevelKey = this.aggregationLevels[this.zoomLevel];
127 | };
128 |
129 | /**
130 | * Get the minimum and maximum times for the current zoom.
131 | *
132 | * @returns {Object}
133 | * An object containing the time range is returned
134 | */
135 | MetricsProvider.prototype.getAvailableTimeRange = function getAvailableTimeRange() {
136 | const maxAverages = this._aggregation[this.lowestAggregateTimeUnits].data.length - 1;
137 | return {
138 | minTime: {
139 | label: time.getLabel(0),
140 | value: 0
141 | },
142 | maxTime: {
143 | label: time.getLabel(maxAverages * this.lowestAggregateTimeUnits),
144 | value: maxAverages * this.lowestAggregateTimeUnits
145 | }
146 | };
147 | };
148 |
149 | /**
150 | * Check to see if data exists at the current zoom level.
151 | *
152 | * @returns {Boolean}
153 | * Truthy if there is data, falsey otherwise.
154 | */
155 | MetricsProvider.prototype.hasZoomLevelData = function hasZoomLevelData() {
156 | return this.getCurrentAggregation().data.length > 0;
157 | };
158 |
159 | /**
160 | * Adjust the zoom level using the delta provided. The zoom level
161 | * may be clamped. Once set, the consumer is notified.
162 | *
163 | * @param {Number} zoom
164 | * The delta to apply to the zoom level.
165 | *
166 | * @returns {void}
167 | */
168 | MetricsProvider.prototype.adjustZoomLevel = function adjustZoomLevel(zoom) {
169 | // apply zoom delta while staying in boundaries
170 | this.setZoomLevel(this.zoomLevel + zoom);
171 |
172 | // if there is an aggregate at this level, but there is no data, go back a level
173 | while (!this.hasZoomLevelData() && this.zoomLevel > 0) {
174 | this.setZoomLevel(this.zoomLevel - 1);
175 | }
176 |
177 | this.emit("refreshMetrics");
178 | };
179 |
180 | /**
181 | * Adjust the scroll offset using the delta specified. The scroll may
182 | * be clamped. Once set, the consumer is notified.
183 | *
184 | * @param {Number} scroll
185 | * The scroll delta to apply.
186 | *
187 | * @param {Boolean} absolute
188 | * When truthy, the scroll is an absolute value. When falsey,
189 | * the scroll is a relative value.
190 | *
191 | * @returns {void}
192 | */
193 | MetricsProvider.prototype.adjustScrollOffset = function adjustScrollOffset(scroll, absolute) {
194 | const currentAggregation = this.getCurrentAggregation();
195 |
196 | // if absolute position is set, clear any existing scroll offset
197 | if (absolute) {
198 | currentAggregation.scrollOffset = 0;
199 | }
200 |
201 | // apply the offset (but do not go above zero)
202 | currentAggregation.scrollOffset
203 | = Math.min(currentAggregation.scrollOffset + scroll, 0);
204 |
205 | this.emit("refreshMetrics");
206 | };
207 |
208 | /**
209 | * Given a time value entered, go there.
210 | *
211 | * @param {String} timeValue
212 | * The time value to go to.
213 | *
214 | * @returns {void}
215 | */
216 | MetricsProvider.prototype.gotoTimeValue = function gotoTimeValue(timeValue) {
217 | // set a goto offset
218 | this.gotoOffset = -convertElapsedTimeToTimeIndex(timeValue, 0, Number(this.zoomLevelKey));
219 | this.emit("refreshMetrics");
220 | };
221 |
222 | /**
223 | * Start the graphs at the beginning or the end of the data available.
224 | *
225 | * @param {Number} goto
226 | * If the goto value is negative, start at the beginning. Otherwise,
227 | * use the end of the data set.
228 | *
229 | * @returns {void}
230 | */
231 | MetricsProvider.prototype.startGraphs = function startGraphs(goto) {
232 | const adjustment = this.getCurrentAggregation().data.length * (goto < 0 ? -1 : 1);
233 | this.adjustScrollOffset(adjustment, true);
234 | };
235 |
236 | /**
237 | * Reset the graphs including offsets and zooming. Once reset,
238 | * the consumer is notified.
239 | *
240 | * @returns {void}
241 | */
242 | MetricsProvider.prototype.resetGraphs = function resetGraphs() {
243 | // reset to start zoom
244 | this.setZoomLevel(0);
245 |
246 | // clear all scroll offsets
247 | for (const aggregateKey in this._aggregation) {
248 | this._aggregation[aggregateKey].scrollOffset = 0;
249 | }
250 |
251 | this.emit("refreshMetrics");
252 | };
253 |
254 | /**
255 | * Check to see if the current zoom is scrolled.
256 | *
257 | * @returns {Boolean}
258 | * Truthy if it is scrolled, falsey otherwise.
259 | */
260 | MetricsProvider.prototype.isScrolled = function isScrolled() {
261 | return !!this.getCurrentAggregation().scrollOffset;
262 | };
263 |
264 | /**
265 | * Check to see if the current zoom matches the aggregation specified.
266 | *
267 | * @param {String} aggregateKey
268 | * The aggregate key being processed.
269 | *
270 | * @returns {Boolean}
271 | * Truthy if the aggregation level matches the zoom level, falsey otherwise.
272 | */
273 | MetricsProvider.prototype.isCurrentZoom = function isCurrentZoom(aggregateKey) {
274 | return this.zoomLevelKey === aggregateKey;
275 | };
276 |
277 | /**
278 | * Get the scroll offset for the current zoom.
279 | *
280 | * @returns {Number}
281 | * The scroll offset for the current zoom is returned.
282 | */
283 | MetricsProvider.prototype.getCurrentScrollOffset = function getCurrentScrollOffset() {
284 | return this.getCurrentAggregation().scrollOffset;
285 | };
286 |
287 | /**
288 | * Given a metric data object, construct an initialized average.
289 | *
290 | * @param {Object} data
291 | * The metric data received.
292 | *
293 | * @returns {Object}
294 | * The initialized average object is returned.
295 | */
296 | const getInitializedAverage
297 | = function getInitializedAverage(data) {
298 | return _.reduce(data, (prev, a, dataKey) => {
299 | // create a first-level object of the key
300 | prev[dataKey] = {};
301 |
302 | _.each(data[dataKey], (b, dataMetricKey) => {
303 | // the metrics are properties inside this object
304 | prev[dataKey][dataMetricKey] = 0;
305 | });
306 |
307 | return prev;
308 | }, {});
309 | };
310 |
311 | /**
312 | * Perform event-driven aggregation at all configured units of time.
313 | *
314 | * @param {Number} currentTime
315 | * The current time of the aggregation.
316 | *
317 | * @param {Object} metricData
318 | * The metric data template received.
319 | *
320 | * @this MetricsProvider
321 | *
322 | * @returns {void}
323 | */
324 | const aggregateMetrics
325 | = function aggregateMetrics(currentTime, metricData) {
326 | let aggregateKey;
327 |
328 | /**
329 | * Place aggregate data into the specified slot. If the current zoom
330 | * level matches the aggregate level, the data is emitted to keep the
331 | * display in sync.
332 | *
333 | * @param {Number} index
334 | * The desired slot for the aggregate.
335 | *
336 | * @param {Object} data
337 | * The aggregate data.
338 | *
339 | * @returns {void}
340 | */
341 | const setAggregateData
342 | = function setAggregateData(index, data) {
343 | this._aggregation[aggregateKey].data[index] = data;
344 |
345 | // if this view (current or not) is scrolled, adjust it
346 | if (this._aggregation[aggregateKey].scrollOffset) {
347 | this._aggregation[aggregateKey].scrollOffset--;
348 | }
349 |
350 | // emit to keep the display in sync
351 | if (this.isCurrentZoom(aggregateKey)) {
352 | if (this.isScrolled()) {
353 | this.emit("refreshMetrics");
354 | } else {
355 | this.emit("metrics", data);
356 | }
357 | }
358 | }.bind(this);
359 |
360 | /**
361 | * Given the current time time index, add any missing logical
362 | * time slots to have a complete picture of data.
363 | *
364 | * @param {Number} currentTimeIndex
365 | * The time index currently being processed.
366 | *
367 | * @returns {void}
368 | */
369 | const addMissingTimeSlots
370 | = function addMissingTimeSlots(currentTimeIndex) {
371 | let aggregateIndex = this._aggregation[aggregateKey].data.length;
372 | while (aggregateIndex < currentTimeIndex) {
373 | setAggregateData(aggregateIndex++, this.emptyAverage);
374 | }
375 | }.bind(this);
376 |
377 | /**
378 | * After having detected a new sampling, aggregate the relevant data points
379 | *
380 | * @param {Object[]} rows
381 | * The array reference.
382 | *
383 | * @param {Number} startIndex
384 | * The starting index to derive an average.
385 | *
386 | * @param {Number} endIndex
387 | * The ending index to derive an average.
388 | *
389 | * @returns {void}
390 | */
391 | const getAveragedAggregate
392 | = function getAveragedAggregate(rows, startIndex, endIndex) {
393 | const averagedAggregate = getInitializedAverage(metricData);
394 |
395 | // this is the number of elements we will aggregate
396 | const aggregateCount = endIndex - startIndex + 1;
397 |
398 | // you can compute an average of a set of numbers two ways
399 | // first, you can add all the numbers together and then divide by the count
400 | // second, you call divide each number by the count and add the quotients
401 | // the first method is more accurate, however you can overflow an accumulator
402 | // and result with NaN
403 | // the second method is employed here to ensure no overflows
404 |
405 | for (const dataKey in metricData) {
406 | for (const dataMetricKey in metricData[dataKey]) {
407 | for (let rowIndex = startIndex; rowIndex <= endIndex; rowIndex++) {
408 | averagedAggregate[dataKey][dataMetricKey]
409 | += rows[rowIndex][dataKey][dataMetricKey] / aggregateCount;
410 | }
411 |
412 | // after the average is done, truncate the averages to one decimal point
413 | averagedAggregate[dataKey][dataMetricKey]
414 | = Number(averagedAggregate[dataKey][dataMetricKey].toFixed(1));
415 | }
416 | }
417 |
418 | return averagedAggregate;
419 | };
420 |
421 | /**
422 | * Process one row of metric data into aggregate.
423 | *
424 | * @param {Number} rowIndex
425 | * The index of the row being processed.
426 | *
427 | * @param {Object[]} rows
428 | * The array reference.
429 | *
430 | * @returns {void}
431 | */
432 | const processRow = function processRow(rowIndex, rows) {
433 | let averagedAggregate;
434 | const lastTimeIndex = this._aggregation[aggregateKey].lastTimeIndex;
435 |
436 | // get the time index of the aggregate
437 | const currentTimeIndex
438 | = convertElapsedTimeToTimeIndex(currentTime, this._startTime, Number(aggregateKey));
439 |
440 | // when the time index changes, average the data for the time band
441 | if (currentTimeIndex !== lastTimeIndex) {
442 | // except for the first one
443 | if (lastTimeIndex !== undefined) {
444 | // add in any missing logical time slots
445 | addMissingTimeSlots.call(this, lastTimeIndex);
446 |
447 | // get the average across the discovered time band
448 | averagedAggregate = getAveragedAggregate(
449 | rows,
450 | this._aggregation[aggregateKey].lastAggregateIndex,
451 | rowIndex - 1
452 | );
453 |
454 | // place the average
455 | setAggregateData(lastTimeIndex, averagedAggregate);
456 |
457 | // now we know where the next aggregate begins
458 | this._aggregation[aggregateKey].lastAggregateIndex = rowIndex;
459 | }
460 |
461 | // level-break
462 | this._aggregation[aggregateKey].lastTimeIndex = currentTimeIndex;
463 | }
464 | }.bind(this);
465 |
466 | // iterate over the configured aggregation time buckets
467 | for (aggregateKey in this._aggregation) {
468 | // iterate through metrics, beginning where we left off
469 | processRow(this._lastAggregationIndex, this._metrics);
470 | }
471 |
472 | // remember where we will begin again
473 | this._lastAggregationIndex++;
474 | };
475 |
476 | /**
477 | * When metrics are received collect, aggregate, and emit them.
478 | *
479 | * @param {Object} data
480 | * The metrics data received.
481 | *
482 | * @returns {void}
483 | */
484 | MetricsProvider.prototype._onMetrics
485 | = function _onMetrics(data) {
486 | // get the current moment in time
487 | const currentTime = Date.now();
488 |
489 | // capture the metrics
490 | this._metrics.push(data);
491 |
492 | // one time, build an empty average - used for missing time slots
493 | if (!this.emptyAverage) {
494 | this.emptyAverage = getInitializedAverage(data);
495 | }
496 |
497 | // run aggregation process
498 | aggregateMetrics.call(this, currentTime, data);
499 |
500 | // always emit the data, but send a new arg to indicates whether
501 | // zoom is in effect (and therefore should be ignored)
502 | this.emit("metrics", data, this.zoomLevelKey !== undefined);
503 | };
504 |
505 | /**
506 | * Provide all the metrics desired, up to the limit.
507 | *
508 | * @param {Number} limit
509 | * The limit of the metrics to return.
510 | *
511 | * @returns {Number[]}
512 | * The array of metrics is returned.
513 | */
514 | MetricsProvider.prototype.getMetrics
515 | = function getMetrics(limit) {
516 | const currentAggregation = this.getCurrentAggregation();
517 |
518 | /**
519 | * Given an offset and length, get the corrected offset.
520 | *
521 | * @param {Number} offset
522 | * The current offset value.
523 | *
524 | * @param {Number} length
525 | * The length of available data to offset.
526 | *
527 | * @returns {Number}
528 | * The corrected scroll offset is returned.
529 | */
530 | const getFixedScrollOffset
531 | = function getFixedScrollOffset(offset, length) {
532 | if (offset && length + offset <= limit) {
533 | return Math.min(limit - length, 0);
534 | }
535 | return Math.min(offset, 0);
536 | };
537 |
538 | // if there is a goto offset, try to place it in the center of the display
539 | if (this.gotoOffset !== undefined) {
540 | // eslint-disable-next-line no-magic-numbers
541 | currentAggregation.scrollOffset = this.gotoOffset + Math.floor(limit / 2);
542 |
543 | // once applied, remove it
544 | delete this.gotoOffset;
545 | }
546 |
547 | // correct the offset now that we know the width to display
548 | currentAggregation.scrollOffset
549 | = getFixedScrollOffset(currentAggregation.scrollOffset, currentAggregation.data.length);
550 |
551 | // if there is an offset, take that into account
552 | if (currentAggregation.scrollOffset) {
553 | // the three slices:
554 | // 1- limit to the available data at the time scrolling started
555 | // 2- get the back end of the array, given the scroll offset
556 | // 3- limit the final result to the limit originally specified
557 | return currentAggregation.data
558 | .slice(0, currentAggregation.data.length)
559 | .slice(-limit + currentAggregation.scrollOffset)
560 | .slice(0, limit);
561 | }
562 | // when there is no offset, just get the back end of the array
563 | // up to the desired limit specified
564 | return currentAggregation.data.slice(-limit);
565 | };
566 |
567 | /**
568 | * Return the X-Axis for the metrics.
569 | *
570 | * @param {Number} limit
571 | * The limit of the X-Axis size.
572 | *
573 | * @returns {String[]}
574 | * The X-Axis labels array is returned.
575 | */
576 | MetricsProvider.prototype.getXAxis
577 | = function getXAxis(limit) {
578 | const scrollOffset = this.getCurrentScrollOffset();
579 | const xAxis = [];
580 |
581 | for (
582 | let timeIndex = -scrollOffset + limit - 1;
583 | timeIndex >= -scrollOffset;
584 | timeIndex--
585 | ) {
586 | xAxis.push(time.getLabel(timeIndex * Number(this.zoomLevelKey)));
587 | }
588 |
589 | return xAxis;
590 | };
591 |
592 | /**
593 | * Given a time label value, validate it.
594 | *
595 | * @param {String} label
596 | * The time label to validate.
597 | *
598 | * @throws {Error}
599 | * An error is thrown if the time label is invalid.
600 | *
601 | * @returns {Number}
602 | * If the time label is valid, the time value is returned.
603 | */
604 | MetricsProvider.prototype.validateTimeLabel
605 | = function validateTimeLabel(label) {
606 | const timeRange = this.getAvailableTimeRange();
607 |
608 | // can't be empty
609 | if (!label) {
610 | throw new Error("Time value is required");
611 | }
612 |
613 | // has to look right
614 | if (!TIME_LABEL_PATTERN.test(label)) {
615 | throw new Error("Enter a valid time value");
616 | }
617 |
618 | // must be able to convert (this can throw too)
619 | const timeValue = time.convertTimeLabelToMilliseconds(label);
620 |
621 | // must be a number in range
622 | if (isNaN(timeValue) || !_.inRange(timeValue, 0, timeRange.maxTime.value + 1)) {
623 | throw new Error(
624 | `Enter a time value between ${
625 | timeRange.minTime.label
626 | } and ${
627 | timeRange.maxTime.label}`
628 | );
629 | }
630 |
631 | return timeValue;
632 | };
633 |
634 | module.exports = MetricsProvider;
635 |
--------------------------------------------------------------------------------
/lib/time.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const _ = require("lodash");
4 | const constants = require("./constants");
5 |
6 | const TIME_SCALES = constants.TIME_SCALES;
7 | const TIME_LABEL_PATTERN = constants.TIME_LABEL_PATTERN;
8 |
9 | /**
10 | * Compute a condensed human-readable label for a given time value.
11 | *
12 | * @param {Number} timeValue
13 | * The logical index of time.
14 | *
15 | * @returns {String}
16 | * A scaled, string-representation of time at the index is returned.
17 | */
18 | exports.getLabel
19 | = function getLabel(timeValue) {
20 | const DIGITS_PER_UNIT = 2;
21 | const timeElements = [];
22 |
23 | if (timeValue === 0) {
24 | return ":00";
25 | }
26 |
27 | _.every(TIME_SCALES, (timeScale, index, timeScales) => {
28 | const timeElement = {
29 | units: timeScale.units,
30 | value: 0
31 | };
32 |
33 | // stop reducing when it cannot be divided
34 | if (timeValue < timeScale.divisor) {
35 | return false;
36 | }
37 |
38 | // don't capture a time element for milliseconds
39 | if (timeScale.units !== "ms") {
40 | // reduce by the divisor
41 | timeElement.value = timeValue / timeScale.divisor;
42 |
43 | // if there are more elements after, take the modulo to get the remainder
44 | if (index < timeScales.length - 1) {
45 | timeElement.value = Math.floor(timeElement.value % timeScales[index + 1].divisor);
46 | } else {
47 | timeElement.value = Math.floor(timeElement.value);
48 | }
49 |
50 | timeElements.push(timeElement);
51 | }
52 |
53 | // reduce
54 | timeValue /= timeScale.divisor;
55 |
56 | return true;
57 | });
58 |
59 | return _.reduce(timeElements, (prev, curr, index) => {
60 | switch (curr.units) {
61 | case "s":
62 | return `:${_.padStart(curr.value, DIGITS_PER_UNIT, "0")}`;
63 | case "m":
64 | case "h":
65 | if (index < timeElements.length - 1) {
66 | return (curr.units === "m" ? ":" : " ")
67 | + _.padStart(curr.value, DIGITS_PER_UNIT, "0")
68 | + prev;
69 | }
70 | return curr.value + prev;
71 |
72 | default:
73 | return curr.value + curr.units + prev;
74 | }
75 | }, "");
76 | };
77 |
78 | /**
79 | * Given a time label value (ex: 2y 5d 1:22:33), produce the actual
80 | * time value in ms.
81 | *
82 | * @param {String} label
83 | * The time label to convert.
84 | *
85 | * @throws {Error}
86 | * An error is thrown if the time label cannot be converted to ms.
87 | *
88 | * @returns {Number}
89 | * The time value in ms is returned.
90 | */
91 | exports.convertTimeLabelToMilliseconds = function (label) {
92 | /* eslint-disable no-magic-numbers */
93 |
94 | // a container for all time elements
95 | const timeElements = {
96 | y: 0,
97 | d: 0,
98 | t: [],
99 | h: 0,
100 | m: 0,
101 | s: 0
102 | };
103 |
104 | // the initial divisor
105 | let divisor = TIME_SCALES[0].divisor;
106 |
107 | // break up the input
108 | const split = TIME_LABEL_PATTERN.exec(label);
109 |
110 | // take the broken apart pieces and consume them
111 | _.each(_.slice(split, 1), (value, index) => {
112 | // skip undefined values
113 | if (value === undefined) {
114 | return;
115 | }
116 |
117 | // get the numeric and unit components, if any
118 | const pieces = (/^:?(\d*)([yd])?/).exec(value);
119 |
120 | switch (index) {
121 | case 0:
122 | case 1:
123 | // year and day are just keys
124 | timeElements[pieces[2]] = Number(pieces[1]);
125 | break;
126 | case 2:
127 | case 3:
128 | case 4:
129 | // time is only slightly trickier; missing elements get pushed down
130 | timeElements.t.push(Number(pieces[1]));
131 | break;
132 | }
133 | });
134 |
135 | while (timeElements.t.length < 3) {
136 | // complete the time picture with leading zeros
137 | timeElements.t.unshift(0);
138 | }
139 |
140 | // convert time parts to keys
141 | timeElements.h = timeElements.t[0];
142 | timeElements.m = timeElements.t[1];
143 | timeElements.s = timeElements.t[2];
144 |
145 | // now we can discard the time array
146 | delete timeElements.t;
147 |
148 | // now, reduce the time elements by the scaling factors
149 | return _.reduce(TIME_SCALES, (prev, timeScale, index) => {
150 | // the divisor grows with each time scale factor
151 | divisor *= timeScale.divisor;
152 |
153 | // if the time element is represented, multiply by current divisor
154 | if (timeElements[timeScale.units]) {
155 | // if there are more time scales to go, make sure the current value
156 | // does not exceed its limits (ex: 90s should be 1:30 instead)
157 | if (index < TIME_SCALES.length - 1) {
158 | if (timeElements[timeScale.units] >= TIME_SCALES[index + 1].divisor) {
159 | throw new Error("Enter a valid time value");
160 | }
161 | }
162 |
163 | // continue to accumulate the time
164 | prev += timeElements[timeScale.units] * divisor;
165 | }
166 |
167 | return prev;
168 | }, 0);
169 |
170 | /* eslint-enable no-magic-numbers */
171 | };
172 |
--------------------------------------------------------------------------------
/lib/utils.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const MAX_PERCENT = 100;
4 |
5 | exports.getPercentUsed = function (used, total) {
6 | const percentUsed = Math.floor(used / total * MAX_PERCENT);
7 | return isNaN(percentUsed) ? 0 : percentUsed;
8 | };
9 |
--------------------------------------------------------------------------------
/lib/views/base-details-view.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const blessed = require("blessed");
4 | const _ = require("lodash");
5 | const BaseView = require("./base-view");
6 |
7 | const BaseDetailsView = function BaseDetailsView(options) {
8 | BaseView.call(this, options);
9 |
10 | this.screen = options.parent.screen;
11 | this.node = blessed.box(this.layoutConfig);
12 | this.parent.append(this.node);
13 |
14 | this.refreshContent();
15 | this.recalculatePosition();
16 | };
17 |
18 | BaseDetailsView.prototype = Object.create(BaseView.prototype);
19 |
20 | BaseDetailsView.prototype.refreshContent = function () {
21 | this.node.setContent(this._getBoxContent(this.getDetails()));
22 | this.screen.render();
23 | };
24 |
25 | BaseDetailsView.prototype.getDetails = function () {
26 | return [];
27 | };
28 |
29 | /**
30 | * Given data and optional filters, return the content for a box.
31 | *
32 | * @param {Object[]} data
33 | * This is the array of label/data objects that define each data
34 | * point for the box.
35 | *
36 | * @returns {String}
37 | * The content string for the box is returned.
38 | */
39 | BaseDetailsView.prototype._getBoxContent = function (data) {
40 | const longestLabel = _.reduce(data, (prev, detail) => Math.max(prev, detail.label.length), 0);
41 |
42 | const getFormattedContent = function (prev, details) {
43 | prev += `{cyan-fg}{bold}${details.label}{/}${
44 | _.repeat(" ", longestLabel - details.label.length + 1)
45 | }{green-fg}${details.data}{/}\n`;
46 | return prev;
47 | };
48 |
49 | return _.trimEnd(_.reduce(data, getFormattedContent, ""), "\n");
50 | };
51 |
52 | module.exports = BaseDetailsView;
53 |
--------------------------------------------------------------------------------
/lib/views/base-line-graph.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const assert = require("assert");
4 | const contrib = require("blessed-contrib");
5 | const util = require("util");
6 | const _ = require("lodash");
7 |
8 | const BaseView = require("./base-view");
9 |
10 | const BaseLineGraph = function BaseLineGraph(options) {
11 | const setupEventHandlers = function setupEventHandlers() {
12 | this._boundOnEvent = this.onEvent.bind(this);
13 | this._boundOnRefreshMetrics = this.onRefreshMetrics.bind(this);
14 |
15 | options.metricsProvider.on("metrics", this._boundOnEvent);
16 | options.metricsProvider.on("refreshMetrics", this._boundOnRefreshMetrics);
17 | }.bind(this);
18 |
19 | BaseView.call(this, options);
20 |
21 | assert(options.metricsProvider, "View requires metricsProvider");
22 | this.metricsProvider = options.metricsProvider;
23 |
24 | this.unit = options.unit || "";
25 | this.label = this.layoutConfig.title ? util.format(" %s ", this.layoutConfig.title) : " ";
26 |
27 | this._remountOnResize = true;
28 |
29 | this.limit = this.layoutConfig.limit;
30 | this.seriesOptions = options.series;
31 |
32 | const xAxis = this.metricsProvider.getXAxis(this.layoutConfig.limit);
33 | this.series = _.mapValues(options.series, (seriesConfig) => {
34 | if (seriesConfig.highwater && !seriesConfig.color) {
35 | seriesConfig.color = "red";
36 | }
37 | return {
38 | x: xAxis,
39 | y: _.times(this.layoutConfig.limit, _.constant(0)),
40 | style: {
41 | line: seriesConfig.color
42 | }
43 | };
44 | });
45 |
46 | this._createGraph(options);
47 |
48 | setupEventHandlers();
49 | };
50 |
51 | BaseLineGraph.prototype = Object.create(BaseView.prototype);
52 |
53 | BaseLineGraph.prototype.onEvent = function () {
54 | throw new Error("BaseLineGraph onEvent should be overridden");
55 | };
56 |
57 | BaseLineGraph.prototype.onRefreshMetrics = function () {
58 | throw new Error("BaseLineGraph onRefreshMetrics should be overridden");
59 | };
60 |
61 | BaseLineGraph.prototype._isHighwater = function (name) {
62 | return this.seriesOptions[name].highwater;
63 | };
64 |
65 | // Should be called by child's onEvent handler
66 | BaseLineGraph.prototype.update = function (values) {
67 | _.each(values, (value, seriesName) => {
68 | if (!this.series[seriesName]) {
69 | return;
70 | }
71 | if (this._isHighwater(seriesName)) {
72 | this.series[seriesName].y = _.times(this.limit, _.constant(value));
73 | } else {
74 | this.series[seriesName].y.shift();
75 | this.series[seriesName].y.push(value);
76 | }
77 | });
78 |
79 | this._updateLabel();
80 |
81 | this.node.setData(_.values(this.series));
82 | };
83 |
84 | BaseLineGraph.prototype.refresh = function (mapper) {
85 | const data = mapper(this.metricsProvider.getMetrics(this.limit));
86 | const xAxis = this.metricsProvider.getXAxis(this.layoutConfig.limit);
87 |
88 | _.each(data[0], (value, seriesName) => {
89 | if (!this.series[seriesName]) {
90 | return;
91 | }
92 | if (this._isHighwater(seriesName)) {
93 | this.series[seriesName].y = _.times(this.limit, _.constant(value));
94 | } else {
95 | this.series[seriesName].y = _.times(this.limit, _.constant(0));
96 | }
97 | this.series[seriesName].x = xAxis;
98 | });
99 |
100 | _.each(data, (values) => {
101 | _.each(values, (value, seriesName) => {
102 | if (!this.series[seriesName]) {
103 | return;
104 | }
105 | if (!this._isHighwater(seriesName)) {
106 | this.series[seriesName].y.shift();
107 | this.series[seriesName].y.push(value);
108 | }
109 | });
110 | });
111 |
112 | this._updateLabel();
113 |
114 | this.node.setData(_.values(this.series));
115 | };
116 |
117 | BaseLineGraph.prototype._updateLabel = function () {
118 | // use view label + series labels/data
119 |
120 | const seriesLabels = _.map(this.series, (series, id) => {
121 | let seriesLabel = "";
122 | if (this.seriesOptions[id].label) {
123 | seriesLabel = `${this.seriesOptions[id].label} `;
124 | } else if (!this.seriesOptions[id].hasOwnProperty("label")) {
125 | seriesLabel = `${id} `;
126 | }
127 | return util.format("%s(%d%s)", seriesLabel, _.last(this.series[id].y), this.unit);
128 | }).join(", ");
129 |
130 | this.node.setLabel(util.format("%s%s ", this.label, seriesLabels));
131 | };
132 |
133 | BaseLineGraph.prototype._createGraph = function (options) {
134 | this.node = contrib.line({
135 | label: this.label,
136 | border: "line",
137 | numYLabels: 4,
138 | maxY: options.maxY,
139 | showLegend: false,
140 | wholeNumbersOnly: true,
141 | style: {
142 | border: {
143 | fg: this.layoutConfig.borderColor
144 | }
145 | }
146 | });
147 |
148 | this.recalculatePosition();
149 |
150 | this.parent.append(this.node);
151 |
152 | const values = this.metricsProvider.getMetrics(this.limit);
153 | _.each(values, (value) => {
154 | this.onEvent(value);
155 | });
156 | };
157 |
158 | BaseLineGraph.prototype.destroy = function () {
159 | BaseView.prototype.destroy.call(this);
160 |
161 | this.metricsProvider.removeListener("metrics", this._boundOnEvent);
162 | this.metricsProvider.removeListener("refreshMetrics", this._boundOnRefreshMetrics);
163 |
164 | this._boundOnEvent = null;
165 | this._boundOnRefreshMetrics = null;
166 | this.metricsProvider = null;
167 | };
168 |
169 | module.exports = BaseLineGraph;
170 |
--------------------------------------------------------------------------------
/lib/views/base-view.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const assert = require("assert");
4 | const _ = require("lodash");
5 |
6 | const BaseView = function BaseView(options) {
7 | assert(options.parent, "View requires parent");
8 | assert(options.layoutConfig && _.isFunction(options.layoutConfig.getPosition),
9 | "View requires layoutConfig option with getPosition function");
10 | this._remountOnResize = false;
11 | this._getPosition = options.layoutConfig.getPosition;
12 |
13 | this._boundRecalculatePosition = this.recalculatePosition.bind(this);
14 | options.parent.screen.on("resize", this._boundRecalculatePosition);
15 |
16 | this.parent = options.parent;
17 | this.layoutConfig = Object.assign(
18 | this.getDefaultLayoutConfig(options),
19 | options.layoutConfig.view);
20 | };
21 |
22 | BaseView.prototype.getDefaultLayoutConfig = function () {
23 | return { };
24 | };
25 |
26 | BaseView.prototype.recalculatePosition = function () {
27 | const newPosition = this._getPosition(this.parent);
28 |
29 | if (!_.isEqual(this.node.position, newPosition)) {
30 | this.node.position = newPosition;
31 |
32 | if (this._remountOnResize && this.node.parent === this.parent) {
33 | this.parent.remove(this.node);
34 | this.parent.append(this.node);
35 | }
36 | }
37 | };
38 |
39 | BaseView.prototype.destroy = function () {
40 | if (this.node) {
41 | this.parent.remove(this.node);
42 | this.node = null;
43 | }
44 |
45 | this.parent.screen.removeListener("resize", this._boundRecalculatePosition);
46 | this._boundRecalculatePosition = null;
47 | };
48 |
49 | module.exports = BaseView;
50 |
--------------------------------------------------------------------------------
/lib/views/cpu-details-view.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const os = require("os");
4 | const _ = require("lodash");
5 | const BaseDetailsView = require("./base-details-view");
6 |
7 | const CpuDetailsView = function CpuDetailsView(options) {
8 | BaseDetailsView.call(this, options);
9 | };
10 |
11 | CpuDetailsView.prototype = Object.create(BaseDetailsView.prototype);
12 |
13 | CpuDetailsView.prototype.getDetails = function () {
14 | const cpuInfo = os.cpus();
15 |
16 | return _.map(cpuInfo, (info, index) => ({
17 | label: `[${index}]`,
18 | data: `${info.model} ${info.speed}`
19 | }));
20 | };
21 |
22 | CpuDetailsView.prototype.getDefaultLayoutConfig = function () {
23 | return {
24 | label: " CPU(s) ",
25 | border: "line",
26 | tags: true,
27 | style: {
28 | border: {
29 | fg: "white"
30 | }
31 | }
32 | };
33 | };
34 |
35 | module.exports = CpuDetailsView;
36 |
--------------------------------------------------------------------------------
/lib/views/cpu-view.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const _ = require("lodash");
4 | const BaseLineGraph = require("./base-line-graph");
5 |
6 | const CpuView = function CpuView(options) {
7 | BaseLineGraph.call(this, _.merge({
8 | unit: "%",
9 | maxY: 100,
10 | series: {
11 | cpu: { label: "" }
12 | }
13 | }, options));
14 | };
15 |
16 | CpuView.prototype = Object.create(BaseLineGraph.prototype);
17 |
18 | CpuView.prototype.getDefaultLayoutConfig = function () {
19 | return {
20 | borderColor: "cyan",
21 | title: "cpu utilization",
22 | limit: 30
23 | };
24 | };
25 |
26 | // discardEvent is needed so that the memory guage view can be
27 | // updated real-time while some graphs are aggregate data
28 | CpuView.prototype.onEvent = function (data, discardEvent) {
29 | if (discardEvent) {
30 | return;
31 | }
32 | this.update({ cpu: data.cpu.utilization.toFixed(1) });
33 | };
34 |
35 | CpuView.prototype.onRefreshMetrics = function () {
36 | const mapper = function mapper(rows) {
37 | return _.map(rows, (row) => ({ cpu: Number(row.cpu.utilization.toFixed(1)) }));
38 | };
39 |
40 | this.refresh(mapper);
41 | };
42 |
43 | module.exports = CpuView;
44 |
--------------------------------------------------------------------------------
/lib/views/env-details-view.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const _ = require("lodash");
4 | const BaseDetailsView = require("./base-details-view");
5 |
6 | const EnvDetailsView = function EnvDetailsView(options) {
7 | BaseDetailsView.call(this, options);
8 | };
9 |
10 | EnvDetailsView.prototype = Object.create(BaseDetailsView.prototype);
11 |
12 | EnvDetailsView.prototype.getDefaultLayoutConfig = function () {
13 | return {
14 | label: " Environment Variables ",
15 | border: "line",
16 | style: {
17 | border: {
18 | fg: "white"
19 | }
20 | },
21 | tags: true,
22 | scrollable: true,
23 | keys: true,
24 | input: true,
25 | scrollbar: {
26 | style: {
27 | fg: "white",
28 | inverse: true
29 | },
30 | track: {
31 | ch: ":",
32 | fg: "cyan"
33 | }
34 | }
35 | };
36 | };
37 |
38 | EnvDetailsView.prototype.getDetails = function () {
39 | return _.map(process.env, (value, key) => ({
40 | label: key,
41 | data: value
42 | }));
43 | };
44 |
45 | module.exports = EnvDetailsView;
46 |
--------------------------------------------------------------------------------
/lib/views/eventloop-view.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const _ = require("lodash");
4 | const BaseLineGraph = require("./base-line-graph");
5 |
6 | const EventLoopView = function EventLoopView(options) {
7 | BaseLineGraph.call(this, _.merge({
8 | unit: "ms",
9 | series: {
10 | delay: {},
11 | high: { highwater: true }
12 | }
13 | }, options));
14 | };
15 |
16 | EventLoopView.prototype = Object.create(BaseLineGraph.prototype);
17 |
18 | EventLoopView.prototype.getDefaultLayoutConfig = function () {
19 | return {
20 | borderColor: "cyan",
21 | title: "event loop",
22 | limit: 30
23 | };
24 | };
25 |
26 | // discardEvent is needed so that the memory guage view can be
27 | // updated real-time while some graphs are aggregate data
28 | EventLoopView.prototype.onEvent = function (data, discardEvent) {
29 | if (discardEvent) {
30 | return;
31 | }
32 | this.update({
33 | delay: data.eventLoop.delay,
34 | high: data.eventLoop.high
35 | });
36 | };
37 |
38 | EventLoopView.prototype.onRefreshMetrics = function () {
39 | const mapper = function mapper(rows) {
40 | const filter = function filter() {
41 | return _.reduce(rows, (prev, curr) => Math.max(prev, curr.eventLoop.high), 0);
42 | };
43 |
44 | const maxDelay = filter();
45 |
46 | return _.map(rows, (row) => ({
47 | delay: Number(row.eventLoop.delay.toFixed(1)),
48 | high: Number(maxDelay.toFixed(1))
49 | }));
50 | };
51 |
52 | this.refresh(mapper);
53 | };
54 |
55 | module.exports = EventLoopView;
56 |
--------------------------------------------------------------------------------
/lib/views/goto-time-view.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const blessed = require("blessed");
4 |
5 | const ERROR_TEXT_DISPLAY_TIME = 3000;
6 |
7 | /**
8 | * This is the constructor for the Goto Time View.
9 | *
10 | * @param {Object} options
11 | * Options that may be specified.
12 | *
13 | * @returns {void}
14 | */
15 | const GotoTimeView = function GotoTimeView(options) {
16 | /**
17 | * Create the elements that make up the view.
18 | *
19 | * @returns {void}
20 | */
21 | const createViewElements = function () {
22 | this.node = blessed.box({
23 | position: {
24 | top: "center",
25 | left: "center",
26 | // using fixed numbers to support use of alignment tags
27 | width: 64,
28 | height: 12
29 | },
30 | border: "line",
31 | padding: {
32 | left: 1,
33 | right: 1
34 | },
35 | style: {
36 | border: {
37 | fg: "white"
38 | }
39 | },
40 | tags: true,
41 | hidden: true,
42 | label: " Goto Time "
43 | });
44 |
45 | this.form = blessed.form({
46 | name: "form",
47 | top: 0,
48 | left: 0,
49 | height: "100%-2",
50 | width: "100%-4",
51 | keys: true
52 | });
53 |
54 | this.timeRangeLabel = blessed.text({
55 | top: 1,
56 | align: "center",
57 | width: "100%",
58 | content: this.getTimeRangeLabel()
59 | });
60 |
61 | this.textBox = blessed.textbox({
62 | name: "textBox",
63 | input: true,
64 | inputOnFocus: true,
65 | top: 3,
66 | left: 0,
67 | height: 1,
68 | width: "100%",
69 | style: {
70 | fg: "white",
71 | bg: "black",
72 | focus: {
73 | fg: "yellow"
74 | },
75 | underline: true
76 | },
77 | keys: true,
78 | content: ""
79 | });
80 |
81 | this.errorText = blessed.text({
82 | top: 5,
83 | align: "center",
84 | width: "100%",
85 | height: 1,
86 | content: "",
87 | style: {
88 | fg: "red"
89 | },
90 | hidden: true
91 | });
92 |
93 | this.acceptButton = blessed.button({
94 | top: "100%-3",
95 | height: 3,
96 | width: "half",
97 | name: "accept",
98 | content: "Accept",
99 | align: "center",
100 | style: {
101 | focus: {
102 | bg: "green",
103 | fg: "black"
104 | },
105 | border: {
106 | fg: "green"
107 | },
108 | fg: "green"
109 | },
110 | border: {
111 | type: "line"
112 | }
113 | });
114 |
115 | this.cancelButton = blessed.button({
116 | left: "50%",
117 | top: "100%-3",
118 | height: 3,
119 | width: "half",
120 | name: "cancel",
121 | content: "Cancel",
122 | align: "center",
123 | style: {
124 | focus: {
125 | bg: "red",
126 | fg: "black"
127 | },
128 | fg: "red",
129 | border: {
130 | fg: "red"
131 | }
132 | },
133 | border: {
134 | type: "line"
135 | }
136 | });
137 | }.bind(this);
138 |
139 | /**
140 | * Construct the view now that the elements have been created.
141 | *
142 | * @returns {void}
143 | */
144 | const constructView = function () {
145 | options.parent.append(this.node);
146 |
147 | this.node.append(this.form);
148 | this.form.append(this.timeRangeLabel);
149 | this.form.append(this.textBox);
150 | this.form.append(this.errorText);
151 | this.form.append(this.acceptButton);
152 | this.form.append(this.cancelButton);
153 | }.bind(this);
154 |
155 | /**
156 | * Setup all event handlers for the screen to flow.
157 | *
158 | * @returns {void}
159 | */
160 | const setupEventHandlers = function () {
161 | this.screen.on("metrics", () => {
162 | // dynamically change the range as the underlying data grows
163 | this.timeRangeLabel.setContent(this.getTimeRangeLabel());
164 | });
165 |
166 | this.node.on("show", () => {
167 | this.screen.saveFocus();
168 | this.node.setFront();
169 | this.form.reset();
170 | this.textBox.focus();
171 | });
172 |
173 | this.form.on("reset", () => {
174 | this.errorText.hide();
175 | });
176 |
177 | this.textBox.key("enter", () => {
178 | this.acceptButton.press();
179 | });
180 |
181 | this.textBox.key("escape", () => {
182 | this.cancelButton.press();
183 | });
184 |
185 | this.acceptButton.key("escape", () => {
186 | this.cancelButton.press();
187 | });
188 |
189 | this.acceptButton.on("press", () => {
190 | this.form.submit();
191 | });
192 |
193 | this.cancelButton.key("escape", () => {
194 | this.cancelButton.press();
195 | });
196 |
197 | this.cancelButton.on("press", () => {
198 | this.form.cancel();
199 | });
200 |
201 | this.form.on("submit", (data) => {
202 | if (this.errorTimeout) {
203 | clearTimeout(this.errorTimeout);
204 | delete this.errorTimeout;
205 | }
206 |
207 | try {
208 | const timeValue = this.validate(data);
209 | this.metricsProvider.gotoTimeValue(timeValue);
210 | this.hide();
211 | } catch (e) {
212 | this.errorText.setContent(e.message);
213 | this.errorText.show();
214 | this.textBox.focus();
215 | this.screen.render();
216 |
217 | this.errorTimeout = setTimeout(() => {
218 | this.errorText.hide();
219 | this.screen.render();
220 | }, ERROR_TEXT_DISPLAY_TIME);
221 | }
222 | });
223 |
224 | this.form.on("cancel", () => {
225 | this.hide();
226 | });
227 | }.bind(this);
228 |
229 | // capture options
230 | this.metricsProvider = options.metricsProvider;
231 | this.parent = options.parent;
232 | this.screen = options.screen;
233 |
234 | // build the view
235 | createViewElements();
236 | constructView();
237 | setupEventHandlers();
238 | };
239 |
240 | /**
241 | * Toggle the visibility of the view.
242 | *
243 | * @returns {void}
244 | */
245 | GotoTimeView.prototype.toggle = function () {
246 | this.node.toggle();
247 | };
248 |
249 | /**
250 | * Hide the view.
251 | *
252 | * @returns {void}
253 | */
254 | GotoTimeView.prototype.hide = function () {
255 | this.node.hide();
256 | this.screen.restoreFocus();
257 | this.screen.render();
258 | };
259 |
260 | /**
261 | * Check to see if the view is visible.
262 | *
263 | * @returns {Boolean}
264 | * Truthy if the view is visible, falsey otherwise.
265 | */
266 | GotoTimeView.prototype.isVisible = function () {
267 | return this.node.visible;
268 | };
269 |
270 | /**
271 | * Get the time range for the view.
272 | *
273 | * @returns {Object}
274 | * The time range is returned.
275 | */
276 | GotoTimeView.prototype.getTimeRange = function () {
277 | const timeRange = this.metricsProvider.getAvailableTimeRange();
278 |
279 | return {
280 | min: timeRange.minTime.label,
281 | max: timeRange.maxTime.label
282 | };
283 | };
284 |
285 | /**
286 | * Get the time range label for the view.
287 | *
288 | * @returns {String}
289 | * The time range label is returned.
290 | */
291 | GotoTimeView.prototype.getTimeRangeLabel = function () {
292 | const timeRange = this.getTimeRange();
293 |
294 | return `Enter a time value between ${
295 | timeRange.min
296 | } and ${
297 | timeRange.max}`;
298 | };
299 |
300 | /**
301 | * Validate the view input.
302 | *
303 | * @param {Object} data
304 | * The data entered in the view.
305 | *
306 | * @throws {Error}
307 | * Will throw if there is an error.
308 | *
309 | * @returns {Number}
310 | * The validated view input is returned.
311 | */
312 | GotoTimeView.prototype.validate = function (data) {
313 | return this.metricsProvider.validateTimeLabel(data.textBox);
314 | };
315 |
316 | module.exports = GotoTimeView;
317 |
--------------------------------------------------------------------------------
/lib/views/help.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const blessed = require("blessed");
4 |
5 | const pkg = require("../../package.json");
6 |
7 | const HelpView = function HelpView(options) {
8 | const content = [
9 | "{center}{bold}keybindings{/bold}{/center}",
10 | "",
11 | "{cyan-fg} left, right{/} rotate through layouts",
12 | "{cyan-fg} w, s{/} increase / decrease graph units of time",
13 | "{cyan-fg} a, d{/} scroll left / right graphs",
14 | "{cyan-fg} z, x{/} go to begin / end graphs",
15 | "{cyan-fg} g{/} go to user-defined time graph index...",
16 | "{cyan-fg} esc{/} close popup window / return to default layout",
17 | "{cyan-fg} h, ?{/} toggle this window",
18 | "{cyan-fg} ctrl-c, q{/} quit",
19 | "",
20 | `{right}{white-fg}version: ${pkg.version}{/}`
21 | ].join("\n");
22 |
23 | this.node = blessed.box({
24 | position: {
25 | top: "center",
26 | left: "center",
27 | // using fixed numbers to support use of alignment tags
28 | width: 64,
29 | height: 14
30 | },
31 | border: "line",
32 | padding: {
33 | left: 1,
34 | right: 1
35 | },
36 | style: {
37 | border: {
38 | fg: "white"
39 | }
40 | },
41 | tags: true,
42 | content,
43 | hidden: true
44 | });
45 |
46 | this.node.on("show", () => {
47 | options.parent.screen.saveFocus();
48 | this.node.setFront();
49 | });
50 |
51 | this.node.on("hide", () => {
52 | options.parent.screen.restoreFocus();
53 | });
54 |
55 | options.parent.append(this.node);
56 | };
57 |
58 | /**
59 | * Toggle the visibility of the view.
60 | *
61 | * @returns {void}
62 | */
63 | HelpView.prototype.toggle = function () {
64 | this.node.toggle();
65 | };
66 |
67 | /**
68 | * Hide the view.
69 | *
70 | * @returns {void}
71 | */
72 | HelpView.prototype.hide = function () {
73 | this.node.hide();
74 | };
75 |
76 | /**
77 | * Check to see if the view is visible.
78 | *
79 | * @returns {Boolean}
80 | * Truthy if the view is visible, falsey otherwise.
81 | */
82 | HelpView.prototype.isVisible = function () {
83 | return this.node.visible;
84 | };
85 |
86 |
87 | module.exports = HelpView;
88 |
--------------------------------------------------------------------------------
/lib/views/index.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const _ = require("lodash");
4 | const StreamView = require("./stream-view");
5 | const EventLoopView = require("./eventloop-view");
6 | const MemoryGaugeView = require("./memory-gauge-view");
7 | const MemoryGraphView = require("./memory-graph-view");
8 | const CpuView = require("./cpu-view");
9 | const BaseView = require("./base-view");
10 | const CpuDetailsView = require("./cpu-details-view");
11 | const EnvDetailsView = require("./env-details-view");
12 | const NodeDetailsView = require("./node-details-view");
13 | const SystemDetailsView = require("./system-details-view");
14 | const UserDetailsView = require("./user-details-view");
15 | const Panel = require("./panel");
16 |
17 | const VIEW_MAP = {
18 | cpuDetails: CpuDetailsView,
19 | envDetails: EnvDetailsView,
20 | nodeDetails: NodeDetailsView,
21 | systemDetails: SystemDetailsView,
22 | userDetails: UserDetailsView,
23 | log: StreamView,
24 | cpu: CpuView,
25 | memory: MemoryGaugeView,
26 | memoryGraph: MemoryGraphView,
27 | eventLoop: EventLoopView,
28 | panel: Panel
29 | };
30 |
31 | // Customize view types based on a settings class
32 | const applyCustomizations = function (customizations, layoutConfig) {
33 | const customization = customizations[layoutConfig.view.type];
34 | if (!customization) {
35 | return layoutConfig;
36 | }
37 | return _.merge(layoutConfig, { view: customization });
38 | };
39 |
40 | const getConstructor = function (options) {
41 | options = options || {};
42 | if (VIEW_MAP[options.type]) {
43 | return VIEW_MAP[options.type];
44 | } else if (options.module) {
45 | // eslint-disable-next-line global-require
46 | return require(options.module)(BaseView);
47 | }
48 | return null;
49 | };
50 |
51 | /**
52 | * Creates a view
53 | *
54 | * @param {Object} layoutConfig raw layout { type, views, position }
55 | * @param {Object} options startup options for views
56 | * @param {Object} customizations view type customiztaions
57 | *
58 | * @returns {Object} created view oject
59 | */
60 | module.exports.create = function create(layoutConfig, options, customizations) {
61 | const customized = applyCustomizations(customizations, layoutConfig);
62 | const viewOptions = Object.assign({}, options, {
63 | layoutConfig: customized,
64 | creator(layout) {
65 | return create(layout, options, customizations);
66 | }
67 | });
68 | const View = getConstructor(customized.view);
69 | return View ? new View(viewOptions) : null;
70 | };
71 |
--------------------------------------------------------------------------------
/lib/views/memory-gauge-view.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const assert = require("assert");
4 | const blessed = require("blessed");
5 | const contrib = require("blessed-contrib");
6 | const prettyBytes = require("pretty-bytes");
7 | const util = require("util");
8 |
9 | const BaseView = require("./base-view");
10 | const utils = require("../utils");
11 |
12 | const MAX_PERCENT = 100;
13 |
14 | const MemoryView = function MemoryView(options) {
15 | BaseView.call(this, options);
16 |
17 | assert(options.metricsProvider, "View requires metricsProvider");
18 |
19 | this.metricsProvider = options.metricsProvider;
20 |
21 | this._remountOnResize = true;
22 | this._boundOnEvent = this.onEvent.bind(this);
23 |
24 | this._createViews(options);
25 |
26 | options.metricsProvider.on("metrics", this._boundOnEvent);
27 |
28 | const metrics = this.metricsProvider.getMetrics(1)[0];
29 |
30 | if (metrics) {
31 | this.onEvent(metrics);
32 | }
33 | };
34 |
35 | MemoryView.prototype = Object.create(BaseView.prototype);
36 |
37 | MemoryView.prototype.getDefaultLayoutConfig = function () {
38 | return {
39 | borderColor: "cyan",
40 | title: "memory"
41 | };
42 | };
43 |
44 | MemoryView.prototype._createViews = function (options) {
45 | this.node = blessed.box({
46 | label: util.format(" %s ", this.layoutConfig.title),
47 | border: "line",
48 | style: {
49 | border: {
50 | fg: this.layoutConfig.borderColor
51 | }
52 | }
53 | });
54 |
55 | this.recalculatePosition();
56 |
57 | this.heapGauge = contrib.gauge({ label: "heap" });
58 | this.node.append(this.heapGauge);
59 |
60 | this.rssGauge = contrib.gauge({ label: "resident",
61 | top: "50%" });
62 | this.node.append(this.rssGauge);
63 |
64 | options.parent.append(this.node);
65 | };
66 |
67 | MemoryView.prototype.onEvent = function (data) {
68 | const mem = data.mem;
69 | this.update(this.heapGauge, mem.heapUsed, mem.heapTotal);
70 | this.update(this.rssGauge, mem.rss, mem.systemTotal);
71 | };
72 |
73 | MemoryView.prototype.update = function (gauge, used, total) {
74 | const percentUsed = utils.getPercentUsed(used, total);
75 | if (gauge === this.heapGauge) {
76 | gauge.setStack([
77 | { percent: percentUsed,
78 | stroke: "red" },
79 | { percent: MAX_PERCENT - percentUsed,
80 | stroke: "blue" }
81 | ]);
82 | } else {
83 | gauge.setPercent(percentUsed);
84 | }
85 |
86 | gauge.setLabel(
87 | util.format("%s: %s / %s", gauge.options.label, prettyBytes(used), prettyBytes(total))
88 | );
89 | };
90 |
91 | MemoryView.prototype.destroy = function () {
92 | BaseView.prototype.destroy.call(this);
93 |
94 | this.metricsProvider.removeListener("metrics", this._boundOnEvent);
95 |
96 | this._boundOnEvent = null;
97 | this.metricsProvider = null;
98 | };
99 |
100 | module.exports = MemoryView;
101 |
--------------------------------------------------------------------------------
/lib/views/memory-graph-view.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const _ = require("lodash");
4 |
5 | const BaseLineGraph = require("./base-line-graph");
6 | const utils = require("../utils");
7 |
8 | const MemoryGraphView = function MemoryGraphView(options) {
9 | BaseLineGraph.call(this, _.merge({
10 | unit: "%",
11 | maxY: 100,
12 | series: {
13 | heap: { color: "green" },
14 | resident: {}
15 | }
16 | }, options));
17 | };
18 |
19 | MemoryGraphView.prototype = Object.create(BaseLineGraph.prototype);
20 |
21 | MemoryGraphView.prototype.getDefaultLayoutConfig = function () {
22 | return {
23 | borderColor: "cyan",
24 | title: "memory",
25 | limit: 30
26 | };
27 | };
28 |
29 | // discardEvent is needed so that the memory guage view can be
30 | // updated real-time while some graphs are aggregate data
31 | MemoryGraphView.prototype.onEvent = function (data, discardEvent) {
32 | const mem = data.mem;
33 | if (discardEvent) {
34 | return;
35 | }
36 | this.update({
37 | heap: utils.getPercentUsed(mem.heapUsed, mem.heapTotal),
38 | resident: utils.getPercentUsed(mem.rss, mem.systemTotal)
39 | });
40 | };
41 |
42 | MemoryGraphView.prototype.onRefreshMetrics = function () {
43 | const mapper = function mapper(rows) {
44 | return _.map(rows, (row) => ({
45 | heap: utils.getPercentUsed(row.mem.heapUsed, row.mem.heapTotal),
46 | resident: utils.getPercentUsed(row.mem.rss, row.mem.systemTotal)
47 | }));
48 | };
49 |
50 | this.refresh(mapper);
51 | };
52 |
53 | module.exports = MemoryGraphView;
54 |
--------------------------------------------------------------------------------
/lib/views/node-details-view.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const BaseDetailsView = require("./base-details-view");
4 | const time = require("../time");
5 | const MILLISECONDS_PER_SECOND = require("../constants").MILLISECONDS_PER_SECOND;
6 | const UPTIME_INTERVAL_MS = MILLISECONDS_PER_SECOND;
7 |
8 | const NodeDetailsView = function NodeDetailsView(options) {
9 | BaseDetailsView.call(this, options);
10 |
11 | this.setupdate();
12 | this.node.on("attach", this.setupdate.bind(this));
13 |
14 | this.node.on("detach", () => {
15 | if (this.uptimeInterval) {
16 | clearInterval(this.uptimeInterval);
17 | delete this.uptimeInterval;
18 | }
19 | });
20 | };
21 |
22 | NodeDetailsView.prototype = Object.create(BaseDetailsView.prototype);
23 |
24 | NodeDetailsView.prototype.setupdate = function () {
25 | this.uptimeInterval = this.uptimeInterval || setInterval(() => {
26 | this.refreshContent();
27 | }, UPTIME_INTERVAL_MS);
28 | };
29 |
30 | NodeDetailsView.prototype.getDetails = function () {
31 | return [
32 | {
33 | label: "Version",
34 | data: process.version
35 | }, {
36 | label: "LTS",
37 | data: process.release.lts
38 | }, {
39 | label: "Uptime",
40 | data: time.getLabel(process.uptime() * MILLISECONDS_PER_SECOND)
41 | }
42 | ];
43 | };
44 |
45 | NodeDetailsView.prototype.getDefaultLayoutConfig = function () {
46 | return {
47 | label: " Node ",
48 | border: "line",
49 | tags: true,
50 | height: "shrink",
51 | style: {
52 | border: {
53 | fg: "white"
54 | }
55 | }
56 | };
57 | };
58 |
59 | module.exports = NodeDetailsView;
60 |
--------------------------------------------------------------------------------
/lib/views/panel.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const _ = require("lodash");
4 |
5 | // Each layout consists of vertical panels, that contains its position and horizontal views.
6 | // Flex-like positions of panels and views defined by 'grow' and 'size' parameters.
7 | // View or panel with 'size' has exactly height or width respectively.
8 | // View or panel with 'grow' fills part of the residuary space (it works like flex-grow).
9 | // By default, position = { grow: 1 }
10 |
11 | const normalizePosition = function (position) {
12 | if (!_.has(position, "grow") && !_.has(position, "size")) {
13 | position = { grow: 1 };
14 | }
15 |
16 | return position;
17 | };
18 |
19 | const concatPosition = function (position1, position2) {
20 | position1 = normalizePosition(position1);
21 | position2 = normalizePosition(position2);
22 |
23 | return {
24 | grow: (position1.grow || 0) + (position2.grow || 0),
25 | size: (position1.size || 0) + (position2.size || 0)
26 | };
27 | };
28 |
29 | const getSummaryPosition = function (items) {
30 | return items.map((item) => item.position)
31 | .reduce(concatPosition, { grow: 0,
32 | size: 0 });
33 | };
34 |
35 | const getSize = function (parentSize, itemPosition) {
36 | const position = normalizePosition(itemPosition.position);
37 | if (_.has(position, "size")) {
38 | return position.size;
39 | }
40 |
41 | // Prevent last growing view from overflowing screen
42 | const round = itemPosition.offset.grow + position.grow === itemPosition.summary.grow
43 | ? Math.floor : Math.ceil;
44 |
45 | return round(
46 | (parentSize - itemPosition.summary.size) * position.grow / itemPosition.summary.grow
47 | );
48 | };
49 |
50 | const getOffset = function (parentSize, { offset, summary }) {
51 | return summary.grow ? Math.ceil(
52 | offset.size + (parentSize - summary.size) * offset.grow / summary.grow
53 | ) : 0;
54 | };
55 |
56 | const createViewLayout = function (view, viewPosition, panelPosition) {
57 | return {
58 | view,
59 | getPosition(parent) {
60 | return {
61 | width: getSize(parent.width, panelPosition),
62 | height: getSize(parent.height, viewPosition),
63 | left: getOffset(parent.width, panelPosition),
64 | top: getOffset(parent.height, viewPosition)
65 | };
66 | }
67 | };
68 | };
69 |
70 | const createPanelLayout = function (panelPosition, views) {
71 | const viewSummaryPosition = getSummaryPosition(views);
72 | let offsetPosition = { size: 0,
73 | grow: 0 };
74 |
75 | return _.flatMap(views, (view) => {
76 | const viewPosition = {
77 | summary: viewSummaryPosition,
78 | offset: offsetPosition,
79 | position: view.position
80 | };
81 |
82 | offsetPosition = concatPosition(view.position, offsetPosition);
83 |
84 | return createViewLayout(view, viewPosition, panelPosition);
85 | });
86 | };
87 |
88 | const createLayout = function (panelsConfig) {
89 | const panelSummaryPosition = getSummaryPosition(panelsConfig);
90 | let offsetPosition = { size: 0,
91 | grow: 0 };
92 |
93 | return panelsConfig.reduce((layouts, panelConfig) => {
94 | const panelPosition = {
95 | summary: panelSummaryPosition,
96 | offset: offsetPosition,
97 | position: panelConfig.position
98 | };
99 |
100 | const viewLayouts = createPanelLayout(panelPosition, panelConfig.views);
101 |
102 | offsetPosition = concatPosition(panelConfig.position, offsetPosition);
103 |
104 | return layouts.concat(viewLayouts);
105 | }, []);
106 | };
107 |
108 | // Child views need their position adjusted to fit inside the panel
109 | const wrapGetPosition = function (viewPosition, panelPosition) {
110 | return function (parent) {
111 | return viewPosition(panelPosition(parent));
112 | };
113 | };
114 |
115 | /**
116 | * A psudeo view that creates sub views and lays them out in columns and rows
117 | *
118 | * @param {Object} options view creation options
119 | *
120 | * @returns {null} The class needs to be created with new
121 | */
122 | const Panel = function Panel(options) {
123 | const panelLayout = options.layoutConfig;
124 | const viewLayouts = createLayout(panelLayout.view.views);
125 | this.getPosition = panelLayout.getPosition;
126 | this.views = _.map(viewLayouts, (viewLayout) => {
127 | viewLayout.getPosition = wrapGetPosition(viewLayout.getPosition, panelLayout.getPosition);
128 | return options.creator(viewLayout);
129 | });
130 | };
131 |
132 | Panel.prototype.destroy = function () {
133 | _.each(this.views, (view) => {
134 | if (view && typeof view.destroy === "function") {
135 | view.destroy();
136 | }
137 | });
138 | this.views = [];
139 | };
140 |
141 | module.exports = Panel;
142 |
--------------------------------------------------------------------------------
/lib/views/stream-view.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const assert = require("assert");
4 | const blessed = require("blessed");
5 | const util = require("util");
6 | const _ = require("lodash");
7 |
8 | const BaseView = require("./base-view");
9 |
10 | const MAX_OBJECT_LOG_DEPTH = 20;
11 |
12 | // reapply scroll method override from Log
13 | // https://github.com/chjj/blessed/blob/master/lib/widgets/log.js#L69
14 | // which is broken by workaround in Element
15 | // https://github.com/chjj/blessed/blob/master/lib/widgets/element.js#L35
16 | //
17 | // this method prevents auto-scrolling to bottom if user scrolled the view up
18 | // blessed v0.1.81 - https://github.com/chjj/blessed/issues/284
19 | const fixLogScroll = function (log) {
20 | const maxScrollPercent = 100;
21 |
22 | log.scroll = function (offset, always) {
23 | if (offset === 0) {
24 | return this._scroll(offset, always);
25 | }
26 | this._userScrolled = true;
27 | const ret = this._scroll(offset, always);
28 | if (this.getScrollPerc() === maxScrollPercent) {
29 | this._userScrolled = false;
30 | }
31 | return ret;
32 | };
33 | };
34 |
35 | const StreamView = function StreamView(options) {
36 | BaseView.call(this, options);
37 |
38 | assert(options.logProvider, "StreamView requires logProvider");
39 |
40 | if (this.layoutConfig.exclude) {
41 | this.excludeRegex = new RegExp(this.layoutConfig.exclude);
42 | }
43 |
44 | if (this.layoutConfig.include) {
45 | this.includeRegex = new RegExp(this.layoutConfig.include);
46 | }
47 |
48 | this.logProvider = options.logProvider;
49 |
50 | this._createView(options);
51 |
52 | const content = options.logProvider.getLog(this.layoutConfig.streams, options.scrollback);
53 |
54 | if (content.length > 0) {
55 | this.log(content);
56 | }
57 |
58 | this._boundLog = this.log.bind(this);
59 | _.each(this.layoutConfig.streams, (eventName) => {
60 | this.logProvider.on(eventName, this._boundLog);
61 | });
62 | };
63 |
64 | StreamView.prototype = Object.create(BaseView.prototype);
65 |
66 | StreamView.prototype._createView = function () {
67 | this.node = blessed.log({
68 | label: util.format(" %s ", this.layoutConfig.title || this.layoutConfig.streams.join(" / ")),
69 |
70 | scrollable: true,
71 | alwaysScroll: true,
72 | scrollback: this.layoutConfig.scrollback,
73 | scrollbar: {
74 | inverse: true
75 | },
76 |
77 | input: true,
78 | keys: true,
79 | mouse: true,
80 |
81 | tags: true,
82 |
83 | border: "line",
84 | style: {
85 | fg: this.layoutConfig.fgColor,
86 | bg: this.layoutConfig.bgColor,
87 | border: {
88 | fg: this.layoutConfig.borderColor
89 | }
90 | }
91 | });
92 |
93 | fixLogScroll(this.node);
94 |
95 | this.recalculatePosition();
96 |
97 | this.parent.append(this.node);
98 | };
99 |
100 | StreamView.prototype.getDefaultLayoutConfig = function () {
101 | return {
102 | borderColor: "#F0F0F0",
103 | fgColor: "white",
104 | bgColor: "black",
105 | streams: ["stdout", "stderr"],
106 | scrollback: 1000
107 | };
108 | };
109 |
110 | StreamView.prototype.log = function (data) {
111 | let lines = data.replace(/\n$/, "");
112 | if (this.excludeRegex || this.includeRegex) {
113 | lines = lines.split("\n").reduce((arr, line) => {
114 | if (this.includeRegex && this.includeRegex.test(line)) {
115 | const match = line.match(this.includeRegex);
116 | arr.push(typeof match[1] === "undefined" ? line : match[1]);
117 | }
118 | if (this.excludeRegex && !this.excludeRegex.test(line)) {
119 | arr.push(line);
120 | }
121 |
122 | return arr;
123 | }, []);
124 |
125 | if (lines.length === 0) {
126 | return;
127 | }
128 |
129 | lines = lines.join("\n");
130 | }
131 |
132 | this.node.log(lines);
133 | };
134 |
135 | StreamView.prototype.destroy = function () {
136 | BaseView.prototype.destroy.call(this);
137 |
138 | _.each(this.layoutConfig.streams, (eventName) => {
139 | this.logProvider.removeListener(eventName, this._boundLog);
140 | });
141 |
142 | this._boundLog = null;
143 | this.logProvider = null;
144 | };
145 |
146 | // fix Log's log/add method, which calls shiftLine with two parameters (start, end)
147 | // when it should call it with just one (num lines to shift out)
148 | // blessed v0.1.81 - https://github.com/chjj/blessed/issues/255
149 | /* nyc ignore next */
150 | blessed.log.prototype.log
151 | = blessed.log.prototype.add = function add() {
152 | const args = Array.prototype.slice.call(arguments);
153 | if (typeof args[0] === "object") {
154 | args[0] = util.inspect(args[0], { showHidden: true,
155 | depth: MAX_OBJECT_LOG_DEPTH });
156 | }
157 | const text = util.format(...args);
158 | this.emit("log", text);
159 | const ret = this.pushLine(text);
160 | if (this.scrollback && this._clines.fake.length > this.scrollback) {
161 | this.shiftLine(this._clines.fake.length - this.scrollback);
162 | }
163 | return ret;
164 | };
165 |
166 | // This fix prevents crashing, when view is removed from parent during before nextTick call
167 | // (see https://github.com/chjj/blessed/blob/master/lib/widgets/log.js#L40)
168 | const _setScrollPerc = blessed.scrollablebox.prototype.setScrollPerc;
169 | blessed.scrollablebox.prototype.setScrollPerc = function (percent) {
170 | if (this.parent) {
171 | _setScrollPerc.call(this, percent);
172 | }
173 | };
174 |
175 | module.exports = StreamView;
176 |
--------------------------------------------------------------------------------
/lib/views/system-details-view.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const os = require("os");
4 | const prettyBytes = require("pretty-bytes");
5 | const BaseDetailsView = require("./base-details-view");
6 |
7 | const SystemDetailsView = function SystemDetailsView(options) {
8 | BaseDetailsView.call(this, options);
9 | };
10 |
11 | SystemDetailsView.prototype = Object.create(BaseDetailsView.prototype);
12 |
13 | SystemDetailsView.prototype.getDetails = function () {
14 | return [
15 | {
16 | label: "Architecture",
17 | data: os.arch()
18 | }, {
19 | label: "Endianness",
20 | data: os.endianness() === "BE" ? "Big Endian" : "Little Endian"
21 | }, {
22 | label: "Host Name",
23 | data: os.hostname()
24 | }, {
25 | label: "Total Memory",
26 | data: prettyBytes(os.totalmem())
27 | }, {
28 | label: "Platform",
29 | data: os.platform()
30 | }, {
31 | label: "Release",
32 | data: os.release()
33 | }, {
34 | label: "Type",
35 | data: os.type()
36 | }
37 | ];
38 | };
39 |
40 | SystemDetailsView.prototype.getDefaultLayoutConfig = function () {
41 | return {
42 | label: " System ",
43 | border: "line",
44 | tags: true,
45 | height: "shrink",
46 | style: {
47 | border: {
48 | fg: "white"
49 | }
50 | }
51 | };
52 | };
53 |
54 | module.exports = SystemDetailsView;
55 |
--------------------------------------------------------------------------------
/lib/views/user-details-view.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const os = require("os");
4 | const BaseDetailsView = require("./base-details-view");
5 |
6 | const UserDetailsView = function UserDetailsView(options) {
7 | BaseDetailsView.call(this, options);
8 | };
9 |
10 | UserDetailsView.prototype = Object.create(BaseDetailsView.prototype);
11 |
12 | UserDetailsView.prototype.getDefaultLayoutConfig = function () {
13 | return {
14 | label: " User ",
15 | border: "line",
16 | tags: true,
17 | height: "shrink",
18 | style: {
19 | border: {
20 | fg: "white"
21 | }
22 | }
23 | };
24 | };
25 |
26 | UserDetailsView.prototype.getDetails = function () {
27 | // Node version 6 added userInfo function
28 | if (!os.userInfo) {
29 | return [
30 | {
31 | label: "User Information",
32 | data: "Not supported on this version of Node"
33 | }
34 | ];
35 | }
36 |
37 | const userInfo = os.userInfo({ encoding: "utf8" });
38 |
39 | return [
40 | {
41 | label: "User Name",
42 | data: userInfo.username
43 | }, {
44 | label: "Home",
45 | data: userInfo.homedir
46 | }, {
47 | label: "User ID",
48 | data: userInfo.uid
49 | }, {
50 | label: "Group ID",
51 | data: userInfo.gid
52 | }, {
53 | label: "Shell",
54 | data: userInfo.shell
55 | }
56 | ];
57 | };
58 |
59 | module.exports = UserDetailsView;
60 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nodejs-dashboard",
3 | "version": "0.5.1",
4 | "description": "Telemetry dashboard for node.js apps from the terminal!",
5 | "keywords": [
6 | "dashboard",
7 | "telemetry",
8 | "terminal",
9 | "realtime",
10 | "statistics"
11 | ],
12 | "bin": "bin/nodejs-dashboard.js",
13 | "main": "index.js",
14 | "scripts": {
15 | "lint": "eslint .",
16 | "test": "npm run lint && npm run test-only",
17 | "test-only": "mocha -c --require test/setup.js --recursive \"test/**/*.spec.js\"",
18 | "test-app": "node bin/nodejs-dashboard.js -- node test/app/index.js",
19 | "coverage": "nyc mocha -- -c --recursive --require test/setup.js \"test/**/*.spec.js\""
20 | },
21 | "repository": {
22 | "type": "git",
23 | "url": "git+https://github.com/FormidableLabs/nodejs-dashboard.git"
24 | },
25 | "author": "Jason Wilson",
26 | "license": "MIT",
27 | "bugs": {
28 | "url": "https://github.com/FormidableLabs/nodejs-dashboard/issues"
29 | },
30 | "homepage": "https://github.com/FormidableLabs/nodejs-dashboard#readme",
31 | "engines": {
32 | "node": ">=8.0.0"
33 | },
34 | "dependencies": {
35 | "blessed": "^0.1.81",
36 | "blessed-contrib": "^4.8.18",
37 | "blocked": "^1.2.1",
38 | "commander": "^4.0.1",
39 | "cross-spawn": "^7.0.1",
40 | "jsonschema": "^1.1.1",
41 | "lodash": "^4.16.2",
42 | "pidusage": "^2.0.17",
43 | "pretty-bytes": "^5.3.0",
44 | "socket.io": "^2.3.0",
45 | "socket.io-client": "^2.3.0"
46 | },
47 | "devDependencies": {
48 | "babel-eslint": "^10.0.3",
49 | "chai": "^4.2.0",
50 | "eslint": "^6.5.1",
51 | "eslint-config-formidable": "^4.0.0",
52 | "eslint-plugin-filenames": "^1.3.2",
53 | "eslint-plugin-import": "^2.18.2",
54 | "eslint-plugin-promise": "^4.2.1",
55 | "mocha": "^6.2.2",
56 | "mock-require": "^3.0.3",
57 | "nyc": "^14.1.1",
58 | "sinon": "^7.5.0",
59 | "sinon-chai": "^3.3.0"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/test/.eslintrc:
--------------------------------------------------------------------------------
1 | ---
2 | env:
3 | mocha: true
4 |
5 | rules:
6 | max-nested-callbacks: ["error", 5]
7 | no-unused-expressions: "off"
8 |
--------------------------------------------------------------------------------
/test/app/index.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | /* eslint-disable no-console, no-magic-numbers */
4 |
5 | require("../../index");
6 |
7 | const _ = require("lodash");
8 |
9 | const slowFunc = function (count) {
10 | const begin = Date.now();
11 |
12 | // Deliberately unused variable.
13 | // eslint-disable-next-line no-unused-vars
14 | let values = _.times(count, () => _.random(0, count));
15 | values = _.sortBy(values);
16 |
17 | return Date.now() - begin;
18 | };
19 |
20 | const main = () => {
21 | const bigBuffer = Buffer.alloc(200000000);
22 |
23 | let count = 1;
24 | setInterval(() => {
25 | console.log("Reporting from a test app, %d.", count);
26 | count++;
27 | }, 1000);
28 |
29 | setInterval(() => {
30 | console.log("Slow call started...");
31 | const duration = slowFunc(_.random(1000, 100000));
32 | console.log("Completed in: ", duration);
33 | }, 3000);
34 |
35 | setInterval(() => {
36 | console.log("Filling buffer...");
37 | bigBuffer.fill(2);
38 | }, 5000);
39 |
40 | setInterval(() => {
41 | console.error("bummer shoulda read the dox :(", new Error().stack);
42 | }, 5000);
43 |
44 | let progress = 0;
45 | setInterval(() => {
46 | console.log("[STATUS] Status update: ", progress);
47 | }, 3000);
48 |
49 | setInterval(() => {
50 | console.error("[STATUS] STATUS ERROR! (", progress, ")");
51 | }, 7000);
52 |
53 | setInterval(() => {
54 | console.log("[PROGRESS] ", progress++);
55 | }, 1000);
56 | };
57 |
58 | if (require.main === module) {
59 | main();
60 | }
61 |
--------------------------------------------------------------------------------
/test/app/layouts.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | module.exports = [
4 | [
5 | {
6 | views: [
7 | {
8 | type: "cpu",
9 | limit: 30
10 | },
11 | {
12 | type: "eventLoop",
13 | limit: 30
14 | },
15 | {
16 | type: "memory",
17 | position: {
18 | size: 15
19 | }
20 | }
21 | ]
22 | },
23 | {
24 | position: {
25 | grow: 3
26 | },
27 | views: [
28 | {
29 | type: "log",
30 | streams: ["stdout"],
31 | exclude: "^\\[STATUS\\]"
32 | },
33 | {
34 | type: "log",
35 | streams: ["stderr"],
36 | exclude: "^\\[STATUS\\]",
37 | position: {
38 | size: 15
39 | }
40 | },
41 | {
42 | type: "log",
43 | title: "status",
44 | borderColor: "light-blue",
45 | fgColor: "",
46 | bgColor: "",
47 | streams: ["stdout", "stderr"],
48 | include: "^\\[STATUS\\](.*)",
49 | position: {
50 | size: 3
51 | }
52 | }
53 | ]
54 | }
55 | ]
56 | ];
57 |
--------------------------------------------------------------------------------
/test/lib/dashboard-agent.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-magic-numbers */
2 |
3 | "use strict";
4 |
5 | const { expect } = require("chai");
6 | const sinon = require("sinon");
7 |
8 | const SocketIO = require("socket.io");
9 | const config = require("../../lib/config");
10 | const dashboardAgent = require("../../lib/dashboard-agent");
11 | const pusage = require("pidusage");
12 | const { tryCatch } = require("../utils");
13 |
14 | describe.skip("dashboard-agent", () => {
15 | let sandbox;
16 | let server;
17 | let agent;
18 | const TEST_PORT = 12345;
19 |
20 | before(() => {
21 | sandbox = sinon.createSandbox();
22 | process.env[config.PORT_KEY] = TEST_PORT;
23 | process.env[config.BLOCKED_THRESHOLD_KEY] = 1;
24 | process.env[config.REFRESH_INTERVAL_KEY] = 10;
25 | });
26 |
27 | beforeEach(() => {
28 | agent = dashboardAgent();
29 | server = new SocketIO(TEST_PORT);
30 | });
31 |
32 | afterEach((done) => {
33 | agent.destroy();
34 | sandbox.restore();
35 | server.close(done);
36 | });
37 |
38 | describe("initialization", () => {
39 | it("should use environment variables for configuration", (done) => {
40 | const checkMetrics = function (metrics) {
41 | expect(metrics).to.be.an("object");
42 | expect(metrics.eventLoop.delay).to.be.a("number");
43 | };
44 |
45 | server.on("connection", (socket) => {
46 | try {
47 | expect(socket).to.be.an("object");
48 | socket.on("error", done);
49 | } catch (err) {
50 | done(err);
51 | }
52 | socket.on("metrics", (data) => {
53 | tryCatch(done, () => {
54 | socket.removeAllListeners("metrics");
55 | checkMetrics(JSON.parse(data));
56 | });
57 | });
58 | });
59 | });
60 | });
61 |
62 | describe("reporting", () => {
63 | it("should provide basic metrics", (done) => {
64 | const checkMetrics = function (metrics) {
65 | expect(metrics).to.be.an("object");
66 | expect(metrics.eventLoop.delay).to.be.a("number");
67 | expect(metrics.eventLoop.high).to.be.a("number");
68 | expect(metrics.mem.systemTotal).to.equal(20);
69 | expect(metrics.mem.rss).to.equal(30);
70 | expect(metrics.mem.heapTotal).to.equal(40);
71 | expect(metrics.mem.heapUsed).to.equal(50);
72 | expect(metrics.cpu.utilization).to.equal(60);
73 | };
74 |
75 | sandbox.stub(process, "memoryUsage").callsFake(() => ({
76 | systemTotal: 20,
77 | rss: 30,
78 | heapTotal: 40,
79 | heapUsed: 50
80 | }));
81 |
82 | sandbox.stub(pusage, "stat").callsFake((processId, callback) => {
83 | expect(processId).to.equal(process.pid);
84 | expect(callback).to.be.a("function");
85 |
86 | callback(null, { cpu: 60 });
87 | });
88 |
89 | agent._getStats((err, metrics) => {
90 | tryCatch(done, () => {
91 | expect(err).to.be.null;
92 | checkMetrics(metrics);
93 | });
94 | });
95 | });
96 |
97 | it("should report an event loop delay and cpu stats", (done) => {
98 | const delay = { current: 100,
99 | max: 150 };
100 | const pusageResults = { cpu: 50 };
101 | sandbox.stub(pusage, "stat").yields(null, pusageResults);
102 |
103 | agent._delayed(delay.max);
104 | agent._delayed(delay.current);
105 |
106 | const checkMetrics = function (metrics) {
107 | expect(metrics.eventLoop.delay).to.equal(delay.current);
108 | expect(metrics.eventLoop.high).to.equal(delay.max);
109 | expect(metrics.cpu.utilization).to.equal(pusageResults.cpu);
110 | };
111 |
112 | agent._getStats((err, metrics) => {
113 | tryCatch(done, () => {
114 | expect(err).to.be.null;
115 | checkMetrics(metrics);
116 | });
117 | });
118 | });
119 |
120 | it("should return an error when pusage fails", (done) => {
121 | sandbox.stub(pusage, "stat").yields(new Error("bad error"));
122 |
123 | agent._getStats((err, metrics) => {
124 | tryCatch(done, () => {
125 | expect(err).to.exist;
126 | expect(metrics).to.be.undefined;
127 | expect(err.message).to.equal("bad error");
128 | });
129 | });
130 | });
131 | });
132 | });
133 |
--------------------------------------------------------------------------------
/test/lib/generate-layouts.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable strict, no-magic-numbers */
2 |
3 | const expect = require("chai").expect;
4 |
5 | // Strict mode leads to odd bug in Node < 0.12
6 | const mockRequire = require("mock-require");
7 |
8 | // to ensure cross-platform consistency with mixed Posix & Win32 paths, use path.resolve()
9 | const resolve = require("path").resolve;
10 |
11 | const mock = function (path, obj) {
12 | return mockRequire(resolve(path), obj);
13 | };
14 |
15 | const generateLayouts = require("../../lib/generate-layouts");
16 |
17 | describe("generate-layouts", () => {
18 | it("should validate default layout", () => {
19 | expect(generateLayouts("lib/default-layout-config.js")).to.be.an("array");
20 | });
21 |
22 | it("should fail on bad layouts", () => {
23 | expect(() => {
24 | generateLayouts("fake/layout-not-found");
25 | }).to.throw(/Cannot find module/);
26 |
27 | expect(() => {
28 | mock("fake/invalid-config-layout", { invalid: "config" });
29 | generateLayouts("fake/invalid-config-layout");
30 | }).to.throw(/instance is not of a type\(s\) array/);
31 | });
32 |
33 | it("should generate empty layout", () => {
34 | mock("fake/empty-layout", []);
35 | expect(generateLayouts("fake/empty-layout")).to.be.empty;
36 | });
37 |
38 | it("should include a getPosition method", () => {
39 | const layout = generateLayouts("lib/default-layout-config.js");
40 | const fake = { fake: "result" };
41 |
42 | expect(layout[0]).to.respondTo("getPosition");
43 | expect(layout[0].getPosition(fake)).to.eql(fake);
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/test/lib/parse-settings.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable strict, no-magic-numbers */
2 |
3 | const expect = require("chai").expect;
4 |
5 | const parseSettings = require("../../lib/parse-settings");
6 |
7 | describe("parse-settings", () => {
8 | it("should fail on invalid settings", () => {
9 | expect(parseSettings("fail").error).to.contain("settings should have format");
10 | });
11 |
12 | it("should have valid setting path", () => {
13 | expect(parseSettings("test()=fail").error).to.contain("invalid path");
14 | expect(parseSettings("=fail").error).to.contain("invalid path");
15 | });
16 |
17 | it("should parse valid settings", () => {
18 | expect(parseSettings("view1.scrollback=100, view1.enabled = true, view2.title=test").result)
19 | .to.deep.equal({
20 | view1: {
21 | scrollback: 100,
22 | enabled: true
23 | },
24 | view2: {
25 | title: "test"
26 | }
27 | });
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/test/lib/providers/log-provider.spec.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const expect = require("chai").expect;
4 | const sinon = require("sinon");
5 |
6 | const utils = require("../../utils");
7 | const LogProvider = require("../../../lib/providers/log-provider");
8 |
9 | describe("LogProvider", () => {
10 | let sandbox;
11 | let testContainer;
12 | let logProvider;
13 |
14 | before(() => {
15 | sandbox = sinon.createSandbox();
16 | });
17 |
18 | beforeEach(() => {
19 | testContainer = utils.getTestContainer(sandbox);
20 | logProvider = new LogProvider(testContainer.screen);
21 | });
22 |
23 | afterEach(() => {
24 | sandbox.restore();
25 | });
26 |
27 | it("should store logs", () => {
28 | logProvider._onLog("stdout", "a\n");
29 | logProvider._onLog("stderr", "b\n");
30 | logProvider._onLog("stdout", "c\n");
31 | logProvider._onLog("stderr", "d\n");
32 | expect(logProvider.getLog("stdout")).to.equal("a\nc");
33 | expect(logProvider.getLog("stderr")).to.equal("b\nd");
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/test/lib/providers/metrics-provider.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-statements,no-magic-numbers,max-nested-callbacks */
2 |
3 | "use strict";
4 |
5 | const expect = require("chai").expect;
6 | const sinon = require("sinon");
7 | const _ = require("lodash");
8 |
9 | const AGGREGATE_TIME_LEVELS = require("../../../lib/constants").AGGREGATE_TIME_LEVELS;
10 |
11 | const utils = require("../../utils");
12 | const MetricsProvider = require("../../../lib/providers/metrics-provider");
13 |
14 |
15 | const createMockMetric = function () {
16 | return {
17 | metricA: {
18 | valueA: Math.random() * 100
19 | },
20 | metricB: {
21 | valueA: Math.random() * 100,
22 | valueB: Math.random() * 100
23 | }
24 | };
25 | };
26 |
27 | const mapToAverage = function (metric) {
28 | return {
29 | metricA: {
30 | valueA: Number(metric.metricA.valueA.toFixed(1))
31 | },
32 | metricB: {
33 | valueA: Number(metric.metricB.valueA.toFixed(1)),
34 | valueB: Number(metric.metricB.valueB.toFixed(1))
35 | }
36 | };
37 | };
38 |
39 | describe("MetricsProvider", () => {
40 | let sandbox;
41 | let testContainer;
42 | let metricsProvider;
43 |
44 | let mockStart;
45 | let mockNow;
46 | let fill;
47 |
48 | beforeEach(() => {
49 | sandbox = sinon.createSandbox();
50 |
51 | mockStart = 10000000;
52 | mockNow = mockStart;
53 | sandbox.stub(Date, "now").callsFake(() => mockNow);
54 |
55 | fill = function (count, interval) {
56 | const mockData = [];
57 | for (let i = 0; i < count; ++i) {
58 | mockNow += interval;
59 | const mockMetric = createMockMetric();
60 | metricsProvider._onMetrics(mockMetric);
61 | mockData.push(mockMetric);
62 | }
63 | return mockData;
64 | };
65 |
66 | testContainer = utils.getTestContainer(sandbox);
67 | metricsProvider = new MetricsProvider(testContainer.screen);
68 | });
69 |
70 | afterEach(() => {
71 | sandbox.restore();
72 | });
73 |
74 | describe("constructor", () => {
75 | it("builds an aggregation container from configuration", () => {
76 | expect(metricsProvider).to.be.an.instanceOf(MetricsProvider);
77 |
78 | expect(metricsProvider)
79 | .to.be.an("object")
80 | .with.property("_aggregation")
81 | .which.is.an("object")
82 | .with.keys(AGGREGATE_TIME_LEVELS);
83 |
84 | let index = 0;
85 | _.each(metricsProvider._aggregation, (value, key) => {
86 | expect(key)
87 | .to.be.a("string")
88 | .that.equals(AGGREGATE_TIME_LEVELS[index++]);
89 |
90 | expect(value)
91 | .to.be.an("object")
92 | .that.deep.equals({
93 | data: [],
94 | lastTimeIndex: undefined,
95 | lastAggregateIndex: 0,
96 | scrollOffset: 0
97 | });
98 | });
99 |
100 | expect(metricsProvider)
101 | .to.be.an("object")
102 | .with.property("aggregationLevels")
103 | .which.is.an("array")
104 | .that.deep.equals(AGGREGATE_TIME_LEVELS);
105 |
106 | expect(metricsProvider)
107 | .to.be.an("object")
108 | .with.property("highestAggregationKey")
109 | .which.is.a("string")
110 | .that.equals(_.last(AGGREGATE_TIME_LEVELS));
111 |
112 | expect(metricsProvider)
113 | .to.be.an("object")
114 | .with.property("zoomLevel")
115 | .which.is.a("number")
116 | .that.equals(0);
117 |
118 | expect(metricsProvider)
119 | .to.be.an("object")
120 | .with.property("zoomLevelKey")
121 | .which.is.a("string")
122 | .that.equals(AGGREGATE_TIME_LEVELS[0]);
123 |
124 | expect(metricsProvider)
125 | .to.be.an("object")
126 | .with.property("_startTime")
127 | .which.is.a("number")
128 | .that.equals(mockStart);
129 |
130 | expect(metricsProvider)
131 | .to.be.an("object")
132 | .with.property("_lastAggregationIndex")
133 | .which.is.a("number")
134 | .that.equals(0);
135 |
136 | expect(metricsProvider)
137 | .to.be.an("object")
138 | .with.property("_metrics")
139 | .which.is.an("array")
140 | .that.deep.equals([]);
141 | });
142 | });
143 |
144 | describe("_onMetrics", () => {
145 | it("retains metrics received", () => {
146 | // create some mock data
147 | const mockMetrics = fill(10, 500);
148 |
149 | // the number of data points retained must match the number provided
150 | expect(metricsProvider)
151 | .to.be.an("object")
152 | .with.property("_metrics")
153 | .which.is.an("array")
154 | .that.has.lengthOf(mockMetrics.length);
155 |
156 | // now, examine each metric
157 | _.each(metricsProvider._metrics, (value, index) => {
158 | expect(value)
159 | .to.be.an("object")
160 | .with.property("metricA")
161 | .with.property("valueA")
162 | .that.equals(mockMetrics[index].metricA.valueA);
163 |
164 | expect(value)
165 | .to.be.an("object")
166 | .with.property("metricB")
167 | .with.property("valueA")
168 | .that.equals(mockMetrics[index].metricB.valueA);
169 |
170 | expect(value)
171 | .to.be.an("object")
172 | .with.property("metricB")
173 | .with.property("valueB")
174 | .that.equals(mockMetrics[index].metricB.valueB);
175 | });
176 | });
177 |
178 | it("creates missing average even if first", () => {
179 | const timeKey = AGGREGATE_TIME_LEVELS[0];
180 | const timeLength = Number(timeKey);
181 |
182 | // Fill 2 time slots skiping the first
183 | // 2 slots are needed to cause average calculation
184 | const mockMetrics = fill(2, timeLength);
185 |
186 | expect(metricsProvider._aggregation[timeKey].data)
187 | .to.be.an("array")
188 | .that.eql([
189 | {
190 | metricA: {
191 | valueA: 0
192 | },
193 | metricB: {
194 | valueA: 0,
195 | valueB: 0
196 | }
197 | },
198 | mapToAverage(mockMetrics[0])
199 | ]);
200 | });
201 |
202 | it("creates missing average in the middle", () => {
203 | const timeKey = AGGREGATE_TIME_LEVELS[0];
204 | const timeLength = Number(timeKey);
205 |
206 | // Fill data until first average created
207 | let mockMetrics = fill(2, timeLength - 1);
208 |
209 | // Then add 1 more metric to split lastTimeIndex from lastAggregateIndex
210 | mockMetrics = mockMetrics.concat(fill(1, timeLength * 2));
211 |
212 | // Then skip a time slot and add 1 more metric
213 | mockMetrics = mockMetrics.concat(fill(1, timeLength * 3));
214 |
215 | expect(metricsProvider._aggregation[timeKey].data)
216 | .to.be.an("array")
217 | .that.eql([
218 | mapToAverage(mockMetrics[0]),
219 | mapToAverage(mockMetrics[1]),
220 | {
221 | metricA: {
222 | valueA: 0
223 | },
224 | metricB: {
225 | valueA: 0,
226 | valueB: 0
227 | }
228 | },
229 | mapToAverage(mockMetrics[2])
230 | ]);
231 | });
232 |
233 | it("aggregates metrics into time buckets", () => {
234 | // Fill in a single event so all future events result in a average calculation
235 | let mockMetrics = fill(1, 1);
236 |
237 | // Add an event at the 2nd time slot of the largest bucket
238 | // This will cause an average calculation for all buckets
239 | const maxZoomLevel = Number(AGGREGATE_TIME_LEVELS[AGGREGATE_TIME_LEVELS.length - 1]);
240 | mockMetrics = mockMetrics.concat(fill(1, maxZoomLevel));
241 |
242 | // The 2nd event filled all average buckets with the first event
243 | // Since there is only 1 event the average is the same values
244 | _.each(metricsProvider._aggregation, (value) => {
245 | expect(value.data)
246 | .to.be.an("array")
247 | .that.has.lengthOf(1);
248 |
249 | const row = value.data[0];
250 | const lastMock = mockMetrics[0];
251 | const averageA = lastMock.metricA;
252 | const averageB = lastMock.metricB;
253 |
254 | // verify
255 | expect(row)
256 | .to.be.an("object")
257 | .with.property("metricA")
258 | .with.property("valueA")
259 | .which.is.a("number")
260 | .that.equals(Number(averageA.valueA.toFixed(1)));
261 |
262 | expect(row)
263 | .to.be.an("object")
264 | .with.property("metricB")
265 | .with.property("valueA")
266 | .which.is.a("number")
267 | .that.equals(Number(averageB.valueA.toFixed(1)));
268 |
269 | expect(row)
270 | .to.be.an("object")
271 | .with.property("metricB")
272 | .with.property("valueB")
273 | .which.is.a("number")
274 | .that.equals(Number(averageB.valueB.toFixed(1)));
275 | });
276 | });
277 | });
278 |
279 | describe("adjustZoomLevel", () => {
280 | it("allows for changing the zoom level", () => {
281 | // try to adjust the zoom right now
282 | metricsProvider.adjustZoomLevel(1);
283 |
284 | // there is no data, so it shouldn't change
285 | expect(metricsProvider)
286 | .to.be.an("object")
287 | .with.property("zoomLevel")
288 | .which.is.a("number")
289 | .that.equals(0);
290 |
291 | expect(metricsProvider)
292 | .to.be.an("object")
293 | .with.property("zoomLevelKey")
294 | .which.is.a("string")
295 | .that.equals(AGGREGATE_TIME_LEVELS[0]);
296 |
297 | // add some mock data
298 | fill(2, 2500);
299 |
300 | // given the uniform data, this should allow for one higher
301 | // aggregation
302 | metricsProvider.adjustZoomLevel(1);
303 |
304 | expect(metricsProvider)
305 | .to.be.an("object")
306 | .with.property("zoomLevel")
307 | .which.is.a("number")
308 | .that.equals(1);
309 |
310 | expect(metricsProvider)
311 | .to.be.an("object")
312 | .with.property("zoomLevelKey")
313 | .which.is.a("string")
314 | .that.equals(AGGREGATE_TIME_LEVELS[1]);
315 |
316 | // zooming again should have no change
317 | metricsProvider.adjustZoomLevel(1);
318 |
319 | expect(metricsProvider)
320 | .to.be.an("object")
321 | .with.property("zoomLevel")
322 | .which.is.a("number")
323 | .that.equals(1);
324 |
325 | expect(metricsProvider)
326 | .to.be.an("object")
327 | .with.property("zoomLevelKey")
328 | .which.is.a("string")
329 | .that.equals(AGGREGATE_TIME_LEVELS[1]);
330 |
331 | // getting metrics should come from the aggregate now
332 | const metrics = metricsProvider.getMetrics(3);
333 |
334 | expect(metrics)
335 | .to.be.an("array")
336 | .that.deep.equals(metricsProvider._aggregation[metricsProvider.zoomLevelKey].data);
337 |
338 | // receiving metrics now would cause an emit
339 | sandbox.stub(metricsProvider, "emit").callsFake((key, data, discardEvent) => {
340 | if (discardEvent) {
341 | return;
342 | }
343 |
344 | expect(key)
345 | .to.be.a("string")
346 | .that.equals("metrics");
347 |
348 | expect(data)
349 | .to.be.an("object")
350 | .that.deep.equals(
351 | _.last(metricsProvider._aggregation[metricsProvider.zoomLevelKey].data)
352 | );
353 | });
354 |
355 | fill(1, 7500);
356 |
357 | // if time were to be skipped, some missing time slots should be generated too
358 | fill(3, 25000);
359 | });
360 | });
361 |
362 | describe("getXAxis", () => {
363 | it("should return labels appropriate for their highest measure of time", () => {
364 | const limit = 10;
365 | let axis = metricsProvider.getXAxis(limit);
366 |
367 | let expected
368 | = _.reverse([":00", ":01", ":02", ":03", ":04", ":05", ":06", ":07", ":08", ":09"]);
369 |
370 | expect(axis)
371 | .to.be.an("array")
372 | .that.deep.equals(expected);
373 |
374 | // lets get some aggregation for another zoom level
375 | fill(100, 500);
376 |
377 | // zoom
378 | metricsProvider.adjustZoomLevel(2);
379 |
380 | axis = metricsProvider.getXAxis(limit);
381 | expected
382 | = _.reverse([":00", ":10", ":20", ":30", ":40", ":50", "1:00", "1:10", "1:20", "1:30"]);
383 |
384 | expect(axis)
385 | .to.be.an("array")
386 | .that.deep.equals(expected);
387 |
388 | // max zoom
389 | metricsProvider.setZoomLevel(AGGREGATE_TIME_LEVELS.length);
390 |
391 | // there are 8,760 hours in a day, getting an axis of 10,000 will get us full coverage
392 | axis = metricsProvider.getXAxis(10000);
393 |
394 | // here is the expected (use 9999 not 10000 because the last axis element is zero-based)
395 | const { zoomLevelKey } = metricsProvider;
396 | const years = Math.floor(zoomLevelKey * 9999 / (1000 * 60 * 60 * 24 * 365.25));
397 | const days = Math.floor(zoomLevelKey * 9999 / (1000 * 60 * 60 * 24) % 365.25);
398 | const hours = Math.floor(zoomLevelKey * 9999 / (1000 * 60 * 60) % 24);
399 | const minutes = Math.floor(zoomLevelKey * 9999 / (1000 * 60) % 60);
400 | const seconds = Math.floor(zoomLevelKey * 9999 / 1000 % 60);
401 |
402 | // build a label
403 | const label
404 | = `${years}y${
405 | days}d ${
406 | hours}:${
407 | _.padStart(minutes, 2, "0")}:${
408 | _.padStart(seconds, 2, "0")}`;
409 |
410 | expect(axis[0])
411 | .to.be.a("string")
412 | .that.equals(label);
413 | });
414 | });
415 |
416 | describe("adjustScrollOffset", () => {
417 | it("adjusts the scroll either relative or absolute", () => {
418 | // add some data
419 | fill(1, 0);
420 | fill(1, 500);
421 |
422 | // go left one
423 | metricsProvider.adjustScrollOffset(-1);
424 |
425 | // should be offset one
426 | expect(metricsProvider)
427 | .to.be.an("object")
428 | .with.property("_aggregation")
429 | .with.property(metricsProvider.zoomLevelKey)
430 | .which.is.an("object")
431 | .with.property("scrollOffset")
432 | .which.is.a("number")
433 | .that.equals(-1);
434 |
435 | // go forward two
436 | metricsProvider.adjustScrollOffset(+2);
437 |
438 | // won't go above zero
439 | expect(metricsProvider)
440 | .to.be.an("object")
441 | .with.property("_aggregation")
442 | .with.property(metricsProvider.zoomLevelKey)
443 | .which.is.an("object")
444 | .with.property("scrollOffset")
445 | .which.is.a("number")
446 | .that.equals(0);
447 |
448 | metricsProvider.adjustScrollOffset(-5);
449 |
450 | // add some more data to verify that scroll offset adjusts
451 | // 50 more elements at lowest time interval is 50 more aggregates
452 | fill(50, Number(AGGREGATE_TIME_LEVELS[0]));
453 |
454 | // previous offset should be adjusted by the number of additional aggregate
455 | // elements
456 | expect(metricsProvider)
457 | .to.be.an("object")
458 | .with.property("_aggregation")
459 | .with.property(metricsProvider.zoomLevelKey)
460 | .which.is.an("object")
461 | .with.property("scrollOffset")
462 | .which.is.a("number")
463 | .that.equals(-55);
464 |
465 | // test out absolute position
466 | metricsProvider.adjustScrollOffset(-1, true);
467 |
468 | // should be offset one
469 | expect(metricsProvider)
470 | .to.be.an("object")
471 | .with.property("_aggregation")
472 | .with.property(metricsProvider.zoomLevelKey)
473 | .which.is.an("object")
474 | .with.property("scrollOffset")
475 | .which.is.a("number")
476 | .that.equals(-1);
477 |
478 | // reset
479 | metricsProvider.resetGraphs();
480 |
481 | // now try to go way left
482 | metricsProvider.adjustScrollOffset(-5000);
483 |
484 | // should be offset
485 | expect(metricsProvider)
486 | .to.be.an("object")
487 | .with.property("_aggregation")
488 | .with.property(metricsProvider.zoomLevelKey)
489 | .which.is.an("object")
490 | .with.property("scrollOffset")
491 | .which.is.a("number")
492 | .that.equals(-5000);
493 |
494 | // getting metrics now will correct the offset considering limit
495 | metricsProvider.getMetrics(30);
496 |
497 | expect(metricsProvider)
498 | .to.be.an("object")
499 | .with.property("_aggregation")
500 | .with.property(metricsProvider.zoomLevelKey)
501 | .which.is.an("object")
502 | .with.property("scrollOffset")
503 | .which.is.a("number")
504 | .that.equals(30 - metricsProvider._aggregation[AGGREGATE_TIME_LEVELS[0]].data.length);
505 | });
506 | });
507 |
508 | describe("startGraphs", () => {
509 | it("offsets at the end or the beginning of the data set", () => {
510 | // load some data
511 | fill(100, 500);
512 |
513 | sandbox.stub(metricsProvider, "adjustScrollOffset").callsFake((direction) => {
514 | let length = metricsProvider._aggregation[AGGREGATE_TIME_LEVELS[0]].data.length;
515 |
516 | length = direction < 0 ? -length : Number(length);
517 |
518 | expect(direction)
519 | .to.be.a("number")
520 | .that.equals(length);
521 | });
522 |
523 | metricsProvider.startGraphs(-1);
524 | metricsProvider.startGraphs(+1);
525 | });
526 | });
527 |
528 | describe("resetGraphs", () => {
529 | it("resets zoom level and scroll offsets", () => {
530 | sandbox.stub(metricsProvider, "setZoomLevel").callsFake((zoom) => {
531 | expect(zoom)
532 | .to.be.a("number")
533 | .that.equals(0);
534 | });
535 |
536 | _.each(AGGREGATE_TIME_LEVELS, (level) => {
537 | expect(metricsProvider)
538 | .to.be.an("object")
539 | .with.property("_aggregation")
540 | .which.is.an("object")
541 | .with.property(level)
542 | .which.is.an("object")
543 | .with.property("scrollOffset")
544 | .which.is.a("number")
545 | .that.equals(0);
546 | });
547 |
548 | metricsProvider.resetGraphs();
549 | });
550 | });
551 | });
552 |
--------------------------------------------------------------------------------
/test/lib/views/base-line-graph.spec.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const expect = require("chai").expect;
4 | const sinon = require("sinon");
5 |
6 | const contrib = require("blessed-contrib");
7 | const _ = require("lodash");
8 |
9 | const BaseView = require("../../../lib/views/base-view");
10 | const BaseLineGraph = require("../../../lib/views/base-line-graph");
11 | const utils = require("../../utils");
12 | const MetricsProvider = require("../../../lib/providers/metrics-provider");
13 |
14 | describe("BaseLineGraph", () => {
15 | let sandbox;
16 | let testContainer;
17 | let options;
18 |
19 | before(() => {
20 | sandbox = sinon.createSandbox();
21 | });
22 |
23 | beforeEach(() => {
24 | utils.stubWidgets(sandbox);
25 | testContainer = utils.getTestContainer(sandbox);
26 | options = {
27 | parent: testContainer,
28 | metricsProvider: new MetricsProvider(testContainer.screen),
29 | series: {
30 | a: { label: "" }
31 | },
32 | layoutConfig: {
33 | getPosition() { return { top: "10%" }; },
34 | view: {
35 | title: "graph A",
36 | limit: 10
37 | }
38 | }
39 | };
40 | });
41 |
42 | afterEach(() => {
43 | sandbox.restore();
44 | });
45 |
46 | describe("constructor", () => {
47 | beforeEach(() => {
48 | sandbox.stub(BaseLineGraph.prototype, "_createGraph");
49 | });
50 |
51 | it("should use limit from layoutConfig", () => {
52 | const limit = 7;
53 | options.layoutConfig.view.limit = limit;
54 | const baseGraph = new BaseLineGraph(options);
55 | expect(baseGraph).to.have.property("limit", limit);
56 | expect(baseGraph).to.have.nested.property("series.a.y")
57 | .that.deep.equals(_.times(limit, _.constant(0)));
58 | });
59 |
60 | it("should create graph and set up event listener", () => {
61 | const baseGraph = new BaseLineGraph(options);
62 | expect(baseGraph).to.be.an.instanceof(BaseView);
63 | expect(baseGraph._createGraph).to.have.been.calledOnce;
64 | expect(testContainer.screen.on).to.have.been.calledWithExactly("metrics", sinon.match.func);
65 | });
66 | });
67 |
68 | describe("onEvent", () => {
69 | it("should throw an error because it's meant to be overridden by child class", () => {
70 | const baseGraph = new BaseLineGraph(options);
71 | expect(() => {
72 | baseGraph.onEvent();
73 | }).to.throw("BaseLineGraph onEvent should be overridden");
74 | });
75 | });
76 |
77 | describe("recalculatePosition", () => {
78 | it("should set new position and recreate node", () => {
79 | const baseGraph = new BaseLineGraph(options);
80 |
81 | sandbox.spy(testContainer, "remove");
82 | sandbox.spy(testContainer, "append");
83 | sandbox.stub(baseGraph, "_getPosition").returns({ top: "20%" });
84 | baseGraph.recalculatePosition();
85 |
86 | expect(baseGraph.node).to.have.property("position").that.deep.equals({ top: "20%" });
87 | expect(testContainer.remove).to.have.been.calledOnce;
88 | expect(testContainer.append).to.have.been.calledOnce;
89 | });
90 |
91 | it("should do nothing if position is unchanged", () => {
92 | options.layoutConfig.getPosition = function () { return { top: "10%" }; };
93 | const baseGraph = new BaseLineGraph(options);
94 | const originalPosition = baseGraph.node.position;
95 |
96 | sandbox.spy(testContainer, "remove");
97 | sandbox.stub(baseGraph, "_getPosition").returns({ top: "10%" });
98 | baseGraph.recalculatePosition();
99 |
100 | expect(baseGraph.node).to.have.property("position", originalPosition);
101 | expect(testContainer.remove).to.have.not.been.called;
102 | });
103 | });
104 |
105 | describe("update", () => {
106 | /* eslint-disable no-magic-numbers */
107 |
108 | it("should update series and label", () => {
109 | options.layoutConfig.view.limit = 4;
110 | options.layoutConfig.view.title = "cpu";
111 | options.unit = "%";
112 | const baseGraph = new BaseLineGraph(options);
113 | expect(baseGraph).to.have.nested.property("series.a.y").that.deep.equals([0, 0, 0, 0]);
114 |
115 | baseGraph.update({ a: 29 });
116 | expect(baseGraph).to.have.nested.property("series.a.y").that.deep.equals([0, 0, 0, 29]);
117 | expect(baseGraph.node.setLabel).to.have.been.calledWith(" cpu (29%) ");
118 |
119 | baseGraph.update({ a: 8 });
120 | expect(baseGraph).to.have.nested.property("series.a.y").that.deep.equals([0, 0, 29, 8]);
121 | expect(baseGraph.node.setLabel).to.have.been.calledWith(" cpu (8%) ");
122 | });
123 |
124 | it("should update highwater series", () => {
125 | options.layoutConfig.view.limit = 3;
126 | options.series.high = {
127 | highwater: true
128 | };
129 | const baseGraph = new BaseLineGraph(options);
130 |
131 | expect(baseGraph).to.have.nested.property("series.a.y").that.deep.equals([0, 0, 0]);
132 | expect(baseGraph).to.have.nested.property("series.high").that.deep.equals({
133 | x: [":02", ":01", ":00"],
134 | y: [0, 0, 0],
135 | style: { line: "red" }
136 | });
137 |
138 | baseGraph.update({ a: 2,
139 | high: 4 });
140 | expect(baseGraph).to.have.nested.property("series.a.y").that.deep.equals([0, 0, 2]);
141 | expect(baseGraph).to.have.nested.property("series.high").that.deep.equals({
142 | x: [":02", ":01", ":00"],
143 | y: [4, 4, 4],
144 | style: { line: "red" }
145 | });
146 | expect(baseGraph.node.setLabel).to.have.been.calledWith(" graph A (2), high (4) ");
147 | });
148 |
149 | it("should update series without exceeding limit", () => {
150 | options.layoutConfig.view.limit = 3;
151 | options.series.high = {
152 | highwater: true
153 | };
154 | const baseGraph = new BaseLineGraph(options);
155 |
156 | baseGraph.update({ a: 27,
157 | high: 27 });
158 | expect(baseGraph).to.have.nested.property("series.a.y").that.deep.equals([0, 0, 27]);
159 | expect(baseGraph).to.have.nested.property("series.high.y").that.deep.equals([27, 27, 27]);
160 | });
161 |
162 | /* eslint-enable no-magic-numbers */
163 | });
164 |
165 | describe("_createGraph", () => {
166 | it("should create a blessed-contrib line graph", () => {
167 | sandbox.spy(testContainer, "append");
168 | options.layoutConfig.view.limit = 8;
169 | sandbox.stub(BaseLineGraph.prototype, "_createGraph");
170 | const baseGraph = new BaseLineGraph(options);
171 | BaseLineGraph.prototype._createGraph.restore();
172 |
173 | expect(baseGraph).to.not.have.property("node");
174 |
175 | baseGraph._createGraph(options);
176 |
177 | expect(baseGraph).to.have.property("node").that.is.an.instanceof(contrib.line);
178 | expect(baseGraph.node).to.have.nested.property("options.label", " graph A ");
179 | expect(baseGraph.node).to.have.nested.property("options.maxY", undefined);
180 | expect(baseGraph.node).to.have.property("position")
181 | .that.deep.equals(options.layoutConfig.getPosition(options.parent));
182 |
183 | expect(testContainer.append).to.have.been.calledOnce.and.calledWithExactly(baseGraph.node);
184 | });
185 | });
186 | });
187 |
--------------------------------------------------------------------------------
/test/lib/views/base-view.spec.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const expect = require("chai").expect;
4 | const sinon = require("sinon");
5 |
6 | const BaseView = require("../../../lib/views/base-view");
7 | const utils = require("../../utils");
8 | const blessed = require("blessed");
9 |
10 | describe("BaseView", () => {
11 | let sandbox;
12 | let testContainer;
13 | let options;
14 |
15 | before(() => {
16 | sandbox = sinon.createSandbox();
17 | });
18 |
19 | beforeEach(() => {
20 | testContainer = utils.getTestContainer(sandbox);
21 | options = {
22 | parent: testContainer,
23 | layoutConfig: {
24 | getPosition: sandbox.stub().returns({ left: 22 })
25 | }
26 | };
27 | });
28 |
29 | afterEach(() => {
30 | sandbox.restore();
31 | });
32 |
33 | describe("constructor", () => {
34 | it("should require parent", () => {
35 | expect(() => {
36 | new BaseView({}); // eslint-disable-line no-new
37 | }).to.throw("View requires parent");
38 | });
39 |
40 | it("should require layoutConfig with getPosition function", () => {
41 | const msg = "View requires layoutConfig option with getPosition function";
42 | expect(() => {
43 | // eslint-disable-next-line no-new
44 | new BaseView({ parent: testContainer,
45 | layoutConfig: {} });
46 | }).to.throw(msg);
47 | });
48 |
49 | it("should set up resize listener that calls recalculatePosition", () => {
50 | sandbox.spy(BaseView.prototype, "recalculatePosition");
51 | const baseView = new BaseView(options);
52 |
53 | expect(testContainer.screen.on).to.have.been.calledOnce
54 | .and.calledWithExactly("resize", sinon.match.func);
55 |
56 | const resizeHandler = testContainer.screen.on.firstCall.args[1];
57 | baseView.node = blessed.element();
58 | resizeHandler();
59 | expect(baseView.recalculatePosition).to.have.been.calledOnce;
60 | });
61 | });
62 |
63 | describe("getPosition", () => {
64 | it("should return result of layoutConfig getPosition", () => {
65 | const baseView = new BaseView(options);
66 | baseView.node = blessed.element();
67 | expect(options.layoutConfig.getPosition).to.have.not.been.called;
68 |
69 | baseView.recalculatePosition();
70 | expect(options.layoutConfig.getPosition).to.have.been.calledOnce
71 | .and.calledWithExactly(testContainer);
72 | expect(baseView._getPosition(testContainer)).to.equal(
73 | options.layoutConfig.getPosition.firstCall.returnValue
74 | );
75 | });
76 | });
77 |
78 | describe("recalculatePosition", () => {
79 | it("should set node position", () => {
80 | const baseView = new BaseView(options);
81 |
82 | baseView.node = blessed.element();
83 | const newPosition = { top: "50%" };
84 | baseView._getPosition = sandbox.stub().returns(newPosition);
85 |
86 | baseView.recalculatePosition();
87 | expect(baseView.node).to.have.property("position", newPosition);
88 | });
89 | });
90 | });
91 |
--------------------------------------------------------------------------------
/test/lib/views/cpu-details-view.spec.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const expect = require("chai").expect;
4 | const sinon = require("sinon");
5 |
6 | const CpuDetailsView = require("../../../lib/views/cpu-details-view");
7 | const utils = require("../../utils");
8 |
9 | describe("CpuDetailsView", () => {
10 | let sandbox;
11 | let testContainer;
12 | let view;
13 |
14 | before(() => {
15 | sandbox = sinon.createSandbox();
16 | });
17 |
18 | beforeEach(() => {
19 | utils.stubWidgets(sandbox);
20 | testContainer = utils.getTestContainer(sandbox);
21 | view = new CpuDetailsView({
22 | layoutConfig: {
23 | getPosition: sandbox.stub()
24 | },
25 | parent: testContainer
26 | });
27 | });
28 |
29 | afterEach(() => {
30 | sandbox.restore();
31 | });
32 |
33 | describe("getDetails", () => {
34 | it("should include labels", () => {
35 | const details = view.getDetails();
36 | expect(details).to.be.an("array");
37 | const labels = details.map((detail) => detail.label).sort();
38 | expect(labels).to.include("[0]");
39 | });
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/test/lib/views/cpu-view.spec.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const expect = require("chai").expect;
4 | const sinon = require("sinon");
5 |
6 | const CpuView = require("../../../lib/views/cpu-view");
7 | const BaseLineGraph = require("../../../lib/views/base-line-graph");
8 | const utils = require("../../utils");
9 | const MetricsProvider = require("../../../lib/providers/metrics-provider");
10 |
11 | describe("CpuView", () => {
12 | let sandbox;
13 | let testContainer;
14 | let options;
15 |
16 | before(() => {
17 | sandbox = sinon.createSandbox();
18 | });
19 |
20 | beforeEach(() => {
21 | utils.stubWidgets(sandbox);
22 | testContainer = utils.getTestContainer(sandbox);
23 | options = {
24 | parent: testContainer,
25 | metricsProvider: new MetricsProvider(testContainer.screen),
26 | layoutConfig: {
27 | limit: 10,
28 | getPosition: sandbox.stub().returns({ left: "75%" })
29 | }
30 | };
31 | });
32 |
33 | afterEach(() => {
34 | sandbox.restore();
35 | });
36 |
37 | describe("constructor", () => {
38 | it("should inherit from BaseLineGraph, with cpu graph options", () => {
39 | const cpu = new CpuView(options);
40 | expect(cpu).to.be.an.instanceof(CpuView);
41 | expect(cpu).to.be.an.instanceof(BaseLineGraph);
42 |
43 | expect(cpu).to.have.property("unit", "%");
44 | const MAX_PERCENT = 100;
45 | expect(cpu).to.have.nested.property("node.options.maxY", MAX_PERCENT);
46 | });
47 | });
48 |
49 | describe("onEvent", () => {
50 | it("should call update with formatted cpu utilization", () => {
51 | const cpu = new CpuView(options);
52 | sandbox.spy(cpu, "update");
53 |
54 | cpu.onEvent({ cpu: { utilization: 3.24346 } });
55 | expect(cpu.update).to.have.been.calledOnce.and.calledWithExactly({ cpu: "3.2" });
56 |
57 | cpu.onEvent({ cpu: { utilization: 9 } });
58 | expect(cpu.update).to.have.been.calledTwice.and.calledWithExactly({ cpu: "9.0" });
59 | });
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/test/lib/views/env-details-view.spec.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const expect = require("chai").expect;
4 | const sinon = require("sinon");
5 |
6 | const EnvDetailsView = require("../../../lib/views/env-details-view");
7 | const utils = require("../../utils");
8 |
9 | describe("EnvDetailsView", () => {
10 | let sandbox;
11 | let testContainer;
12 | let view;
13 |
14 | before(() => {
15 | sandbox = sinon.createSandbox();
16 | });
17 |
18 | beforeEach(() => {
19 | utils.stubWidgets(sandbox);
20 | testContainer = utils.getTestContainer(sandbox);
21 | view = new EnvDetailsView({
22 | layoutConfig: {
23 | getPosition: sandbox.stub()
24 | },
25 | parent: testContainer
26 | });
27 | });
28 |
29 | afterEach(() => {
30 | sandbox.restore();
31 | });
32 |
33 | describe("getDetails", () => {
34 | it("should include labels", () => {
35 | const details = view.getDetails();
36 | expect(details).to.be.an("array");
37 | });
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/test/lib/views/eventloop-view.spec.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const expect = require("chai").expect;
4 | const sinon = require("sinon");
5 |
6 | const BaseView = require("../../../lib/views/base-view");
7 | const BaseLineGraph = require("../../../lib/views/base-line-graph");
8 | const EventLoopView = require("../../../lib/views/eventloop-view");
9 | const utils = require("../../utils");
10 | const MetricsProvider = require("../../../lib/providers/metrics-provider");
11 |
12 | describe("EventLoopView", () => {
13 | let sandbox;
14 | let testContainer;
15 | let options;
16 |
17 | before(() => {
18 | sandbox = sinon.createSandbox();
19 | });
20 |
21 | beforeEach(() => {
22 | utils.stubWidgets(sandbox);
23 | testContainer = utils.getTestContainer(sandbox);
24 | options = {
25 | parent: testContainer,
26 | metricsProvider: new MetricsProvider(testContainer.screen),
27 | layoutConfig: {
28 | limit: 10,
29 | getPosition: sandbox.stub().returns({ left: "75%" })
30 | }
31 | };
32 | });
33 |
34 | afterEach(() => {
35 | sandbox.restore();
36 | });
37 |
38 | describe("constructor", () => {
39 | it("should inherit from BaseLineGraph, with eventLoop graph options", () => {
40 | const eventLoop = new EventLoopView(options);
41 | expect(eventLoop).to.be.an.instanceof(EventLoopView);
42 | expect(eventLoop).to.be.an.instanceof(BaseLineGraph);
43 | expect(eventLoop).to.be.an.instanceof(BaseView);
44 |
45 | expect(eventLoop).to.have.property("label", " event loop ");
46 | expect(eventLoop).to.have.property("unit", "ms");
47 | expect(eventLoop).to.have.property("series").that.has.keys("delay", "high");
48 | });
49 | });
50 |
51 | describe("onEvent", () => {
52 | it("should call update with event loop delay and high", () => {
53 | const eventLoop = new EventLoopView(options);
54 | sandbox.spy(eventLoop, "update");
55 |
56 | const data = { delay: 24,
57 | high: 24.346 };
58 | eventLoop.onEvent({ eventLoop: data });
59 | expect(eventLoop.update).to.have.been.calledOnce
60 | .and.calledWithExactly({ delay: data.delay,
61 | high: data.high });
62 | });
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/test/lib/views/goto-time-view.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-statements,max-len */
2 |
3 | "use strict";
4 |
5 | const expect = require("chai").expect;
6 | const sinon = require("sinon");
7 | const blessed = require("blessed");
8 |
9 | const GotoTimeView = require("../../../lib/views/goto-time-view");
10 | const utils = require("../../utils");
11 | const MetricsProvider = require("../../../lib/providers/metrics-provider");
12 |
13 | describe("GotoTimeView", () => {
14 | let clock;
15 | let sandbox;
16 | let testContainer;
17 | let options;
18 |
19 | const createTestContainer = function (stubEvents) {
20 | testContainer = utils.getTestContainer(sandbox, stubEvents);
21 | options = {
22 | parent: testContainer,
23 | screen: testContainer.screen,
24 | metricsProvider: new MetricsProvider(testContainer.screen)
25 | };
26 | };
27 |
28 | before(() => {
29 | sandbox = sinon.createSandbox();
30 | });
31 |
32 | beforeEach(() => {
33 | clock = sinon.useFakeTimers();
34 | utils.stubWidgets(sandbox);
35 | createTestContainer(true);
36 | });
37 |
38 | afterEach(() => {
39 | sandbox.restore();
40 | clock.restore();
41 | });
42 |
43 | describe("constructor", () => {
44 | it("should create a popup screen", () => {
45 | const gotoTimeView = new GotoTimeView(options);
46 | expect(gotoTimeView).to.be.an.instanceof(GotoTimeView);
47 |
48 | expect(gotoTimeView).to.have.property("metricsProvider").which.is.an.instanceOf(MetricsProvider);
49 | expect(gotoTimeView).to.have.property("screen").which.is.an("object");
50 | expect(gotoTimeView).to.have.property("parent").which.is.an.instanceOf(blessed.box);
51 |
52 | expect(gotoTimeView).to.have.property("node").which.is.an.instanceOf(blessed.node);
53 | expect(gotoTimeView).to.have.property("form").which.is.an.instanceOf(blessed.form);
54 | expect(gotoTimeView).to.have.property("timeRangeLabel").which.is.an.instanceOf(blessed.text);
55 | expect(gotoTimeView).to.have.property("textBox").which.is.an.instanceOf(blessed.textbox);
56 | expect(gotoTimeView).to.have.property("errorText").which.is.an.instanceOf(blessed.text);
57 | expect(gotoTimeView).to.have.property("acceptButton").which.is.an.instanceOf(blessed.button);
58 | expect(gotoTimeView).to.have.property("cancelButton").which.is.an.instanceOf(blessed.button);
59 | });
60 | });
61 |
62 | describe("screen_onMetrics", () => {
63 | it("updates the time range label", () => {
64 | // to make the screen act like a real event emitter, set stubEvents to false
65 | // and create a new testContainer
66 | createTestContainer(false);
67 |
68 | const gotoTimeView = new GotoTimeView(options);
69 | const spyGetTimeRangeLabel = sandbox.spy(gotoTimeView, "getTimeRangeLabel");
70 |
71 | gotoTimeView.screen.emit("metrics");
72 |
73 | expect(spyGetTimeRangeLabel).to.have.been.calledOnce;
74 |
75 | expect(gotoTimeView.timeRangeLabel.setContent)
76 | .to.have.been.calledWithExactly(spyGetTimeRangeLabel.returnValues[0]);
77 | });
78 | });
79 |
80 | describe("node_onShow", () => {
81 | it("saves focus and pops up the dialog", () => {
82 | const gotoTimeView = new GotoTimeView(options);
83 | const spyTextBoxFocus = sandbox.spy(gotoTimeView.textBox, "focus");
84 |
85 | gotoTimeView.node.emit("show");
86 |
87 | expect(gotoTimeView.screen.saveFocus).to.have.been.calledOnce;
88 | expect(gotoTimeView.node.setFront).to.have.been.calledOnce;
89 | expect(gotoTimeView.form.reset).to.have.been.calledOnce;
90 | expect(spyTextBoxFocus).to.have.been.calledOnce;
91 | });
92 | });
93 |
94 | describe("form_onReset", () => {
95 | it("hides any error text being shown", () => {
96 | const gotoTimeView = new GotoTimeView(options);
97 | gotoTimeView.form.emit("reset");
98 |
99 | expect(gotoTimeView.errorText.hide).to.have.been.calledOnce;
100 | });
101 | });
102 |
103 | describe("textBox_onKey_enter", () => {
104 | it("presses the accept button programmatically", () => {
105 | // to make the screen act like a real event emitter, set stubEvents to false
106 | // and create a new testContainer
107 | createTestContainer(false);
108 |
109 | const gotoTimeView = new GotoTimeView(options);
110 |
111 | gotoTimeView.textBox.focus();
112 | gotoTimeView.screen.program.emit("keypress", "enter", { full: "enter" });
113 |
114 | expect(gotoTimeView.acceptButton.press).to.have.been.calledOnce;
115 | });
116 | });
117 |
118 | describe("textBox_onKey_escape", () => {
119 | it("presses the cancel button programmatically", () => {
120 | // to make the screen act like a real event emitter, set stubEvents to false
121 | // and create a new testContainer
122 | createTestContainer(false);
123 |
124 | const gotoTimeView = new GotoTimeView(options);
125 |
126 | gotoTimeView.textBox.focus();
127 | gotoTimeView.screen.program.emit("keypress", "escape", { full: "escape" });
128 |
129 | expect(gotoTimeView.cancelButton.press).to.have.been.calledOnce;
130 | });
131 | });
132 |
133 | describe("acceptButton_onKey_escape", () => {
134 | it("presses the cancel button programmatically", () => {
135 | // to make the screen act like a real event emitter, set stubEvents to false
136 | // and create a new testContainer
137 | createTestContainer(false);
138 |
139 | const gotoTimeView = new GotoTimeView(options);
140 |
141 | gotoTimeView.acceptButton.focus();
142 | gotoTimeView.screen.program.emit("keypress", "escape", { full: "escape" });
143 |
144 | expect(gotoTimeView.cancelButton.press).to.have.been.calledOnce;
145 | });
146 | });
147 |
148 | describe("cancelButton_onKey_escape", () => {
149 | it("presses itself programmatically", () => {
150 | // to make the screen act like a real event emitter, set stubEvents to false
151 | // and create a new testContainer
152 | createTestContainer(false);
153 |
154 | const gotoTimeView = new GotoTimeView(options);
155 |
156 | gotoTimeView.cancelButton.focus();
157 | gotoTimeView.screen.program.emit("keypress", "escape", { full: "escape" });
158 |
159 | expect(gotoTimeView.cancelButton.press).to.have.been.calledOnce;
160 | });
161 | });
162 |
163 | describe("acceptButton_onPress", () => {
164 | it("submits the form", () => {
165 | const gotoTimeView = new GotoTimeView(options);
166 | gotoTimeView.acceptButton.emit("press");
167 |
168 | expect(gotoTimeView.form.submit).to.have.been.calledOnce;
169 | });
170 | });
171 |
172 | describe("cancelButton_onPress", () => {
173 | it("cancels the form", () => {
174 | const gotoTimeView = new GotoTimeView(options);
175 | gotoTimeView.cancelButton.emit("press");
176 |
177 | expect(gotoTimeView.form.cancel).to.have.been.calledOnce;
178 | });
179 | });
180 |
181 | describe("form_onSubmit", () => {
182 | it("validates data received and hides when valid", () => {
183 | const mockData = {
184 | textBox: ""
185 | };
186 | const mockValidatedData = "";
187 |
188 | const stubValidate
189 | = sandbox.stub(options.metricsProvider, "validateTimeLabel").returns(mockValidatedData);
190 |
191 | const gotoTimeView = new GotoTimeView(options);
192 | const spyValidate = sandbox.spy(gotoTimeView, "validate");
193 | const spyHide = sandbox.spy(gotoTimeView, "hide");
194 |
195 | gotoTimeView.form.emit("submit", mockData);
196 |
197 | expect(stubValidate)
198 | .to.have.been.calledOnce
199 | .and.to.have.been.calledWithExactly(mockData.textBox);
200 |
201 | expect(spyValidate)
202 | .to.have.been.calledOnce
203 | .and.to.have.been.calledWithExactly(mockData)
204 | .and.to.have.returned(mockValidatedData);
205 |
206 | expect(spyHide).to.have.been.calledOnce;
207 | });
208 |
209 | it("validates data received and displays error when invalid", () => {
210 | const mockData = {
211 | textBox: ""
212 | };
213 | const mockError = new Error("Invalid");
214 |
215 | const stubValidate
216 | = sandbox.stub(options.metricsProvider, "validateTimeLabel").throws(mockError);
217 |
218 | const gotoTimeView = new GotoTimeView(options);
219 |
220 | const spyClearTimeout = sandbox.spy(clock, "clearTimeout");
221 | const spyValidate = sandbox.spy(gotoTimeView, "validate");
222 | const spyTextBoxFocus = sandbox.spy(gotoTimeView.textBox, "focus");
223 |
224 | gotoTimeView.form.emit("submit", mockData);
225 |
226 | expect(stubValidate)
227 | .to.have.been.calledOnce
228 | .and.to.have.been.calledWithExactly(mockData.textBox);
229 |
230 | expect(spyValidate)
231 | .to.have.been.calledOnce
232 | .and.to.have.been.calledWithExactly(mockData)
233 | .and.to.have.thrown(mockError);
234 |
235 | expect(gotoTimeView.errorText.setContent)
236 | .to.have.been.calledWithExactly(mockError.message);
237 |
238 | expect(gotoTimeView.errorText.show).to.have.been.calledOnce;
239 | expect(spyTextBoxFocus).to.have.been.calledOnce;
240 | expect(gotoTimeView.screen.render).to.have.been.calledOnce;
241 |
242 | // delay to cause the error text to disappear
243 | clock.tick(clock.timers[1].delay);
244 |
245 | expect(gotoTimeView.errorText.hide).to.have.been.calledOnce;
246 | expect(gotoTimeView.screen.render).to.have.been.calledTwice;
247 |
248 | // call it again to cause clearTimeout
249 | gotoTimeView.form.emit("submit", mockData);
250 |
251 | expect(spyClearTimeout).to.have.been.calledOnce;
252 | });
253 | });
254 |
255 | describe("form_onCancel", () => {
256 | it("hides the popup", () => {
257 | const gotoTimeView = new GotoTimeView(options);
258 | const spyHide = sandbox.spy(gotoTimeView, "hide");
259 |
260 | gotoTimeView.form.emit("cancel");
261 |
262 | expect(spyHide).to.have.been.calledOnce;
263 | });
264 | });
265 |
266 | describe("toggle", () => {
267 | it("toggles the visibility of the popup", () => {
268 | const gotoTimeView = new GotoTimeView(options);
269 | gotoTimeView.toggle();
270 |
271 | expect(gotoTimeView.node.toggle).to.have.been.calledOnce;
272 | });
273 | });
274 |
275 | describe("hide", () => {
276 | it("hides the popup and restores focus", () => {
277 | const gotoTimeView = new GotoTimeView(options);
278 |
279 | gotoTimeView.hide();
280 |
281 | expect(gotoTimeView.node.hide).to.have.been.calledOnce;
282 | expect(gotoTimeView.screen.restoreFocus).to.have.been.calledOnce;
283 | expect(gotoTimeView.screen.render).to.have.been.calledOnce;
284 | });
285 | });
286 |
287 | describe("isVisible", () => {
288 | it("returns the visibility of the popup", () => {
289 | const gotoTimeView = new GotoTimeView(options);
290 | gotoTimeView.toggle();
291 |
292 | expect(gotoTimeView.node.toggle).to.have.been.calledOnce;
293 | expect(gotoTimeView.isVisible()).to.equal(false);
294 | });
295 | });
296 | });
297 |
--------------------------------------------------------------------------------
/test/lib/views/help.spec.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const blessed = require("blessed");
4 | const expect = require("chai").expect;
5 | const sinon = require("sinon");
6 |
7 | const HelpView = require("../../../lib/views/help");
8 | const utils = require("../../utils");
9 |
10 | describe("HelpView", () => {
11 | let sandbox;
12 | let testContainer;
13 |
14 | before(() => {
15 | sandbox = sinon.createSandbox();
16 | });
17 |
18 | beforeEach(() => {
19 | utils.stubWidgets(sandbox);
20 | testContainer = utils.getTestContainer(sandbox);
21 | });
22 |
23 | afterEach(() => {
24 | sandbox.restore();
25 | });
26 |
27 | it("should create a box with text describing keybindings", () => {
28 | const help = new HelpView({ parent: testContainer });
29 | expect(help).to.have.property("node").that.is.an.instanceof(blessed.box);
30 | expect(help.node).to.have.nested.property("options.content").that.contains("keybindings");
31 | expect(help.node).to.have.property("hidden", true);
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/test/lib/views/memory-gauge-view.spec.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const expect = require("chai").expect;
4 | const sinon = require("sinon");
5 |
6 | const blessed = require("blessed");
7 | const contrib = require("blessed-contrib");
8 |
9 | const MemoryGaugeView = require("../../../lib/views/memory-gauge-view");
10 | const utils = require("../../utils");
11 | const MetricsProvider = require("../../../lib/providers/metrics-provider");
12 |
13 | describe("MemoryGaugeView", () => {
14 | let sandbox;
15 | let testContainer;
16 | let options;
17 |
18 | before(() => {
19 | sandbox = sinon.createSandbox();
20 | });
21 |
22 | beforeEach(() => {
23 | utils.stubWidgets(sandbox);
24 | testContainer = utils.getTestContainer(sandbox);
25 | options = {
26 | metricsProvider: new MetricsProvider(testContainer.screen),
27 | layoutConfig: {
28 | getPosition: sandbox.stub()
29 | },
30 | parent: testContainer
31 | };
32 | });
33 |
34 | afterEach(() => {
35 | testContainer.destroy();
36 | sandbox.restore();
37 | });
38 |
39 | describe("constructor", () => {
40 | it("should create a box with two gauges and listen for metrics event", () => {
41 | const append = sandbox.spy(blessed.node.prototype, "append");
42 |
43 | const memory = new MemoryGaugeView(options);
44 |
45 | expect(memory).to.have.property("node").that.is.an.instanceof(blessed.box);
46 | expect(memory.node).to.have.nested.property("options.label", " memory ");
47 | expect(append.thirdCall).to.have.been.calledOn(testContainer)
48 | .and.calledWithExactly(memory.node);
49 |
50 | expect(testContainer.screen.on).to.have.been.calledWithExactly("metrics", sinon.match.func);
51 |
52 | expect(memory).to.have.property("heapGauge").that.is.an.instanceof(contrib.gauge);
53 | expect(memory.heapGauge).to.have.nested.property("options.label", "heap");
54 | expect(append.firstCall).to.have.been.calledOn(memory.node)
55 | .and.calledWithExactly(memory.heapGauge);
56 |
57 | expect(memory).to.have.property("rssGauge").that.is.an.instanceof(contrib.gauge);
58 | expect(memory.rssGauge).to.have.nested.property("options.label", "resident");
59 | expect(append.secondCall).to.have.been.calledOn(memory.node)
60 | .and.calledWithExactly(memory.rssGauge);
61 | });
62 | });
63 |
64 | describe("onEvent", () => {
65 | it("should call update for each gauge", () => {
66 | const memory = new MemoryGaugeView(options);
67 |
68 | expect(memory).to.have.property("heapGauge").that.is.an.instanceof(contrib.gauge);
69 | expect(memory).to.have.property("rssGauge").that.is.an.instanceof(contrib.gauge);
70 |
71 | sandbox.stub(memory, "update");
72 |
73 | const mem = {
74 | heapUsed: 23,
75 | heapTotal: 39,
76 | rss: 290,
77 | systemTotal: 80010
78 | };
79 |
80 | memory.onEvent({ mem });
81 |
82 | expect(memory.update).to.have.been.calledTwice
83 | .and.to.have.been.calledWithExactly(memory.heapGauge, mem.heapUsed, mem.heapTotal)
84 | .and.to.have.been.calledWithExactly(memory.rssGauge, mem.rss, mem.systemTotal);
85 | });
86 | });
87 |
88 | describe("update", () => {
89 | it("should update label and call setPercent for rssGauge", () => {
90 | const memory = new MemoryGaugeView(options);
91 | const used = 50000;
92 | const total = 60300000;
93 | const pct = Math.floor(100 * used / total); // eslint-disable-line no-magic-numbers
94 |
95 | sandbox.stub(memory.rssGauge, "setPercent");
96 | memory.update(memory.rssGauge, used, total);
97 |
98 | expect(memory.rssGauge.setPercent).to.have.been.calledOnce.and.calledWithExactly(pct);
99 | expect(memory.heapGauge.setLabel).to.have.been.calledWithExactly("resident: 50 kB / 60.3 MB");
100 | });
101 |
102 | it("should update label and call setStack for heapGauge", () => {
103 | const memory = new MemoryGaugeView(options);
104 | const used = 500;
105 | const total = 2500;
106 |
107 | sandbox.stub(memory.heapGauge, "setStack");
108 | memory.update(memory.heapGauge, used, total);
109 |
110 | expect(memory.heapGauge.setStack).to.have.been.calledOnce
111 | .and.calledWithExactly([
112 | { percent: 20,
113 | stroke: "red" },
114 | { percent: 80,
115 | stroke: "blue" }
116 | ]);
117 |
118 | expect(memory.heapGauge.setLabel).to.have.been.calledWithExactly("heap: 500 B / 2.5 kB");
119 | });
120 | });
121 | });
122 |
--------------------------------------------------------------------------------
/test/lib/views/node-details-view.spec.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const expect = require("chai").expect;
4 | const sinon = require("sinon");
5 |
6 | const NodeDetailsView = require("../../../lib/views/node-details-view");
7 | const utils = require("../../utils");
8 |
9 | describe("NodeDetailsView", () => {
10 | let sandbox;
11 | let testContainer;
12 | let view;
13 |
14 | before(() => {
15 | sandbox = sinon.createSandbox();
16 | });
17 |
18 | beforeEach(() => {
19 | utils.stubWidgets(sandbox);
20 | testContainer = utils.getTestContainer(sandbox);
21 | view = new NodeDetailsView({
22 | layoutConfig: {
23 | getPosition: sandbox.stub()
24 | },
25 | parent: testContainer
26 | });
27 | });
28 |
29 | afterEach(() => {
30 | // Need to detach the node to clean up and not hang the tests.
31 | view.node.emit("detach");
32 | sandbox.restore();
33 | });
34 |
35 | describe("getDetails", () => {
36 | it("should include labels", () => {
37 | const details = view.getDetails();
38 | expect(details).to.be.an("array");
39 | const labels = details.map((detail) => detail.label).sort();
40 | expect(labels).to.eql([
41 | "LTS",
42 | "Uptime",
43 | "Version"
44 | ]);
45 | });
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/test/lib/views/panel.spec.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const _ = require("lodash");
4 | const expect = require("chai").expect;
5 |
6 | const Panel = require("../../../lib/views/panel");
7 |
8 | /* eslint-disable no-magic-numbers */
9 | describe("Panel", () => {
10 | const parent = {
11 | top: 0,
12 | left: 0,
13 | width: 17,
14 | height: 13
15 | };
16 |
17 | const createPanel = function (layouts) {
18 | const views = layouts.map((config) => _.merge({ type: "panel" }, config));
19 |
20 | return new Panel({
21 | layoutConfig: {
22 | view: {
23 | type: "panel",
24 | views
25 | },
26 | getPosition: _.identity
27 | },
28 | creator: _.identity
29 | });
30 | };
31 |
32 | describe("layout panel", () => {
33 | it("should create fullscreen view", () => {
34 | const layouts = createPanel([{
35 | views: [
36 | {
37 | type: "memory"
38 | }
39 | ]
40 | }]);
41 | expect(layouts.views[0]).to.have.property("getPosition").that.is.a("function");
42 | expect(layouts.views[0].getPosition(parent)).to.be.deep.equal({
43 | left: 0,
44 | top: 0,
45 | width: parent.width,
46 | height: parent.height
47 | });
48 | });
49 |
50 | it("should create exact width panel", () => {
51 | const layouts = createPanel([{
52 | position: {
53 | size: 11
54 | },
55 | views: [
56 | {
57 | type: "memory"
58 | }
59 | ]
60 | }]);
61 | expect(layouts.views[0].getPosition(parent)).to.be.deep.equal({
62 | left: 0,
63 | top: 0,
64 | width: 11,
65 | height: parent.height
66 | });
67 | });
68 |
69 | it("should create growing panels", () => {
70 | const layouts = createPanel([
71 | {
72 | position: {
73 | grow: 2
74 | },
75 | views: [
76 | {
77 | type: "memory"
78 | }
79 | ]
80 | },
81 | {
82 | position: {
83 | grow: 3
84 | },
85 | views: [
86 | {
87 | type: "memory"
88 | }
89 | ]
90 | }
91 | ]);
92 | expect(layouts.views[0].getPosition(parent)).to.be.deep.equal({
93 | left: 0,
94 | top: 0,
95 | width: 7,
96 | height: parent.height
97 | });
98 | expect(layouts.views[1].getPosition(parent)).to.be.deep.equal({
99 | left: 7,
100 | top: 0,
101 | width: 10,
102 | height: parent.height
103 | });
104 | });
105 |
106 | it("should create mixed width panels", () => {
107 | const layouts = createPanel([
108 | {
109 | position: {
110 | grow: 2
111 | },
112 | views: [
113 | {
114 | type: "memory"
115 | }
116 | ]
117 | },
118 | {
119 | position: {
120 | size: 4
121 | },
122 | views: [
123 | {
124 | type: "memory"
125 | }
126 | ]
127 | },
128 | {
129 | position: {
130 | grow: 3
131 | },
132 | views: [
133 | {
134 | type: "memory"
135 | }
136 | ]
137 | }
138 | ]);
139 | expect(layouts.views[0].getPosition(parent)).to.be.deep.equal({
140 | left: 0,
141 | top: 0,
142 | width: 6,
143 | height: parent.height
144 | });
145 | expect(layouts.views[1].getPosition(parent)).to.be.deep.equal({
146 | left: 6,
147 | top: 0,
148 | width: 4,
149 | height: parent.height
150 | });
151 | expect(layouts.views[2].getPosition(parent)).to.be.deep.equal({
152 | left: 10,
153 | top: 0,
154 | width: 7,
155 | height: parent.height
156 | });
157 | });
158 |
159 | it("should create exact height view", () => {
160 | const layouts = createPanel([
161 | {
162 | views: [
163 | {
164 | position: {
165 | size: 11
166 | },
167 | type: "memory"
168 | }
169 | ]
170 | }
171 | ]);
172 | expect(layouts.views[0].getPosition(parent)).to.be.deep.equal({
173 | left: 0,
174 | top: 0,
175 | width: parent.width,
176 | height: 11
177 | });
178 | });
179 |
180 | it("should create growing views", () => {
181 | const layouts = createPanel([
182 | {
183 | views: [
184 | {
185 | position: {
186 | grow: 2
187 | },
188 | type: "memory"
189 | },
190 | {
191 | position: {
192 | grow: 3
193 | },
194 | type: "memory"
195 | }
196 | ]
197 | }
198 | ]);
199 | expect(layouts.views[0].getPosition(parent)).to.be.deep.equal({
200 | left: 0,
201 | top: 0,
202 | width: parent.width,
203 | height: 6
204 | });
205 | expect(layouts.views[1].getPosition(parent)).to.be.deep.equal({
206 | left: 0,
207 | top: 6,
208 | width: parent.width,
209 | height: 7
210 | });
211 | });
212 |
213 | it("should create mixed height views", () => {
214 | const layouts = createPanel([
215 | {
216 | views: [
217 | {
218 | position: {
219 | grow: 2
220 | },
221 | type: "memory"
222 | },
223 | {
224 | position: {
225 | size: 4
226 | },
227 | type: "memory"
228 | },
229 | {
230 | position: {
231 | grow: 3
232 | },
233 | type: "memory"
234 | }
235 | ]
236 | }
237 | ]);
238 | expect(layouts.views[0].getPosition(parent)).to.be.deep.equal({
239 | left: 0,
240 | top: 0,
241 | width: parent.width,
242 | height: 4
243 | });
244 | expect(layouts.views[1].getPosition(parent)).to.be.deep.equal({
245 | left: 0,
246 | top: 4,
247 | width: parent.width,
248 | height: 4
249 | });
250 | expect(layouts.views[2].getPosition(parent)).to.be.deep.equal({
251 | left: 0,
252 | top: 8,
253 | width: parent.width,
254 | height: 5
255 | });
256 | });
257 | });
258 | });
259 |
--------------------------------------------------------------------------------
/test/lib/views/stream-view.spec.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const expect = require("chai").expect;
4 | const sinon = require("sinon");
5 |
6 | const blessed = require("blessed");
7 |
8 | const StreamView = require("../../../lib/views/stream-view");
9 | const utils = require("../../utils");
10 | const LogProvider = require("../../../lib/providers/log-provider");
11 |
12 | describe("StreamView", () => {
13 | let sandbox;
14 | let testContainer;
15 | let options;
16 |
17 | before(() => {
18 | sandbox = sinon.createSandbox();
19 | });
20 |
21 | beforeEach(() => {
22 | utils.stubWidgets(sandbox);
23 | testContainer = utils.getTestContainer(sandbox);
24 | options = {
25 | logProvider: new LogProvider(testContainer.screen),
26 | layoutConfig: {
27 | getPosition: sandbox.stub()
28 | },
29 | parent: testContainer
30 | };
31 | sandbox.stub(StreamView.prototype, "log");
32 | });
33 |
34 | afterEach(() => {
35 | sandbox.restore();
36 | });
37 |
38 | describe("constructor", () => {
39 | it("should require logProvider", () => {
40 | options.logProvider = undefined;
41 | expect(() => {
42 | new StreamView(options); // eslint-disable-line no-new
43 | }).to.throw("StreamView requires logProvider");
44 | });
45 |
46 | it("should create a log node and listen for given events", () => {
47 | const streamView = new StreamView(options);
48 |
49 | expect(streamView).to.have.property("node").that.is.an.instanceof(blessed.log);
50 | expect(streamView.node).to.have.nested.property("options.label", " stdout / stderr ");
51 | expect(testContainer.screen.on).to.have.been
52 | .calledWithExactly("stdout", sinon.match.func)
53 | .and.calledWithExactly("stderr", sinon.match.func);
54 | });
55 | });
56 |
57 | describe("log", () => {
58 | it("should strip trailing newline before logging data", () => {
59 | const streamView = new StreamView(options);
60 |
61 | StreamView.prototype.log.restore();
62 | sandbox.stub(streamView.node, "log");
63 | streamView.log("something\nmultiline\n");
64 | expect(streamView.node.log).to.have.been.calledOnce
65 | .and.calledWithExactly("something\nmultiline");
66 | });
67 |
68 | it("should filter logs with include", () => {
69 | StreamView.prototype.log.restore();
70 |
71 | options.layoutConfig.view = {
72 | include: "^THIS"
73 | };
74 | let streamView = new StreamView(options);
75 | sandbox.stub(streamView.node, "log");
76 |
77 | streamView.log("THIS should be included\nbut not THIS one\nor that one\n");
78 | expect(streamView.node.log).to.have.been.calledOnce
79 | .and.calledWithExactly("THIS should be included");
80 |
81 | options.layoutConfig.view = {
82 | include: "^THIS(.*)"
83 | };
84 | streamView = new StreamView(options);
85 | sandbox.stub(streamView.node, "log");
86 |
87 | streamView.log("THIS should be included\nbut not THIS one\nor that one\n");
88 | expect(streamView.node.log).to.have.been.calledOnce
89 | .and.calledWithExactly(" should be included");
90 | });
91 |
92 | it("should filter logs with exclude", () => {
93 | StreamView.prototype.log.restore();
94 |
95 | options.layoutConfig.view = {
96 | exclude: "^THIS"
97 | };
98 | const streamView = new StreamView(options);
99 | sandbox.stub(streamView.node, "log");
100 |
101 | streamView.log("THIS should be included\nbut not THIS one\nor that one\n");
102 | expect(streamView.node.log).to.have.been.calledOnce
103 | .and.calledWithExactly("but not THIS one\nor that one");
104 | });
105 | });
106 | });
107 |
--------------------------------------------------------------------------------
/test/lib/views/system-details-view.spec.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const expect = require("chai").expect;
4 | const sinon = require("sinon");
5 |
6 | const SystemDetailsView = require("../../../lib/views/system-details-view");
7 | const utils = require("../../utils");
8 |
9 | describe("SystemDetailsView", () => {
10 | let sandbox;
11 | let testContainer;
12 | let view;
13 |
14 | before(() => {
15 | sandbox = sinon.createSandbox();
16 | });
17 |
18 | beforeEach(() => {
19 | utils.stubWidgets(sandbox);
20 | testContainer = utils.getTestContainer(sandbox);
21 | view = new SystemDetailsView({
22 | layoutConfig: {
23 | getPosition: sandbox.stub()
24 | },
25 | parent: testContainer
26 | });
27 | });
28 |
29 | afterEach(() => {
30 | sandbox.restore();
31 | });
32 |
33 | describe("getDetails", () => {
34 | it("should include labels", () => {
35 | const details = view.getDetails();
36 | expect(details).to.be.an("array");
37 | const labels = details.map((detail) => detail.label).sort();
38 | expect(labels).to.eql([
39 | "Architecture",
40 | "Endianness",
41 | "Host Name",
42 | "Platform",
43 | "Release",
44 | "Total Memory",
45 | "Type"
46 | ]);
47 | });
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/test/lib/views/user-details-view.spec.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const expect = require("chai").expect;
4 | const sinon = require("sinon");
5 |
6 | const UserDetailsView = require("../../../lib/views/user-details-view");
7 | const utils = require("../../utils");
8 |
9 | describe("UserDetailsView", () => {
10 | let sandbox;
11 | let testContainer;
12 | let view;
13 |
14 | before(() => {
15 | sandbox = sinon.createSandbox();
16 | });
17 |
18 | beforeEach(() => {
19 | utils.stubWidgets(sandbox);
20 | testContainer = utils.getTestContainer(sandbox);
21 | view = new UserDetailsView({
22 | layoutConfig: {
23 | getPosition: sandbox.stub()
24 | },
25 | parent: testContainer
26 | });
27 | });
28 |
29 | afterEach(() => {
30 | sandbox.restore();
31 | });
32 |
33 | describe("getDetails", () => {
34 | it("should include labels", () => {
35 | const details = view.getDetails();
36 | expect(details).to.be.an("array");
37 | });
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/test/setup.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const chai = require("chai");
4 | const sinonChai = require("sinon-chai");
5 |
6 | chai.use(sinonChai);
7 | chai.config.includeStack = true;
8 |
--------------------------------------------------------------------------------
/test/utils.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const assert = require("assert");
4 | const blessed = require("blessed");
5 | const contrib = require("blessed-contrib");
6 | const { EventEmitter } = require("events");
7 |
8 | exports.tryCatch = function (done, func) {
9 | try {
10 | func();
11 | done();
12 | } catch (err) {
13 | done(err);
14 | }
15 | };
16 |
17 | /**
18 | * Create the test container.
19 | *
20 | * @param {Object} sandbox
21 | * The sinon sandbox in which to create the container. It is required.
22 | *
23 | * @param {Boolean} stubEvents
24 | * To keep the screen as a real EventEmitter, set this to falsey.
25 | *
26 | * @returns {Object}
27 | * The test container is returned.
28 | */
29 | exports.getTestContainer = function (sandbox, stubEvents) {
30 | assert(sandbox, "getTestContainer requires sandbox");
31 |
32 | const MockProgram = function MockProgram() {
33 | Object.assign(this, {
34 | key: blessed.program.prototype.key
35 | });
36 |
37 | EventEmitter.call(this);
38 | };
39 |
40 | MockProgram.prototype = Object.create(EventEmitter.prototype);
41 |
42 | const MockScreen = function MockScreen() {
43 | // organized by primitive, Object, stubs, alphabetically
44 | Object.assign(this, {
45 | program: new MockProgram(),
46 | type: "screen",
47 | clickable: [],
48 | keyable: [],
49 | _listenKeys: blessed.screen.prototype._listenKeys,
50 | _listenMouse: sandbox.stub(),
51 | append: sandbox.stub(),
52 | remove: sandbox.stub(),
53 | render: sandbox.stub(),
54 | restoreFocus: sandbox.stub(),
55 | rewindFocus: sandbox.stub(),
56 | saveFocus: sandbox.stub(),
57 | setEffects: sandbox.stub()
58 | });
59 |
60 | EventEmitter.call(this);
61 |
62 | if (stubEvents === undefined || stubEvents) {
63 | sandbox.stub(this, "on");
64 | }
65 | };
66 |
67 | MockScreen.prototype = Object.create(EventEmitter.prototype);
68 |
69 | const mockScreen = new MockScreen();
70 |
71 | // prevent "Error: no active screen"
72 | blessed.Screen.total = 1;
73 | blessed.Screen.global = mockScreen;
74 |
75 | const container = blessed.box({ parent: mockScreen });
76 | sandbox.stub(container, "render");
77 | return container;
78 | };
79 |
80 | // stub functions that require an active screen
81 | exports.stubWidgets = function (sandbox) {
82 | assert(sandbox, "stubWidgets requires sandbox");
83 |
84 | sandbox.stub(blessed.element.prototype, "_getHeight");
85 | sandbox.stub(blessed.element.prototype, "hide");
86 | sandbox.stub(blessed.element.prototype, "setContent");
87 | sandbox.stub(blessed.element.prototype, "setFront");
88 | sandbox.stub(blessed.element.prototype, "setLabel");
89 | sandbox.stub(blessed.element.prototype, "show");
90 | sandbox.stub(blessed.element.prototype, "toggle");
91 | sandbox.stub(blessed.scrollablebox.prototype, "getScrollHeight");
92 | sandbox.stub(blessed.form.prototype, "cancel");
93 | sandbox.stub(blessed.form.prototype, "reset");
94 | sandbox.stub(blessed.form.prototype, "submit");
95 | sandbox.stub(blessed.button.prototype, "press");
96 | sandbox.stub(contrib.gauge.prototype, "setData");
97 | sandbox.stub(contrib.line.prototype, "setData");
98 | };
99 |
--------------------------------------------------------------------------------