diff --git a/.jscsrc b/.jscsrc new file mode 100644 index 0000000..52050e6 --- /dev/null +++ b/.jscsrc @@ -0,0 +1,28 @@ +{ + "preset": "airbnb", + "esnext": true, + "verbose": true, + "disallowCapitalizedComments": true, + "disallowMultipleLineBreaks": true, + "disallowMultipleSpaces": true, + "disallowSemicolons": true, + "disallowSpacesInAnonymousFunctionExpression": null, + "disallowSpacesInFunctionExpression": null, + "disallowYodaConditions": null, + "requireCamelCaseOrUpperCaseIdentifiers": null, + "requirePaddingNewLinesAfterUseStrict": true, + "requireSpacesInAnonymousFunctionExpression": { + "beforeOpeningRoundBrace": true, + "beforeOpeningCurlyBrace": true + }, + "safeContextKeyword": ["self", "doc", "ctx"], + "validateIndentation": 2, + "plugins": [ + "jscs-jsdoc" + ], + "jsDoc": { + "checkAnnotations": "closurecompiler", + "checkTypes": "strictNativeCase", + "enforceExistence": "exceptExports" + } +} diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000..14489d8 --- /dev/null +++ b/.jshintrc @@ -0,0 +1,11 @@ +{ + "asi": true, + "esnext": true, + "expr": true, + "mocha": true, + "node": true, + "noyield": true, + "strict": true, + "unused": true, + "validthis": true +} diff --git a/.scss-lint.yml b/.scss-lint.yml new file mode 100644 index 0000000..b96a7b4 --- /dev/null +++ b/.scss-lint.yml @@ -0,0 +1,11 @@ +linters: + ImportantRule: + enabled: false + Compass::*: + enabled: false + SelectorDepth: + enabled: false + NestingDepth: + enabled: false + PropertyCount: + enabled: false diff --git a/README.md b/README.md index 2aba4de..fb2a72a 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ [![License](https://img.shields.io/npm/l/rtail.svg?style=flat-square)](https://www.npmjs.com/package/rtail) [![Gitter](https://img.shields.io/badge/≡_gitter-join_chat_➝-04cd7e.svg?style=flat-square)](https://gitter.im/kilianc/rtail?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) -## Pipe your terminal output to the browser in seconds, using UNIX pipes. +## Terminal output to the browser in seconds, using UNIX pipes. -`rtail` is a command line utility that grabs every line in `stdin` and broadcast it over **UDP**. That's it, nothing fancy, nothing complicated. You can tail your log files, your app output or whatever you wish to pipe, in `rtail` to a `rtail-server` and see multiple streams in the browser, realtime. +`rtail` is a command line utility that grabs every line in `stdin` and broadcasts it over **UDP**. That's it. Nothing fancy. Nothing complicated. Tail log files, app output, or whatever you wish, using `rtail` broadcasting to an `rtail-server` – See multiple streams in the browser, in realtime. ## Installation @@ -24,22 +24,22 @@ ## Rationale -If you develop software for work and you deploy your code on remote servers using multiple environments, or simply have multiple projects, you know that to monitor realtime your logs **you need to `ssh` to every single machine running your code**. +Whether you deploy your code on remote servers using multiple environments or simply have multiple projects, **you must `ssh` to each machine running your code, in order to monitor the logs in realtime**. -There are many log aggregation tools out there, and few of them are realtime. **Most of them require you to change your application source code to support their logging protocol/transport**. +There are many log aggregation tools out there, but few of them are realtime. **Most other tools require you to change your application source code to support their logging protocol/transport**. -This is meant to be a replacement of [logio](https://github.com/NarrativeScience/Log.io/commits/master) which was a cool idea, but not actively maintained anymore, doesn't support node v0.12.* and uses *TCP*, which means strict handshaking between servers and clients other than being resource consuming and very difficult to scale. +`rtail` is meant to be a replacement of [logio](https://github.com/NarrativeScience/Log.io/commits/master), which isn't actively maintained anymore, doesn't support node v0.12., and uses *TCP. (TCP requires strict client / server handshaking, is resource-hungry, and very difficult to scale.)* -**`rtail` approach is very simple:** -* pipe something into it using [UNIX I/O redirection](http://www.westwind.com/reference/os-x/commandline/pipes.html) [[2]](http://www.codecoffee.com/tipsforlinux/articles2/042.html) +**The `rtail` approach is very simple:** +* pipe something into `rtail` using [UNIX I/O redirection](http://www.westwind.com/reference/os-x/commandline/pipes.html) [[2]](http://www.codecoffee.com/tipsforlinux/articles2/042.html) * broadcast every line using UDP -* `rtail-server` **if listening** will dispatch the stream into your browser using [socket.io](http://socket.io/). +* `rtail-server`, **if listening**, will dispatch the stream into your browser, using [socket.io](http://socket.io/). -**There is no persistent layer and it is not meant to persist any data**, `rtail` is a realtime monitoring tool meant to aggregate multiple streams and serve them with a modern web interface, mostly for debugging and realtime monitoring. If you need a persistent layer use something like [loggly](https://www.loggly.com/). +`rtail` is a realtime debugging and monitoring tool, which can display multiple aggregate streams via a modern web interface. **There is no persistent layer, nor does the tool store any data**. If you need a persistent layer, use something like [loggly](https://www.loggly.com/). ## Examples -In your init script: +In your app init script: $ node server.js 2>&1 | rtail --id "api.myproject.com" @@ -47,112 +47,112 @@ In your init script: $ node server.js 2>&1 | rtail --mute -Supports JSON lines +Supports JSON5 lines: $ while true; do echo [1, 2, 3, "hello"]; sleep 1; done | rtail $ echo { "foo": "bar" } | rtail + $ echo { format: 'JSON5' } | rtail -Using log files (log rotate safe!) +Using log files (log rotate safe!): $ node server.js 2>&1 > log.txt $ tail -F log.txt | rtail -For fun and debugging +For fun and debugging: $ cat ~/myfile.txt | rtail $ echo "Server rebooted!" | rtail --id `hostname` ## Params - $ rtail -h - Usage: cmd | rtail --host [string] --port [num] [--mute] [--id [string]] + $ rtail --help + Usage: cmd | rtail [OPTIONS] + + Options: + --host, -h The server host [string] [default: "127.0.0.1"] + --port, -p The server port [string] [default: 9999] + --id, --name The log stream id [string] [default: (moniker)] + --mute, -m Don't pipe stdin with stdout [boolean] + --tty Keeps ansi colors [boolean] [default: true] + --parse-date Looks for dates to use as timestamp [boolean] [default: true] + --help Show help [boolean] + --version, -v Show version number [boolean] Examples: - server | rtail --host 127.0.0.1 > server.log Broadcast to localhost + file - server | rtail --port 43567 Custom port - server | rtail --mute Only remote - server | rtail --id api.domain.com Name the log stream - server | rtail --not-tty Strips ANSI colors + server | rtail > server.log localhost + file + server | rtail --id api.domain.com Name the log stream + server | rtail --host example.com Sends to example.com + server | rtail --port 43567 Uses custom port + server | rtail --mute No stdout + server | rtail --no-tty Strips ansi colors + server | rtail --no-date-parse Disable date parsing/stripping - Options: - --mute, -m Don't pipe stdin with stdout - --host The recipient server host [default: "127.0.0.1"] - --port, -p The recipient server port [default: 9999] - --id, --name The log stream id [default: moniker()] - --not-tty Strips ansi colors - --help, -h Show help - --version, -v Show version number - ## `rtail-server(1)` -`rtail-server` catches all messages broadcasted from every `rtail` client and serves a web interface to view the incoming log streams in realtime. **Under the hood uses [socket.io](http://socket.io) to pipe every incoming UDP message to the browser.** +`rtail-server` receives all messages broadcast from every `rtail` client, displaying all incoming log streams in a realtime web view. **Under the hood, the server uses [socket.io](http://socket.io) to pipe every incoming UDP message to the browser.** -There is tiny to nothing to configure, you can chage the default UDP/HTTP ports, but other than that you're all set. +There is little to no configuration – The default UDP/HTTP ports can be changed, but that's it. ## Examples -With default values +Use default values: $ rtail-server -Stay up to date! +Always use latest, stable webapp: $ rtail-server --web-version stable -With custom ports +Use custom ports: $ rtail-server --web-port 8080 --udp-port 9090 -With debugging on +Set debugging on: $ DEBUG=rtail:* rtail-server -With serving always latest stable webapp - - $ DEBUG=rtail:* rtail-server --web-version stable - Open your browser and start tailing logs! ## Params - $ rtail-server -h - Usage: rtail-server [--udp-host [string] --udp-port [num] --web-host [string] --web-port [num] --web-version [stable,unstable,]] - - Examples: - rtail-server --web-port 8080 Use custom http port - rtail-server --udp-port 8080 Use custom udp port - rtail-server --web-version stable Always uses latest stable webapp - rtail-server --web-version 0.1.3 Use webapp v0.1.3 - + $ rtail-server --help + Usage: rtail-server [OPTIONS] Options: - --udp-host, --uh The listening udp hostname [default: "127.0.0.1"] - --udp-port, --up The listening udp port [default: 9999] - --web-host, --wh The listening http hostname [default: "127.0.0.1"] - --web-port, --wp The listening http port [default: 8888] - --web-version Define web app version to serve - --help, -h Show help - --version, -v Show version number + --udp-host, --uh The listening UDP hostname [default: "127.0.0.1"] + --udp-port, --up The listening UDP port [default: 9999] + --web-host, --wh The listening HTTP hostname [default: "127.0.0.1"] + --web-port, --wp The listening HTTP port [default: 8888] + --web-version Define web app version to serve [string] + --help, -h Show help [boolean] + --version, -v Show version number [boolean] + + Examples: + rtail-server --web-port 8080 Use custom HTTP port + rtail-server --udp-port 8080 Use custom UDP port + rtail-server --web-version stable Always uses latest stable webapp + rtail-server --web-version unstable Always uses latest develop webapp + rtail-server --web-version 0.1.3 Use webapp v0.1.3 ## UDP Broadcasting -You may want to scale and broadcast on multiple servers, in that case just instruct the `rtail` client to stream to the broadcast address and every message will be delivered to all servers in your subnet. +To scale and broadcast on multiple servers, instruct the `rtail` client to stream to the broadcast address. Every message will then be delivered to all servers in your subnet. ## Authentication layer -The webapp doesn't have an authentication layer for the time being, it's assumed you run it behind a VPN or a reverse proxy with a simple `Authorization` header check. +For the time being, the webapp doesn't have an authentication layer; it assumes that you will run it behind a VPN or reverse proxy, with a simple `Authorization` header check. # How to contribute This project follows the awesome [Vincent Driessen](http://nvie.com/about/) [branching model](http://nvie.com/posts/a-successful-git-branching-model/). -* You must add a new feature on his own topic branch -* You must contribute to hot-fixing directly into the master branch (and pull-request to it) +* You must add a new feature on its own branch +* You must contribute to hot-fixing, directly into the master branch (and pull-request to it) -This project follows (more or less) the [Felix's Node.js Style Guide](http://nodeguide.com/style.html), your contribution must be consistent with this style. +This project uses JSCS to enforce a consistent code style. Your contribution must be pass jscs validation. -The test suite is written on top of [visionmedia/mocha](http://visionmedia.github.com/mocha/) and it took hours of hard work. Please use the tests to check if your contribution is breaking some part of the library and add new tests for each new feature. +The test suite is written on top of [mochajs/mocha](http://mochajs.org/). Use the tests to check if your contribution breaks some part of the library and be sure to add new tests for each new feature. $ npm test @@ -161,12 +161,35 @@ The test suite is written on top of [visionmedia/mocha](http://visionmedia.githu * [Kilian Ciuffolo](https://github.com/kilianc) * [Luca Orio](https://www.behance.net/lucaorio) * [Sandaruwan Silva](https://github.com/s-silva) +* [Sorel Mihai](https://dribbble.com/sorelmihai) +* [Tim Riot](https://www.linkedin.com/in/timriot) + +## Roadmap (aka where you can help) + +* Write a rock solid test suite +* Allow use of DTLS (waiting for node to support this https://github.com/joyent/node/pull/6704) +* Add GitHub OAuth and basic auth for teams (join proposal convo here: https://github.com/kilianc/rtail/issues/44) +* Implement infinite-scroll like behavior in the webapp to support bigger backlogs and make it future proof. +* Publish base rtail docker image to DockerHub +* Create a catch all docker logs image +* Rewrite webapp using ng2 + +## Sponsors +❤ rTail? Consider sponsoring this project to keep it alive and free for the community. + +* Lukibear (domain) +* ? (wildcard TLS cert) +* ? (.io domain) + +[![PayPal donate button](https://img.shields.io/badge/$_paypal-one_time_donation_➝-04cd7e.svg?style=flat-square)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=info%40rtail%2eorg&lc=US&item_name=rtail&item_number=rtail¤cy_code=USD&bn=PP%2dDonationsBF%3abtn_donateCC_LG%2egif%3aNonHosted) + +Professional support or ad-hoc is also available. ## License _This software is released under the MIT license cited below_. - Copyright (c) 2015 Kilian Ciuffolo, me@nailik.org. All Rights Reserved. + Copyright (c) 2014 Kilian Ciuffolo, me@nailik.org. All Rights Reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation diff --git a/app/app.ejs b/app/app.ejs index fc77ca7..a9a2944 100644 --- a/app/app.ejs +++ b/app/app.ejs @@ -1,19 +1,56 @@ -/** - * Main JS controller for webapp - */ +/* global angular, $, window, io, document, hljs, ansi_up */ + +'use strict' +/*! + * main js controller for webapp + */ angular .module('app', [ 'ngAnimate', 'angularMoment', 'LocalForageModule', - 'rt.popup' + 'rt.popup', + 'ui.router' ]) + .config(function ($stateProvider) { + $stateProvider.state('streams', { + url: '/streams/:stream' + }) + }) + .directive('resizable', function ($localForage) { + return { + restrict: 'A', + link: function ($scope, $element) { + var $window = $(window) + var $body = $('body') + var handler = $element.find('.resize-handler') + var minWidth = $element.width() + + handler.on('mousedown', function () { + $body.addClass('resizing') + $window.on('mousemove', onMouseMove) + }) + + $window.on('mouseup', function () { + $body.removeClass('resizing') + $window.off('mousemove', onMouseMove) + $localForage.setItem('sidebarWidth', $element.width()) + }) + + function onMouseMove(e) { + var width = Math.min(600, Math.max(minWidth, e.clientX)) + $element.css('width', width) + } + } + } + }) .controller('MainController', function MainController($scope, $injector) { var $sce = $injector.get('$sce') - var $moment = $injector.get('moment') var $localForage = $injector.get('$localForage') - var $window = $injector.get('$window') + var $rootScope = $injector.get('$rootScope') + var $state = $injector.get('$state') + var $stateParams = $injector.get('$stateParams') var $streamLines = $('.stream-lines') var BUFFER_SIZE = 100 var ctrl = this @@ -22,10 +59,9 @@ angular ctrl.version = '<%= version %>' ctrl.lines = [] - /** - * Socket stuff + /*! + * socket stuff */ - ctrl.socket = io(document.location.origin, { path: document.location.pathname + 'socket.io' }) ctrl.socket.on('streams', function (streams) { @@ -53,6 +89,7 @@ angular }) ctrl.selectStream = function selectStream(stream) { + ctrl.lines = [] ctrl.activeStream = stream ctrl.socket.emit('select stream', stream) ctrl.resume() @@ -78,13 +115,32 @@ angular line.html = $sce.trustAsHtml('
' + line.html + '
') } else { // for log lines use ansi format - line.html = ansi_up.ansi_to_html(line.content, { use_classes: true }) + line.html = escapeHtml(line.content) + line.html = ansi_up.ansi_to_html(line.html, { use_classes: true }) line.html = $sce.trustAsHtml(line.html) } return line } + // https://github.com/component/escape-html/blob/master/index.js#L22 + function escapeHtml(html) { + return String(html) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); + } + + ctrl.activeStreamFilter = function activeStreamFilter(line) { + try { + return (new RegExp(ctrl.activeStreamRegExp)).test(line.content) + } catch (err) { + return true + } + } + ctrl.resume = function resume() { if (!ctrl.paused) return ctrl.paused = false @@ -102,10 +158,9 @@ angular $streamLines.on('mousewheel', ctrl.pause) - /** - * Settings and preferences + /*! + * settings and preferences */ - ctrl.toggleFavorite = function toggleFavorite(stream) { if (ctrl.favorites[stream]) { delete ctrl.favorites[stream] @@ -116,6 +171,16 @@ angular $localForage.setItem('favorites', ctrl.favorites) } + ctrl.toggleTimestamp = function toggleTimestamp(stream) { + if (ctrl.hiddenTimestamps[stream]) { + delete ctrl.hiddenTimestamps[stream] + } else { + ctrl.hiddenTimestamps[stream] = true + } + + $localForage.setItem('hiddenTimestamps', ctrl.hiddenTimestamps) + } + ctrl.setTheme = function setTheme(theme) { ctrl.theme = theme $localForage.setItem('theme', theme) @@ -126,7 +191,7 @@ angular $localForage.setItem('fontFamily', fontFamily) } - ctrl.incFontSize = function incFontSize(fontSize) { + ctrl.incFontSize = function incFontSize() { ctrl.fontSize = Math.min(7, ctrl.fontSize + 1) $localForage.setItem('fontSize', ctrl.fontSize) } @@ -146,9 +211,12 @@ angular $localForage.setItem('streamDirection', streamDirection) } - /** - * Load storage in memory and boot + /*! + * load storage in memory and boot */ + $localForage.getItem('sidebarWidth').then(function (sidebarWidth) { + ctrl.sidebarWidth = sidebarWidth + }) $localForage.getItem('theme').then(function (theme) { ctrl.theme = theme || 'dark' @@ -166,19 +234,30 @@ angular ctrl.favorites = favorites || {} }) + $localForage.getItem('hiddenTimestamps').then(function (hiddenTimestamps) { + ctrl.hiddenTimestamps = hiddenTimestamps || {} + }) + $localForage.getItem('activeStream').then(function (activeStream) { - if (!activeStream) return + if (!activeStream || ('streams' === $state.current.name && $stateParams.stream)) return console.info('%s: restoring session', activeStream) - ctrl.selectStream(activeStream) + $state.go('streams', { stream: activeStream }) }) $localForage.getItem('streamDirection').then(function (streamDirection) { ctrl.streamDirection = undefined === streamDirection ? true : streamDirection }) - /** - * Tell UI we're ready to roll + /*! + * respond to url change */ + $rootScope.$on('$stateChangeStart', function (e, toState, toParams) { + if ('streams' !== toState.name) return + ctrl.selectStream(toParams.stream) + }) + /*! + * tell UI we're ready to roll + */ ctrl.loaded = true - }) \ No newline at end of file + }) diff --git a/app/index.html b/app/index.html index c72bee4..d149a6d 100644 --- a/app/index.html +++ b/app/index.html @@ -37,7 +37,7 @@
-
- +
@@ -92,13 +89,24 @@

Streams

class="stream-line" ng-repeat=" line in ctrl.lines - | filter:{ content: ctrl.activeStreamFilter } + | filter:ctrl.activeStreamFilter | orderBy:'timestamp':!ctrl.streamDirection " > -
+
{{::line.timestamp | amDateFormat:'MM/DD/YY hh:mm:ss'}}
+ + +
@@ -168,7 +176,8 @@

Theme

+ - \ No newline at end of file + diff --git a/app/scss/_theme.scss b/app/scss/_theme.scss index d5aaef7..65bf4d7 100644 --- a/app/scss/_theme.scss +++ b/app/scss/_theme.scss @@ -37,7 +37,7 @@ color: $stream-list-title-color; } - li { + a { color: $stream-list-color; &:hover { @@ -96,6 +96,15 @@ } } + .btn-toggle-timestamp { + background-color: $stream-view-bg-color; + background-repeat: no-repeat; + background-size: 4px 7px; + background-position: 50%; + border: 1px solid $stream-view-timestamp-border-color; + background-image: url('../images/#{$theme}/icn-close-date.svg'); + } + .btn-resume { background-color: $main-color; background-image: url('../images/common/icn-resume.svg'); @@ -149,6 +158,10 @@ color: $popover-font-color; } + &:before { + border-bottom-color: $popover-bg-color !important; + } + div.btn-group .btn { &.btn-font-smaller { background-image: url(../images/#{$theme}/icn-font-decrease.svg); @@ -179,4 +192,4 @@ } } } -} \ No newline at end of file +} diff --git a/app/scss/main.scss b/app/scss/main.scss index 5e110fd..63d35eb 100644 --- a/app/scss/main.scss +++ b/app/scss/main.scss @@ -4,33 +4,34 @@ $spacing: 20px; $border-radius: 5px; -$font-family: "Nunito"; +$font-family: 'Nunito'; // ** // imports // ** -@import "_fonts"; +@import 'fonts'; // ** // reset // ** * { + background: none; border: 0; - padding: 0; + box-sizing: border-box; + color: inherit; margin: 0; - background: none; + padding: 0; text-decoration: none; - color: inherit; - box-sizing: border-box; &:focus { outline: none; } } -ol, ul { +ol, +ul { list-style: none; } @@ -47,37 +48,41 @@ input { // ** body { + align-items: stretch; display: flex; flex-direction: column; - align-items: stretch; - min-height: 100vh; font-family: $font-family; font-size: 16px; + min-height: 100vh; + + &.resizing { + user-select: none; + } } header { + align-items: center; display: flex; flex-direction: row; - align-items: center; height: 58px; .rtail-logo { - height: 100px; - width: 94px; - background-repeat: no-repeat; background-position: 50% 50%; + background-repeat: no-repeat; background-size: 53px 13px; + height: 100px; + width: 94px; } .btn { - width: 19px; - height: 19px; background-position: 50% 50%; background-repeat: no-repeat; background-size: 100%; - margin-right: $spacing; - opacity: 0.4; cursor: pointer; + height: 19px; + margin-right: $spacing; + opacity: .4; + width: 19px; &.btn-info { margin-left: auto; @@ -86,28 +91,38 @@ header { } .split-pane { - flex: 1; display: flex; + flex: 1; flex-direction: row; } .sidebar { display: flex; flex-direction: column; - width: 230px; font-size: 16px; + position: relative; + width: 230px; + + .resize-handler { + cursor: col-resize; + height: 100%; + position: absolute; + right: -5px; + top: 0; + width: 10px; + } .search-box { - display: flex; - height: 57px; + background-position: $spacing 50%; background-repeat: no-repeat; background-size: 16px 16px; - background-position: $spacing 50%; + display: flex; + height: 57px; input { flex: 1; - height: 57px; font-size: 14px; + height: 57px; margin-left: 56px; margin-right: $spacing; } @@ -115,40 +130,46 @@ header { .stream-sections { flex: 1; - overflow-y: auto; height: 0; + overflow-y: auto; .stream-section { flex: 1; h4 { - display: flex; align-items: center; + display: flex; + font-size: 16px; + font-weight: normal; height: 32px; margin-top: $spacing; padding-left: $spacing; - font-size: 16px; - font-weight: normal; } - li { - display: flex; + a { align-items: center; + cursor: pointer; + display: flex; height: 32px; padding-left: 40px; - cursor: pointer; &.selected { - padding-left: 38px; border-left: 2px solid; + padding-left: 38px; + } + + span { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; } i { - width: 8px; - height: 8px; border-radius: 50%; + height: 8px; margin-left: auto; margin-right: $spacing; + width: 8px; } } } @@ -156,99 +177,114 @@ header { } .stream-view { - flex: 1; + position: relative; display: flex; + flex: 1; flex-direction: column; .stream-header { - display: flex; align-items: center; - height: 57px; + display: flex; font-size: 16px; + height: 57px; .stream-title { - display: flex; align-items: center; + display: flex; + } - .stream-title-favorite { - display: inline-block; - margin: 0 $spacing; - background-repeat: no-repeat; - background-size: 15px 15px; - width: 15px; - height: 15px; - } + .stream-title-favorite { + background-repeat: no-repeat; + background-size: 15px 15px; + display: inline-block; + height: 15px; + margin: 0 $spacing; + width: 15px; } .filter-box { - display: flex; - width: 210px; - height: 30px; + background-position: 0 50%; background-repeat: no-repeat; background-size: 14px 14px; - background-position: 0 50%; - opacity: 0.7; + display: flex; font-size: 14px; + height: 30px; margin-left: auto; margin-right: $spacing; + opacity: .7; + width: 210px; + } - input { - flex: 1; - margin-left: 24px; - } + input { + flex: 1; + margin-left: 24px; } } .stream-lines { flex: 1; - overflow-y: auto; - height: 0; font-size: 12px; + height: 0; + overflow-y: auto; padding: 2px 0; .stream-line { display: flex; - line-height: 0.5em; + line-height: .5em; pre { line-height: 1.2; } .stream-line-timestamp { + border-right: 1px solid; flex-shrink: 0; padding-right: $spacing; - border-right: 1px solid; } .stream-line-content { - white-space: nowrap; + white-space: pre; } .stream-line-timestamp, .stream-line-content { - display: flex; align-items: center; + display: flex; + padding-bottom: 5px; padding-left: $spacing; padding-top: 5px; - padding-bottom: 5px; } } } - .btn-resume { + .btn-toggle-timestamp { position: absolute; - bottom: $spacing; - right: $spacing; - width: 85px; - height: 30px; - padding-left: 25px; + bottom: 15px; + width: 20px; + height: 20px; border-radius: $border-radius; - background-repeat: no-repeat; + cursor: pointer; + transform: translateX(-50%); + + &.closed { + transform: translateX(-50%) rotate(180deg); + } + } + + .btn-resume { background-position: 10px 50%; + background-repeat: no-repeat; background-size: 8px 10px; + border-radius: $border-radius; + bottom: $spacing; + cursor: pointer; font-size: 14px; + height: 30px; + padding-left: 25px; + position: absolute; + right: $spacing; text-align: left; - cursor: pointer; + width: 85px; } } @@ -256,46 +292,46 @@ header { position: absolute; .popover-content { + border-radius: 5px; display: flex; flex-direction: column; - position: relative; - width: 156px; + font-size: 12px; margin-top: $spacing * 2; - border-radius: 5px; overflow: visible !important; - font-size: 12px; + position: relative; + width: 156px; &:before { - position: absolute; - left: 50%; - top: 0; - content: ""; - width: 0; - height: 0; + border-bottom: $spacing / 2 solid transparent; border-left: $spacing / 2 solid transparent; border-right: $spacing / 2 solid transparent; - border-bottom: $spacing / 2 solid transparent; + content: ''; + height: 0; + left: 50%; + position: absolute; + top: 0; transform: translate(-50%, -100%); + width: 0; } .btn { - border-width: 1px; - border-style: solid; border-radius: $border-radius; + border-style: solid; + border-width: 1px; } } .popover-info { - height: 265px; align-items: center; + height: 265px; .rtail-logo { - height: 48px; - width: 100%; - background-repeat: no-repeat; background-position: 50% 50%; + background-repeat: no-repeat; background-size: 53px 13px; border-radius: $border-radius $border-radius 0 0; + height: 48px; + width: 100%; } .version { @@ -303,22 +339,22 @@ header { } .btn { - display: flex; align-items: center; - justify-content: center; - width: 87px; + display: flex; height: 30px; + justify-content: center; margin-bottom: 5px; + width: 87px; } .lukibear-logo { - flex: 1; - margin-top: 10px; - width: 100%; - background-repeat: no-repeat; background-position: 50% 50%; + background-repeat: no-repeat; background-size: 30%; border-radius: 0 0 $border-radius $border-radius; + flex: 1; + margin-top: 10px; + width: 100%; } } @@ -334,7 +370,7 @@ header { font-weight: normal; } - div.btn-group { + .btn-group { display: flex; flex-direction: row; flex-wrap: wrap; @@ -345,40 +381,36 @@ header { } .btn { - width: 42px; - height: 30px; - display: block; - background-size: auto 50%; - background-repeat: no-repeat; background-position: 50% 50%; + background-repeat: no-repeat; + background-size: auto 50%; + display: block; font-size: 16px; + height: 30px; + width: 42px; &:nth-child(1) { - border-right: 0; border-radius: $border-radius 0 0 $border-radius; + border-right: 0; } - & + .btn { + + .btn { border-radius: 0; } &:nth-child(3) { border-left: 0; - border-radius: 0 $border-radius $border-radius 0; - } - - &:nth-child(3) { border-radius: 0 $border-radius 0 0; } &:nth-child(4) { - border-top: 0; - border-right: 0; border-radius: 0 0 0 $border-radius; + border-right: 0; + border-top: 0; } &:last-child { - border-radius: 0 $border-radius $border-radius 0; + border-radius: 0 $border-radius $border-radius 0; } &:nth-child(5) { @@ -386,16 +418,16 @@ header { } &:nth-child(6) { - border-top: 0; border-left: 0; - border-radius: 0 0 $border-radius 0; + border-radius: 0 0 $border-radius; + border-top: 0; } } &.six-grid { .btn:nth-child(1) { + border-radius: $border-radius 0 0; border-right: 0; - border-radius: $border-radius 0 0 0; } .btn:nth-child(n + 4) { diff --git a/cli/lib/webapp.js b/cli/lib/webapp.js index d0fdc92..c0e96c9 100644 --- a/cli/lib/webapp.js +++ b/cli/lib/webapp.js @@ -4,19 +4,20 @@ * (c) 2014-2015 */ -var debug = require('debug')('rtail:webapp') - , get = require('request').defaults({ encoding: null }) +'use strict' + +const debug = require('debug')('rtail:webapp') +const get = require('request').defaults({ encoding: null }) // serve frontend from s3 module.exports = function webapp(opts) { - var cache = Object.create(null) - var cacheTTL = opts.cacheTTL - var s3 = opts.s3 + let cache = Object.create(null) + let cacheTTL = opts.cacheTTL + let s3 = opts.s3 - /** - * Middleware + /*! + * middleware */ - return function (req, res) { if (cache[req.path]) { return serveCache(req, res) @@ -34,21 +35,19 @@ module.exports = function webapp(opts) { }) } - /** - * Wipes out cache every cacheTTL ms + /*! + * wipes out cache every cachettl ms */ - setInterval(function () { cache = Object.create(null) }, cacheTTL) - /** - * Serves req from cache + /*! + * serves req from cache */ - function serveCache(req, res) { debug('serving from cache %s', req.path) res.writeHead(200, cache[req.path].headers) res.end(cache[req.path].body) } -} \ No newline at end of file +} diff --git a/cli/rtail-client.js b/cli/rtail-client.js new file mode 100755 index 0000000..188e463 --- /dev/null +++ b/cli/rtail-client.js @@ -0,0 +1,156 @@ +#!/bin/sh +":" //# comment; exec /usr/bin/env node --harmony "$0" "$@" + +/*! + * rtail-client.js + * Created by Kilian Ciuffolo on Oct 26, 2014 + * (c) 2014-2015 + */ + +'use strict' + +const dgram = require('dgram') +const split = require('split') +const chrono = require('chrono-node') +const JSON5 = require('json5') +const yargs = require('yargs') +const map = require('through2-map') +const stripAnsi = require('strip-ansi') +const moniker_ = require('moniker').choose +const updateNotifier = require('update-notifier') +const pkg = require('../package') + +/*! + * inform the user of updates + */ +updateNotifier({ + packageName: pkg.name, + packageVersion: pkg.version +}).notify() + +/*! + * parsing argv + */ +let argv = yargs + .usage('Usage: cmd | rtail [OPTIONS]') + .example('server | rtail > server.log', 'localhost + file') + .example('server | rtail --id api.domain.com', 'Name the log stream') + .example('server | rtail --host example.com', 'Sends to example.com') + .example('server | rtail --port 43567', 'Uses custom port') + .example('server | rtail --mute', 'No stdout') + .example('server | rtail --no-tty', 'Strips ansi colors') + .example('server | rtail --no-date-parse', 'Disable date parsing/stripping') + .option('host', { + alias: 'h', + type: 'string', + default: '127.0.0.1', + describe: 'The server host' + }) + .option('port', { + alias: 'p', + type: 'string', + default: 9999, + describe: 'The server port' + }) + .option('id', { + alias: 'name', + type: 'string', + default: function moniker() { return moniker_() } , + describe: 'The log stream id' + }) + .option('mute', { + alias: 'm', + type: 'boolean', + describe: 'Don\'t pipe stdin with stdout' + }) + .option('tty', { + type: 'boolean', + default: true, + describe: 'Keeps ansi colors' + }) + .option('parse-date', { + type: 'boolean', + default: true, + describe: 'Looks for dates to use as timestamp' + }) + .help('help') + .version(pkg.version, 'version') + .alias('version', 'v') + .strict() + .argv + +/*! + * setup pipes + */ +if (!argv.mute) { + if (!process.stdout.isTTY || !argv.tty) { + process.stdin + .pipe(map(function (chunk) { + return stripAnsi(chunk.toString('utf8')) + })) + .pipe(process.stdout) + } else { + process.stdin.pipe(process.stdout) + } +} + +/*! + * initialize socket + */ +let isClosed = false +let isSending = 0 +let socket = dgram.createSocket('udp4') +let baseMessage = { id: argv.id } + +socket.bind(function () { + socket.setBroadcast(true) +}) + +/*! + * broadcast lines to browser + */ +process.stdin + .pipe(split(null, null, { trailing: false })) + .on('data', function (line) { + let timestamp = null + + try { + // try to JSON parse + line = JSON5.parse(line) + } catch (err) { + // look for timestamps if not an object + timestamp = argv.parseDate ? chrono.parse(line)[0] : null + } + + if (timestamp) { + // escape for regexp and remove from line + timestamp.text = timestamp.text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') + line = line.replace(new RegExp(' *[^ ]?' + timestamp.text + '[^ ]? *'), '') + // use timestamp as line timestamp + baseMessage.timestamp = Date.parse(timestamp.start.date()) + } else { + baseMessage.timestamp = Date.now() + } + + // update default message + baseMessage.content = line + + // prepare binary message + let buffer = new Buffer(JSON.stringify(baseMessage)) + + // set semaphore + isSending ++ + + socket.send(buffer, 0, buffer.length, argv.port, argv.host, function () { + isSending -- + if (isClosed && !isSending) socket.close() + }) + }) + +/*! + * drain pipe and exit + */ +process.stdin.on('end', function () { + isClosed = true + if (!isSending) socket.close() +}) diff --git a/cli/rtail-server.js b/cli/rtail-server.js index 777d11d..eea31fd 100755 --- a/cli/rtail-server.js +++ b/cli/rtail-server.js @@ -10,7 +10,6 @@ 'use strict' const dgram = require('dgram') -const path = require('path') const app = require('express')() const serve = require('express').static const http = require('http').Server(app) @@ -21,44 +20,43 @@ const webapp = require('./lib/webapp') const updateNotifier = require('update-notifier') const pkg = require('../package') -/** - * Inform the user of updates +/*! + * inform the user of updates */ - updateNotifier({ packageName: pkg.name, packageVersion: pkg.version }).notify() -/** - * Parsing argv +/*! + * parsing argv */ - -var argv = yargs - .usage('Usage: rtail-server [--udp-host [string] --udp-port [num] --web-host [string] --web-port [num] --web-version [stable,unstable,]]') - .example('rtail-server --web-port 8080', 'Use custom http port') - .example('rtail-server --udp-port 8080', 'Use custom udp port') +let argv = yargs + .usage('Usage: rtail-server [OPTIONS]') + .example('rtail-server --web-port 8080', 'Use custom HTTP port') + .example('rtail-server --udp-port 8080', 'Use custom UDP port') .example('rtail-server --web-version stable', 'Always uses latest stable webapp') + .example('rtail-server --web-version unstable', 'Always uses latest develop webapp') .example('rtail-server --web-version 0.1.3', 'Use webapp v0.1.3') .option('udp-host', { alias: 'uh', default: '127.0.0.1', - describe: 'The listening udp hostname' + describe: 'The listening UDP hostname' }) .option('udp-port', { alias: 'up', default: 9999, - describe: 'The listening udp port' + describe: 'The listening UDP port' }) .option('web-host', { alias: 'wh', default: '127.0.0.1', - describe: 'The listening http hostname' + describe: 'The listening HTTP hostname' }) .option('web-port', { alias: 'wp', default: 8888, - describe: 'The listening http port' + describe: 'The listening HTTP port' }) .option('web-version', { type: 'string', @@ -71,25 +69,24 @@ var argv = yargs .strict() .argv -/** +/*! * UDP sockets setup */ - -var streams = {} -var socket = dgram.createSocket('udp4') +let streams = {} +let socket = dgram.createSocket('udp4') socket.on('message', function (data, remote) { // try to decode JSON try { data = JSON.parse(data) } - catch (e) { return debug('invalid data sent') } + catch (err) { return debug('invalid data sent') } if (!streams[data.id]) { streams[data.id] = [] io.sockets.emit('streams', Object.keys(streams)) } - var message = { - timestamp: Date.now(), + let message = { + timestamp: data.timestamp, streamid: data.id, host: remote.address, port: remote.port, @@ -105,10 +102,9 @@ socket.on('message', function (data, remote) { io.sockets.to(data.id).emit('line', message) }) -/** +/*! * socket.io */ - io.on('connection', function (socket) { socket.emit('streams', Object.keys(streams)) socket.on('select stream', function (stream) { @@ -119,10 +115,9 @@ io.on('connection', function (socket) { }) }) -/** - * Serve static webapp from s3 +/*! + * serve static webapp from S3 */ - if (!argv.webVersion) { app.use(serve(__dirname + '/../dist')) } else if ('development' === argv.webVersion) { @@ -138,13 +133,12 @@ if (!argv.webVersion) { debug('serving webapp from: http://rtail.s3-website-us-east-1.amazonaws.com/%s', argv.webVersion) } -/** - * Listen! +/*! + * listen! */ - io.attach(http, { serveClient: false }) socket.bind(argv.udpPort, argv.udpHost) http.listen(argv.webPort, argv.webHost) debug('UDP server listening: %s:%s', argv.udpHost, argv.udpPort) -debug('HTTP server listening: http://%s:%s', argv.webHost, argv.webPort) \ No newline at end of file +debug('HTTP server listening: http://%s:%s', argv.webHost, argv.webPort) diff --git a/cli/rtail.js b/cli/rtail.js deleted file mode 100755 index e738a72..0000000 --- a/cli/rtail.js +++ /dev/null @@ -1,143 +0,0 @@ -#!/bin/sh -":" //# comment; exec /usr/bin/env node --harmony "$0" "$@" - -/*! - * client.js - * Created by Kilian Ciuffolo on Oct 26, 2014 - * (c) 2014-2015 - */ - -'use strict' - -const dgram = require('dgram') -const split = require('split') -const crypto = require('crypto') -const Writable = require('stream').Writable -const tty = require('tty') -const JSON5 = require('json5') -const yargs = require('yargs') -const map = require('through2-map') -const stripAnsi = require('strip-ansi') -const moniker = require('moniker').choose -const updateNotifier = require('update-notifier') -const pkg = require('../package') - -/** - * Inform the user of updates - */ - -updateNotifier({ - packageName: pkg.name, - packageVersion: pkg.version -}).notify() - -// fix yargs help -moniker.toString = function () { return 'moniker()' } - -/** - * Parsing argv - */ - -var argv = yargs - .usage('Usage: cmd | rtail --host [string] --port [num] [--mute] [--id [string]]') - .example('server | rtail --host 127.0.0.1 > server.log', 'Broadcast to localhost + file') - .example('server | rtail --port 43567', 'Custom port') - .example('server | rtail --mute', 'Only remote', 'No stdout') - .example('server | rtail --id api.domain.com', 'Name the log stream') - .example('server | rtail --not-tty', 'Strips ansi colors') - .option('mute', { - alias: 'm', - type: 'boolean', - describe: 'Don\'t pipe stdin with stdout' - }) - .option('host', { - alias: 'h', - type: 'string', - default: '127.0.0.1', - describe: 'The recipient server host' - }) - .option('port', { - alias: 'p', - type: 'string', - default: 9999, - describe: 'The recipient server port' - }) - .option('id', { - alias: 'name', - type: 'string', - default: moniker, - describe: 'The log stream id' - }) - .option('not-tty', { - type: 'boolean', - describe: 'Strips ansi colors' - }) - .help('help') - .version(pkg.version, 'version') - .alias('version', 'v') - .strict() - .argv - -/** - * Setup pipes - */ - -if (!argv.mute) { - if (!process.stdout.isTTY || argv['not-tty']) { - process.stdin - .pipe(map(function (chunk) { - return stripAnsi(chunk.toString('utf8')) - })) - .pipe(process.stdout) - } else { - process.stdin.pipe(process.stdout) - } -} - -/** - * Initialize socket - */ - -var isClosed = false -var isSending = 0 -var socket = dgram.createSocket('udp4') -var baseMessage = { id: argv.id } - -socket.bind(function () { - socket.setBroadcast(true) -}) - -/** - * Broadcast lines to browser - */ - -process.stdin - .pipe(split()) - .on('data', function (line) { - // set semaphore - isSending ++ - - // try to JSON parse - try { line = JSON5.parse(line) } - catch (e) {} - - // update default message - baseMessage.content = line - - // prepare binary message - var buffer = new Buffer(JSON.stringify(baseMessage)) - - socket.send(buffer, 0, buffer.length, argv.port, argv.host, function () { - isSending -- - if (isClosed && !isSending) socket.close() - }) - }) - -/** - * Drain pipe and exit - */ - -process.stdin.on('end', function () { - isClosed = true - if (!isSending) socket.close() -}) \ No newline at end of file diff --git a/gulpfile.js b/gulpfile.js index 3a01e8d..a8dac63 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -3,7 +3,6 @@ var run = require('run-sequence') var plugins = require('gulp-load-plugins')() var del = require('del') var autoprefixer = require('autoprefixer-core') -var express = require('express') var version = require('./package.json').version var spawn = require('child_process').spawn @@ -95,7 +94,7 @@ gulp.task('hjs', function (done) { */ gulp.task('app', ['build:app'], function (done) { - gulp.watch('app/css/*.scss', ['sass']) + gulp.watch('app/scss/*.scss', ['sass']) gulp.watch('app/app.ejs', ['ejs']) plugins.livereload({ start: true }) @@ -108,17 +107,19 @@ gulp.task('app', ['build:app'], function (done) { plugins.livereload.changed(file.path) }) - plugins.util.log('spinning rtail client and server ...') + plugins.util.log('spinning rtail client and server ... http://localhost:8888/app') var rTailServer = spawn('node', ['--harmony', 'cli/rtail-server.js', '--web-version', 'development']) rTailServer.stdout.pipe(process.stdout) rTailServer.stderr.pipe(process.stdout) - var rTailClient = spawn('node', ['--harmony', 'cli/rtail.js']) + var rTailClient = spawn('node', ['--harmony', 'cli/rtail-client.js']) rTailClient.stdout.pipe(process.stdout) rTailClient.stderr.pipe(process.stdout) var lines = [ + '', + 'A B C', '200 GET /1/geocode?address=ny', '200 GET /1/config', '500 GET /1/users/556605ede9fa35333befa9e6/profile', @@ -142,7 +143,7 @@ gulp.task('app', ['build:app'], function (done) { setInterval(function () { var debug = require('debug')('api:logs') - var index = Math.round(Math.random() * lines.length) + var index = Math.floor(Math.random() * lines.length) var line = lines[index] debug.log = log2rtail @@ -252,4 +253,4 @@ gulp.task('build:dist', function (done) { * Default task */ -gulp.task('default', ['build:dist']) \ No newline at end of file +gulp.task('default', ['build:dist']) diff --git a/package.json b/package.json index 43c9e3d..b189fd3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "rtail", - "version": "0.1.1", - "description": "Pipe your terminal output to the browser in seconds, using UNIX pipes.", + "version": "0.2.0", + "description": "Terminal output to the browser in seconds, using UNIX pipes.", "keywords": [ "tail", "log", @@ -11,7 +11,7 @@ "udp" ], "bin": { - "rtail": "./cli/rtail.js", + "rtail": "./cli/rtail-client.js", "rtail-server": "./cli/rtail-server.js" }, "repository": { @@ -19,17 +19,18 @@ "url": "git@github.com:kilianc/rtail.git" }, "dependencies": { + "chrono-node": "^1.0.6", "debug": "^2.1.0", "express": "^4.10.0", "json5": "^0.4.0", "moniker": "^0.1.2", - "request": "^2.47.0", + "request": "^2.58.0", "socket.io": "^1.1.0", - "split": "^0.3.3", - "strip-ansi": "^2.0.1", - "through2-map": "^1.4.0", - "update-notifier": "^0.2.2", - "yargs": "^1.3.2" + "split": "^1.0.0", + "strip-ansi": "^3.0.0", + "through2-map": "^2.0.0", + "update-notifier": "^0.5.0", + "yargs": "^3.14.0" }, "devDependencies": { "angular": "^1.3.15", @@ -37,11 +38,12 @@ "angular-localforage": "^1.2.2", "angular-moment": "^0.10.1", "angular-rt-popup": "^1.0.5", + "angular-ui-router": "^0.2.15", "ansi_up": "^1.2.1", "autoprefixer-core": "^5.1.11", + "chai": "^3.0.0", "del": "^1.1.1", - "highlight.js": "isagalaev/highlight.js", - "gulp": "^3.8.11", + "gulp": "^3.9.0", "gulp-ejs": "^1.1.0", "gulp-livereload": "^3.8.0", "gulp-load-plugins": "^0.10.0", @@ -54,16 +56,21 @@ "gulp-uglify": "^1.2.0", "gulp-useref": "^1.1.2", "gulp-util": "^3.0.4", + "highlight.js": "isagalaev/highlight.js", + "istanbul": "^0.3.17", + "istanbul-harmony": "^0.3.16", "jquery": "^2.1.4", "localforage": "^1.2.2", + "mocha": "^2.2.5", "moment": "^2.10.3", "node-sass": "^3.1.1", "run-sequence": "^1.1.0" }, "scripts": { - "test": "echo OK", - "app": "DEBUG=rtail:*,api:* gulp app" + "app": "DEBUG=rtail:*,api:* gulp app", + "test": "NODE_ENV=test node --harmony test/runner.js", + "test:watch": "NODE_ENV=test nodemon --harmony test/runner.js" }, "author": "Kilian Ciuffolo ", "license": "MIT" -} \ No newline at end of file +} diff --git a/test/rtail-client.test.js b/test/rtail-client.test.js new file mode 100644 index 0000000..b4b6635 --- /dev/null +++ b/test/rtail-client.test.js @@ -0,0 +1,131 @@ +/*! + * cli.test.js + * Created by Kilian Ciuffolo on Jul 7, 2015 + * (c) 2015 + */ + +'use strict' + +const assert = require('chai').assert +const dgram = require('dgram') +const dns = require('dns') +const os = require('os') +const s = require('./util').s +const spawnClient = require('./util').spawnClient + +describe('rtail-client.js', function () { + it('should split stdin by \\n', function (done) { + spawnClient({ + args: [], + done: done, + test: function (messages) { + assert.equal(3, messages.length, s(messages)) + + assert.equal(messages[0].content, '0') + assert.equal(messages[1].content, '1') + assert.equal(messages[2].content, '2') + + assert.isDefined(messages[0].id) + assert.isNumber(messages[0].timestamp) + } + }).stdin.end(['0', '1', '2', ''].join('\n')) + }) + + it('should use custom name', function (done) { + spawnClient({ + args: ['--name', 'test'], + done: done, + test: function (messages) { + assert.equal(3, messages.length, s(messages)) + assert.equal(messages[0].id, 'test') + } + }).stdin.end(['0', '1', '2', ''].join('\n')) + }) + + it('should respect --mute', function (done) { + let client = spawnClient({ args: ['--mute'], done: done }) + client.stdout.on('data', function (data) { + done(new Error('Expected no output instead got: "' + data.toString() + '"')) + }) + client.stdin.end(['0', '1', '2', ''].join('\n')) + }) + + it('should parse JSON lines', function (done) { + spawnClient({ + args: [], + done: done, + test: function (messages) { + assert.equal(1, messages.length, s(messages)) + assert.equal(messages[0].content.foo, 'bar') + } + }).stdin.end(['{ "foo": "bar" }', ''].join('\n')) + }) + + it('should parse JSON5 lines', function (done) { + spawnClient({ + args: [], + done: done, + test: function (messages) { + assert.equal(1, messages.length, s(messages)) + assert.equal(messages[0].content.foo, 'bar') + } + }).stdin.end(['{ foo: "bar" }', ''].join('\n')) + }) + + it('should support custom port / host', function (done) { + dns.lookup(os.hostname(), function (err, address) { + let socket = dgram.createSocket('udp4') + socket.bind(9998, address) + + spawnClient({ + done: done, + socket: socket, + args: ['-p', '9998', '-h', address], + test: function (messages) { + assert.equal(1, messages.length, s(messages)) + assert.equal(messages[0].content.foo, 'bar') + } + }).stdin.end(['{ foo: "bar" }', ''].join('\n')) + }) + }) + + it('should strip colors with --no-tty', function (done) { + let client = spawnClient({ + args: ['--no-tty'], + done: done + }) + + client.stdout.on('data', function (data) { + assert.equal(data.toString(), 'Hello world\n') + }) + + client.stdin.end(['\u001b[31mHello world\u001b[0m', ''].join('\n')) + }) + + it('should parse date if --parse-date', function (done) { + let date = 'Wed Jul 08 2010 01:01:03 GMT-0700 (PDT)' + let client = spawnClient({ + done: done, + test: function (messages) { + assert.equal(messages[0].timestamp, Date.parse(date)) + assert.equal(messages[0].content, 'hello') + } + }) + + client.stdin.end(['[' + date + '] hello', ''].join('\n')) + }) + + it('should not parse date if --no-parse-date', function (done) { + let date = 'Wed Jul 08 2010 01:01:03 GMT-0700 (PDT)' + let client = spawnClient({ + args: ['--no-parse-date'], + done: done, + test: function (messages) { + assert.notEqual(messages[0].timestamp, Date.parse(date)) + assert.equal(messages[0].content, '[' + date + '] hello') + } + }) + + client.stdin.end(['[' + date + '] hello', ''].join('\n')) + }) +}) diff --git a/test/rtail-server.test.js b/test/rtail-server.test.js new file mode 100644 index 0000000..dca0e5d --- /dev/null +++ b/test/rtail-server.test.js @@ -0,0 +1,138 @@ +/*! + * rtail-server.js + * Created by Kilian Ciuffolo on Jul 7, 2015 + * (c) 2015 + */ + +'use strict' + +const assert = require('chai').assert +const dgram = require('dgram') +const dns = require('dns') +const io = require('socket.io/node_modules/socket.io-client') +const spawnClient = require('./util').spawnClient +const spawnServer = require('./util').spawnServer +const get = require('request').get +const os = require('os') + +describe('rtail-server.js', function () { + this.timeout(5000) + + let server = null + let streams = null + let lines = [] + let backlogs = [] + let fakeSocket = { close: function () {}, on: function () {} } + + before(function (done) { + server = spawnServer() + + setTimeout(function () { + spawnClient({ socket: fakeSocket }).stdin.end(['1', '2', ''].join('\n')) + spawnClient({ socket: fakeSocket }).stdin.end(['1', '2', ''].join('\n')) + + setTimeout(function () { + let ws = io.connect('http://localhost:8888') + + ws.on('streams', function (data) { + if (null === streams) { + ws.emit('select stream', data[0]) + } + streams = data + }) + + ws.on('backlog', function (data) { + backlogs.push(data) + spawnClient({ args: ['--name', streams[0]], socket: fakeSocket }).stdin.end(['A', 'B', ''].join('\n')) + }) + + ws.on('line', function (data) { + lines.push(data) + if (lines.length >= 2) done() + }) + }, 500) + }, 500) + }) + + after(function () { + server.kill() + }) + + it('should send the streams list on connect (WS)', function () { + assert.lengthOf(streams, 2) + }) + + it('should listen for messages', function () { + assert.equal(lines[0].content, 'A') + assert.equal(lines[1].content, 'B') + }) + + it('should skip non JSON messages', function () { + let socket = dgram.createSocket('udp4') + let buffer = new Buffer('foo') + socket.send(buffer, 0, buffer.length, 9999, 'localhost') + }) + + it('should serve the webapp', function (done) { + server.kill() + server = spawnServer() + + setTimeout(function () { + get('http://localhost:8888', function (err, res, body) { + if (err) return done(err) + assert.match(body, /ng-app="app"/) + done(err) + }) + }, 1000) + }) + + + it('should serve the webapp from s3', function (done) { + server.kill() + server = spawnServer({ + args: ['--web-version', 'stable'] + }) + + setTimeout(function () { + get('http://localhost:8888/index.html', function (err, res, body) { + if (err) return done(err) + assert.match(body, /ng-app="app"/) + assert.isDefined(res.headers['x-amz-request-id']) + done(err) + }) + }, 1000) + }) + + it('should support custom port / host', function (done) { + server.kill() + + dns.lookup(os.hostname(), function (err, address) { + server = spawnServer({ + args: [ + '--udp-host', address, + '--udp-port', 9998, + '--web-host', address, + '--web-port', 8889, + ] + }) + + // check websocket + setTimeout(function () { + spawnClient({ + socket: fakeSocket, + args: ['--port', 9998, '--host', address, '--name', 'foobar'] + }).stdin.end(['1', '2', ''].join('\n')) + + setTimeout(function () { + let ws = io.connect('http://' + address + ':8889') + + ws.on('streams', function (data) { + assert.lengthOf(data, 1) + assert.equal(data[0], 'foobar') + done() + }) + }, 500) + }, 1000) + }) + }) +}) diff --git a/test/runner.js b/test/runner.js new file mode 100644 index 0000000..9c3fb68 --- /dev/null +++ b/test/runner.js @@ -0,0 +1,31 @@ +/*! + * runner.js + * Created by Kilian Ciuffolo on Jul 7, 2015 + * (c) 2015 + */ + +'use strict' + +const path = require('path') +const Mocha = require('mocha') + +const argv = process.argv +const NODE_ENV = process.env.NODE_ENV +const regExp = new RegExp((argv[2] || '').trim() || '.') + +console.log(' Running test suite with NODE_ENV=%s (%s)', NODE_ENV, regExp) + +let mocha = new Mocha() +mocha.suite.bail(true) +mocha.reporter('spec') +mocha.useColors(true) + +;[ + 'rtail-client', + 'rtail-server' +].forEach(function (file) { + if (!regExp.test(file)) return + mocha.addFile(path.join(__dirname, '../', 'test', file + '.test.js')) +}) + +mocha.run(process.exit) diff --git a/test/util.js b/test/util.js new file mode 100644 index 0000000..5af3e1a --- /dev/null +++ b/test/util.js @@ -0,0 +1,65 @@ +/*! + * util.js + * Created by Kilian Ciuffolo on Jul 7, 2015 + * (c) 2015 + */ + +'use strict' + +const spawn = require('child_process').spawn +const dgram = require('dgram') + +/** + * + */ +module.exports.spawnClient = function spawnClient(opts) { + opts = opts || {} + + if (!opts.socket) { + opts.socket = dgram.createSocket('udp4') + opts.socket.bind(9999) + } + + let client = spawn('cli/rtail-client.js', opts.args) + let messages = [] + + client.stderr.pipe(process.stderr) + + opts.socket.on('message', function (data) { + messages.push(JSON.parse(data)) + }) + + client.on('exit', function (code) { + let err = code ? new Error('rtail exited with code: ' + code) : null + opts.test && opts.test(messages) + opts.socket.close() + opts.done && opts.done(err) + }) + + return client +} + +/** + * + */ +module.exports.spawnServer = function spawnServer(opts) { + opts = opts || {} + + let server = spawn('cli/rtail-server.js', opts.args) + server.stderr.pipe(process.stderr) + server.stdout.pipe(process.stdout) + + server.on('exit', function (code) { + let err = code ? new Error('rtail exited with code: ' + code) : null + opts.done && opts.done(err) + }) + + return server +} + +/** + * + */ +module.exports.s = function s(obj) { + return JSON.stringify(obj, null, ' ') +} diff --git a/wercker.yml b/wercker.yml index 4faf3ca..71962a6 100644 --- a/wercker.yml +++ b/wercker.yml @@ -3,8 +3,8 @@ box: wercker/nodejs build: steps: - npm-install - - npm-test - hgen/gulp + - npm-test deploy: steps: