diff --git a/services/dashboard/src/dashboard.js b/services/dashboard/src/dashboard.js index 75fbe837..dac2a300 100644 --- a/services/dashboard/src/dashboard.js +++ b/services/dashboard/src/dashboard.js @@ -5,17 +5,14 @@ * somewhat-graphical terminal dashboard display. */ -import async from 'async'; import blessed from 'blessed'; import contrib from 'blessed-contrib'; import chalk from 'chalk'; import _ from 'lodash'; -import request from 'request'; import { sprintf } from 'sprintf-js'; -import { stats } from './messages'; - -import { parseMessage } from './util'; +import DataRetriever from './data-retriever'; +import { sparkline, sparklineHeading } from './sparkline'; // The main screen object. let screen = blessed.screen(); @@ -23,6 +20,8 @@ let screen = blessed.screen(); // The screen is set up in a 12x12 grid. let grid = new contrib.grid({ rows: 12, cols: 12, screen: screen }); +let dataRetriever = new DataRetriever(); + // The ping table shows the output from the pong service. This // differs from the built-in table in that we can specify a more // custom header. @@ -71,127 +70,50 @@ function setPingTableData(data) { // Initially, we show that there isn't any ping data. setPingTableData([]); -// Keep making requests to the pong service to get the current ping -// times. -async.forever((next) => { - request.get({ - uri: 'http://' + process.env.PONG_URL + '/api/ping', - encoding: null, - timeout: 2000, - }, (err, resp) => { - // Parse the message as the PingTimes protobuf. - let message = parseMessage(err, resp, stats.PingTimes); - let data = []; - - // If we have a message (i.e. no error), then take each - // listing and put it in a 5 element array corresponding to - // the ping time headers, with the first being if the service - // is online or not. This is sorted first by increasing - // hostname, and then by increasing port number. - if (message !== null) { - data = message.list.sort((a, b) => - a.host !== b.host ? a.host > b.host : a.port > b.port - ).map(time => [ - time.online, time.name, time.host, time.port, time.ms - ]); - } - - setPingTableData(data); - screen.render(); - - setTimeout(next, 1000); - }); +dataRetriever.on('ping-times', (data) => { + setPingTableData(data); + screen.render(); }); -// The telemetry upload data graphs the stats from forward interop. +// The telemetry upload graph has the stats from forward interop. // This is the rate at which telemetry is uploaded (over 1 and 5 // seconds), and the rate at which new telemetry is being uploaded as // well, which excludes any stale or duplicated data that we're // sending. -let telemUploadGraph = grid.set(0, 0, 6, 12, blessed.box, { +let telemUploadGraph = grid.set(0, 0, 6, 8, blessed.box, { label: 'Telemetry Uplink' }); function setTelemUploadGraphData(data, available = true) { - // Makes the bar that goes above the sparkline. - function stylizeRate(desc, rateList, width) { - desc = chalk.bold.green(desc); - - // The rate is simply the last element in the list or n/a if - // we don't have any current rate. - if (available) { - let rate = rateList[rateList.length - 1] || 0.0; - let rateStr = sprintf(`%${width - 13}.1f Hz`, rate); - - // We'll say that a rate of 3.0 seconds is good, but 1.0 - // is the bare minimum. - if (rate >= 3.0) { - return desc + chalk.green(rateStr); - } else if (rate >= 1.0) { - return desc + chalk.yellow(rateStr); - } else { - return desc + chalk.bold.red(rateStr); - } - } else { - return desc + chalk.bold.red(sprintf(`%${width - 10}s`, '(n/a)')); - } - } - - // Makes a sparkline in the same manner as shiwano/sparkline on - // github. Also colors them with the same rules for displaying - // the rate. - function makeSparkline(rateList, width) { - let str = ' '; - - for (let i = rateList.length - width; i < rateList.length; i++) { - // If there's not enough points, just add spaces. - if (i < 0) { - str += ' '; - continue; - } - - let rate = rateList[i]; - - // FIXME: This is a ton of escape codes that could - // ideally be grouped together by color. - - if (rate >= 5.0) { - str += chalk.cyan('\u2587'); - } else if (rate >= 4.0) { - str += chalk.cyan('\u2586'); - } else if (rate >= 3.0) { - str += chalk.cyan('\u2585'); - } else if (rate >= 2.0) { - str += chalk.yellow('\u2584'); - } else if (rate >= 1.0) { - str += chalk.yellow('\u2583'); - } else if (rate > 0.0) { - str += chalk.bold.red('\u2582'); - } else { - str += chalk.bold.red('\u2581'); - } - } - - return str; - } - // The graph box is a 2x2 sparkline display with total telemetry // on the left and the fresh on the right. let leftWidth = Math.floor((telemUploadGraph.width - 8) / 2); let rightWidth = telemUploadGraph.width - 8 - leftWidth; + let levels = [5.0, 4.0, 3.0, 2.0, 1.0, 0.001]; + let low = 3.0; + let critical = 1.0; + let leftStrs = [ - stylizeRate('Total (1s):', data[0], leftWidth), - makeSparkline(data[0], leftWidth), - stylizeRate('Total (5s):', data[1], leftWidth), - makeSparkline(data[1], leftWidth) + sparklineHeading( + 'Total (1s):', 'Hz', data[0], available, low, critical, leftWidth + ), + ' ' + sparkline(data[0], levels, low, critical, leftWidth), + sparklineHeading( + 'Total (5s):', 'Hz', data[1], available, low, critical, leftWidth + ), + ' ' + sparkline(data[1], levels, low, critical, leftWidth) ]; let rightStrs = [ - stylizeRate('Fresh (1s):', data[2], rightWidth), - makeSparkline(data[2], rightWidth ), - stylizeRate('Fresh (5s):', data[3], rightWidth), - makeSparkline(data[3], rightWidth), + sparklineHeading( + 'Fresh (1s):', 'Hz', data[2], available, low, critical, rightWidth + ), + ' ' + sparkline(data[2], levels, low, critical, rightWidth), + sparklineHeading( + 'Fresh (5s):', 'Hz', data[3], available, low, critical, rightWidth + ), + ' ' + sparkline(data[3], levels, low, critical, rightWidth) ]; // Take the left and right string arrays, format them to be to @@ -205,49 +127,86 @@ function setTelemUploadGraphData(data, available = true) { } let telemUploadData = [[], [], [], []]; -let telemUploadAvailable = false; // At the beginning we'll mark the telem upload as being not // available. -setTelemUploadGraphData(telemUploadData, telemUploadAvailable); - -// Keep making requests to the pong service to get the current ping -// times. -async.forever((next) => { - request.get({ - uri: 'http://' + process.env.FORWARD_INTEROP_URL + '/api/upload-rate', - encoding: null, - timeout: 2000, - }, (err, resp) => { - // Parse the message as the InteropUploadRate protobuf. - let message = parseMessage(err, resp, stats.InteropUploadRate); - let data = []; - - // If we have a message (i.e. no error) then add the latest - // rates to the current lists, (and trim off the first one if - // if we already have 40 points). - let available = message !== null; - - if (available) { - telemUploadData[0].push(message.total_1); - telemUploadData[1].push(message.total_5); - telemUploadData[2].push(message.fresh_1); - telemUploadData[3].push(message.fresh_5); - } else { - telemUploadData[0].push(0.0); - telemUploadData[1].push(0.0); - telemUploadData[2].push(0.0); - telemUploadData[3].push(0.0); - } - - if (telemUploadData[0].length === 40) - telemUploadData.map(list => list.shift()); - - setTelemUploadGraphData(telemUploadData, available); - screen.render(); - - setTimeout(next, 1000); - }); +setTelemUploadGraphData(telemUploadData, false); + +dataRetriever.on('telem-upload-rate', (data) => { + // Add the latest rates to the current lists, and trim off the + // first one if if we already have 40 points. + for (let i = 0; i < 4; i++) + telemUploadData[i].push(data.rates[i]); + + if (telemUploadData[0].length === 40) + telemUploadData.map(list => list.shift()); + + setTelemUploadGraphData(telemUploadData, data.available); + screen.render(); +}); + +let imageCapGraph = grid.set(0, 8, 6, 4, blessed.box, { + label: 'Image Capture Rate' +}); + +function setImageCapGraphData(data, available = [true, true]) { + // The graph box is a 1x2 sparkline display with the plane image + // rate on top and the ground image rate on the bottom. + let width = imageCapGraph.width - 5; + + let levels = [1.0, 0.8, 0.6, 0.4, 0.2, 0.001]; + let low = 0.4; + let critical = 0.2; + + let strs = [ + sparklineHeading( + 'Plane (5s):', 'Hz', data[0], available[0], low, critical, width + ), + ' ' + sparkline(data[0], levels, low, critical, width), + sparklineHeading( + 'Ground (5s):', 'Hz', data[1], available[1], low, critical, width + ), + ' ' + sparkline(data[1], levels, low, critical, width) + ]; + + imageCapGraph.setContent( + '\n' + strs.map(str => ' ' + str + ' ').join('\n\n') + ); +} + +let imageCapData = [[], []]; +let imageCapAvailable = [false, false]; + +// At the beginning we'll mark the image capture rate as being not +// available. +setImageCapGraphData(telemUploadData, imageCapAvailable); + +dataRetriever.on('image-cap-rate-plane', (data) => { + // Add the latest rate to the plane list, and trim off the first + // one if if we already have 40 points. + imageCapData[0].push(data.rate); + + if (imageCapData[0].length === 40) + imageCapData.map(list => list.shift()); + + imageCapAvailable = [data.available, imageCapAvailable[1]]; + + setImageCapGraphData(imageCapData, imageCapAvailable); + screen.render(); +}); + +dataRetriever.on('image-cap-rate-ground', (data) => { + // Add the latest rate to the ground list, and trim off the first + // one if if we already have 40 points. + imageCapData[1].push(data.rate); + + if (imageCapData[1].length === 40) + imageCapData.map(list => list.shift()); + + imageCapAvailable = [imageCapAvailable[0], data.available]; + + setImageCapGraphData(imageCapData, imageCapAvailable); + screen.render(); }); // Allow escape, q, and Ctrl+C to exit the process. Note this is only diff --git a/services/dashboard/src/data-retriever.js b/services/dashboard/src/data-retriever.js new file mode 100644 index 00000000..a802ae90 --- /dev/null +++ b/services/dashboard/src/data-retriever.js @@ -0,0 +1,145 @@ +import EventEmitter from 'events'; + +import async from 'async'; +import request from 'request'; + +import { stats } from './messages'; + +/** + * Starts workers to poll data from different services. + */ +export default class DataRetriever extends EventEmitter { + constructor() { + super(); + + this._startPingTimesWorker(); + this._startTelemUploadRateWorker(); + this._startImageCapRateWorker(); + } + + _startPingTimesWorker() { + async.forever((next) => { + _getPingTimes((data) => { + this.emit('ping-times', data); + setTimeout(next, 1000); + }); + }); + } + + _startTelemUploadRateWorker() { + async.forever((next) => { + _getTelemUploadRate((data) => { + this.emit('telem-upload-rate', data); + setTimeout(next, 1000); + }); + }); + } + + _startImageCapRateWorker() { + async.forever((next) => { + _getImageCapRate('plane', (data) => { + this.emit('image-cap-rate-plane', data); + setTimeout(next, 1000); + }); + }); + + async.forever((next) => { + _getImageCapRate('ground', (data) => { + this.emit('image-cap-rate-ground', data); + setTimeout(next, 1000); + }); + }); + } +} + +/** Convert a request response to a protobuf message object. */ +function _parseMessage(err, resp, message) { + // If we don't have a 2xx response code, return null. + if (err || !(/^2/.test('' + resp.statusCode))) { + return null; + } else { + return message.decode(resp.body); + } +} + +function _getPingTimes(cb) { + request.get({ + uri: 'http://' + process.env.PONG_URL + '/api/ping', + encoding: null, + timeout: 2000, + }, (err, resp) => { + // Parse the message as the PingTimes protobuf. + let message = _parseMessage(err, resp, PingTimes); + let data = []; + + // If we have a message (i.e. no error), then take each + // listing and put it in a 5 element array corresponding to + // the ping time headers, with the first being if the service + // is online or not. This is sorted first by increasing + // hostname, and then by increasing port number. + if (message !== null) { + data = message.list.sort((a, b) => + a.host !== b.host ? a.host > b.host : a.port > b.port + ).map(time => [ + time.online, time.name, time.host, time.port, time.ms + ]); + } + + cb(data); + }); +} + +function _getTelemUploadRate(cb) { + request.get({ + uri: 'http://' + process.env.FORWARD_INTEROP_URL + '/api/upload-rate', + encoding: null, + timeout: 2000, + }, (err, resp) => { + // Parse the message as the InteropUploadRate protobuf. + let message = _parseMessage(err, resp, stats.InteropUploadRate); + + let available = message !== null; + let rates; + + if (available) { + rates = [ + message.total_1, message.total_5, + message.fresh_1, message.fresh_5 + ]; + } else { + rates = [0.0, 0.0, 0.0, 0.0]; + } + + cb({ rates, available }); + }); +} + +function _getImageCapRate(source, cb) { + let url; + + if (source === 'plane') { + url = process.env.PLANE_IMAGERY_URL; + } else if (source === 'ground') { + url = process.env.GROUND_IMAGERY_URL; + } + + request.get({ + uri: 'http://' + url + '/api/capture-rate', + encoding: null, + timeout: 2000, + }, (err, resp) => { + // Parse the message as the ImageCaptureRate protobuf. + let message = _parseMessage(err, resp, stats.ImageCaptureRate); + + let available = message !== null; + let rate; + + if (available) { + rate = message.rate_5; + } else { + rate = [0.0]; + } + + cb({ rate, available }); + }); +} diff --git a/services/dashboard/src/sparkline.js b/services/dashboard/src/sparkline.js new file mode 100644 index 00000000..7344ea86 --- /dev/null +++ b/services/dashboard/src/sparkline.js @@ -0,0 +1,78 @@ +import chalk from 'chalk'; +import { sprintf } from 'sprintf-js'; + +// Makes a sparkline in the same manner as shiwano/sparkline on +// github. Also colors them with the same rules for displaying the +// rate. +export function sparkline(data, levels, low, critical, width) { + let str = ''; + + for (let i = data.length - width; i < data.length; i++) { + // If there's not enough points, just add spaces. + if (i < 0) { + str += ' '; + continue; + } + + let rate = data[i]; + + let char; + let color; + + if (rate >= levels[0]) { + char = '\u2587'; + } else if (rate >= levels[1]) { + char = '\u2586'; + } else if (rate >= levels[2]) { + char = '\u2585'; + } else if (rate >= levels[3]) { + char = '\u2584'; + } else if (rate >= levels[4]) { + char = '\u2583'; + } else if (rate >= levels[5]) { + char = '\u2582'; + } else { + char = '\u2581'; + } + + if (rate >= low) { + color = chalk.cyan; + } else if (rate >= critical) { + color = chalk.yellow; + } else { + color = chalk.bold.red; + } + + str += color(char); + } + + return str; +} + +// Makes the heading that goes above a sparkline. +export function sparklineHeading(desc, unit, data, available, low, critical, + width) { + let left = chalk.bold.green(desc); + let right; + + // The right is simply the last element in the list or n/a if we + // don't have any current rate. + if (available) { + let rate = data[data.length - 1] || 0.0; + let rightLen = width - desc.length - unit.length - 1; + let rightStr = sprintf(` %${rightLen}.1f ${unit}`, rate); + + if (rate >= low) { + right = chalk.green(rightStr); + } else if (rate >= critical) { + right = chalk.yellow(rightStr); + } else { + right = chalk.bold.red(rightStr); + } + } else { + let rightLen = width - desc.length; + right = chalk.bold.red(sprintf(` %${rightLen}s`, '(n/a)')); + } + + return left + right; +} diff --git a/services/dashboard/src/util.js b/services/dashboard/src/util.js deleted file mode 100644 index eaa9d917..00000000 --- a/services/dashboard/src/util.js +++ /dev/null @@ -1,10 +0,0 @@ -/** Convert a request response to a protobuf message object. */ -export function parseMessage(err, resp, message) { - // If we don't have a 2xx response code, return null. - if (err || !(/^2/.test('' + resp.statusCode))) { - return null; - } else { - return message.decode(resp.body); - } -} - diff --git a/test/docker-compose.yml b/test/docker-compose.yml index 18a8a636..2e2c4cfa 100644 --- a/test/docker-compose.yml +++ b/test/docker-compose.yml @@ -61,7 +61,7 @@ services: interop-proxy,172.16.238.13:8000 pong,172.16.238.14:7000 forward-interop,172.16.238.15:4000 - imagery,172.16.238.50:8081 + plane-imagery,172.16.238.50:8081 image-rec-master,172.16.238.52:8082 - PING_DEVICES=localhost-pong,127.0.0.1 ports: @@ -88,6 +88,7 @@ services: - INTEROP_PROXY_URL=172.16.238.13:8000 - FORWARD_INTEROP_URL=172.16.238.15:4000 - PONG_URL=172.16.238.14:7000 + - PLANE_IMAGERY_URL=172.16.238.50:8081 networks: test_net: ipv4_address: 172.16.238.16