├── .gitignore ├── Cakefile ├── bin └── terphite ├── index.js ├── lib ├── composer.js └── helpers.js ├── package.json ├── readme.md └── src ├── composer.coffee └── helpers.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /Cakefile: -------------------------------------------------------------------------------- 1 | {print} = require 'util' 2 | {spawn, exec} = require 'child_process' 3 | 4 | build = (watch, callback) -> 5 | if typeof watch is 'function' 6 | callback = watch 7 | watch = false 8 | options = ['-c', '-o', 'lib', 'src'] 9 | options.unshift '-w' if watch 10 | 11 | coffee = spawn 'node_modules/.bin/coffee', options 12 | coffee.stdout.on 'data', (data) -> print data.toString() 13 | coffee.stderr.on 'data', (data) -> print data.toString() 14 | coffee.on 'exit', (status) -> callback?() if status is 0 15 | 16 | 17 | task 'build', 'Compile CoffeeScript source files', -> 18 | build() 19 | 20 | task 'watch', 'Recompile CoffeeScript source files when modified', -> 21 | build true 22 | 23 | -------------------------------------------------------------------------------- /bin/terphite: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../'); 3 | 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var process = require('process'); 2 | var path = require('path'); 3 | var Terphite = require("./lib/composer.js"); 4 | 5 | var node_binary = process.argv.shift(); 6 | var script_name = path.basename(process.argv.shift()); 7 | 8 | if (process.argv.length < 1) { 9 | console.error("Usage: " + script_name + " https://user:pass@yourgraphite.net:4321"); 10 | process.exit(1); 11 | } 12 | 13 | var graphite_uri = process.argv.shift(); 14 | 15 | t = new Terphite(graphite_uri); 16 | t.composer(); 17 | -------------------------------------------------------------------------------- /lib/composer.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.10.0 2 | (function() { 3 | var Terphite, _, blessed, contrib, k, path, ref, request, v, 4 | indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; 5 | 6 | blessed = require('blessed'); 7 | 8 | contrib = require('blessed-contrib'); 9 | 10 | request = require('request'); 11 | 12 | path = require('path'); 13 | 14 | _ = require('lodash'); 15 | 16 | ref = require('./helpers'); 17 | for (k in ref) { 18 | v = ref[k]; 19 | global[k] = v; 20 | } 21 | 22 | module.exports = Terphite = (function() { 23 | function Terphite(graphite_uri1) { 24 | this.graphite_uri = graphite_uri1; 25 | } 26 | 27 | Terphite.prototype.composer = function() { 28 | var autorefresh, autorefresh_loop, autorefresh_time, fetchMetricData, functions_tree, graphite_uri, help, help_content, layout, line, loadMetricsTree, max_data_points, metricURI, metrics_popup, screen, selected_metrics, setStatus, status, statusString, target_popup, time_from, time_popup, toggleAutorefresh, tree; 29 | selected_metrics = []; 30 | time_from = '-1min'; 31 | autorefresh_time = 10; 32 | max_data_points = 300; 33 | graphite_uri = this.graphite_uri; 34 | autorefresh = 0; 35 | screen = blessed.screen({ 36 | smartCSR: true, 37 | debug: true, 38 | title: 'Graphite Browser', 39 | warnings: true, 40 | dockBorders: true, 41 | ignoreDockContrast: true 42 | }); 43 | layout = blessed.layout({ 44 | parent: screen, 45 | width: '100%', 46 | height: '100%', 47 | border: 'line', 48 | layout: 'grid', 49 | style: { 50 | bg: 'black', 51 | border: { 52 | fg: 'blue' 53 | } 54 | } 55 | }); 56 | tree = contrib.tree({ 57 | parent: layout, 58 | top: 0, 59 | left: 0, 60 | padding: 1, 61 | width: '25%+1', 62 | height: '80%-2', 63 | template: { 64 | lines: true 65 | } 66 | }); 67 | functions_tree = contrib.tree({ 68 | parent: screen, 69 | top: 'center', 70 | left: 'center', 71 | padding: 1, 72 | width: '80%', 73 | height: '50%', 74 | hidden: true, 75 | template: { 76 | lines: true 77 | } 78 | }); 79 | statusString = function() { 80 | return ["from: " + time_from, "autorefresh: " + (!autorefresh ? 'off' : autorefresh + 's'), "maxdatapoints: " + (!max_data_points ? 'unlimited' : max_data_points)].join(' '); 81 | }; 82 | setStatus = function() { 83 | return status.setContent(statusString()); 84 | }; 85 | status = blessed.box({ 86 | parent: layout, 87 | top: 0, 88 | left: 0, 89 | height: 3, 90 | width: '100%', 91 | content: statusString(), 92 | border: { 93 | type: 'line', 94 | fg: 'blue' 95 | } 96 | }); 97 | screen.append(status); 98 | help_content = ' [ decrease time 1min, { decrease time 1s\n'; 99 | help_content += ' ] increase time 1min, } increase time 1s\n'; 100 | help_content += ' t set relative "from" time, m metrics list popup, i set autorefresh interval\n'; 101 | help_content += ' a autorefresh toggle, x set max datapoints\n'; 102 | help_content += ' o open in browser, c copy graphite URI to clipboard (iTerm2 only)\n'; 103 | help_content += 'Metrics List Keys:\n'; 104 | help_content += ' C-a append new target, C-d delete selected, edit selected\n'; 105 | help = blessed.box({ 106 | parent: layout, 107 | top: '80%-1', 108 | left: 0, 109 | height: '20%+2', 110 | content: help_content, 111 | border: { 112 | type: 'line', 113 | fg: 'blue' 114 | } 115 | }); 116 | screen.append(help); 117 | line = contrib.line({ 118 | parent: layout, 119 | left: '25%', 120 | top: 2, 121 | height: '80%-2', 122 | width: '75%+1', 123 | showLegend: true, 124 | legend: { 125 | width: 60 126 | }, 127 | border: { 128 | type: 'line', 129 | fg: 'blue' 130 | } 131 | }); 132 | screen.append(line); 133 | time_popup = blessed.prompt({ 134 | parent: layout, 135 | left: 'center', 136 | top: 'center', 137 | width: '80%', 138 | height: 8, 139 | keys: true, 140 | mouse: true, 141 | style: { 142 | fg: 'blue' 143 | }, 144 | border: { 145 | type: 'line', 146 | fg: 'gray' 147 | } 148 | }); 149 | screen.append(time_popup); 150 | metrics_popup = blessed.list({ 151 | parent: layout, 152 | hidden: true, 153 | left: 'center', 154 | top: 'center', 155 | width: '90%', 156 | height: 'half', 157 | padding: 1, 158 | interactive: true, 159 | items: selected_metrics, 160 | mouse: true, 161 | keys: true, 162 | tags: true, 163 | style: { 164 | bg: 'blue' 165 | }, 166 | border: { 167 | type: 'line', 168 | fg: 'gray' 169 | } 170 | }); 171 | screen.append(metrics_popup); 172 | target_popup = blessed.prompt({ 173 | parent: layout, 174 | left: 'center', 175 | top: 'center', 176 | width: '80%', 177 | height: 8, 178 | mouse: true, 179 | keys: true, 180 | style: { 181 | fg: 'blue' 182 | }, 183 | border: { 184 | type: 'line', 185 | fg: 'gray' 186 | } 187 | }); 188 | screen.append(target_popup); 189 | loadMetricsTree = function() { 190 | var createObject, options; 191 | options = { 192 | uri: graphite_uri + "/metrics/index.json", 193 | json: true 194 | }; 195 | request(options, function(err, resp, body) { 196 | var j, len, metric, metrics, obj; 197 | metrics = {}; 198 | for (j = 0, len = body.length; j < len; j++) { 199 | metric = body[j]; 200 | obj = createObject(metric); 201 | _.merge(metrics, obj); 202 | } 203 | tree.setData({ 204 | extended: true, 205 | name: 'metrics', 206 | path: 'metrics', 207 | children: metrics 208 | }); 209 | return screen.render(); 210 | }); 211 | return createObject = function(key, original_key) { 212 | var obj, original_parts, parts, remainingParts; 213 | if (original_key == null) { 214 | original_key = ''; 215 | } 216 | obj = {}; 217 | parts = key.split('.'); 218 | if (original_key === '') { 219 | original_key = key; 220 | } 221 | original_parts = original_key.split('.'); 222 | path = original_parts.slice(0, +(original_parts.length - parts.length) + 1 || 9e9).join('.'); 223 | if (parts.length === 1) { 224 | obj[parts[0]] = { 225 | name: parts[0], 226 | extended: false, 227 | path: path, 228 | leaf: true 229 | }; 230 | } else if (parts.length > 1) { 231 | remainingParts = parts.slice(1, parts.length).join('.'); 232 | obj[parts[0]] = { 233 | name: parts[0], 234 | extended: false, 235 | path: path, 236 | leaf: false, 237 | children: createObject(remainingParts, original_key) 238 | }; 239 | } 240 | return obj; 241 | }; 242 | }; 243 | metricURI = function(metrics, from, params, opts) { 244 | var extra, maxdp, target; 245 | if (params == null) { 246 | params = {}; 247 | } 248 | if (opts == null) { 249 | opts = { 250 | format: 'json' 251 | }; 252 | } 253 | extra = ''; 254 | if (opts.format === 'json') { 255 | extra += '&format=json'; 256 | } 257 | maxdp = parseInt(opts.maxDataPoints, 10); 258 | if (maxdp > 0) { 259 | extra += "&maxDataPoints=" + maxdp; 260 | } 261 | target = ''; 262 | if (metrics.length) { 263 | target += '&target=' + metrics.join('&target='); 264 | } 265 | return graphite_uri + "/render?from=" + from + target + extra; 266 | }; 267 | fetchMetricData = function(metrics, from) { 268 | var options; 269 | options = { 270 | uri: metricURI(metrics, from, {}, { 271 | format: 'json', 272 | maxDataPoints: max_data_points 273 | }), 274 | json: true 275 | }; 276 | return request(options, function(err, resp, body) { 277 | var i, point, series, target, ts; 278 | series = (function() { 279 | var j, len, results; 280 | results = []; 281 | for (i = j = 0, len = body.length; j < len; i = ++j) { 282 | target = body[i]; 283 | results.push({ 284 | title: target.target || '[unnamed_target]', 285 | y: (function() { 286 | var l, len1, ref1, results1; 287 | ref1 = target.datapoints; 288 | results1 = []; 289 | for (l = 0, len1 = ref1.length; l < len1; l++) { 290 | point = ref1[l]; 291 | results1.push(point[0]); 292 | } 293 | return results1; 294 | })(), 295 | x: (function() { 296 | var l, len1, ref1, results1; 297 | ref1 = target.datapoints; 298 | results1 = []; 299 | for (l = 0, len1 = ref1.length; l < len1; l++) { 300 | point = ref1[l]; 301 | ts = new Date(point[1] * 1000); 302 | results1.push((ts.getHours()) + ":" + (format_twodigit(ts.getUTCMinutes()))); 303 | } 304 | return results1; 305 | })(), 306 | style: { 307 | line: colors[i % 15] 308 | } 309 | }); 310 | } 311 | return results; 312 | })(); 313 | if (series.length === 0) { 314 | series = [ 315 | { 316 | title: 'no data', 317 | x: [], 318 | y: [] 319 | } 320 | ]; 321 | } 322 | line.setData(series); 323 | return screen.render(); 324 | }); 325 | }; 326 | tree.on('select', function(node) { 327 | path = node.path; 328 | if (indexOf.call(selected_metrics, path) >= 0) { 329 | selected_metrics = selected_metrics.filter(function(metric) { 330 | return metric !== path; 331 | }); 332 | } else if (node.leaf) { 333 | selected_metrics.push(path); 334 | } 335 | return fetchMetricData(selected_metrics, time_from); 336 | }); 337 | screen.key(['['], function(ch, key) { 338 | var t; 339 | t = (parse_time_to_i(time_from)) - MIN; 340 | time_from = t < MIN ? '-1min' : seconds_to_time_string(t); 341 | setStatus(); 342 | return fetchMetricData(selected_metrics, time_from); 343 | }); 344 | screen.key([']'], function(ch, key) { 345 | var t; 346 | t = (parse_time_to_i(time_from)) + MIN; 347 | time_from = seconds_to_time_string(t); 348 | setStatus(); 349 | return fetchMetricData(selected_metrics, time_from); 350 | }); 351 | screen.key(['{'], function(ch, key) { 352 | var t; 353 | t = (parse_time_to_i(time_from)) - H; 354 | time_from = t < H ? '-1h' : seconds_to_time_string(t); 355 | setStatus(); 356 | return fetchMetricData(selected_metrics, time_from); 357 | }); 358 | screen.key(['}'], function(ch, key) { 359 | var t; 360 | t = (parse_time_to_i(time_from)) + H; 361 | time_from = seconds_to_time_string(t); 362 | setStatus(); 363 | return fetchMetricData(selected_metrics, time_from); 364 | }); 365 | screen.key(['t'], function(ch, key) { 366 | return time_popup.input('set relative _from_ time (y, mon, w, d, h, min, s)\n eg: -1d12h', time_from, function(err, value) { 367 | var d, h, min, mon, ref1, s, w, y; 368 | ref1 = parse_time(value), y = ref1[0], mon = ref1[1], w = ref1[2], d = ref1[3], h = ref1[4], min = ref1[5], s = ref1[6]; 369 | time_from = get_time_string(y, mon, w, d, h, min, s); 370 | setStatus(); 371 | return fetchMetricData(selected_metrics, time_from); 372 | }); 373 | }); 374 | screen.key(['m'], function(ch, key) { 375 | metrics_popup.setItems(selected_metrics); 376 | metrics_popup.focus(); 377 | metrics_popup.show(); 378 | return screen.render(); 379 | }); 380 | metrics_popup.on('select', function(item, select) { 381 | return target_popup.input('Edit target', item.getText(), function(err, value) { 382 | selected_metrics[select] = value; 383 | metrics_popup.setItems(selected_metrics); 384 | return screen.render(); 385 | }); 386 | }); 387 | metrics_popup.key('C-d', function(ch, key) { 388 | selected_metrics.splice(metrics_popup.selected, 1); 389 | metrics_popup.setItems(selected_metrics); 390 | return fetchMetricData(selected_metrics, time_from); 391 | }); 392 | metrics_popup.key('C-a', function(ch, key) { 393 | return target_popup.input('Add target', '', function(err, value) { 394 | selected_metrics.push(value); 395 | metrics_popup.setItems(selected_metrics); 396 | return fetchMetricData(selected_metrics, time_from); 397 | }); 398 | }); 399 | metrics_popup.key(['escape'], function(ch, key) { 400 | metrics_popup.hide(); 401 | tree.focus(); 402 | return fetchMetricData(selected_metrics, time_from); 403 | }); 404 | screen.key(['c'], function(ch, key) { 405 | screen.cursorReset(); 406 | screen.copyToClipboard(metricURI(selected_metrics, time_from, {}, {})); 407 | screen.realloc(); 408 | return screen.render(); 409 | }); 410 | screen.key(['o'], function(ch, key) { 411 | return screen.exec('open', [metricURI(selected_metrics, time_from, {}, {})]); 412 | }); 413 | autorefresh_loop = null; 414 | toggleAutorefresh = function() { 415 | if (!autorefresh) { 416 | autorefresh = autorefresh_time; 417 | setStatus(); 418 | screen.render(); 419 | return autorefresh_loop = setInterval(function() { 420 | return fetchMetricData(selected_metrics, time_from); 421 | }, autorefresh * 1000); 422 | } else { 423 | autorefresh = 0; 424 | clearInterval(autorefresh_loop); 425 | setStatus(); 426 | screen.render(); 427 | return autorefresh_loop = null; 428 | } 429 | }; 430 | screen.key(['a'], function(ch, key) { 431 | return toggleAutorefresh(); 432 | }); 433 | screen.key(['i'], function(ch, key) { 434 | return time_popup.input('set autorefresh interval time (seconds)', autorefresh_time.toString(), function(e, v) { 435 | var t; 436 | t = parseInt(v); 437 | autorefresh_time = t > 0 ? t : 1; 438 | if (autorefresh) { 439 | clearInterval(autorefresh_loop); 440 | } 441 | autorefresh = autorefresh_time; 442 | setStatus(); 443 | screen.render(); 444 | return autorefresh_loop = setInterval(function() { 445 | return fetchMetricData(selected_metrics, time_from); 446 | }, autorefresh * 1000); 447 | }); 448 | }); 449 | screen.key(['x'], function(ch, key) { 450 | return time_popup.input('set max datapoints for graphite api to return in json response\n 0 = unlimited', max_data_points.toString(), function(e, v) { 451 | var p; 452 | p = parseInt(v); 453 | max_data_points = p < 0 ? 0 : p; 454 | setStatus(); 455 | return fetchMetricData(selected_metrics, time_from); 456 | }); 457 | }); 458 | screen.key(['q', 'C-c'], function(ch, key) { 459 | return process.exit(0); 460 | }); 461 | tree.focus(); 462 | loadMetricsTree(); 463 | return fetchMetricData(selected_metrics, time_from); 464 | }; 465 | 466 | return Terphite; 467 | 468 | })(); 469 | 470 | }).call(this); 471 | -------------------------------------------------------------------------------- /lib/helpers.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.10.0 2 | (function() { 3 | module.exports = { 4 | S: 1, 5 | MIN: 60, 6 | H: 60 * 60, 7 | D: 60 * 60 * 24, 8 | W: 60 * 60 * 24 * 7, 9 | MON: 60 * 60 * 24 * 30, 10 | Y: 60 * 60 * 24 * 365, 11 | format_twodigit: function(n) { 12 | if (n.toString().length < 2) { 13 | return '0' + n.toString(); 14 | } else { 15 | return n; 16 | } 17 | }, 18 | get_time_string: function(y, mon, w, d, h, min, s) { 19 | var str; 20 | str = "-"; 21 | if (y) { 22 | str += y + "y"; 23 | } 24 | if (mon) { 25 | str += mon + "mon"; 26 | } 27 | if (w) { 28 | str += w + "w"; 29 | } 30 | if (d) { 31 | str += d + "d"; 32 | } 33 | if (h) { 34 | str += h + "h"; 35 | } 36 | if (min) { 37 | str += min + "min"; 38 | } 39 | if (s) { 40 | str += s + "s"; 41 | } 42 | if (str === '-') { 43 | str = '-1min'; 44 | } 45 | return str; 46 | }, 47 | parse_time: function(time) { 48 | var d, h, min, mon, s, time_ary, w, y; 49 | time_ary = /\-?(([0-9]+)y)?(([0-9]+)mon)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)min)?(([0-9]+)s)?/.exec(time); 50 | y = time_ary[2] || 0; 51 | mon = time_ary[4] || 0; 52 | w = time_ary[6] || 0; 53 | d = time_ary[8] || 0; 54 | h = time_ary[10] || 0; 55 | min = time_ary[12] || 0; 56 | s = time_ary[14] || 0; 57 | return [y, mon, w, d, h, min, s]; 58 | }, 59 | parse_time_to_i: function(time) { 60 | var d, h, min, mon, s, seconds, time_ary, w, y; 61 | time_ary = /\-?(([0-9]+)y)?(([0-9]+)mon)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)min)?(([0-9]+)s)?/.exec(time); 62 | y = time_ary[2] || 0; 63 | mon = time_ary[4] || 0; 64 | w = time_ary[6] || 0; 65 | d = time_ary[8] || 0; 66 | h = time_ary[10] || 0; 67 | min = time_ary[12] || 0; 68 | s = time_ary[14] || 0; 69 | seconds = s * S; 70 | seconds += min * MIN; 71 | seconds += h * H; 72 | seconds += d * D; 73 | seconds += w * W; 74 | seconds += mon * MON; 75 | seconds += y * Y; 76 | return parseInt(seconds); 77 | }, 78 | seconds_to_time_string: function(seconds) { 79 | var d, h, min, mon, s, str, w, y; 80 | str = '-'; 81 | if (seconds > Y) { 82 | y = parseInt(seconds / Y); 83 | seconds = seconds - y * Y; 84 | str += y + "y"; 85 | } 86 | if (seconds > MON) { 87 | mon = parseInt(seconds / MON); 88 | seconds = seconds - mon * MON; 89 | str += mon + "mon"; 90 | } 91 | if (seconds > W) { 92 | w = parseInt(seconds / W); 93 | seconds = seconds - w * W; 94 | str += w + "w"; 95 | } 96 | if (seconds > D) { 97 | d = parseInt(seconds / D); 98 | seconds = seconds - d * D; 99 | str += d + "d"; 100 | } 101 | if (seconds > H) { 102 | h = parseInt(seconds / H); 103 | seconds = seconds - h * H; 104 | str += h + "h"; 105 | } 106 | if (seconds > MIN) { 107 | min = parseInt(seconds / MIN); 108 | seconds = seconds - min * MIN; 109 | str += min + "min"; 110 | } 111 | if (seconds) { 112 | s = seconds; 113 | str += s + "s"; 114 | } 115 | return str; 116 | }, 117 | colors: ["red", "green", "yellow", "blue", "magenta", "cyan", "white", "lightblack", "lightred", "lightgreen", "lightyellow", "lightblue", "lightmagenta", "lightcyan", "lightwhite"] 118 | }; 119 | 120 | }).call(this); 121 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "terphite", 3 | "version": "0.0.3", 4 | "description": "Browse and display Graphite graphs in terminal", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "prepublish": "cake build" 9 | }, 10 | "bin": { 11 | "terphite": "./bin/terphite" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/benwtr/terphite.git" 16 | }, 17 | "keywords": [ 18 | "graphite", 19 | "graph", 20 | "blessed-contrib" 21 | ], 22 | "author": "benwtr ", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/benwtr/terphite/issues" 26 | }, 27 | "homepage": "https://github.com/benwtr/terphite#readme", 28 | "dependencies": { 29 | "blessed": "https://github.com/benwtr/blessed#e7a82bdc8593c6a9ef03ec18fafdc9e061aedc83", 30 | "blessed-contrib": "2.5.x", 31 | "lodash": "^4.5.0", 32 | "request": "^2.69.0" 33 | }, 34 | "devDependencies": { 35 | "coffee-script": "^1.10.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Terphite 2 | 3 | This is a toy/experimental Console [Graphite](http://graphite.readthedocs.org/) Browser loosely based on Graphite Composer. 4 | 5 | It uses [blessed](https://github.com/chjj/blessed) and [blessed-contrib](https://github.com/yaronn/blessed-contrib) to do all the heavy lifting. *blessed-contrib* is a library for building console dashboards, it provides the tree and graph widgets. *blessed* is the UI toolkit, it has a DOM-like API and is surprisingly easy to work with. 6 | 7 | Next steps might be to add the _Graph Options_ and _Apply Function_ features from Composer. And make a dashboard view a la [blessed-graphite](https://github.com/lovehandle/blessed-graphite) that can display and save a grid of graphs. 8 | 9 | #### Demo Screencast 10 | ![](http://i.imgur.com/l8LbbrG.gif) 11 | 12 | ##### Reactions to the Screencast :-) 13 | > grubernaut [4:37 PM] 14 | holy shit 15 | 16 | > obfuscurity [8:37 AM] 17 | whoa wtf 18 | 19 | >obfuscurity [8:37 AM] 20 | that’s better than the real thing lol 21 | 22 | ### Install 23 | 24 | npm install -g terphite 25 | 26 | ### Usage 27 | 28 | terphite http://user:pass@your.graphite.com:1234 29 | 30 | ### Install and run from source 31 | 32 | git clone git@github.com:benwtr/terphite.git 33 | cd terphite 34 | npm install 35 | ./bin/terphite http://your.graphite.com 36 | 37 | #### Getting started with this code (for people unfamiliar with CoffeeScript) 38 | 39 | The code in `src/` is CoffeeScript, it gets compiled to JavaScript and output to `lib/`. 40 | 41 | To compile the CoffeeScript source: 42 | 43 | cake build 44 | 45 | Or watch source for changes and compile when modified: 46 | 47 | cake watch 48 | 49 | Or, if you don't like CoffeeScript, just edit the JS directly. :-) 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/composer.coffee: -------------------------------------------------------------------------------- 1 | blessed = require 'blessed' 2 | contrib = require 'blessed-contrib' 3 | request = require 'request' 4 | path = require 'path' 5 | _ = require 'lodash' 6 | 7 | global[k] = v for k,v of require './helpers' 8 | 9 | 10 | module.exports = class Terphite 11 | constructor: (@graphite_uri) -> 12 | 13 | composer: -> 14 | 15 | selected_metrics = [] 16 | time_from = '-1min' 17 | autorefresh_time = 10 # default autorefresh time in seconds 18 | max_data_points = 300 19 | 20 | graphite_uri = @graphite_uri 21 | autorefresh = 0 22 | 23 | screen = blessed.screen { 24 | smartCSR: true 25 | debug: true # F12 to open debug popup 26 | title: 'Graphite Browser' 27 | warnings: true 28 | dockBorders: true 29 | ignoreDockContrast: true 30 | } 31 | 32 | layout = blessed.layout { 33 | parent: screen 34 | width: '100%' 35 | height: '100%' 36 | border: 'line' 37 | layout: 'grid' 38 | style: 39 | bg: 'black', 40 | border: 41 | fg: 'blue' 42 | } 43 | 44 | tree = contrib.tree { 45 | parent: layout 46 | #label: 'Metrics Browser' 47 | top: 0 48 | left: 0 49 | padding: 1 50 | width: '25%+1' 51 | height: '80%-2' 52 | template: 53 | lines: true 54 | } 55 | 56 | functions_tree = contrib.tree { 57 | parent: screen 58 | top: 'center' 59 | left: 'center' 60 | padding: 1 61 | width: '80%' 62 | height: '50%' 63 | hidden: true 64 | template: 65 | lines: true 66 | } 67 | 68 | statusString = -> 69 | [ 70 | "from: #{time_from}" 71 | "autorefresh: #{if !autorefresh then 'off' else autorefresh + 's'}" 72 | "maxdatapoints: #{if !max_data_points then 'unlimited' else max_data_points}" 73 | ].join(' ') 74 | 75 | setStatus = -> status.setContent statusString() 76 | 77 | status = blessed.box { 78 | parent: layout 79 | top: 0 80 | left: 0 81 | height: 3 82 | width: '100%' 83 | # padding: 1 84 | content: statusString() 85 | border: 86 | type: 'line' 87 | fg: 'blue' 88 | } 89 | screen.append(status) 90 | 91 | help_content = ' [ decrease time 1min, { decrease time 1s\n' 92 | help_content += ' ] increase time 1min, } increase time 1s\n' 93 | help_content += ' t set relative "from" time, m metrics list popup, i set autorefresh interval\n' 94 | help_content += ' a autorefresh toggle, x set max datapoints\n' 95 | help_content += ' o open in browser, c copy graphite URI to clipboard (iTerm2 only)\n' 96 | help_content += 'Metrics List Keys:\n' 97 | help_content += ' C-a append new target, C-d delete selected, edit selected\n' 98 | 99 | help = blessed.box { 100 | parent: layout 101 | top: '80%-1' 102 | left: 0 103 | height: '20%+2' 104 | content: help_content 105 | border: 106 | type: 'line' 107 | fg: 'blue' 108 | } 109 | screen.append(help) 110 | 111 | line = contrib.line { 112 | parent: layout 113 | #label: 'Graph' 114 | left: '25%' 115 | top: 2 116 | height: '80%-2' 117 | width: '75%+1' 118 | showLegend: true 119 | legend: 120 | width: 60 121 | border: 122 | type: 'line' 123 | fg: 'blue' 124 | } 125 | screen.append(line) 126 | 127 | time_popup = blessed.prompt { 128 | parent: layout 129 | left: 'center' 130 | top: 'center' 131 | width: '80%' 132 | height: 8 133 | keys: true 134 | mouse: true 135 | style: 136 | fg: 'blue' 137 | border: 138 | type: 'line' 139 | fg: 'gray' 140 | } 141 | screen.append time_popup 142 | 143 | metrics_popup = blessed.list { 144 | parent: layout 145 | hidden: true 146 | left: 'center' 147 | top: 'center' 148 | width: '90%' 149 | height: 'half' 150 | padding: 1 151 | interactive: true 152 | items: selected_metrics 153 | mouse: true 154 | keys: true 155 | tags: true 156 | style: 157 | #fg: 'blue' 158 | bg: 'blue' 159 | border: 160 | type: 'line' 161 | fg: 'gray' 162 | } 163 | screen.append metrics_popup 164 | 165 | target_popup = blessed.prompt { 166 | parent: layout 167 | left: 'center' 168 | top: 'center' 169 | width: '80%' 170 | height: 8 171 | mouse: true 172 | keys: true 173 | style: 174 | fg: 'blue' 175 | border: 176 | type: 'line' 177 | fg: 'gray' 178 | } 179 | screen.append target_popup 180 | 181 | loadMetricsTree = -> 182 | options = { 183 | uri: "#{graphite_uri}/metrics/index.json" 184 | json: true 185 | } 186 | request options, (err, resp, body) -> 187 | metrics = {} 188 | for metric in body 189 | obj = createObject metric 190 | _.merge metrics, obj 191 | tree.setData( 192 | extended: true 193 | name: 'metrics' 194 | path: 'metrics' 195 | children: metrics 196 | ) 197 | screen.render() 198 | 199 | createObject = (key, original_key = '') -> 200 | obj = {} 201 | parts = key.split('.') 202 | original_key = key if original_key == '' 203 | original_parts = original_key.split('.') 204 | path = original_parts[0..original_parts.length-parts.length].join('.') 205 | if (parts.length == 1) 206 | # leaf 207 | obj[parts[0]] = 208 | name: parts[0] 209 | extended: false 210 | path: path 211 | leaf: true 212 | else if(parts.length > 1) 213 | remainingParts = parts.slice(1,parts.length).join('.') 214 | obj[parts[0]] = 215 | name: parts[0] 216 | extended: false 217 | path: path 218 | leaf: false 219 | children: createObject(remainingParts, original_key) 220 | return obj 221 | 222 | metricURI = (metrics, from, params = {}, opts = {format:'json'}) -> 223 | extra = '' 224 | extra += '&format=json' if opts.format == 'json' 225 | maxdp = parseInt(opts.maxDataPoints,10) 226 | extra += "&maxDataPoints=#{maxdp}" if maxdp > 0 227 | target = '' 228 | target += '&target=' + metrics.join('&target=') if metrics.length 229 | "#{graphite_uri}/render?from=#{from}#{target}#{extra}" 230 | 231 | fetchMetricData = (metrics, from) -> 232 | options = 233 | uri: metricURI metrics, from, {}, { 234 | format: 'json' 235 | maxDataPoints: max_data_points 236 | } 237 | json: true 238 | request options, (err, resp, body) -> 239 | series = for target,i in body 240 | title: target.target || '[unnamed_target]' 241 | y: (point[0] for point in target.datapoints) 242 | x: for point in target.datapoints 243 | ts = new Date(point[1]*1000) 244 | "#{ts.getHours()}:#{format_twodigit(ts.getUTCMinutes())}" 245 | style: { line: colors[i%15] } 246 | if series.length == 0 247 | series = [{ title: 'no data', x: [], y: [] }] 248 | line.setData(series) 249 | screen.render() 250 | 251 | tree.on 'select', (node) -> 252 | path = node.path 253 | if path in selected_metrics 254 | selected_metrics = selected_metrics.filter (metric) -> metric isnt path 255 | else if node.leaf 256 | selected_metrics.push path 257 | fetchMetricData selected_metrics, time_from 258 | 259 | screen.key ['['], (ch, key) -> 260 | t = ( parse_time_to_i time_from ) - MIN 261 | time_from = if t < MIN then '-1min' else seconds_to_time_string t 262 | setStatus() 263 | fetchMetricData(selected_metrics, time_from) 264 | 265 | screen.key [']'], (ch, key) -> 266 | t = ( parse_time_to_i time_from ) + MIN 267 | time_from = seconds_to_time_string t 268 | setStatus() 269 | fetchMetricData(selected_metrics, time_from) 270 | 271 | screen.key ['{'], (ch, key) -> 272 | t = ( parse_time_to_i time_from ) - H 273 | time_from = if t < H then '-1h' else seconds_to_time_string t 274 | setStatus() 275 | fetchMetricData(selected_metrics, time_from) 276 | 277 | screen.key ['}'], (ch, key) -> 278 | t = ( parse_time_to_i time_from ) + H 279 | time_from = seconds_to_time_string t 280 | setStatus() 281 | fetchMetricData(selected_metrics, time_from) 282 | 283 | screen.key ['t'], (ch, key) -> 284 | time_popup.input( 285 | 'set relative _from_ time (y, mon, w, d, h, min, s)\n eg: -1d12h', 286 | time_from, 287 | (err, value) -> 288 | [ y, mon, w, d, h, min, s ] = parse_time value 289 | time_from = get_time_string y, mon, w, d, h, min, s 290 | setStatus() 291 | fetchMetricData selected_metrics, time_from 292 | ) 293 | 294 | screen.key ['m'], (ch, key) -> 295 | metrics_popup.setItems selected_metrics 296 | metrics_popup.focus() 297 | metrics_popup.show() 298 | screen.render() 299 | 300 | metrics_popup.on 'select', (item, select) -> 301 | target_popup.input( 302 | 'Edit target', 303 | item.getText(), 304 | (err, value) -> 305 | selected_metrics[select] = value 306 | metrics_popup.setItems selected_metrics 307 | screen.render() 308 | ) 309 | 310 | metrics_popup.key 'C-d', (ch, key) -> 311 | selected_metrics.splice(metrics_popup.selected, 1) 312 | metrics_popup.setItems selected_metrics 313 | fetchMetricData selected_metrics, time_from 314 | 315 | metrics_popup.key 'C-a', (ch, key) -> 316 | target_popup.input( 317 | 'Add target', 318 | '', 319 | (err, value) -> 320 | selected_metrics.push value 321 | metrics_popup.setItems selected_metrics 322 | fetchMetricData selected_metrics, time_from 323 | ) 324 | 325 | metrics_popup.key ['escape'], (ch, key) -> 326 | metrics_popup.hide() 327 | tree.focus() 328 | fetchMetricData selected_metrics, time_from 329 | 330 | # screen.key ['f'], (ch, key) -> 331 | # functions_tree.setData( 332 | # extended: true 333 | # name: 'foo' 334 | # children: 335 | # 'bar': 336 | # extended: false 337 | # name: 'bar' 338 | # 'stuff': 339 | # extended: false 340 | # name: 'stuff' 341 | # ) 342 | # functions_tree.focus() 343 | # functions_tree.setFront() 344 | # functions_tree.show() 345 | # screen.render() 346 | 347 | screen.key ['c'], (ch, key) -> 348 | screen.cursorReset() 349 | screen.copyToClipboard(metricURI selected_metrics, time_from, {}, {}) 350 | screen.realloc() 351 | screen.render() 352 | 353 | screen.key ['o'], (ch, key) -> 354 | screen.exec 'open', [(metricURI selected_metrics, time_from, {}, {})] 355 | 356 | autorefresh_loop = null 357 | 358 | toggleAutorefresh = -> 359 | if !autorefresh 360 | autorefresh = autorefresh_time 361 | setStatus() 362 | screen.render() 363 | autorefresh_loop = setInterval( -> 364 | fetchMetricData selected_metrics, time_from 365 | , autorefresh * 1000) 366 | else 367 | autorefresh = 0 368 | clearInterval autorefresh_loop 369 | setStatus() 370 | screen.render() 371 | autorefresh_loop = null 372 | 373 | screen.key ['a'], (ch, key) -> 374 | toggleAutorefresh() 375 | 376 | screen.key ['i'], (ch, key) -> 377 | time_popup.input( 378 | 'set autorefresh interval time (seconds)', 379 | autorefresh_time.toString(), 380 | (e, v) -> 381 | t = parseInt(v) 382 | autorefresh_time = if t > 0 then t else 1 383 | if autorefresh 384 | clearInterval autorefresh_loop 385 | autorefresh = autorefresh_time 386 | setStatus() 387 | screen.render() 388 | autorefresh_loop = setInterval( -> 389 | fetchMetricData selected_metrics, time_from 390 | , autorefresh * 1000) 391 | ) 392 | 393 | screen.key ['x'], (ch, key) -> 394 | time_popup.input( 395 | 'set max datapoints for graphite api to return in json response\n 0 = unlimited', 396 | max_data_points.toString(), 397 | (e,v) -> 398 | p = parseInt(v) 399 | max_data_points = if p < 0 then 0 else p 400 | setStatus() 401 | fetchMetricData selected_metrics, time_from 402 | ) 403 | 404 | screen.key(['q', 'C-c'], (ch, key) -> 405 | process.exit(0) 406 | ) 407 | 408 | 409 | tree.focus() 410 | loadMetricsTree() 411 | fetchMetricData selected_metrics, time_from 412 | 413 | -------------------------------------------------------------------------------- /src/helpers.coffee: -------------------------------------------------------------------------------- 1 | module.exports = 2 | 3 | # for calculating time in seconds 4 | S: 1 5 | MIN: 60 6 | H: 60 * 60 7 | D: 60 * 60 * 24 8 | W: 60 * 60 * 24 * 7 9 | MON: 60 * 60 * 24 * 30 10 | Y: 60 * 60 * 24 * 365 11 | 12 | format_twodigit: (n) -> 13 | if n.toString().length < 2 then '0' + n.toString() else n 14 | 15 | get_time_string: (y, mon, w, d, h, min, s) -> 16 | str = "-" 17 | str += "#{y}y" if y 18 | str += "#{mon}mon" if mon 19 | str += "#{w}w" if w 20 | str += "#{d}d" if d 21 | str += "#{h}h" if h 22 | str += "#{min}min" if min 23 | str += "#{s}s" if s 24 | str = '-1min' if str == '-' 25 | str 26 | 27 | parse_time: (time) -> 28 | time_ary = /\-?(([0-9]+)y)?(([0-9]+)mon)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)min)?(([0-9]+)s)?/.exec time 29 | y = time_ary[2] || 0 30 | mon = time_ary[4] || 0 31 | w = time_ary[6] || 0 32 | d = time_ary[8] || 0 33 | h = time_ary[10] || 0 34 | min = time_ary[12] || 0 35 | s = time_ary[14] || 0 36 | [y, mon, w, d, h, min, s] 37 | 38 | parse_time_to_i: (time) -> 39 | time_ary = /\-?(([0-9]+)y)?(([0-9]+)mon)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)min)?(([0-9]+)s)?/.exec time 40 | y = time_ary[2] || 0 41 | mon = time_ary[4] || 0 42 | w = time_ary[6] || 0 43 | d = time_ary[8] || 0 44 | h = time_ary[10] || 0 45 | min = time_ary[12] || 0 46 | s = time_ary[14] || 0 47 | seconds = s * S 48 | seconds += min * MIN 49 | seconds += h * H 50 | seconds += d * D 51 | seconds += w * W 52 | seconds += mon * MON 53 | seconds += y * Y 54 | parseInt seconds 55 | 56 | seconds_to_time_string: (seconds) -> 57 | str = '-' 58 | if seconds > Y 59 | y = parseInt(seconds / Y) 60 | seconds = seconds - y * Y 61 | str += "#{y}y" 62 | if seconds > MON 63 | mon = parseInt(seconds / MON) 64 | seconds = seconds - mon * MON 65 | str += "#{mon}mon" 66 | if seconds > W 67 | w = parseInt(seconds / W) 68 | seconds = seconds - w * W 69 | str += "#{w}w" 70 | if seconds > D 71 | d = parseInt(seconds / D) 72 | seconds = seconds - d * D 73 | str += "#{d}d" 74 | if seconds > H 75 | h = parseInt(seconds / H) 76 | seconds = seconds - h * H 77 | str += "#{h}h" 78 | if seconds > MIN 79 | min = parseInt(seconds / MIN) 80 | seconds = seconds - min * MIN 81 | str += "#{min}min" 82 | if seconds 83 | s = seconds 84 | str += "#{s}s" 85 | str 86 | 87 | colors: [ 88 | "red", 89 | "green", 90 | "yellow", 91 | "blue", 92 | "magenta", 93 | "cyan", 94 | "white", 95 | "lightblack", 96 | "lightred", 97 | "lightgreen", 98 | "lightyellow", 99 | "lightblue", 100 | "lightmagenta", 101 | "lightcyan", 102 | "lightwhite" 103 | ] 104 | --------------------------------------------------------------------------------