├── .gitignore ├── .npmignore ├── app ├── images │ ├── common │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon-96x96.png │ │ ├── icn-filter.svg │ │ ├── btn-fav-on.svg │ │ ├── icn-resume.svg │ │ ├── btn-fav-off.svg │ │ └── logo-rtail.svg │ ├── light │ │ ├── btn-json-expand.svg │ │ ├── btn-json-collapse.svg │ │ ├── icn-font-reset.svg │ │ ├── icn-font-decrease.svg │ │ ├── icn-theme-dark.svg │ │ ├── icn-search.svg │ │ ├── icn-close-date.svg │ │ ├── btn-info.svg │ │ ├── icn-font-increase.svg │ │ ├── btn-github.svg │ │ ├── btn-settings.svg │ │ ├── icn-sort-desc.svg │ │ ├── icn-sort-asc.svg │ │ ├── logo-rtail-info.svg │ │ ├── icn-theme-light.svg │ │ └── logo-lukibear.svg │ └── dark │ │ ├── btn-json-expand.svg │ │ ├── icn-font-reset.svg │ │ ├── btn-json-collapse.svg │ │ ├── icn-font-decrease.svg │ │ ├── icn-theme-dark.svg │ │ ├── icn-search.svg │ │ ├── icn-close-date.svg │ │ ├── btn-info.svg │ │ ├── icn-font-increase.svg │ │ ├── btn-github.svg │ │ ├── btn-settings.svg │ │ ├── icn-sort-desc.svg │ │ ├── icn-sort-asc.svg │ │ ├── logo-rtail-info.svg │ │ ├── icn-theme-light.svg │ │ └── logo-lukibear.svg ├── scss │ ├── _highlightjs.scss │ ├── _fonts.scss │ ├── _ansi.scss │ ├── dark-theme.scss │ ├── light-theme.scss │ ├── _theme.scss │ └── main.scss ├── index.html └── app.ejs ├── .jshintrc ├── scripts ├── set-version.sh └── npm-publish.sh ├── .scss-lint.yml ├── wercker.yml ├── test ├── runner.js ├── util.js ├── rtail-server.test.js └── rtail-client.test.js ├── .jscsrc ├── cli ├── lib │ └── webapp.js ├── rtail-server.js └── rtail-client.js ├── package.json ├── gulpfile.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | app/css/* 2 | app/*.js 3 | dist 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | !dist 2 | app 3 | gulpfile.js 4 | wercker.yml -------------------------------------------------------------------------------- /app/images/common/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kilianc/rtail/HEAD/app/images/common/favicon-16x16.png -------------------------------------------------------------------------------- /app/images/common/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kilianc/rtail/HEAD/app/images/common/favicon-32x32.png -------------------------------------------------------------------------------- /app/images/common/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kilianc/rtail/HEAD/app/images/common/favicon-96x96.png -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "asi": true, 3 | "esnext": true, 4 | "expr": true, 5 | "mocha": true, 6 | "node": true, 7 | "noyield": true, 8 | "strict": true, 9 | "unused": true, 10 | "validthis": true 11 | } 12 | -------------------------------------------------------------------------------- /scripts/set-version.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | if [ ! -z "$NPM_PUBLISH" ]; then 4 | echo "setting VERSION env" 5 | export VERSION=`node -e "console.log(require('./package.json').version)"` 6 | export AWS_BUCKET_URL="$AWS_BUCKET_URL/$VERSION/" 7 | fi -------------------------------------------------------------------------------- /.scss-lint.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | ImportantRule: 3 | enabled: false 4 | Compass::*: 5 | enabled: false 6 | SelectorDepth: 7 | enabled: false 8 | NestingDepth: 9 | enabled: false 10 | PropertyCount: 11 | enabled: false 12 | -------------------------------------------------------------------------------- /app/scss/_highlightjs.scss: -------------------------------------------------------------------------------- 1 | // ** 2 | // colors for JSON objects hightlight 3 | // ** 4 | 5 | .#{$theme} { 6 | pre { 7 | color: $ansi-blue; 8 | } 9 | 10 | .hljs-string { 11 | color: $ansi-green-bright; 12 | } 13 | 14 | .hljs-number { 15 | color: $ansi-red-bright; 16 | } 17 | 18 | .hljs-attribute { 19 | color: $ansi-yellow-bright; 20 | } 21 | } -------------------------------------------------------------------------------- /scripts/npm-publish.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | if [ ! -n "$NPM_PUBLISH" ]; then 4 | echo "Skipping npm publish, NPM_PUBLISH is not set." 5 | exit 0 6 | fi 7 | 8 | if [ ! -n "$NPM_TOKEN" ]; then 9 | echo "Please set NPM_TOKEN env variable" 10 | exit 1 11 | fi 12 | 13 | if [ ! -n "$NPM_EMAIL" ]; then 14 | echo "Please set NPM_EMAIL env variable" 15 | exit 1 16 | fi 17 | 18 | echo "_auth = $NPM_TOKEN" > ~/.npmrc 19 | echo "email = $NPM_EMAIL" >> ~/.npmrc 20 | 21 | npm publish -------------------------------------------------------------------------------- /wercker.yml: -------------------------------------------------------------------------------- 1 | box: wercker/nodejs 2 | 3 | build: 4 | steps: 5 | - npm-install 6 | - hgen/gulp 7 | - npm-test 8 | 9 | deploy: 10 | steps: 11 | - script: 12 | name: set $VERSION 13 | code: source scripts/set-version.sh 14 | - s3sync: 15 | key_id: $AWS_ACCESS_KEY_ID 16 | key_secret: $AWS_SECRET_ACCESS_KEY 17 | bucket_url: $AWS_BUCKET_URL 18 | source_dir: dist/ 19 | - script: 20 | name: npm publish 21 | code: scripts/npm-publish.sh 22 | -------------------------------------------------------------------------------- /test/runner.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * runner.js 3 | * Created by Kilian Ciuffolo on Jul 7, 2015 4 | * (c) 2015 5 | */ 6 | 7 | 'use strict' 8 | 9 | const path = require('path') 10 | const Mocha = require('mocha') 11 | 12 | const argv = process.argv 13 | const NODE_ENV = process.env.NODE_ENV 14 | const regExp = new RegExp((argv[2] || '').trim() || '.') 15 | 16 | console.log(' Running test suite with NODE_ENV=%s (%s)', NODE_ENV, regExp) 17 | 18 | let mocha = new Mocha() 19 | mocha.suite.bail(true) 20 | mocha.reporter('spec') 21 | mocha.useColors(true) 22 | 23 | ;[ 24 | 'rtail-client', 25 | 'rtail-server' 26 | ].forEach(function (file) { 27 | if (!regExp.test(file)) return 28 | mocha.addFile(path.join(__dirname, '../', 'test', file + '.test.js')) 29 | }) 30 | 31 | mocha.run(process.exit) 32 | -------------------------------------------------------------------------------- /app/scss/_fonts.scss: -------------------------------------------------------------------------------- 1 | // ** 2 | // font variables 3 | // ** 4 | 5 | $font-families: ( 6 | "Inconsolata" 7 | "Roboto Mono" 8 | "Source Code Pro" 9 | "Ubuntu Mono" 10 | "Droid Sans Mono" 11 | "Anonymous Pro" 12 | ); 13 | 14 | $font-sizes: ( 15 | 11px 16 | 12px 17 | 13px 18 | 14px 19 | 15px 20 | 16px 21 | 17px 22 | ); 23 | 24 | // ** 25 | // font families 26 | // ** 27 | 28 | @each $font-family in $font-families { 29 | $i: index($font-families, $font-family); 30 | 31 | .font-family-#{$i} .stream-line, 32 | .btn-font-#{$i} { 33 | font-family: $font-family; 34 | } 35 | } 36 | 37 | // ** 38 | // font sizes 39 | // ** 40 | 41 | @each $font-size in $font-sizes { 42 | $i: index($font-sizes, $font-size); 43 | 44 | .font-size-#{$i} .stream-line { 45 | font-size: $font-size; 46 | } 47 | } -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "airbnb", 3 | "esnext": true, 4 | "verbose": true, 5 | "disallowCapitalizedComments": true, 6 | "disallowMultipleLineBreaks": true, 7 | "disallowMultipleSpaces": true, 8 | "disallowSemicolons": true, 9 | "disallowSpacesInAnonymousFunctionExpression": null, 10 | "disallowSpacesInFunctionExpression": null, 11 | "disallowYodaConditions": null, 12 | "requireCamelCaseOrUpperCaseIdentifiers": null, 13 | "requirePaddingNewLinesAfterUseStrict": true, 14 | "requireSpacesInAnonymousFunctionExpression": { 15 | "beforeOpeningRoundBrace": true, 16 | "beforeOpeningCurlyBrace": true 17 | }, 18 | "safeContextKeyword": ["self", "doc", "ctx"], 19 | "validateIndentation": 2, 20 | "plugins": [ 21 | "jscs-jsdoc" 22 | ], 23 | "jsDoc": { 24 | "checkAnnotations": "closurecompiler", 25 | "checkTypes": "strictNativeCase", 26 | "enforceExistence": "exceptExports" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/images/common/icn-filter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | common/icn-filter 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/images/common/btn-fav-on.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | common/btn-fav-on 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /cli/lib/webapp.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * webapp.js 3 | * Created by Kilian Ciuffolo on Nov 11, 2014 4 | * (c) 2014-2015 5 | */ 6 | 7 | 'use strict' 8 | 9 | const debug = require('debug')('rtail:webapp') 10 | const get = require('request').defaults({ encoding: null }) 11 | 12 | // serve frontend from s3 13 | module.exports = function webapp(opts) { 14 | let cache = Object.create(null) 15 | let cacheTTL = opts.ttl 16 | let s3 = opts.s3 17 | 18 | /*! 19 | * wipes out cache every cacheTTL ms 20 | */ 21 | setInterval(function () { 22 | cache = Object.create(null) 23 | debug('cleared cache') 24 | }, cacheTTL) 25 | 26 | /*! 27 | * middleware 28 | */ 29 | return function (req, res) { 30 | if (cache[req.path]) { 31 | return serveCache(req, res) 32 | } 33 | 34 | debug('caching %s', req.path) 35 | 36 | get(s3 + req.path, function (err, s3res, body) { 37 | cache[req.path] = { 38 | headers: s3res.headers, 39 | body: body 40 | } 41 | 42 | serveCache(req, res) 43 | }) 44 | } 45 | 46 | /*! 47 | * serves req from cache 48 | */ 49 | function serveCache(req, res) { 50 | debug('serving from cache %s', req.path) 51 | res.writeHead(200, cache[req.path].headers) 52 | res.end(cache[req.path].body) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/images/common/icn-resume.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | common/icn-resume 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/images/light/btn-json-expand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | light/btn-json-expand 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/images/dark/btn-json-expand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dark/btn-json-expand 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/images/light/btn-json-collapse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | light/btn-json-collapse 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/images/dark/icn-font-reset.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dark/icn-font-reset 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/images/light/icn-font-reset.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | light/icn-font-reset 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/images/dark/btn-json-collapse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dark/btn-json-collapse 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/images/dark/icn-font-decrease.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dark/icn-font-decrease 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/images/light/icn-font-decrease.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | light/icn-font-decrease 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/images/common/btn-fav-off.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | common/btn-fav-off 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/images/dark/icn-theme-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dark/icn-theme-dark 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/images/light/icn-theme-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | light/icn-theme-dark 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * util.js 3 | * Created by Kilian Ciuffolo on Jul 7, 2015 4 | * (c) 2015 5 | */ 6 | 7 | 'use strict' 8 | 9 | const spawn = require('child_process').spawn 10 | const dgram = require('dgram') 11 | 12 | /** 13 | * 14 | */ 15 | module.exports.spawnClient = function spawnClient(opts) { 16 | opts = opts || {} 17 | 18 | if (!opts.socket) { 19 | opts.socket = dgram.createSocket('udp4') 20 | opts.socket.bind(9999) 21 | } 22 | 23 | let client = spawn('cli/rtail-client.js', opts.args) 24 | let messages = [] 25 | 26 | client.stderr.pipe(process.stderr) 27 | 28 | opts.socket.on('message', function (data) { 29 | messages.push(JSON.parse(data)) 30 | }) 31 | 32 | client.on('exit', function (code) { 33 | let err = code ? new Error('rtail exited with code: ' + code) : null 34 | opts.test && opts.test(messages) 35 | opts.socket.close() 36 | opts.done && opts.done(err) 37 | }) 38 | 39 | return client 40 | } 41 | 42 | /** 43 | * 44 | */ 45 | module.exports.spawnServer = function spawnServer(opts) { 46 | opts = opts || {} 47 | 48 | let server = spawn('cli/rtail-server.js', opts.args) 49 | server.stderr.pipe(process.stderr) 50 | server.stdout.pipe(process.stdout) 51 | 52 | server.on('exit', function (code) { 53 | let err = code ? new Error('rtail exited with code: ' + code) : null 54 | opts.done && opts.done(err) 55 | }) 56 | 57 | return server 58 | } 59 | 60 | /** 61 | * 62 | */ 63 | module.exports.s = function s(obj) { 64 | return JSON.stringify(obj, null, ' ') 65 | } 66 | -------------------------------------------------------------------------------- /app/scss/_ansi.scss: -------------------------------------------------------------------------------- 1 | .#{$theme} { 2 | 3 | // ** 4 | // black 5 | // ** 6 | 7 | .ansi-black-fg { 8 | color: $ansi-black; 9 | } 10 | 11 | .ansi-bright-black-fg { 12 | color: $ansi-black-bright; 13 | } 14 | 15 | // ** 16 | // red 17 | // ** 18 | 19 | .ansi-red-fg { 20 | color: $ansi-red; 21 | } 22 | 23 | .ansi-bright-red-fg { 24 | color: $ansi-red-bright; 25 | } 26 | 27 | // ** 28 | // green 29 | // ** 30 | 31 | .ansi-green-fg { 32 | color: $ansi-green; 33 | } 34 | 35 | .ansi-bright-green-fg { 36 | color: $ansi-green-bright; 37 | } 38 | 39 | // ** 40 | // yellow 41 | // ** 42 | 43 | .ansi-yellow-fg { 44 | color: $ansi-yellow; 45 | } 46 | 47 | .ansi-bright-yellow-fg { 48 | color: $ansi-yellow-bright; 49 | } 50 | 51 | // ** 52 | // blue 53 | // ** 54 | 55 | .ansi-blue-fg { 56 | color: $ansi-blue; 57 | } 58 | 59 | .ansi-bright-blue-fg { 60 | color: $ansi-blue-bright; 61 | } 62 | 63 | // ** 64 | // magenta 65 | // ** 66 | 67 | .ansi-magenta-fg { 68 | color: $ansi-magenta; 69 | } 70 | 71 | .ansi-bright-magenta-fg { 72 | color: $ansi-magenta-bright; 73 | } 74 | 75 | // ** 76 | // cyan 77 | // ** 78 | 79 | .ansi-cyan-fg { 80 | color: $ansi-cyan; 81 | } 82 | 83 | .ansi-bright-cyan-fg { 84 | color: $ansi-cyan-bright; 85 | } 86 | 87 | // ** 88 | // white 89 | // ** 90 | 91 | .ansi-white-fg { 92 | color: $ansi-white; 93 | } 94 | 95 | .ansi-bright-white-fg { 96 | color: $ansi-white-bright; 97 | } 98 | } -------------------------------------------------------------------------------- /app/images/dark/icn-search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dark/icn-search 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/images/light/icn-search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | light/icn-search 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/images/dark/icn-close-date.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dark/icn-close-date 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/images/light/icn-close-date.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | light/icn-close-date 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/images/dark/btn-info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dark/btn-info 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/images/dark/icn-font-increase.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dark/icn-font-increase 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/images/light/btn-info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | light/btn-info 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/images/light/icn-font-increase.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | light/icn-font-increase 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/scss/dark-theme.scss: -------------------------------------------------------------------------------- 1 | // ** 2 | // define theme specific variables 3 | // ** 4 | 5 | $theme: dark; 6 | 7 | $main-color: #ec7b65; 8 | $header-bg-color: #fdfdfd; 9 | 10 | $stream-search-color: #a6a6a6; 11 | $stream-search-bg-color: #404E57; 12 | 13 | $stream-list-bg-color: #374249; 14 | $stream-list-title-color: #f0f0f0; 15 | $stream-list-color: #787e80; 16 | $stream-list-selected-color: #45a7b9; 17 | $stream-list-selected-bg-color: rgba(69, 167, 185, 0.17); 18 | $stream-list-bg-hover-color: lighten($stream-list-bg-color, 2%); 19 | 20 | $stream-view-bg-color: #2f383f; 21 | $stream-view-content-color: #cfcfcf; 22 | $stream-view-content-bg-hover-color: lighten($stream-view-bg-color, 5%); 23 | $stream-view-timestamp-color: #6d6d6d; 24 | $stream-view-timestamp-border-color: #444c52; 25 | 26 | $popover-font-color: #6b7780; 27 | $popover-bg-color: #fdfdfd; 28 | $popover-alt-bg-color: #f5f5f5; 29 | $popover-btn-bg-hover-color: #f0f0f0; 30 | $popover-btn-bg-active-color: darken($popover-btn-bg-hover-color, 10%); 31 | $popover-btn-border-color: #c7c7c7; 32 | $popover-btn-font-color: rgba(#7a8891, 0.6); 33 | $popover-btn-font-selected-color: darken($popover-btn-font-color, 30%); 34 | 35 | $ansi-black: #6B6F72; 36 | $ansi-black-bright: #BDC3C7; 37 | $ansi-red: #C0392B; 38 | $ansi-red-bright: #E74C3C; 39 | $ansi-green: #27AF60; 40 | $ansi-green-bright: #2ECC71; 41 | $ansi-yellow: #F39C12; 42 | $ansi-yellow-bright: #F1C40F; 43 | $ansi-blue: #2E8DCD; 44 | $ansi-blue-bright: #3498DB; 45 | $ansi-magenta: #8E44AD; 46 | $ansi-magenta-bright: #9B59B6; 47 | $ansi-cyan: #0097A4; 48 | $ansi-cyan-bright: #02C8D9; 49 | $ansi-white: #BDC3C7; 50 | $ansi-white-bright: #FFFFFF; 51 | 52 | // ** 53 | // import templates 54 | // ** 55 | 56 | @import "_theme.scss"; 57 | @import "_ansi.scss"; 58 | @import "_highlightjs.scss"; -------------------------------------------------------------------------------- /app/scss/light-theme.scss: -------------------------------------------------------------------------------- 1 | // ** 2 | // define theme specific variables 3 | // ** 4 | 5 | $theme: light; 6 | 7 | $main-color: #ec7b65; 8 | $header-bg-color: #374249; 9 | 10 | $stream-search-color: #787e80; 11 | $stream-search-bg-color: #dddddd; 12 | 13 | $stream-list-bg-color: #ececec; 14 | $stream-list-title-color: #495861; 15 | $stream-list-color: #787e80; 16 | $stream-list-selected-color: #45a7b9; 17 | $stream-list-selected-bg-color: rgba(69, 167, 185, 0.17); 18 | $stream-list-bg-hover-color: $stream-list-selected-bg-color; 19 | 20 | $stream-view-bg-color: #fff; 21 | $stream-view-content-color: #6D6D6D; 22 | $stream-view-content-bg-hover-color: darken($stream-view-bg-color, 3%); 23 | $stream-view-timestamp-color: #bbbbbb; 24 | $stream-view-timestamp-border-color: #ececec; 25 | 26 | $popover-font-color: #fff; 27 | $popover-bg-color: #374249; 28 | $popover-alt-bg-color: #434C53; 29 | $popover-btn-bg-hover-color: lighten($popover-bg-color, 5%); 30 | $popover-btn-bg-active-color: darken($popover-btn-bg-hover-color, 10%); 31 | $popover-btn-border-color: #6B7780; 32 | $popover-btn-font-color: rgba(#FFFFFF, 0.6); 33 | $popover-btn-font-selected-color: darken($popover-btn-font-color, 10%); 34 | 35 | $ansi-black: #6B6F72; 36 | $ansi-black-bright: #BDC3C7; 37 | $ansi-red: #C0392B; 38 | $ansi-red-bright: #E74C3C; 39 | $ansi-green: #27AF60; 40 | $ansi-green-bright: #2ECC71; 41 | $ansi-yellow: #F39C12; 42 | $ansi-yellow-bright: #F1C40F; 43 | $ansi-blue: #2E8DCD; 44 | $ansi-blue-bright: #3498DB; 45 | $ansi-magenta: #8E44AD; 46 | $ansi-magenta-bright: #9B59B6; 47 | $ansi-cyan: #0097A4; 48 | $ansi-cyan-bright: #02C8D9; 49 | $ansi-white: #D8D6D6; 50 | $ansi-white-bright: #A7A7A7; 51 | 52 | // ** 53 | // import templates 54 | // ** 55 | 56 | @import "_theme.scss"; 57 | @import "_ansi.scss"; 58 | @import "_highlightjs.scss"; -------------------------------------------------------------------------------- /app/images/dark/btn-github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dark/btn-github 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/images/light/btn-github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | light/btn-github 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rtail", 3 | "version": "0.2.1", 4 | "description": "Terminal output to the browser in seconds, using UNIX pipes.", 5 | "keywords": [ 6 | "log", 7 | "logging", 8 | "logio", 9 | "logs", 10 | "tail", 11 | "udp" 12 | ], 13 | "bin": { 14 | "rtail": "./cli/rtail-client.js", 15 | "rtail-server": "./cli/rtail-server.js" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git@github.com:kilianc/rtail.git" 20 | }, 21 | "dependencies": { 22 | "chrono-node": "^1.0.6", 23 | "debug": "^2.1.0", 24 | "express": "^4.10.0", 25 | "json5": "^0.4.0", 26 | "moniker": "^0.1.2", 27 | "request": "^2.58.0", 28 | "socket.io": "^1.1.0", 29 | "split": "^1.0.0", 30 | "strip-ansi": "^3.0.0", 31 | "through2-map": "^2.0.0", 32 | "update-notifier": "^0.5.0", 33 | "yargs": "^3.14.0" 34 | }, 35 | "devDependencies": { 36 | "angular": "^1.3.15", 37 | "angular-animate": "^1.4.0", 38 | "angular-hotkeys": "^1.4.5", 39 | "angular-localforage": "^1.2.2", 40 | "angular-moment": "^0.10.1", 41 | "angular-rt-popup": "^1.0.5", 42 | "angular-ui-router": "^0.2.15", 43 | "ansi_up": "^1.2.1", 44 | "autoprefixer-core": "^5.1.11", 45 | "chai": "^3.0.0", 46 | "del": "^1.1.1", 47 | "gulp": "^3.9.0", 48 | "gulp-ejs": "^1.1.0", 49 | "gulp-livereload": "^3.8.0", 50 | "gulp-load-plugins": "^0.10.0", 51 | "gulp-minify-css": "^1.1.1", 52 | "gulp-minify-html": "^1.0.2", 53 | "gulp-ng-annotate": "^1.0.0", 54 | "gulp-postcss": "^5.1.6", 55 | "gulp-sass": "^2.0.1", 56 | "gulp-sourcemaps": "^1.5.2", 57 | "gulp-uglify": "^1.2.0", 58 | "gulp-useref": "^1.1.2", 59 | "gulp-util": "^3.0.4", 60 | "highlight.js": "isagalaev/highlight.js", 61 | "istanbul": "^0.3.17", 62 | "istanbul-harmony": "^0.3.16", 63 | "jquery": "^2.1.4", 64 | "localforage": "^1.2.2", 65 | "mocha": "^2.2.5", 66 | "moment": "^2.10.3", 67 | "node-sass": "^3.1.1", 68 | "run-sequence": "^1.1.0" 69 | }, 70 | "scripts": { 71 | "app": "DEBUG=rtail:*,api:* gulp app", 72 | "test": "NODE_ENV=test node --harmony test/runner.js", 73 | "test:watch": "NODE_ENV=test nodemon --harmony test/runner.js" 74 | }, 75 | "author": "Kilian Ciuffolo ", 76 | "license": "MIT" 77 | } 78 | -------------------------------------------------------------------------------- /app/images/dark/btn-settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dark/btn-settings 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/images/light/btn-settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | light/btn-settings 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/images/dark/icn-sort-desc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dark/icn-sort-desc 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/images/light/icn-sort-desc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | light/icn-sort-desc 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/images/dark/icn-sort-asc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dark/icn-sort-asc 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/images/light/icn-sort-asc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | light/icn-sort-asc 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /test/rtail-server.test.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * rtail-server.js 3 | * Created by Kilian Ciuffolo on Jul 7, 2015 4 | * (c) 2015 5 | */ 6 | 7 | 'use strict' 8 | 9 | const assert = require('chai').assert 10 | const dgram = require('dgram') 11 | const dns = require('dns') 12 | const io = require('socket.io/node_modules/socket.io-client') 13 | const spawnClient = require('./util').spawnClient 14 | const spawnServer = require('./util').spawnServer 15 | const get = require('request').get 16 | const os = require('os') 17 | 18 | describe('rtail-server.js', function () { 19 | this.timeout(5000) 20 | 21 | let server = null 22 | let streams = null 23 | let lines = [] 24 | let backlogs = [] 25 | let fakeSocket = { close: function () {}, on: function () {} } 26 | 27 | before(function (done) { 28 | server = spawnServer() 29 | 30 | setTimeout(function () { 31 | spawnClient({ socket: fakeSocket }).stdin.end(['1', '2', ''].join('\n')) 32 | spawnClient({ socket: fakeSocket }).stdin.end(['1', '2', ''].join('\n')) 33 | 34 | setTimeout(function () { 35 | let ws = io.connect('http://localhost:8888') 36 | 37 | ws.on('streams', function (data) { 38 | if (null === streams) { 39 | ws.emit('select stream', data[0]) 40 | } 41 | streams = data 42 | }) 43 | 44 | ws.on('backlog', function (data) { 45 | backlogs.push(data) 46 | spawnClient({ args: ['--name', streams[0]], socket: fakeSocket }).stdin.end(['A', 'B', ''].join('\n')) 47 | }) 48 | 49 | ws.on('line', function (data) { 50 | lines.push(data) 51 | if (lines.length >= 2) done() 52 | }) 53 | }, 500) 54 | }, 500) 55 | }) 56 | 57 | after(function () { 58 | server.kill() 59 | }) 60 | 61 | it('should send the streams list on connect (WS)', function () { 62 | assert.lengthOf(streams, 2) 63 | }) 64 | 65 | it('should listen for messages', function () { 66 | assert.equal(lines[0].content, 'A') 67 | assert.equal(lines[1].content, 'B') 68 | }) 69 | 70 | it('should skip non JSON messages', function () { 71 | let socket = dgram.createSocket('udp4') 72 | let buffer = new Buffer('foo') 73 | socket.send(buffer, 0, buffer.length, 9999, 'localhost') 74 | }) 75 | 76 | it('should serve the webapp', function (done) { 77 | server.kill() 78 | server = spawnServer() 79 | 80 | setTimeout(function () { 81 | get('http://localhost:8888', function (err, res, body) { 82 | if (err) return done(err) 83 | assert.match(body, /ng-app="app"/) 84 | done(err) 85 | }) 86 | }, 1000) 87 | }) 88 | 89 | 90 | it('should serve the webapp from s3', function (done) { 91 | server.kill() 92 | server = spawnServer({ 93 | args: ['--web-version', 'stable'] 94 | }) 95 | 96 | setTimeout(function () { 97 | get('http://localhost:8888/index.html', function (err, res, body) { 98 | if (err) return done(err) 99 | assert.match(body, /ng-app="app"/) 100 | assert.isDefined(res.headers['x-amz-request-id']) 101 | done(err) 102 | }) 103 | }, 1000) 104 | }) 105 | 106 | it('should support custom port / host', function (done) { 107 | server.kill() 108 | 109 | dns.lookup(os.hostname(), function (err, address) { 110 | server = spawnServer({ 111 | args: [ 112 | '--udp-host', address, 113 | '--udp-port', 9998, 114 | '--web-host', address, 115 | '--web-port', 8889, 116 | ] 117 | }) 118 | 119 | // check websocket 120 | setTimeout(function () { 121 | spawnClient({ 122 | socket: fakeSocket, 123 | args: ['--port', 9998, '--host', address, '--name', 'foobar'] 124 | }).stdin.end(['1', '2', ''].join('\n')) 125 | 126 | setTimeout(function () { 127 | let ws = io.connect('http://' + address + ':8889') 128 | 129 | ws.on('streams', function (data) { 130 | assert.lengthOf(data, 1) 131 | assert.equal(data[0], 'foobar') 132 | done() 133 | }) 134 | }, 500) 135 | }, 1000) 136 | }) 137 | }) 138 | }) 139 | -------------------------------------------------------------------------------- /cli/rtail-server.js: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ":" //# comment; exec /usr/bin/env node --harmony "$0" "$@" 3 | 4 | /*! 5 | * server.js 6 | * Created by Kilian Ciuffolo on Oct 26, 2014 7 | * (c) 2014-2015 8 | */ 9 | 10 | 'use strict' 11 | 12 | const dgram = require('dgram') 13 | const app = require('express')() 14 | const serve = require('express').static 15 | const http = require('http').Server(app) 16 | const io = require('socket.io')() 17 | const yargs = require('yargs') 18 | const debug = require('debug')('rtail:server') 19 | const webapp = require('./lib/webapp') 20 | const updateNotifier = require('update-notifier') 21 | const pkg = require('../package') 22 | 23 | /*! 24 | * inform the user of updates 25 | */ 26 | updateNotifier({ 27 | packageName: pkg.name, 28 | packageVersion: pkg.version 29 | }).notify() 30 | 31 | /*! 32 | * parsing argv 33 | */ 34 | let argv = yargs 35 | .usage('Usage: rtail-server [OPTIONS]') 36 | .example('rtail-server --web-port 8080', 'Use custom HTTP port') 37 | .example('rtail-server --udp-port 8080', 'Use custom UDP port') 38 | .example('rtail-server --web-version stable', 'Always uses latest stable webapp') 39 | .example('rtail-server --web-version unstable', 'Always uses latest develop webapp') 40 | .example('rtail-server --web-version 0.1.3', 'Use webapp v0.1.3') 41 | .option('udp-host', { 42 | alias: 'uh', 43 | default: '127.0.0.1', 44 | describe: 'The listening UDP hostname' 45 | }) 46 | .option('udp-port', { 47 | alias: 'up', 48 | default: 9999, 49 | describe: 'The listening UDP port' 50 | }) 51 | .option('web-host', { 52 | alias: 'wh', 53 | default: '127.0.0.1', 54 | describe: 'The listening HTTP hostname' 55 | }) 56 | .option('web-port', { 57 | alias: 'wp', 58 | default: 8888, 59 | describe: 'The listening HTTP port' 60 | }) 61 | .option('web-version', { 62 | type: 'string', 63 | describe: 'Define web app version to serve' 64 | }) 65 | .help('help') 66 | .alias('help', 'h') 67 | .version(pkg.version, 'version') 68 | .alias('version', 'v') 69 | .strict() 70 | .argv 71 | 72 | /*! 73 | * UDP sockets setup 74 | */ 75 | let streams = {} 76 | let socket = dgram.createSocket('udp4') 77 | 78 | socket.on('message', function (data, remote) { 79 | // try to decode JSON 80 | try { data = JSON.parse(data) } 81 | catch (err) { return debug('invalid data sent') } 82 | 83 | if (!streams[data.id]) { 84 | streams[data.id] = [] 85 | io.sockets.emit('streams', Object.keys(streams)) 86 | } 87 | 88 | let message = { 89 | timestamp: data.timestamp, 90 | streamid: data.id, 91 | host: remote.address, 92 | port: remote.port, 93 | content: data.content, 94 | type: typeof data.content 95 | } 96 | 97 | // limit backlog to 100 lines 98 | streams[data.id].length >= 100 && streams[data.id].shift() 99 | streams[data.id].push(message) 100 | 101 | debug(JSON.stringify(message)) 102 | io.sockets.to(data.id).emit('line', message) 103 | }) 104 | 105 | /*! 106 | * socket.io 107 | */ 108 | io.on('connection', function (socket) { 109 | socket.emit('streams', Object.keys(streams)) 110 | socket.on('select stream', function (stream) { 111 | socket.leave(socket.rooms[0]) 112 | if (!stream) return 113 | socket.join(stream) 114 | socket.emit('backlog', streams[stream]) 115 | }) 116 | }) 117 | 118 | /*! 119 | * serve static webapp from S3 120 | */ 121 | if (!argv.webVersion) { 122 | app.use(serve(__dirname + '/../dist')) 123 | } else if ('development' === argv.webVersion) { 124 | app.use('/app', serve(__dirname + '/../app')) 125 | app.use('/node_modules', serve(__dirname + '/../node_modules')) 126 | io.path('/app/socket.io') 127 | } else { 128 | app.use(webapp({ 129 | s3: 'http://rtail.s3-website-us-east-1.amazonaws.com/' + argv.webVersion, 130 | ttl: 1000 * 60 * 60 * 6 // 6H 131 | })) 132 | 133 | debug('serving webapp from: http://rtail.s3-website-us-east-1.amazonaws.com/%s', argv.webVersion) 134 | } 135 | 136 | /*! 137 | * listen! 138 | */ 139 | io.attach(http, { serveClient: false }) 140 | socket.bind(argv.udpPort, argv.udpHost) 141 | http.listen(argv.webPort, argv.webHost) 142 | 143 | debug('UDP server listening: %s:%s', argv.udpHost, argv.udpPort) 144 | debug('HTTP server listening: http://%s:%s', argv.webHost, argv.webPort) 145 | -------------------------------------------------------------------------------- /app/images/dark/logo-rtail-info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dark/logo-rtail-info 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/images/light/logo-rtail-info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | light/logo-rtail-info 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /test/rtail-client.test.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * cli.test.js 3 | * Created by Kilian Ciuffolo on Jul 7, 2015 4 | * (c) 2015 5 | */ 6 | 7 | 'use strict' 8 | 9 | const assert = require('chai').assert 10 | const dgram = require('dgram') 11 | const dns = require('dns') 12 | const os = require('os') 13 | const s = require('./util').s 14 | const spawnClient = require('./util').spawnClient 15 | 16 | describe('rtail-client.js', function () { 17 | it('should split stdin by \\n', function (done) { 18 | spawnClient({ 19 | args: [], 20 | done: done, 21 | test: function (messages) { 22 | assert.equal(3, messages.length, s(messages)) 23 | 24 | assert.equal(messages[0].content, '0') 25 | assert.equal(messages[1].content, '1') 26 | assert.equal(messages[2].content, '2') 27 | 28 | assert.isDefined(messages[0].id) 29 | assert.isNumber(messages[0].timestamp) 30 | } 31 | }).stdin.end(['0', '1', '2', ''].join('\n')) 32 | }) 33 | 34 | it('should use custom name', function (done) { 35 | spawnClient({ 36 | args: ['--name', 'test'], 37 | done: done, 38 | test: function (messages) { 39 | assert.equal(3, messages.length, s(messages)) 40 | assert.equal(messages[0].id, 'test') 41 | } 42 | }).stdin.end(['0', '1', '2', ''].join('\n')) 43 | }) 44 | 45 | it('should respect --mute', function (done) { 46 | let client = spawnClient({ args: ['--mute'], done: done }) 47 | client.stdout.on('data', function (data) { 48 | done(new Error('Expected no output instead got: "' + data.toString() + '"')) 49 | }) 50 | client.stdin.end(['0', '1', '2', ''].join('\n')) 51 | }) 52 | 53 | it('should parse JSON lines', function (done) { 54 | spawnClient({ 55 | args: [], 56 | done: done, 57 | test: function (messages) { 58 | assert.equal(1, messages.length, s(messages)) 59 | assert.equal(messages[0].content.foo, 'bar') 60 | } 61 | }).stdin.end(['{ "foo": "bar" }', ''].join('\n')) 62 | }) 63 | 64 | it('should parse JSON5 lines', function (done) { 65 | spawnClient({ 66 | args: [], 67 | done: done, 68 | test: function (messages) { 69 | assert.equal(1, messages.length, s(messages)) 70 | assert.equal(messages[0].content.foo, 'bar') 71 | } 72 | }).stdin.end(['{ foo: "bar" }', ''].join('\n')) 73 | }) 74 | 75 | it('should support custom port / host', function (done) { 76 | dns.lookup(os.hostname(), function (err, address) { 77 | let socket = dgram.createSocket('udp4') 78 | socket.bind(9998, address) 79 | 80 | spawnClient({ 81 | done: done, 82 | socket: socket, 83 | args: ['-p', '9998', '-h', address], 84 | test: function (messages) { 85 | assert.equal(1, messages.length, s(messages)) 86 | assert.equal(messages[0].content.foo, 'bar') 87 | } 88 | }).stdin.end(['{ foo: "bar" }', ''].join('\n')) 89 | }) 90 | }) 91 | 92 | it('should strip colors with --no-tty', function (done) { 93 | let client = spawnClient({ 94 | args: ['--no-tty'], 95 | done: done 96 | }) 97 | 98 | client.stdout.on('data', function (data) { 99 | assert.equal(data.toString(), 'Hello world\n') 100 | }) 101 | 102 | client.stdin.end(['\u001b[31mHello world\u001b[0m', ''].join('\n')) 103 | }) 104 | 105 | it('should parse date if --parse-date', function (done) { 106 | let date = 'Wed Jul 08 2010 01:01:03 GMT-0700 (PDT)' 107 | let client = spawnClient({ 108 | done: done, 109 | test: function (messages) { 110 | assert.equal(messages[0].timestamp, Date.parse(date)) 111 | assert.equal(messages[0].content, 'hello') 112 | } 113 | }) 114 | 115 | client.stdin.end(['[' + date + '] hello', ''].join('\n')) 116 | }) 117 | 118 | it('should not parse date if --no-parse-date', function (done) { 119 | let date = 'Wed Jul 08 2010 01:01:03 GMT-0700 (PDT)' 120 | let client = spawnClient({ 121 | args: ['--no-parse-date'], 122 | done: done, 123 | test: function (messages) { 124 | assert.notEqual(messages[0].timestamp, Date.parse(date)) 125 | assert.equal(messages[0].content, '[' + date + '] hello') 126 | } 127 | }) 128 | 129 | client.stdin.end(['[' + date + '] hello', ''].join('\n')) 130 | }) 131 | }) 132 | -------------------------------------------------------------------------------- /app/images/common/logo-rtail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | common/logo-rtail 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /cli/rtail-client.js: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ":" //# comment; exec /usr/bin/env node --harmony "$0" "$@" 3 | 4 | /*! 5 | * rtail-client.js 6 | * Created by Kilian Ciuffolo on Oct 26, 2014 7 | * (c) 2014-2015 8 | */ 9 | 10 | 'use strict' 11 | 12 | const dgram = require('dgram') 13 | const split = require('split') 14 | const chrono = require('chrono-node') 15 | const JSON5 = require('json5') 16 | const yargs = require('yargs') 17 | const map = require('through2-map') 18 | const stripAnsi = require('strip-ansi') 19 | const moniker_ = require('moniker').choose 20 | const updateNotifier = require('update-notifier') 21 | const pkg = require('../package') 22 | 23 | /*! 24 | * inform the user of updates 25 | */ 26 | updateNotifier({ 27 | packageName: pkg.name, 28 | packageVersion: pkg.version 29 | }).notify() 30 | 31 | /*! 32 | * parsing argv 33 | */ 34 | let argv = yargs 35 | .usage('Usage: cmd | rtail [OPTIONS]') 36 | .example('server | rtail > server.log', 'localhost + file') 37 | .example('server | rtail --id api.domain.com', 'Name the log stream') 38 | .example('server | rtail --host example.com', 'Sends to example.com') 39 | .example('server | rtail --port 43567', 'Uses custom port') 40 | .example('server | rtail --mute', 'No stdout') 41 | .example('server | rtail --no-tty', 'Strips ansi colors') 42 | .example('server | rtail --no-date-parse', 'Disable date parsing/stripping') 43 | .option('host', { 44 | alias: 'h', 45 | type: 'string', 46 | default: '127.0.0.1', 47 | describe: 'The server host' 48 | }) 49 | .option('port', { 50 | alias: 'p', 51 | type: 'string', 52 | default: 9999, 53 | describe: 'The server port' 54 | }) 55 | .option('id', { 56 | alias: 'name', 57 | type: 'string', 58 | default: function moniker() { return moniker_() } , 59 | describe: 'The log stream id' 60 | }) 61 | .option('mute', { 62 | alias: 'm', 63 | type: 'boolean', 64 | describe: 'Don\'t pipe stdin with stdout' 65 | }) 66 | .option('tty', { 67 | type: 'boolean', 68 | default: true, 69 | describe: 'Keeps ansi colors' 70 | }) 71 | .option('parse-date', { 72 | type: 'boolean', 73 | default: true, 74 | describe: 'Looks for dates to use as timestamp' 75 | }) 76 | .help('help') 77 | .version(pkg.version, 'version') 78 | .alias('version', 'v') 79 | .strict() 80 | .argv 81 | 82 | /*! 83 | * setup pipes 84 | */ 85 | if (!argv.mute) { 86 | if (!process.stdout.isTTY || !argv.tty) { 87 | process.stdin 88 | .pipe(map(function (chunk) { 89 | return stripAnsi(chunk.toString('utf8')) 90 | })) 91 | .pipe(process.stdout) 92 | } else { 93 | process.stdin.pipe(process.stdout) 94 | } 95 | } 96 | 97 | /*! 98 | * initialize socket 99 | */ 100 | let isClosed = false 101 | let isSending = 0 102 | let socket = dgram.createSocket('udp4') 103 | let baseMessage = { id: argv.id } 104 | 105 | socket.bind(function () { 106 | socket.setBroadcast(true) 107 | }) 108 | 109 | /*! 110 | * broadcast lines to browser 111 | */ 112 | process.stdin 113 | .pipe(split(null, null, { trailing: false })) 114 | .on('data', function (line) { 115 | let timestamp = null 116 | 117 | try { 118 | // try to JSON parse 119 | line = JSON5.parse(line) 120 | } catch (err) { 121 | // look for timestamps if not an object 122 | timestamp = argv.parseDate ? chrono.parse(line)[0] : null 123 | } 124 | 125 | if (timestamp) { 126 | // escape for regexp and remove from line 127 | timestamp.text = timestamp.text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') 128 | line = line.replace(new RegExp(' *[^ ]?' + timestamp.text + '[^ ]? *'), '') 129 | // use timestamp as line timestamp 130 | baseMessage.timestamp = Date.parse(timestamp.start.date()) 131 | } else { 132 | baseMessage.timestamp = Date.now() 133 | } 134 | 135 | // update default message 136 | baseMessage.content = line 137 | 138 | // prepare binary message 139 | let buffer = new Buffer(JSON.stringify(baseMessage)) 140 | 141 | // set semaphore 142 | isSending ++ 143 | 144 | socket.send(buffer, 0, buffer.length, argv.port, argv.host, function () { 145 | isSending -- 146 | if (isClosed && !isSending) socket.close() 147 | }) 148 | }) 149 | 150 | /*! 151 | * drain pipe and exit 152 | */ 153 | process.stdin.on('end', function () { 154 | isClosed = true 155 | if (!isSending) socket.close() 156 | }) 157 | -------------------------------------------------------------------------------- /app/images/dark/icn-theme-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dark/icn-theme-light 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/images/light/icn-theme-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | light/icn-theme-light 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/scss/_theme.scss: -------------------------------------------------------------------------------- 1 | .#{$theme} { 2 | header { 3 | background: $header-bg-color; 4 | 5 | .rtail-logo { 6 | background-color: $main-color; 7 | background-image: url('../images/common/logo-rtail.svg'); 8 | } 9 | 10 | .btn-info { 11 | background-image: url('../images/#{$theme}/btn-info.svg'); 12 | } 13 | 14 | .btn-github { 15 | background-image: url('../images/#{$theme}/btn-github.svg'); 16 | } 17 | 18 | .btn-settings { 19 | background-image: url('../images/#{$theme}/btn-settings.svg'); 20 | } 21 | } 22 | 23 | .sidebar { 24 | background: $stream-list-bg-color; 25 | 26 | .search-box { 27 | background-color: $stream-search-bg-color; 28 | background-image: url('../images/#{$theme}/icn-search.svg'); 29 | 30 | input { 31 | color: #787E80; 32 | } 33 | } 34 | 35 | .stream-section { 36 | h4 { 37 | color: $stream-list-title-color; 38 | } 39 | 40 | a { 41 | color: $stream-list-color; 42 | 43 | &:hover { 44 | background: $stream-list-bg-hover-color; 45 | } 46 | 47 | &.selected { 48 | background: $stream-list-selected-bg-color; 49 | border-left-color: $stream-list-selected-color; 50 | color: $stream-list-selected-color; 51 | } 52 | 53 | i { 54 | background-color: $main-color; 55 | } 56 | } 57 | } 58 | } 59 | 60 | .stream-view { 61 | background-color: $stream-view-bg-color; 62 | 63 | .stream-header { 64 | background: #45a7b9; 65 | color: #fff; 66 | 67 | .stream-title-favorite { 68 | background-image: url('../images/common/btn-fav-off.svg'); 69 | 70 | &.on { 71 | background-image: url('../images/common/btn-fav-on.svg'); 72 | } 73 | } 74 | 75 | .filter-box { 76 | background-image: url('../images/common/icn-filter.svg'); 77 | 78 | input::-webkit-input-placeholder { 79 | color: white; 80 | } 81 | } 82 | } 83 | 84 | .stream-lines { 85 | .stream-line:hover { 86 | background: $stream-view-content-bg-hover-color; 87 | } 88 | 89 | .stream-line-timestamp { 90 | color: $stream-view-timestamp-color; 91 | border-right-color: $stream-view-timestamp-border-color; 92 | } 93 | 94 | .stream-line-content { 95 | color: $stream-view-content-color; 96 | } 97 | } 98 | 99 | .btn-toggle-timestamp { 100 | background-color: $stream-view-bg-color; 101 | background-repeat: no-repeat; 102 | background-size: 4px 7px; 103 | background-position: 50%; 104 | border: 1px solid $stream-view-timestamp-border-color; 105 | background-image: url('../images/#{$theme}/icn-close-date.svg'); 106 | } 107 | 108 | .btn-resume { 109 | background-color: $main-color; 110 | background-image: url('../images/common/icn-resume.svg'); 111 | color: white; 112 | } 113 | } 114 | 115 | .popover { 116 | .popover-content { 117 | background: $popover-bg-color; 118 | color: $popover-font-color; 119 | 120 | &:before { 121 | border-bottom-color: $popover-alt-bg-color; 122 | } 123 | } 124 | 125 | .popover-info { 126 | .rtail-logo, 127 | .lukibear-logo { 128 | background-color: $popover-alt-bg-color; 129 | } 130 | 131 | .rtail-logo { 132 | background-image: url('../images/#{$theme}/logo-rtail-info.svg'); 133 | } 134 | 135 | .lukibear-logo { 136 | background-image: url('../images/#{$theme}/logo-lukibear.svg'); 137 | } 138 | } 139 | 140 | .btn { 141 | border-color: $popover-btn-border-color; 142 | color: $popover-btn-font-color; 143 | 144 | &:hover, 145 | &.selected { 146 | color: $popover-btn-font-selected-color; 147 | background-color: $popover-btn-bg-hover-color; 148 | } 149 | 150 | &:active { 151 | background-color: $popover-btn-bg-active-color; 152 | } 153 | } 154 | } 155 | 156 | .popover-settings { 157 | h4 { 158 | color: $popover-font-color; 159 | } 160 | 161 | &:before { 162 | border-bottom-color: $popover-bg-color !important; 163 | } 164 | 165 | div.btn-group .btn { 166 | &.btn-font-smaller { 167 | background-image: url(../images/#{$theme}/icn-font-decrease.svg); 168 | } 169 | 170 | &.btn-font-reset { 171 | background-image: url(../images/#{$theme}/icn-font-reset.svg); 172 | } 173 | 174 | &.btn-font-bigger { 175 | background-image: url(../images/#{$theme}/icn-font-increase.svg); 176 | } 177 | 178 | &.btn-sorting-asc { 179 | background-image: url(../images/#{$theme}/icn-sort-asc.svg); 180 | } 181 | 182 | &.btn-sorting-desc { 183 | background-image: url(../images/#{$theme}/icn-sort-desc.svg); 184 | } 185 | 186 | &.btn-theme-dark { 187 | background-image: url(../images/#{$theme}/icn-theme-dark.svg); 188 | } 189 | 190 | &.btn-theme-light { 191 | background-image: url(../images/#{$theme}/icn-theme-light.svg); 192 | } 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp') 2 | var run = require('run-sequence') 3 | var plugins = require('gulp-load-plugins')() 4 | var del = require('del') 5 | var autoprefixer = require('autoprefixer-core') 6 | var version = require('./package.json').version 7 | var spawn = require('child_process').spawn 8 | 9 | /** 10 | * Clean builds 11 | */ 12 | 13 | gulp.task('clean:dist', function (done) { 14 | del('dist', done) 15 | }) 16 | 17 | gulp.task('clean:sass', function (done) { 18 | del('app/css/*', done) 19 | }) 20 | 21 | gulp.task('clean', function (done) { 22 | run(['clean:dist', 'clean:sass'], done) 23 | }) 24 | 25 | /** 26 | * Clean deps 27 | */ 28 | 29 | gulp.task('clean:npm', function (done) { 30 | del('node_modules', done) 31 | }) 32 | 33 | /** 34 | * Compile CSS 35 | */ 36 | 37 | gulp.task('sass', function () { 38 | return gulp.src('app/scss/*', { base: 'app/scss' }) 39 | .pipe(plugins.sourcemaps.init()) 40 | .pipe(plugins.sass()) 41 | .on('error', function (err) { 42 | plugins.util.log('sass error', err.message) 43 | plugins.util.beep() 44 | }) 45 | .pipe(plugins.postcss([ autoprefixer({ browsers: ['last 2 version'] }) ])) 46 | .pipe(plugins.sourcemaps.write()) 47 | .pipe(gulp.dest('app/css')) 48 | }) 49 | 50 | /** 51 | * Compile templates 52 | */ 53 | 54 | gulp.task('ejs', function () { 55 | return gulp.src('app/app.ejs') 56 | .pipe(plugins.ejs({ version: version }, { ext: '.js' })) 57 | .on('error', function (err) { 58 | plugins.util.log('ejs error', err.message) 59 | plugins.util.beep() 60 | }) 61 | .pipe(gulp.dest('app')) 62 | }) 63 | 64 | 65 | /** 66 | * Compile templates 67 | */ 68 | 69 | gulp.task('hjs', function (done) { 70 | var opts = { 71 | cwd: __dirname + '/node_modules/highlight.js' 72 | } 73 | 74 | var npmInstall = spawn('npm', ['install'], opts) 75 | npmInstall.stdout.pipe(process.stdout) 76 | npmInstall.stderr.pipe(process.stderr) 77 | 78 | npmInstall.on('close', function (code) { 79 | if (0 !== code) throw new Error('npm install exited with ' + code) 80 | 81 | var build = spawn('node', ['tools/build.js', '-n', 'json'], opts) 82 | build.stdout.pipe(process.stdout) 83 | build.stderr.pipe(process.stderr) 84 | 85 | build.on('close', function (code) { 86 | if (0 !== code) throw new Error('node tools/build.js exited with ' + code) 87 | done() 88 | }) 89 | }) 90 | }) 91 | 92 | /** 93 | * Launch server + livereload in dev mode 94 | */ 95 | 96 | gulp.task('app', ['build:app'], function (done) { 97 | gulp.watch('app/scss/*.scss', ['sass']) 98 | gulp.watch('app/app.ejs', ['ejs']) 99 | 100 | plugins.livereload({ start: true }) 101 | 102 | gulp.watch([ 103 | 'app/**/*', 104 | '!app/app.ejs', 105 | '!app/scss/*', 106 | ]).on('change', function (file) { 107 | plugins.livereload.changed(file.path) 108 | }) 109 | 110 | plugins.util.log('spinning rtail client and server ... http://localhost:8888/app') 111 | 112 | var rTailServer = spawn('node', ['--harmony', 'cli/rtail-server.js', '--web-version', 'development']) 113 | rTailServer.stdout.pipe(process.stdout) 114 | rTailServer.stderr.pipe(process.stdout) 115 | 116 | var rTailClient = spawn('node', ['--harmony', 'cli/rtail-client.js']) 117 | rTailClient.stdout.pipe(process.stdout) 118 | rTailClient.stderr.pipe(process.stdout) 119 | 120 | var lines = [ 121 | '', 122 | 'A B C', 123 | '200 GET /1/geocode?address=ny', 124 | '200 GET /1/config', 125 | '500 GET /1/users/556605ede9fa35333befa9e6/profile', 126 | '200 POST /1/signin', 127 | '200 GET /1/users/556605ede9fa35333befa9e6/profile', 128 | '200 PUT /1/me/gcm_tokens/duUOo8jRIxq547jAaAHvsF9v', 129 | '200 PUT /1/me/review_status/seen', 130 | '301 GET /1/config', 131 | '200 GET /1/users/555f7494e9fa35333befa9ab/profile', 132 | '200 POST /1/signin', 133 | '200 GET /1/users/555f7494e9fa35333befa9ab/profile', 134 | '400 PUT /1/me/gcm_tokens/3G7ggYFcGXIHkIgaGLW16s4sobrkAPA91bGM8t9MJwfDbFA', 135 | '200 GET /1/me/notifications', 136 | '200 GET /1/me/picture', 137 | '200 GET /1/alive' 138 | ] 139 | 140 | function log2rtail(str) { 141 | rTailClient.stdin.write(str + '\n') 142 | } 143 | 144 | setInterval(function () { 145 | var debug = require('debug')('api:logs') 146 | var index = Math.floor(Math.random() * lines.length) 147 | var line = lines[index] 148 | 149 | debug.log = log2rtail 150 | 151 | if (Math.random() < 0.8) { 152 | debug(line) 153 | } else { 154 | log2rtail(JSON.stringify({ 155 | foo: 'bar', 156 | bar: 'foo', 157 | count: Math.random() * 1000, 158 | list: [ 159 | "foo", 160 | "bar" 161 | ], 162 | doc: { 163 | foo: 'bar', 164 | bar: 'foo' 165 | }, 166 | link: "http://google.com", 167 | regexp: /a.?/, 168 | color: "#fff" 169 | })) 170 | } 171 | }, 1000) 172 | }) 173 | 174 | /** 175 | * Build app 176 | */ 177 | 178 | gulp.task('build:app', function (done) { 179 | run('clean:sass', 'sass', 'ejs', 'hjs', done) 180 | }) 181 | 182 | /** 183 | * Copy SVG 184 | */ 185 | 186 | gulp.task('copy:images', function () { 187 | return gulp.src('app/images/**/*') 188 | .pipe(gulp.dest('dist/images')) 189 | }) 190 | 191 | /** 192 | * Bundle CSS / JS 193 | */ 194 | 195 | gulp.task('html', function () { 196 | var assets = plugins.useref.assets() 197 | 198 | return gulp.src('app/index.html') 199 | .pipe(assets) 200 | .pipe(assets.restore()) 201 | .pipe(plugins.useref()) 202 | .pipe(gulp.dest('dist')) 203 | }) 204 | 205 | /** 206 | * Minify JS 207 | */ 208 | 209 | gulp.task('minify:js', function () { 210 | return gulp.src('dist/bundle.min.js') 211 | .pipe(plugins.ngAnnotate()) 212 | .pipe(plugins.uglify()) 213 | .pipe(gulp.dest('dist/')) 214 | }) 215 | 216 | /** 217 | * Minify CSS 218 | */ 219 | 220 | gulp.task('minify:css', function () { 221 | return gulp.src('dist/css/bundle.min.css') 222 | .pipe(plugins.minifyCss( { keepSpecialComments: 0, keepBreaks: true })) 223 | .pipe(gulp.dest('dist/css/')) 224 | }) 225 | 226 | /** 227 | * Minify HTML 228 | */ 229 | 230 | gulp.task('minify:html', function () { 231 | return gulp.src('dist/index.html') 232 | .pipe(plugins.minifyHtml( { conditionals: true, quotes: true })) 233 | .pipe(gulp.dest('dist/')) 234 | }) 235 | 236 | /** 237 | * Minify all 238 | */ 239 | 240 | gulp.task('minify', function (done) { 241 | run(['minify:js', 'minify:css', 'minify:html'], done) 242 | }) 243 | 244 | /** 245 | * Build dist 246 | */ 247 | 248 | gulp.task('build:dist', function (done) { 249 | run(['build:app', 'clean:dist'], 'copy:images', 'html', 'minify', done) 250 | }) 251 | 252 | /** 253 | * Default task 254 | */ 255 | 256 | gulp.task('default', ['build:dist']) 257 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | rTail : loading ... 7 | rTail : {{ctrl.activeStream}} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 29 | 30 | 31 | 32 | 37 |
38 | 39 |
40 | 75 | 76 |
77 |
78 |
79 | 80 | {{ctrl.activeStream}} 81 |
82 |
83 | 84 |
85 |
86 | 87 |
88 |
96 |
97 | {{::line.timestamp | amDateFormat:ctrl.timestampFormat}} 98 |
99 | 100 | 109 | 110 |
111 | 112 |
113 |
114 |
115 | 116 | 117 |
118 |
119 | 120 | 133 | 134 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | -------------------------------------------------------------------------------- /app/app.ejs: -------------------------------------------------------------------------------- 1 | /* global angular, $, window, io, document, hljs, ansi_up */ 2 | 3 | 'use strict' 4 | 5 | /*! 6 | * main js controller for webapp 7 | */ 8 | angular 9 | .module('app', [ 10 | 'ngAnimate', 11 | 'angularMoment', 12 | 'LocalForageModule', 13 | 'rt.popup', 14 | 'ui.router', 15 | 'cfp.hotkeys' 16 | ]) 17 | .config(function ($stateProvider) { 18 | $stateProvider.state('streams', { 19 | url: '/streams/:stream' 20 | }) 21 | }) 22 | .directive('resizable', function ($localForage) { 23 | return { 24 | restrict: 'A', 25 | link: function ($scope, $element) { 26 | var $window = $(window) 27 | var $body = $('body') 28 | var handler = $element.find('.resize-handler') 29 | var minWidth = $element.width() 30 | 31 | handler.on('mousedown', function () { 32 | $body.addClass('resizing') 33 | $window.on('mousemove', onMouseMove) 34 | }) 35 | 36 | $window.on('mouseup', function () { 37 | $body.removeClass('resizing') 38 | $window.off('mousemove', onMouseMove) 39 | $localForage.setItem('sidebarWidth', $element.width()) 40 | }) 41 | 42 | function onMouseMove(e) { 43 | var width = Math.min(600, Math.max(minWidth, e.clientX)) 44 | $element.css('width', width) 45 | } 46 | } 47 | } 48 | }) 49 | .controller('MainController', function MainController($scope, $injector) { 50 | var $sce = $injector.get('$sce') 51 | var $localForage = $injector.get('$localForage') 52 | var $rootScope = $injector.get('$rootScope') 53 | var $state = $injector.get('$state') 54 | var $stateParams = $injector.get('$stateParams') 55 | var $streamLines = $('.stream-lines') 56 | var BUFFER_SIZE = 100 57 | var ctrl = this 58 | 59 | ctrl.paused = false 60 | ctrl.version = '<%= version %>' 61 | ctrl.lines = [] 62 | 63 | /*! 64 | * socket stuff 65 | */ 66 | ctrl.socket = io(document.location.origin, { path: document.location.pathname + 'socket.io' }) 67 | 68 | ctrl.socket.on('connect', function () { 69 | console.info('connected') 70 | ctrl.activeStream && ctrl.socket.emit('select stream', ctrl.activeStream) 71 | }) 72 | 73 | ctrl.socket.on('disconnect', function (err) { 74 | console.warn('disconnected: %s', err) 75 | }) 76 | 77 | ctrl.socket.on('streams', function (streams) { 78 | ctrl.streams = streams 79 | $scope.$apply() 80 | }) 81 | 82 | ctrl.socket.on('backlog', function (lines) { 83 | if (!lines) return 84 | ctrl.lines = lines.map(formatLine) 85 | $scope.$apply() 86 | updateScroll() 87 | console.info('%s: backlog received %d', ctrl.activeStream, lines.length) 88 | }) 89 | 90 | ctrl.socket.on('line', function (line) { 91 | ctrl.lines.push(formatLine(line)) 92 | 93 | if (ctrl.lines.length > BUFFER_SIZE) { 94 | ctrl.lines.length >= 100 && ctrl.lines.shift() 95 | } 96 | 97 | $scope.$apply() 98 | updateScroll() 99 | }) 100 | 101 | ctrl.selectStream = function selectStream(stream) { 102 | ctrl.lines = [] 103 | ctrl.activeStream = stream 104 | ctrl.socket.emit('select stream', stream) 105 | ctrl.resume() 106 | $localForage.setItem('activeStream', stream) 107 | } 108 | 109 | ctrl.isSelected = function isSelected(stream) { 110 | return ctrl.activeStream === stream 111 | } 112 | 113 | function updateScroll() { 114 | var where = ctrl.streamDirection ? $streamLines[0].scrollHeight : 0 115 | $streamLines.scrollTop(where) 116 | } 117 | 118 | function formatLine(line) { 119 | if (!line.content) { 120 | // handle empty line 121 | line.html = $sce.trustAsHtml('') 122 | } else if ('object' === line.type) { 123 | // for object just format JSON 124 | line.html = hljs.highlight('json', JSON.stringify(line.content, null, ' ')).value 125 | line.html = $sce.trustAsHtml('
' + line.html + '
') 126 | } else { 127 | // for log lines use ansi format 128 | line.html = escapeHtml(line.content) 129 | line.html = ansi_up.ansi_to_html(line.html, { use_classes: true }) 130 | line.html = $sce.trustAsHtml(line.html) 131 | } 132 | 133 | return line 134 | } 135 | 136 | // https://github.com/component/escape-html/blob/master/index.js#L22 137 | function escapeHtml(html) { 138 | return String(html) 139 | .replace(/&/g, '&') 140 | .replace(/"/g, '"') 141 | .replace(/'/g, ''') 142 | .replace(//g, '>'); 144 | } 145 | 146 | ctrl.activeStreamFilter = function activeStreamFilter(line) { 147 | try { 148 | return (new RegExp(ctrl.activeStreamRegExp)).test(line.content) 149 | } catch (err) { 150 | return true 151 | } 152 | } 153 | 154 | ctrl.resume = function resume() { 155 | if (!ctrl.paused) return 156 | ctrl.paused = false 157 | ctrl.socket.emit('select stream', ctrl.activeStream) 158 | $streamLines.on('wheel', ctrl.pause) 159 | } 160 | 161 | ctrl.pause = function pause() { 162 | if (ctrl.paused) return 163 | ctrl.paused = true 164 | ctrl.socket.emit('select stream') 165 | $streamLines.off('wheel', ctrl.pause) 166 | $scope.$apply() 167 | } 168 | 169 | $streamLines.on('wheel', ctrl.pause) 170 | 171 | /*! 172 | * settings and preferences 173 | */ 174 | ctrl.toggleFavorite = function toggleFavorite(stream) { 175 | if (ctrl.favorites[stream]) { 176 | delete ctrl.favorites[stream] 177 | } else { 178 | ctrl.favorites[stream] = true 179 | } 180 | 181 | $localForage.setItem('favorites', ctrl.favorites) 182 | } 183 | 184 | ctrl.toggleTimestamp = function toggleTimestamp(stream) { 185 | if (ctrl.hiddenTimestamps[stream]) { 186 | delete ctrl.hiddenTimestamps[stream] 187 | } else { 188 | ctrl.hiddenTimestamps[stream] = true 189 | } 190 | 191 | $localForage.setItem('hiddenTimestamps', ctrl.hiddenTimestamps) 192 | } 193 | 194 | ctrl.setTheme = function setTheme(theme) { 195 | ctrl.theme = theme 196 | $localForage.setItem('theme', theme) 197 | } 198 | 199 | ctrl.setFontFamily = function setFontFamily(fontFamily) { 200 | ctrl.fontFamily = fontFamily 201 | $localForage.setItem('fontFamily', fontFamily) 202 | } 203 | 204 | ctrl.incFontSize = function incFontSize() { 205 | ctrl.fontSize = Math.min(7, ctrl.fontSize + 1) 206 | $localForage.setItem('fontSize', ctrl.fontSize) 207 | } 208 | 209 | ctrl.resetFontSize = function resetFontSize() { 210 | ctrl.fontSize = 4 211 | $localForage.setItem('fontSize', ctrl.fontSize) 212 | } 213 | 214 | ctrl.decFontSize = function decFontSize(fontSize) { 215 | ctrl.fontSize = Math.max(1, ctrl.fontSize - 1) 216 | $localForage.setItem('fontSize', fontSize) 217 | } 218 | 219 | ctrl.setStreamDirection = function setStreamDirection(streamDirection) { 220 | ctrl.streamDirection = streamDirection 221 | $localForage.setItem('streamDirection', streamDirection) 222 | } 223 | 224 | /*! 225 | * load storage in memory and boot 226 | */ 227 | $localForage.getItem('sidebarWidth').then(function (sidebarWidth) { 228 | ctrl.sidebarWidth = sidebarWidth 229 | }) 230 | 231 | $localForage.getItem('theme').then(function (theme) { 232 | ctrl.theme = theme || 'dark' 233 | }) 234 | 235 | $localForage.getItem('fontFamily').then(function (fontFamily) { 236 | ctrl.fontFamily = fontFamily || 1 237 | }) 238 | 239 | $localForage.getItem('fontSize').then(function (fontSize) { 240 | ctrl.fontSize = fontSize || 4 241 | }) 242 | 243 | $localForage.getItem('favorites').then(function (favorites) { 244 | ctrl.favorites = favorites || {} 245 | }) 246 | 247 | $localForage.getItem('hiddenTimestamps').then(function (hiddenTimestamps) { 248 | ctrl.hiddenTimestamps = hiddenTimestamps || {} 249 | }) 250 | 251 | $localForage.getItem('timestampFormat').then(function (timestampFormat) { 252 | ctrl.timestampFormat = timestampFormat || 'MM/DD/YY hh:mm:ss' 253 | }) 254 | 255 | $localForage.getItem('activeStream').then(function (activeStream) { 256 | if (!activeStream || ('streams' === $state.current.name && $stateParams.stream)) return 257 | console.info('%s: restoring session', activeStream) 258 | $state.go('streams', { stream: activeStream }) 259 | }) 260 | 261 | $localForage.getItem('streamDirection').then(function (streamDirection) { 262 | ctrl.streamDirection = undefined === streamDirection ? true : streamDirection 263 | }) 264 | 265 | /*! 266 | * respond to url change 267 | */ 268 | $rootScope.$on('$stateChangeStart', function (e, toState, toParams) { 269 | if ('streams' !== toState.name) return 270 | ctrl.selectStream(toParams.stream) 271 | }) 272 | 273 | /*! 274 | * tell UI we're ready to roll 275 | */ 276 | ctrl.loaded = true 277 | }) 278 | -------------------------------------------------------------------------------- /app/scss/main.scss: -------------------------------------------------------------------------------- 1 | // ** 2 | // variables 3 | // ** 4 | 5 | $spacing: 20px; 6 | $border-radius: 5px; 7 | $font-family: 'Nunito'; 8 | 9 | // ** 10 | // imports 11 | // ** 12 | 13 | @import 'fonts'; 14 | 15 | // ** 16 | // reset 17 | // ** 18 | 19 | * { 20 | background: none; 21 | border: 0; 22 | box-sizing: border-box; 23 | color: inherit; 24 | margin: 0; 25 | padding: 0; 26 | text-decoration: none; 27 | 28 | &:focus { 29 | outline: none; 30 | } 31 | } 32 | 33 | ol, 34 | ul { 35 | list-style: none; 36 | } 37 | 38 | pre { 39 | font-family: inherit !important; 40 | } 41 | 42 | input { 43 | font: inherit !important; 44 | } 45 | 46 | // ** 47 | // styles 48 | // ** 49 | 50 | body { 51 | align-items: stretch; 52 | display: flex; 53 | flex-direction: column; 54 | font-family: $font-family; 55 | font-size: 16px; 56 | height: 100vh; 57 | 58 | &.resizing { 59 | user-select: none; 60 | } 61 | } 62 | 63 | header { 64 | align-items: center; 65 | display: flex; 66 | flex-direction: row; 67 | height: 58px; 68 | 69 | .rtail-logo { 70 | background-position: 50% 50%; 71 | background-repeat: no-repeat; 72 | background-size: 53px 13px; 73 | height: 100px; 74 | width: 94px; 75 | } 76 | 77 | .btn { 78 | background-position: 50% 50%; 79 | background-repeat: no-repeat; 80 | background-size: 100%; 81 | cursor: pointer; 82 | height: 19px; 83 | margin-right: $spacing; 84 | opacity: .4; 85 | width: 19px; 86 | 87 | &.btn-info { 88 | margin-left: auto; 89 | } 90 | } 91 | } 92 | 93 | .split-pane { 94 | display: flex; 95 | flex: 1; 96 | flex-direction: row; 97 | min-height: 0; 98 | } 99 | 100 | .sidebar { 101 | display: flex; 102 | flex-direction: column; 103 | font-size: 16px; 104 | position: relative; 105 | width: 230px; 106 | 107 | .resize-handler { 108 | cursor: col-resize; 109 | height: 100%; 110 | position: absolute; 111 | right: -5px; 112 | top: 0; 113 | width: 10px; 114 | } 115 | 116 | .search-box { 117 | background-position: $spacing 50%; 118 | background-repeat: no-repeat; 119 | background-size: 16px 16px; 120 | display: flex; 121 | height: 57px; 122 | 123 | input { 124 | flex: 1; 125 | font-size: 14px; 126 | height: 57px; 127 | margin-left: 56px; 128 | margin-right: $spacing; 129 | } 130 | } 131 | 132 | .stream-sections { 133 | flex: 1; 134 | height: 0; 135 | overflow-y: auto; 136 | 137 | .stream-section { 138 | flex: 1; 139 | 140 | h4 { 141 | align-items: center; 142 | display: flex; 143 | font-size: 16px; 144 | font-weight: normal; 145 | height: 32px; 146 | margin-top: $spacing; 147 | padding-left: $spacing; 148 | } 149 | 150 | a { 151 | align-items: center; 152 | cursor: pointer; 153 | display: flex; 154 | height: 32px; 155 | padding-left: 40px; 156 | 157 | &.selected { 158 | border-left: 2px solid; 159 | padding-left: 38px; 160 | } 161 | 162 | span { 163 | text-overflow: ellipsis; 164 | white-space: nowrap; 165 | overflow: hidden; 166 | } 167 | 168 | i { 169 | border-radius: 50%; 170 | height: 8px; 171 | margin-left: auto; 172 | margin-right: $spacing; 173 | width: 8px; 174 | } 175 | } 176 | } 177 | } 178 | } 179 | 180 | .stream-view { 181 | position: relative; 182 | display: flex; 183 | flex: 1; 184 | flex-direction: column; 185 | min-width: 0; 186 | 187 | .stream-header { 188 | align-items: center; 189 | display: flex; 190 | font-size: 16px; 191 | height: 57px; 192 | 193 | .stream-title { 194 | align-items: center; 195 | display: flex; 196 | } 197 | 198 | .stream-title-favorite { 199 | background-repeat: no-repeat; 200 | background-size: 15px 15px; 201 | display: inline-block; 202 | height: 15px; 203 | margin: 0 $spacing; 204 | width: 15px; 205 | } 206 | 207 | .filter-box { 208 | background-position: 0 50%; 209 | background-repeat: no-repeat; 210 | background-size: 14px 14px; 211 | display: flex; 212 | font-size: 14px; 213 | height: 30px; 214 | margin-left: auto; 215 | margin-right: $spacing; 216 | opacity: .7; 217 | width: 210px; 218 | } 219 | 220 | input { 221 | flex: 1; 222 | margin-left: 24px; 223 | } 224 | } 225 | 226 | .stream-lines { 227 | flex: 1; 228 | font-size: 12px; 229 | height: 0; 230 | overflow: auto; 231 | padding: 2px 0; 232 | 233 | .stream-line { 234 | display: flex; 235 | line-height: .5em; 236 | 237 | pre { 238 | line-height: 1.2; 239 | } 240 | 241 | .stream-line-timestamp { 242 | border-right: 1px solid; 243 | flex-shrink: 0; 244 | padding-right: $spacing; 245 | } 246 | 247 | .stream-line-content { 248 | white-space: pre; 249 | } 250 | 251 | .stream-line-timestamp, 252 | .stream-line-content { 253 | align-items: center; 254 | display: flex; 255 | padding-bottom: 5px; 256 | padding-left: $spacing; 257 | padding-top: 5px; 258 | } 259 | } 260 | } 261 | 262 | .btn-toggle-timestamp { 263 | position: absolute; 264 | bottom: 15px; 265 | width: 20px; 266 | height: 20px; 267 | border-radius: $border-radius; 268 | cursor: pointer; 269 | transform: translateX(-50%); 270 | 271 | &.closed { 272 | transform: translateX(-50%) rotate(180deg); 273 | } 274 | } 275 | 276 | .btn-resume { 277 | background-position: 10px 50%; 278 | background-repeat: no-repeat; 279 | background-size: 8px 10px; 280 | border-radius: $border-radius; 281 | bottom: $spacing; 282 | cursor: pointer; 283 | font-size: 14px; 284 | height: 30px; 285 | padding-left: 25px; 286 | position: absolute; 287 | right: $spacing; 288 | text-align: left; 289 | width: 85px; 290 | } 291 | } 292 | 293 | .popover { 294 | position: absolute; 295 | 296 | .popover-content { 297 | border-radius: 5px; 298 | display: flex; 299 | flex-direction: column; 300 | font-size: 12px; 301 | margin-top: $spacing * 2; 302 | overflow: visible !important; 303 | position: relative; 304 | width: 156px; 305 | 306 | &:before { 307 | border-bottom: $spacing / 2 solid transparent; 308 | border-left: $spacing / 2 solid transparent; 309 | border-right: $spacing / 2 solid transparent; 310 | content: ''; 311 | height: 0; 312 | left: 50%; 313 | position: absolute; 314 | top: 0; 315 | transform: translate(-50%, -100%); 316 | width: 0; 317 | } 318 | 319 | .btn { 320 | border-radius: $border-radius; 321 | border-style: solid; 322 | border-width: 1px; 323 | } 324 | } 325 | 326 | .popover-info { 327 | align-items: center; 328 | height: 265px; 329 | 330 | .rtail-logo { 331 | background-position: 50% 50%; 332 | background-repeat: no-repeat; 333 | background-size: 53px 13px; 334 | border-radius: $border-radius $border-radius 0 0; 335 | height: 48px; 336 | width: 100%; 337 | } 338 | 339 | .version { 340 | margin: 15px 0; 341 | } 342 | 343 | .btn { 344 | align-items: center; 345 | display: flex; 346 | height: 30px; 347 | justify-content: center; 348 | margin-bottom: 5px; 349 | width: 87px; 350 | } 351 | 352 | .lukibear-logo { 353 | background-position: 50% 50%; 354 | background-repeat: no-repeat; 355 | background-size: 30%; 356 | border-radius: 0 0 $border-radius $border-radius; 357 | flex: 1; 358 | margin-top: 10px; 359 | width: 100%; 360 | } 361 | } 362 | 363 | .popover-settings { 364 | left: -60px; 365 | padding: 15px; 366 | 367 | &:before { 368 | transform: translate(48px, -100%); 369 | } 370 | 371 | h4 { 372 | font-weight: normal; 373 | } 374 | 375 | .btn-group { 376 | display: flex; 377 | flex-direction: row; 378 | flex-wrap: wrap; 379 | margin: 10px 0; 380 | 381 | &:last-of-type { 382 | margin-bottom: 0; 383 | } 384 | 385 | .btn { 386 | background-position: 50% 50%; 387 | background-repeat: no-repeat; 388 | background-size: auto 50%; 389 | display: block; 390 | font-size: 16px; 391 | height: 30px; 392 | width: 42px; 393 | 394 | &:nth-child(1) { 395 | border-radius: $border-radius 0 0 $border-radius; 396 | border-right: 0; 397 | } 398 | 399 | + .btn { 400 | border-radius: 0; 401 | } 402 | 403 | &:nth-child(3) { 404 | border-left: 0; 405 | border-radius: 0 $border-radius 0 0; 406 | } 407 | 408 | &:nth-child(4) { 409 | border-radius: 0 0 0 $border-radius; 410 | border-right: 0; 411 | border-top: 0; 412 | } 413 | 414 | &:last-child { 415 | border-radius: 0 $border-radius $border-radius 0; 416 | } 417 | 418 | &:nth-child(5) { 419 | border-top: 0; 420 | } 421 | 422 | &:nth-child(6) { 423 | border-left: 0; 424 | border-radius: 0 0 $border-radius; 425 | border-top: 0; 426 | } 427 | } 428 | 429 | &.six-grid { 430 | .btn:nth-child(1) { 431 | border-radius: $border-radius 0 0; 432 | border-right: 0; 433 | } 434 | 435 | .btn:nth-child(n + 4) { 436 | border-top: 0; 437 | } 438 | } 439 | } 440 | } 441 | } 442 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `rtail(1)` 2 | 3 | [![Wercker CI](https://img.shields.io/wercker/ci/556547b7be632a8c751c857d.svg?style=flat-square)](https://app.wercker.com/project/bykey/54b073dac5b9156509c26031c78c98d4) 4 | [![Coveralls](https://img.shields.io/coveralls/kilianc/rtail.svg?style=flat-square)](https://coveralls.io/r/kilianc/rtail) 5 | [![NPM version](https://img.shields.io/npm/v/rtail.svg?style=flat-square)](https://www.npmjs.com/package/rtail) 6 | [![NPM downloads](https://img.shields.io/npm/dm/rtail.svg?style=flat-square)](https://www.npmjs.com/package/rtail) 7 | [![GitHub Stars](https://img.shields.io/github/stars/kilianc/rtail.svg?style=flat-square)](https://github.com/kilianc/rtail) 8 | [![License](https://img.shields.io/npm/l/rtail.svg?style=flat-square)](https://www.npmjs.com/package/rtail) 9 | [![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) 10 | 11 | ## Terminal output to the browser in seconds, using UNIX pipes. 12 | 13 | `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. 14 | 15 | ## Installation 16 | 17 | $ npm install -g rtail 18 | 19 | ## Web app 20 | 21 | ![](https://s3.amazonaws.com/rtail/github/dark.png) 22 | 23 | ![](https://s3.amazonaws.com/rtail/github/light.png) 24 | 25 | ## Rationale 26 | 27 | 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**. 28 | 29 | 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**. 30 | 31 | `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.)* 32 | 33 | **The `rtail` approach is very simple:** 34 | * 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) 35 | * broadcast every line using UDP 36 | * `rtail-server`, **if listening**, will dispatch the stream into your browser, using [socket.io](http://socket.io/). 37 | 38 | `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/). 39 | 40 | ## Examples 41 | 42 | In your app init script: 43 | 44 | $ node server.js 2>&1 | rtail --id "api.myproject.com" 45 | 46 | $ mycommand | rtail > server.log 47 | 48 | $ node server.js 2>&1 | rtail --mute 49 | 50 | Supports JSON5 lines: 51 | 52 | $ while true; do echo [1, 2, 3, "hello"]; sleep 1; done | rtail 53 | $ echo { "foo": "bar" } | rtail 54 | $ echo { format: 'JSON5' } | rtail 55 | 56 | Using log files (log rotate safe!): 57 | 58 | $ node server.js 2>&1 > log.txt 59 | $ tail -F log.txt | rtail 60 | 61 | For fun and debugging: 62 | 63 | $ cat ~/myfile.txt | rtail 64 | $ echo "Server rebooted!" | rtail --id `hostname` 65 | 66 | ## Params 67 | 68 | $ rtail --help 69 | Usage: cmd | rtail [OPTIONS] 70 | 71 | Options: 72 | --host, -h The server host [string] [default: "127.0.0.1"] 73 | --port, -p The server port [string] [default: 9999] 74 | --id, --name The log stream id [string] [default: (moniker)] 75 | --mute, -m Don't pipe stdin with stdout [boolean] 76 | --tty Keeps ansi colors [boolean] [default: true] 77 | --parse-date Looks for dates to use as timestamp [boolean] [default: true] 78 | --help Show help [boolean] 79 | --version, -v Show version number [boolean] 80 | 81 | Examples: 82 | server | rtail > server.log localhost + file 83 | server | rtail --id api.domain.com Name the log stream 84 | server | rtail --host example.com Sends to example.com 85 | server | rtail --port 43567 Uses custom port 86 | server | rtail --mute No stdout 87 | server | rtail --no-tty Strips ansi colors 88 | server | rtail --no-date-parse Disable date parsing/stripping 89 | 90 | 91 | ## `rtail-server(1)` 92 | 93 | `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.** 94 | 95 | There is little to no configuration – The default UDP/HTTP ports can be changed, but that's it. 96 | 97 | ## Examples 98 | 99 | Use default values: 100 | 101 | $ rtail-server 102 | 103 | Always use latest, stable webapp: 104 | 105 | $ rtail-server --web-version stable 106 | 107 | Use custom ports: 108 | 109 | $ rtail-server --web-port 8080 --udp-port 9090 110 | 111 | Set debugging on: 112 | 113 | $ DEBUG=rtail:* rtail-server 114 | 115 | Open your browser and start tailing logs! 116 | 117 | ## Params 118 | 119 | $ rtail-server --help 120 | Usage: rtail-server [OPTIONS] 121 | 122 | Options: 123 | --udp-host, --uh The listening UDP hostname [default: "127.0.0.1"] 124 | --udp-port, --up The listening UDP port [default: 9999] 125 | --web-host, --wh The listening HTTP hostname [default: "127.0.0.1"] 126 | --web-port, --wp The listening HTTP port [default: 8888] 127 | --web-version Define web app version to serve [string] 128 | --help, -h Show help [boolean] 129 | --version, -v Show version number [boolean] 130 | 131 | Examples: 132 | rtail-server --web-port 8080 Use custom HTTP port 133 | rtail-server --udp-port 8080 Use custom UDP port 134 | rtail-server --web-version stable Always uses latest stable webapp 135 | rtail-server --web-version unstable Always uses latest develop webapp 136 | rtail-server --web-version 0.1.3 Use webapp v0.1.3 137 | 138 | ## UDP Broadcasting 139 | 140 | 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. 141 | 142 | ## Authentication layer 143 | 144 | 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. 145 | 146 | # How to contribute 147 | 148 | This project follows the awesome [Vincent Driessen](http://nvie.com/about/) [branching model](http://nvie.com/posts/a-successful-git-branching-model/). 149 | 150 | * You must add a new feature on its own branch 151 | * You must contribute to hot-fixing, directly into the master branch (and pull-request to it) 152 | 153 | This project uses JSCS to enforce a consistent code style. Your contribution must be pass jscs validation. 154 | 155 | 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. 156 | 157 | $ npm test 158 | 159 | ## Contributors 160 | 161 | * [Kilian Ciuffolo](https://github.com/kilianc) 162 | * [Luca Orio](https://www.behance.net/lucaorio) 163 | * [Sandaruwan Silva](https://github.com/s-silva) 164 | * [Sorel Mihai](https://dribbble.com/sorelmihai) 165 | * [Tim Riot](https://www.linkedin.com/in/timriot) 166 | 167 | ## Roadmap (aka where you can help) 168 | 169 | * Write a rock solid test suite 170 | * Allow use of DTLS (waiting for node to support this https://github.com/joyent/node/pull/6704) 171 | * Add GitHub OAuth and basic auth for teams (join proposal convo here: https://github.com/kilianc/rtail/issues/44) 172 | * Implement infinite-scroll like behavior in the webapp to support bigger backlogs and make it future proof. 173 | * Publish base rtail docker image to DockerHub 174 | * Create a catch all docker logs image 175 | * Rewrite webapp using ng2 176 | 177 | ## Sponsors 178 | ❤ rTail? Consider sponsoring this project to keep it alive and free for the community. 179 | 180 | * Lukibear (domain) 181 | * ? (wildcard TLS cert) 182 | * ? (.io domain) 183 | 184 | [![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) 185 | 186 | Professional support or ad-hoc is also available. 187 | 188 | ## License 189 | 190 | _This software is released under the MIT license cited below_. 191 | 192 | Copyright (c) 2014 Kilian Ciuffolo, me@nailik.org. All Rights Reserved. 193 | 194 | Permission is hereby granted, free of charge, to any person 195 | obtaining a copy of this software and associated documentation 196 | files (the 'Software'), to deal in the Software without 197 | restriction, including without limitation the rights to use, 198 | copy, modify, merge, publish, distribute, sublicense, and/or sell 199 | copies of the Software, and to permit persons to whom the 200 | Software is furnished to do so, subject to the following 201 | conditions: 202 | 203 | The above copyright notice and this permission notice shall be 204 | included in all copies or substantial portions of the Software. 205 | 206 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 207 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 208 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 209 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 210 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 211 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 212 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 213 | OTHER DEALINGS IN THE SOFTWARE. 214 | -------------------------------------------------------------------------------- /app/images/dark/logo-lukibear.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dark/logo-lukibear 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /app/images/light/logo-lukibear.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | light/logo-lukibear 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | --------------------------------------------------------------------------------