diff --git a/bin/xo-server-logs b/bin/xo-server-logs new file mode 100755 index 000000000..c8a45078e --- /dev/null +++ b/bin/xo-server-logs @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +'use strict' + +// =================================================================== + +// Better stack traces if possible. +require('../better-stacks') + +require('exec-promise')(require('../dist/logs-cli')) diff --git a/package.json b/package.json index 6eb98ec53..b6a1459ed 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "julien-f-unzip": "^0.2.1", "kindof": "^2.0.0", "level": "^1.3.0", + "level-party": "^2.1.2", "level-sublevel": "^6.5.2", "lodash.assign": "^3.0.0", "lodash.bind": "^3.0.0", @@ -76,6 +77,7 @@ "lodash.find": "^3.0.0", "lodash.findindex": "^3.0.0", "lodash.foreach": "^3.0.1", + "lodash.get": "^3.7.0", "lodash.has": "^3.0.0", "lodash.includes": "^3.1.1", "lodash.isarray": "^3.0.0", @@ -89,8 +91,11 @@ "lodash.sortby": "^3.1.4", "lodash.startswith": "^3.0.1", "make-error": "^1", + "micromatch": "^2.3.2", + "minimist": "^1.2.0", "ms": "^0.7.1", "multikey-hash": "^1.0.1", + "ndjson": "^1.4.3", "partial-stream": "0.0.0", "passport": "^0.3.0", "passport-local": "^1.0.0", diff --git a/src/glob-matcher.js b/src/glob-matcher.js new file mode 100644 index 000000000..e5fb29fe4 --- /dev/null +++ b/src/glob-matcher.js @@ -0,0 +1,54 @@ +// See: https://gist.github.com/julien-f/5b9a3537eb82a34b04e2 + +var matcher = require('micromatch').matcher + +module.exports = function globMatcher (patterns, opts) { + if (!Array.isArray(patterns)) { + if (patterns[0] === '!') { + var m = matcher(patterns.slice(1), opts) + return function (string) { + return !m(string) + } + } else { + return matcher(patterns, opts) + } + } + + var noneMustMatch = [] + var anyMustMatch = [] + + // TODO: could probably be optimized by combining all positive patterns (and all negative patterns) as a single matcher. + for (var i = 0, n = patterns.length; i < n; ++i) { + var pattern = patterns[i] + if (pattern[0] === '!') { + noneMustMatch.push(matcher(pattern.slice(1), opts)) + } else { + anyMustMatch.push(matcher(pattern, opts)) + } + } + + var nNone = noneMustMatch.length + var nAny = anyMustMatch.length + + return function (string) { + var i + + for (i = 0; i < nNone; ++i) { + if (noneMustMatch[i](string)) { + return false + } + } + + if (nAny === 0) { + return true + } + + for (i = 0; i < nAny; ++i) { + if (anyMustMatch[i](string)) { + return true + } + } + + return false + } +} diff --git a/src/logs-cli.js b/src/logs-cli.js new file mode 100644 index 000000000..c493da707 --- /dev/null +++ b/src/logs-cli.js @@ -0,0 +1,176 @@ +import appConf from 'app-conf' +import get from 'lodash.get' +import highland from 'highland' +import levelup from 'level-party' +import ndjson from 'ndjson' +import parseArgs from 'minimist' +import sublevel from 'level-sublevel' +import util from 'util' + +import {forEach} from './utils' +import globMatcher from './glob-matcher' + +// =================================================================== + +async function printLogs (db, args) { + let stream = highland(db.createReadStream({reverse: true})) + + if (args.since) { + stream = stream.filter(({value}) => (value.time >= args.since)) + } + + if (args.until) { + stream = stream.filter(({value}) => (value.time <= args.until)) + } + + const fields = Object.keys(args.matchers) + + if (fields.length > 0) { + stream = stream.filter(({value}) => { + for (const field of fields) { + const fieldValue = get(value, field) + if (fieldValue === undefined || !args.matchers[field](fieldValue)) { + return false + } + } + + return true + }) + } + + stream = stream.take(args.limit) + + if (args.json) { + stream = highland(stream.pipe(ndjson.serialize())) + .each(value => { + process.stdout.write(value) + }) + } else { + stream = stream.each(value => { + console.log(util.inspect(value, { depth: null })) + }) + } + + return new Promise(resolve => { + stream.done(resolve) + }) +} + +// =================================================================== + +function helper () { + console.error( + `Usage: + xo-server-logs --help, -h + xo-server-logs [--json] [--limit=] [--since=] [--until=] [...] + + --help + Display this help message. + + --json + Display the results as new line delimited JSON for consumption by another program. + + --limit=, -n + Limit the number of results to be displayed (default 100) + + --since=, --until= + Start showing entries on or newer than the specified date, or on or older than the specified date. + should use the format \`YYYY-MM-DD\`. + + + Patterns can be used to filter the entries. + have the following format \`=\`/\`\`.` + ) +} + +// =================================================================== + +function getArgs () { + const stringArgs = ['since', 'until', 'limit'] + const args = parseArgs(process.argv.slice(2), { + string: stringArgs, + boolean: ['help', 'json'], + default: { + limit: 100, + json: false, + help: false + }, + alias: { + limit: 'n', + help: 'h' + } + }) + + const patterns = {} + + for (let value of args._) { + value = String(value) + + const i = value.indexOf('=') + + if (i !== -1) { + const field = value.slice(0, i) + const pattern = value.slice(i + 1) + + patterns[pattern] + ? patterns[field].push(pattern) + : patterns[field] = [ pattern ] + } else if (!patterns[value]) { + patterns[value] = null + } + } + + const trueFunction = () => true + args.matchers = {} + + for (const field in patterns) { + const values = patterns[field] + args.matchers[field] = (values === null) ? trueFunction : globMatcher(values) + } + + // Warning: minimist makes one array of values if the same option is used many times. + // (But only for strings args, not boolean) + forEach(stringArgs, arg => { + if (args[arg] instanceof Array) { + throw new Error(`error: too many values for ${arg} argument`) + } + }) + + ;['since', 'until'].forEach(arg => { + if (args[arg] !== undefined) { + args[arg] = Date.parse(args[arg]) + + if (isNaN(args[arg])) { + throw new Error(`error: bad ${arg} timestamp format`) + } + } + }) + + if (isNaN(args.limit = +args.limit)) { + throw new Error('error: limit is not a valid number') + } + + return args +} + +// =================================================================== + +export default async function main () { + const args = getArgs() + + if (args.help) { + helper() + return + } + + const config = await appConf.load('xo-server', { + ignoreUnknownFormats: true + }) + + const db = sublevel(levelup( + `${config.datadir}/leveldb`, + { valueEncoding: 'json' } + )).sublevel('logs') + + return printLogs(db, args) +} diff --git a/src/xo.js b/src/xo.js index a104fbdea..46c545b05 100644 --- a/src/xo.js +++ b/src/xo.js @@ -8,7 +8,7 @@ import fs from 'fs-promise' import includes from 'lodash.includes' import isFunction from 'lodash.isfunction' import isString from 'lodash.isstring' -import levelup from 'level' +import levelup from 'level-party' import sortBy from 'lodash.sortby' import startsWith from 'lodash.startswith' import sublevel from 'level-sublevel'