├── .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 | ![image](https://cloud.githubusercontent.com/assets/790659/23140845/e4bb1d52-f7c4-11e6-80b8-e456d9cd5628.png) 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 | [![Build Status](https://travis-ci.com/FormidableLabs/nodejs-dashboard.svg?branch=master)](https://travis-ci.com/FormidableLabs/nodejs-dashboard) 8 | 9 | ![http://g.recordit.co/WlUvKhXqnp.gif](http://g.recordit.co/WlUvKhXqnp.gif) 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 | --------------------------------------------------------------------------------