diff --git a/.github/social-preview.png b/.github/social-preview.png new file mode 100644 index 0000000..497c09b Binary files /dev/null and b/.github/social-preview.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index fe22894..d9d0faf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,4 +4,8 @@ - Added the `relay-logs` command, which prints files or standard input colored by level. - Added `-f` to follow files across rotation and truncation, and `-n` to start with the last lines of each file. +- Added `-l` to filter by level and `-g` and `-x` to include or exclude lines by pattern, keeping stack frames with their entry. +- Added the interactive viewer with following, level toggles, and regular-expression search with highlighting, and `--plain` to print lines instead. +- Added `--format cards` for piping into Relay Cards, `--format json`, and reading of Log Howl alerts. +- Added the repository banner, social preview, demo log, and keyboard and format guides. - Added `parseLine`, which recognizes text, JSON, and logfmt lines, and `sanitizeLine`, which removes terminal control sequences. diff --git a/README.md b/README.md index 27ae7aa..d805259 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,23 @@ -# Relay Logs +

+ Relay Logs +

-Relay Logs reads log files and command output in the terminal. It recognizes the level, timestamp, and message of each line, whether the line is plain text, JSON, or logfmt, and colors it by level. +Relay Logs is a live log viewer for the terminal. It follows files as they grow, recognizes the level, timestamp, and message of each line, whether the line is plain text, JSON, or logfmt, and lets you filter by level and search as new lines arrive. -## Read a log +## Try the demo ```sh -relay-logs app.log -relay-logs api.log worker.log -npm run dev 2>&1 | relay-logs +npm install +npm run demo ``` -When several files are given, each line is prefixed with the name of the file it came from. Use `-` to read standard input alongside files. +The demo opens a sample service log with text, JSON, and logfmt lines, a warning, and an error with its stack trace. Press `/` and type `timeout` to search, `1`–`6` to hide or show levels, `?` for the keyboard reference, and `q` to quit. + +For a demonstration that prints the log and exits: + +```sh +npm run demo:plain +``` ## Follow a log @@ -19,17 +26,74 @@ relay-logs -f app.log relay-logs -f -n 50 api.log worker.log ``` -`-f` keeps printing lines as files grow, like `tail -F`. It starts with the last 10 lines of each file, or the number given with `-n`. Lines appear only once their line ending has been written, so a line still being written is never split. When a file is truncated, Relay Logs reads it again from the start, and when it is replaced by log rotation, the new file is read from its first line. A file that does not exist yet is waited for. +`-f` keeps reading files as they grow, like `tail -F`. It starts with the last 10 lines of each file, or the number given with `-n`. A line appears only once its line ending has been written, a truncated file is read again from the start, a file replaced by log rotation is read from its first line, and a file that does not exist yet is waited for. Lines from several files are interleaved in the order they arrive. + +In a terminal, files open in the interactive viewer. When output is piped or the log arrives on standard input, each line is printed colored by its level instead, and `--plain` does the same in a terminal: + +```sh +npm run dev 2>&1 | relay-logs +``` + +## Viewer controls + +| Key | Action | +| --------------- | ------------------------------------------------------------- | +| `↑`, `k` | Select the previous line and pause following | +| `↓`, `j` | Select the next line; reaching the newest line resumes | +| `PgUp`, `b` | Move up a page | +| `PgDn`, `space` | Move down a page | +| `g`, `G` | Jump to the first line or follow the newest one | +| `f` | Pause or resume following | +| `1`–`6` | Show or hide trace, debug, info, warn, error, and fatal lines | +| `/` | Search with a regular expression and highlight matches | +| `n`, `N` | Jump to the next newer or older match | +| `&` | Show only matching lines, or all lines again | +| `Esc` | Clear the search | +| `c` | Clear the lines read so far | +| `q` | Quit | +| `?` | Open the keyboard reference | + +The viewer keeps the newest 50,000 lines. The complete reference is in [the keyboard guide](./docs/keyboard.md). + +## Filter + +```sh +relay-logs -l warn app.log +relay-logs -f -g timeout -g refused -x /health api.log +``` + +`-l` shows only entries at a level or above. Lines without a recognizable level are hidden by `-l`, except indented lines such as stack frames, which stay with the entry above them. `-g` keeps lines matching a regular expression and `-x` hides them; both may be repeated, and patterns ignore case unless they contain an uppercase letter. + +## Use with Relay Cards + +`--format cards` writes each entry in the form [Relay Cards](https://github.com/relay-cli/relay-cards) turns into a card: + +```sh +relay-logs -f -l warn --format cards api.log | relay-cards +``` + +Errors become error cards that collect the stack frames below them, warnings become warning cards, and JSON records become JSON cards. `--format json` writes one object per entry with `time`, `level`, `source`, and `message`, whatever format the log was in. + +## Use with Log Howl + +Relay Logs reads the alerts [Log Howl](https://github.com/dispersal-wolves/log-howl) prints, showing each one at its severity with the rule that raised it: + +```sh +log-howl watch --config rules.json >> alerts.jsonl & +relay-logs -f alerts.jsonl +``` + +Following the alert file keeps the interactive viewer available. Alerts can also be piped straight into `relay-logs --format cards | relay-cards`. -When several files are followed, their lines are interleaved in the order they arrive. +## Documentation -## Recognized lines +- [Architecture](./docs/architecture.md) +- [Recognized lines and output formats](./docs/formats.md) +- [Keyboard reference](./docs/keyboard.md) -- Text lines with an optional leading timestamp and a level such as `ERROR`, `[warn]`, ``, or `FATAL:` -- JSON records with `level`, `lvl`, `severity`, or `levelname`, including pino and bunyan numeric levels -- logfmt lines with `level=` and `msg=` +## Scope -A line whose level cannot be recognized is still shown as it was written. Terminal control sequences are removed from every line before it is printed, so a log cannot move the cursor or change terminal settings. +Relay Logs reads, filters, and displays logs on the machine it runs on. Alerting on log lines belongs in a watcher such as Log Howl, and shipping logs to another host is left to the tools built for it. ## Development @@ -38,7 +102,7 @@ npm install npm run check ``` -Relay Logs requires Node.js 22 or newer. See [the architecture notes](./docs/architecture.md) and [CONTRIBUTING.md](./CONTRIBUTING.md). +`npm run check` verifies formatting, linting, strict types, tests, and the distributable package. Relay Logs requires Node.js 22 or newer. ## License diff --git a/docs/architecture.md b/docs/architecture.md index 93b9c3d..7728550 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,5 +6,7 @@ Relay Logs turns lines from files or standard input into log entries and display 2. `openSources` opens each file, or standard input, as a labeled stream of lines. `followFile` polls a file for appended lines and notices truncation and rotation by its size and identity. `mergeSources` interleaves followed files as their lines arrive. 3. `parseLine` sanitizes a line and recognizes its level, timestamp, and message. The original text is kept in `raw`, so nothing is lost when a line is not recognized. 4. `formatPlain` prints an entry for pipes and plain terminals. - -Filtering and the interactive viewer build on these modules. +5. `createFilter` decides which entries are shown by level and pattern, carrying the decision for an entry over to its indented continuation lines. +6. `LogBuffer` holds the entries the viewer can scroll through, drops the oldest past its capacity, and notifies the viewer in batches. +7. `LogViewer` renders the buffer with Ink. Its state lives in `viewer/model.ts` as plain functions: which entries are visible, which one is selected, and where search matches are. The selection is kept by sequence number, so it stays on the same line as new lines arrive and old ones are dropped. +8. `formatCards` and `formatJson` write entries for other tools, and `readLogHowlAlert` reads alerts from Log Howl. Relay Logs depends on neither project; they meet at the line format. diff --git a/docs/assets/ARTWORK.md b/docs/assets/ARTWORK.md new file mode 100644 index 0000000..0ece7ae --- /dev/null +++ b/docs/assets/ARTWORK.md @@ -0,0 +1,15 @@ +# Relay Logs artwork + +The repository banner and social preview follow the Relay artwork direction used by Relay Chat and Relay Cards. They use the official Relay mark from the organization profile as it is, without redrawing it. + +## Banner + +`banner.png` uses a 3:1 composition. The left side contains the Relay mark, the product name, and a direct description. The right side depicts the real viewer: the header with the visible levels and the following indicator, log lines with their time, level, source, and message, an error selected with its stack frame below it, a highlighted search match, and the search prompt. + +## Social preview + +`.github/social-preview.png` recomposes the same interface for a 2:1 thumbnail. The description is removed, and the mark and name are stacked so they stay legible in repository cards. + +## Direction + +Both images use the shared Relay palette (background `#080B0F`, text `#E8EDF2`, blue `#48A8FF`, orange `#FF8A3D`, red `#FF5C6C`, yellow `#F4C95D`, green `#5DD39E`), monospace interface typography, and restrained terminal borders. The content mirrors what the viewer renders, with no decorative code, robots, or imagery of other products. diff --git a/docs/assets/banner.png b/docs/assets/banner.png new file mode 100644 index 0000000..9780848 Binary files /dev/null and b/docs/assets/banner.png differ diff --git a/docs/formats.md b/docs/formats.md new file mode 100644 index 0000000..76a962b --- /dev/null +++ b/docs/formats.md @@ -0,0 +1,30 @@ +# Recognized lines and output formats + +## Recognized lines + +Relay Logs reads the level, timestamp, and message of each line: + +| Input | Recognized when | +| -------- | ----------------------------------------------------------------------------------------------------- | +| Text | An optional leading timestamp is followed by a level such as `ERROR`, `[warn]`, ``, or `FATAL:` | +| JSON | The record has `level`, `lvl`, `severity`, or `levelname`, including pino and bunyan numeric levels | +| logfmt | The line has `level=`, with the message in `msg=` | +| Log Howl | The record uses the `dispersal-wolves/log-howl-event/v1` schema | + +Level names are read generously: `warning` is `warn`, `err` is `error`, `notice` is `info`, and `critical`, `emerg`, and `panic` are `fatal`. A line whose level cannot be recognized is still shown as it was written, and indented lines such as stack frames stay with the entry above them when filtering. + +Terminal control sequences and control characters are removed from every line before it is shown, so a log cannot move the cursor, change the window title, or create hyperlinks. + +## Log Howl alerts + +Alerts are shown at their severity, with the rule name before the matched line (`failed-login: Failed password for …`). When the alert arrives on standard input, its source is the file Log Howl was watching. Severities outside the usual level names map `low` to `info`, `medium` to `warn`, and `high` to `error`. + +## Output formats + +`--format` chooses how printed lines are written. Any format other than `text` prints lines instead of opening the viewer. + +| Format | Output | +| ------- | ---------------------------------------------------------------------------------------------------- | +| `text` | Each line as it was written, colored by level, prefixed with its file when several files are read | +| `json` | One object per entry with `time`, `level`, `source`, and `message` | +| `cards` | Lines Relay Cards turns into cards: `[time source] ERROR: message`, warnings, JSON, and stack frames | diff --git a/docs/keyboard.md b/docs/keyboard.md new file mode 100644 index 0000000..67d3d5b --- /dev/null +++ b/docs/keyboard.md @@ -0,0 +1,41 @@ +# Keyboard reference + +The viewer opens when standard input and output are both terminals. Press `?` inside it for the same list. + +## Moving + +| Key | Action | +| --------------- | ------------------------------------------------------ | +| `↑`, `k` | Select the previous line and pause following | +| `↓`, `j` | Select the next line; reaching the newest line resumes | +| `PgUp`, `b` | Move up a page | +| `PgDn`, `space` | Move down a page | +| `g`, `Home` | Jump to the first line | +| `G`, `End` | Follow the newest line | +| `f` | Pause or resume following | + +While paused, new lines are still read and counted in the header, and the selected line stays where it is. + +## Levels + +`1` through `6` show or hide trace, debug, info, warn, error, and fatal lines. Hidden levels are struck through in the header. Lines without a level are never hidden this way. + +## Search + +| Key | Action | +| ----- | ----------------------------------------------- | +| `/` | Type a pattern and press `Enter`; `Esc` cancels | +| `n` | Jump to the next newer match | +| `N` | Jump to the next older match | +| `&` | Show only matching lines, or all lines again | +| `Esc` | Clear the search | + +Patterns are regular expressions and ignore case unless they contain an uppercase letter. Committing a search selects the nearest match at or above the selected line. + +## Other + +| Key | Action | +| ------------- | ---------------------------- | +| `c` | Clear the lines read so far | +| `?` | Open or close this reference | +| `q`, `Ctrl+C` | Quit | diff --git a/package.json b/package.json index 4d4a807..645a6c7 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "scripts": { "build": "tsup", "dev": "tsx src/cli.tsx", + "demo": "tsx src/cli.tsx examples/app.log", + "demo:plain": "tsx src/cli.tsx --plain examples/app.log", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint .", diff --git a/src/cli.tsx b/src/cli.tsx index ef65cba..35b719f 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -1,19 +1,106 @@ #!/usr/bin/env node -import {helpText, parseCliOptions} from './cli/options.js'; -import {parseLine} from './core/entry.js'; +import {render} from 'ink'; +import {type CliOptions, helpText, parseCliOptions} from './cli/options.js'; +import {type LogEntry, parseLine} from './core/entry.js'; +import {createFilter} from './core/filter.js'; import {concatSources, type LogSource, mergeSources, openSources} from './core/sources.js'; +import {formatCards} from './format/cards.js'; +import {formatJson} from './format/json.js'; import {formatPlain} from './format/plain.js'; +import {LogBuffer} from './viewer/buffer.js'; +import {LogViewer} from './viewer/LogViewer.js'; import {relayLogsVersion} from './version.js'; +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +/** Read the chosen sources and yield the entries that pass the command-line filters. */ +async function* readEntries( + options: CliOptions, + signal: AbortSignal | undefined, + onError: (source: LogSource, error: unknown) => void, +): AsyncGenerator { + const sources = openSources(options.files, { + follow: options.follow, + ...(options.lines === undefined ? {} : {lines: options.lines}), + ...(signal === undefined ? {} : {signal}), + }); + const lines = options.follow ? mergeSources(sources, onError) : concatSources(sources, onError); + const shown = createFilter({ + ...(options.minLevel === undefined ? {} : {minLevel: options.minLevel}), + include: options.include, + exclude: options.exclude, + }); + let seq = 0; + for await (const {label, line} of lines) { + seq += 1; + const entry = parseLine(line, {seq, ...(label === undefined ? {} : {source: label})}); + if (shown(entry)) yield entry; + } +} + +const runPlain = async (options: CliOptions): Promise => { + // Stop quietly when the reader goes away, as in `relay-logs app.log | head`. + process.stdout.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EPIPE') process.exit(0); + throw error; + }); + const plain = { + color: options.color ?? (process.stdout.isTTY && process.env.NO_COLOR === undefined), + showSource: options.files.length > 1, + }; + const format = + options.format === 'json' + ? formatJson + : options.format === 'cards' + ? formatCards + : (entry: LogEntry) => formatPlain(entry, plain); + const onError = (source: LogSource, error: unknown): void => { + process.stderr.write(`relay-logs: ${source.label ?? 'stdin'}: ${errorMessage(error)}\n`); + process.exitCode = 1; + }; + for await (const entry of readEntries(options, undefined, onError)) { + process.stdout.write(`${format(entry)}\n`); + } +}; + +const runViewer = async (options: CliOptions): Promise => { + const controller = new AbortController(); + const buffer = new LogBuffer(); + const failures: string[] = []; + const app = render( + 1} + onQuit={() => app.unmount()} + />, + {alternateScreen: true, exitOnCtrlC: false}, + ); + const onError = (source: LogSource, error: unknown): void => { + failures.push(`relay-logs: ${source.label ?? 'stdin'}: ${errorMessage(error)}`); + }; + void (async () => { + for await (const entry of readEntries(options, controller.signal, onError)) buffer.push(entry); + })().catch((error: unknown) => failures.push(`relay-logs: ${errorMessage(error)}`)); + + const interrupt = (): void => app.unmount(); + process.once('SIGINT', interrupt); + process.once('SIGTERM', interrupt); + await app.waitUntilExit(); + controller.abort(); + for (const failure of failures) process.stderr.write(`${failure}\n`); + // Standard input may still be open, so leave instead of waiting for the readers. + process.exit(failures.length > 0 ? 1 : 0); +}; + const main = async (): Promise => { let options; try { options = parseCliOptions(process.argv.slice(2)); } catch (error) { - process.stderr.write( - `${error instanceof Error ? error.message : String(error)}\n\n${helpText}`, - ); + process.stderr.write(`${errorMessage(error)}\n\n${helpText}`); process.exitCode = 2; return; } @@ -25,33 +112,16 @@ const main = async (): Promise => { return; } - // Stop quietly when the reader goes away, as in `relay-logs app.log | head`. - process.stdout.on('error', (error: NodeJS.ErrnoException) => { - if (error.code === 'EPIPE') process.exit(0); - throw error; - }); - - const format = { - color: options.color ?? (process.stdout.isTTY && process.env.NO_COLOR === undefined), - showSource: options.files.length > 1, - }; - const sources = openSources(options.files, { - follow: options.follow, - ...(options.lines === undefined ? {} : {lines: options.lines}), - }); - const onError = (source: LogSource, error: unknown): void => { - process.stderr.write( - `relay-logs: ${source.label ?? 'stdin'}: ${error instanceof Error ? error.message : String(error)}\n`, - ); - process.exitCode = 1; - }; - const lines = options.follow ? mergeSources(sources, onError) : concatSources(sources, onError); - let seq = 0; - for await (const {label, line} of lines) { - seq += 1; - const entry = parseLine(line, {seq, ...(label === undefined ? {} : {source: label})}); - process.stdout.write(`${formatPlain(entry, format)}\n`); - } + // The viewer needs the keyboard, so it opens only when standard input is a terminal rather + // than the log itself. + const interactive = + !options.plain && + options.format === 'text' && + process.stdout.isTTY && + process.stdin.isTTY && + options.files.length > 0 && + !options.files.includes('-'); + await (interactive ? runViewer(options) : runPlain(options)); }; await main(); diff --git a/src/cli/options.ts b/src/cli/options.ts index 988e63c..1523e6e 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -1,3 +1,10 @@ +import {compilePattern} from '../core/filter.js'; +import {type Level, levels, parseLevelName} from '../core/levels.js'; + +export const outputFormats = ['text', 'json', 'cards'] as const; + +export type OutputFormat = (typeof outputFormats)[number]; + export interface CliOptions { help: boolean; version: boolean; @@ -5,8 +12,15 @@ export interface CliOptions { color: boolean | undefined; /** Keep reading files as they grow. */ follow: boolean; + /** Print lines instead of opening the interactive viewer. */ + plain: boolean; + /** How printed lines are written. Any format other than text implies `plain`. */ + format: OutputFormat; /** Start with only the last lines of each file. */ lines: number | undefined; + minLevel: Level | undefined; + include: RegExp[]; + exclude: RegExp[]; files: string[]; } @@ -16,12 +30,19 @@ Usage: relay-logs [options] [file...] some-command | relay-logs [options] -Reads log files, or standard input when no file is given, and prints each line colored by its -level. +Opens log files in an interactive viewer with search and level filters. When output is +redirected or the log arrives on standard input, prints each line colored by its level instead. Options: -f, --follow Keep printing lines as files grow, across rotation and truncation -n, --lines Start with the last n lines of each file (10 when following) + -l, --level Show only entries at this level or above (trace, debug, info, warn, + error, fatal) + -g, --grep Show only lines matching this pattern; repeat to match any of several + -x, --exclude Hide lines matching this pattern; may be repeated + --plain Print lines instead of opening the viewer + --format Print lines as text (default), json, or cards for piping into + relay-cards --color Always color output --no-color Never color output -h, --help Show this help @@ -34,7 +55,12 @@ export const parseCliOptions = (arguments_: readonly string[]): CliOptions => { version: false, color: undefined, follow: false, + plain: false, + format: 'text', lines: undefined, + minLevel: undefined, + include: [], + exclude: [], files: [], }; let onlyFiles = false; @@ -54,6 +80,11 @@ export const parseCliOptions = (arguments_: readonly string[]): CliOptions => { else if (argument === '-v' || argument === '--version') options.version = true; else if (argument === '-f' || argument === '--follow') options.follow = true; else if (name === '-n' || name === '--lines') options.lines = parseCount(name, value()); + else if (name === '-l' || name === '--level') options.minLevel = parseLevel(value()); + else if (name === '-g' || name === '--grep') options.include.push(compilePattern(value())); + else if (name === '-x' || name === '--exclude') options.exclude.push(compilePattern(value())); + else if (argument === '--plain') options.plain = true; + else if (name === '--format') options.format = parseFormat(value()); else if (argument === '--color') options.color = true; else if (argument === '--no-color') options.color = false; else throw new Error(`Unknown option: ${argument}`); @@ -75,3 +106,19 @@ const parseCount = (name: string, text: string): number => { } return count; }; + +const parseLevel = (text: string): Level => { + const level = parseLevelName(text); + if (level === undefined) { + throw new Error(`Unknown level: ${text}. Use one of ${levels.join(', ')}`); + } + return level; +}; + +const parseFormat = (text: string): OutputFormat => { + const format = outputFormats.find((candidate) => candidate === text); + if (format === undefined) { + throw new Error(`Unknown format: ${text}. Use one of ${outputFormats.join(', ')}`); + } + return format; +}; diff --git a/src/core/entry.ts b/src/core/entry.ts index 8f54cca..12fd6d8 100644 --- a/src/core/entry.ts +++ b/src/core/entry.ts @@ -1,4 +1,5 @@ import {type EntryLevel, parseLevelName, parseLevelNumber} from './levels.js'; +import {readLogHowlAlert} from './log-howl.js'; import {sanitizeLine} from './sanitize.js'; export interface LogEntry { @@ -41,7 +42,16 @@ export const parseLine = (line: string, context: ParseContext): LogEntry => { raw, ...(context.source === undefined ? {} : {source: context.source}), }; - return {...base, ...(parseJson(raw) ?? parseLogfmt(raw) ?? parseText(raw))}; + const parsed = parseJson(raw) ?? parseLogfmt(raw) ?? parseText(raw); + const alert = parsed.fields === undefined ? undefined : readLogHowlAlert(parsed.fields); + if (alert === undefined) return {...base, ...parsed}; + return { + ...base, + ...parsed, + level: alert.level, + message: alert.message, + ...(context.source === undefined && alert.source !== undefined ? {source: alert.source} : {}), + }; }; type Parsed = Pick; diff --git a/src/core/filter.ts b/src/core/filter.ts new file mode 100644 index 0000000..e1c3214 --- /dev/null +++ b/src/core/filter.ts @@ -0,0 +1,52 @@ +import type {LogEntry} from './entry.js'; +import {type Level, levelRank} from './levels.js'; + +export interface FilterOptions { + /** Hide entries below this level. */ + minLevel?: Level; + /** Show only entries matching at least one of these patterns. */ + include?: readonly RegExp[]; + /** Hide entries matching any of these patterns. */ + exclude?: readonly RegExp[]; +} + +/** + * Compile a search pattern. Patterns are regular expressions and are case-insensitive unless + * they contain an uppercase letter, so `timeout` finds `Timeout` but `Timeout` does not find + * `timeout`. + */ +export const compilePattern = (pattern: string): RegExp => { + try { + return new RegExp(pattern, /\p{Lu}/u.test(pattern) ? 'u' : 'iu'); + } catch { + throw new Error(`Not a valid pattern: ${pattern}`); + } +}; + +/** + * Create a predicate that decides whether an entry is shown. + * + * Indented lines without a level, such as stack frames, continue the entry above them from the + * same source and are shown or hidden with it. Other lines without a level are hidden by + * `minLevel`, since their level is not known. + */ +export const createFilter = (options: FilterOptions): ((entry: LogEntry) => boolean) => { + const {minLevel, include = [], exclude = []} = options; + const minimum = minLevel === undefined ? undefined : levelRank(minLevel); + const lastShown = new Map(); + + return (entry) => { + const continuation = entry.level === 'unknown' && /^\s/.test(entry.raw); + const previous = lastShown.get(entry.source); + if (continuation && previous !== undefined) return previous; + + let shown = true; + if (minimum !== undefined) { + shown = entry.level !== 'unknown' && levelRank(entry.level) >= minimum; + } + if (shown && include.length > 0) shown = include.some((pattern) => pattern.test(entry.raw)); + if (shown && exclude.length > 0) shown = !exclude.some((pattern) => pattern.test(entry.raw)); + lastShown.set(entry.source, shown); + return shown; + }; +}; diff --git a/src/core/log-howl.ts b/src/core/log-howl.ts new file mode 100644 index 0000000..0c9047e --- /dev/null +++ b/src/core/log-howl.ts @@ -0,0 +1,37 @@ +import {type EntryLevel, parseLevelName} from './levels.js'; +import {sanitizeLine} from './sanitize.js'; + +/** The schema Log Howl writes on every alert it prints. */ +export const logHowlSchema = 'dispersal-wolves/log-howl-event/v1'; + +export interface LogHowlAlert { + level: EntryLevel; + /** The rule name followed by the matched line, such as `failed-login: Failed password…`. */ + message: string; + /** The name of the log file the line was found in. */ + source?: string; +} + +// Log Howl severities are free-form rule settings; these cover the common scales. +const severities: Record = {low: 'info', medium: 'warn', high: 'error'}; + +/** + * Read an alert printed by Log Howl (github.com/dispersal-wolves/log-howl), so that + * `log-howl watch | relay-logs` shows each alert at its severity with the rule that raised it. + */ +export const readLogHowlAlert = ( + fields: Readonly>, +): LogHowlAlert | undefined => { + if (fields.schema !== logHowlSchema) return undefined; + const {severity, rule, message, source} = fields; + const level = + typeof severity === 'string' + ? (parseLevelName(severity) ?? severities[severity.toLowerCase()] ?? 'unknown') + : 'unknown'; + const text = typeof message === 'string' ? sanitizeLine(message) : ''; + return { + level, + message: typeof rule === 'string' ? `${sanitizeLine(rule)}: ${text}` : text, + ...(typeof source === 'string' ? {source: sanitizeLine(source)} : {}), + }; +}; diff --git a/src/format/cards.ts b/src/format/cards.ts new file mode 100644 index 0000000..369315d --- /dev/null +++ b/src/format/cards.ts @@ -0,0 +1,30 @@ +import type {LogEntry} from '../core/entry.js'; +import {logHowlSchema} from '../core/log-howl.js'; + +/** + * Render an entry as a line Relay Cards (github.com/relay-cli/relay-cards) turns into the + * matching card: errors and fatal entries become error cards that collect the stack frames + * printed after them, warnings become warning cards, and JSON records become JSON cards. + * Log Howl alerts are written as text so the rule name stays in view. The time and source go + * in a leading bracket, which Relay Cards accepts before a level. + */ +export const formatCards = (entry: LogEntry): string => { + // Stack frames and other continuation lines pass through so they attach to the error above. + if (entry.level === 'unknown' && /^\s/.test(entry.raw)) return entry.raw; + const tag = [entry.time, entry.source] + .filter((part) => part !== undefined) + .join(' ') + .replaceAll(']', ')'); + const prefix = tag === '' ? '' : `[${tag}] `; + const record = entry.fields !== undefined && entry.fields.schema !== logHowlSchema; + switch (entry.level) { + case 'fatal': + case 'error': + case 'warn': + return `${prefix}${entry.level.toUpperCase()}: ${entry.message}`; + case 'unknown': + return record ? entry.raw : `${prefix}${entry.raw}`; + default: + return record ? entry.raw : `${prefix}${entry.level.toUpperCase()} ${entry.message}`; + } +}; diff --git a/src/format/json.ts b/src/format/json.ts new file mode 100644 index 0000000..fdcc9bd --- /dev/null +++ b/src/format/json.ts @@ -0,0 +1,13 @@ +import type {LogEntry} from '../core/entry.js'; + +/** + * Render an entry as one JSON object with the fields every log format shares, so mixed text, + * JSON, and logfmt logs come out in one shape for scripts and other tools. + */ +export const formatJson = (entry: LogEntry): string => + JSON.stringify({ + ...(entry.time === undefined ? {} : {time: entry.time}), + ...(entry.level === 'unknown' ? {} : {level: entry.level}), + ...(entry.source === undefined ? {} : {source: entry.source}), + message: entry.message, + }); diff --git a/src/index.ts b/src/index.ts index ff9e112..501cebb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,13 @@ export * from './core/entry.js'; +export * from './core/filter.js'; export * from './core/levels.js'; export * from './core/lines.js'; +export * from './core/follow.js'; export * from './core/sanitize.js'; export * from './core/sources.js'; +export * from './core/log-howl.js'; +export * from './format/cards.js'; +export * from './format/json.js'; export * from './format/plain.js'; +export * from './viewer/index.js'; export {relayLogsVersion} from './version.js'; diff --git a/src/viewer/HelpOverlay.tsx b/src/viewer/HelpOverlay.tsx new file mode 100644 index 0000000..14be3ca --- /dev/null +++ b/src/viewer/HelpOverlay.tsx @@ -0,0 +1,37 @@ +import {Box, Text} from 'ink'; +import {theme} from './theme.js'; + +const shortcuts: readonly [string, string][] = [ + ['↑ / k', 'select the previous line'], + ['↓ / j', 'select the next line'], + ['PgUp / b', 'move up a page'], + ['PgDn / space', 'move down a page'], + ['g / G', 'jump to the first line / follow the newest line'], + ['f', 'pause or resume following'], + ['1–6', 'show or hide trace, debug, info, warn, error, and fatal lines'], + ['/', 'search; the pattern is a case-insensitive regular expression'], + ['n / N', 'jump to the next newer / older match'], + ['&', 'show only matching lines, or all lines again'], + ['esc', 'clear the search'], + ['c', 'clear the lines read so far'], + ['q', 'quit'], + ['?', 'open or close this help'], +]; + +export const HelpOverlay = () => ( + + + Relay Logs controls + + + {shortcuts.map(([key, description]) => ( + + + {key} + + {description} + + ))} + + +); diff --git a/src/viewer/LogViewer.tsx b/src/viewer/LogViewer.tsx new file mode 100644 index 0000000..bf892c8 --- /dev/null +++ b/src/viewer/LogViewer.tsx @@ -0,0 +1,278 @@ +import {useMemo, useState, useSyncExternalStore} from 'react'; +import {Box, Text, useInput} from 'ink'; +import type {LogEntry} from '../core/entry.js'; +import {compilePattern} from '../core/filter.js'; +import {levels} from '../core/levels.js'; +import type {LogBuffer} from './buffer.js'; +import {HelpOverlay} from './HelpOverlay.js'; +import { + findMatch, + highlight, + initialViewerState, + selectedIndex, + type ViewerState, + visibleEntries, + windowStart, +} from './model.js'; +import {levelStyle, theme} from './theme.js'; +import {useTerminalSize} from './useTerminalSize.js'; + +export interface LogViewerProps { + buffer: LogBuffer; + /** Names of the sources being read, shown in the header. */ + title: string; + /** Show each entry's source, for viewers reading several files. */ + showSource: boolean; + /** Override the terminal size, for tests and snapshots. */ + width?: number; + height?: number; + onQuit: () => void; +} + +export const LogViewer = ({buffer, title, showSource, width, height, onQuit}: LogViewerProps) => { + const version = useSyncExternalStore(buffer.subscribe, buffer.getSnapshot); + const terminal = useTerminalSize(); + const columns = width ?? terminal.columns; + const rows = Math.max(1, (height ?? terminal.rows) - 3); + const [state, setState] = useState(initialViewerState); + const [draft, setDraft] = useState(); + const [notice, setNotice] = useState(); + const [showHelp, setShowHelp] = useState(false); + + // `version` changes whenever the buffer does; the entries array itself is reused. + const visible = useMemo(() => visibleEntries(buffer.entries, state), [version, state]); + const cursor = selectedIndex(visible, state); + const start = windowStart(cursor, visible.length, rows); + + const select = (index: number): void => { + const clamped = Math.max(0, Math.min(index, visible.length - 1)); + const following = clamped >= visible.length - 1; + setState((current) => ({...current, following, selected: visible[clamped]?.seq})); + }; + + const jump = (direction: 1 | -1, search = state.search, from = cursor): void => { + if (search === undefined) return setNotice('Press / to search first'); + const found = findMatch(visible, from, direction, search); + if (found === undefined) + return setNotice(direction === 1 ? 'No newer match' : 'No older match'); + setNotice(undefined); + select(found); + }; + + useInput((input, key) => { + if (showHelp) { + if (input === '?' || input === 'q' || key.escape) setShowHelp(false); + return; + } + if (draft !== undefined) { + if (key.escape) setDraft(undefined); + else if (key.return) { + setDraft(undefined); + if (draft === '') return; + let search: RegExp; + try { + search = compilePattern(draft); + } catch (error) { + return setNotice(error instanceof Error ? error.message : String(error)); + } + setState((current) => ({...current, search})); + // Start from the selection itself, then look further back in time. + jump(-1, search, cursor + 1); + } else if (key.backspace || key.delete) setDraft(draft.slice(0, -1)); + else if (!key.ctrl && !key.meta) setDraft(draft + input); + return; + } + + setNotice(undefined); + if (input === 'q' || (key.ctrl && input === 'c')) onQuit(); + else if (input === '?') setShowHelp(true); + else if (key.upArrow || input === 'k') select(cursor - 1); + else if (key.downArrow || input === 'j') select(cursor + 1); + else if (key.pageUp || input === 'b') select(cursor - rows); + else if (key.pageDown || input === ' ') select(cursor + rows); + else if (key.home || input === 'g') select(0); + else if (key.end || input === 'G') select(visible.length - 1); + else if (input === 'f') { + setState((current) => ({ + ...current, + following: !current.following, + selected: visible[cursor]?.seq, + })); + } else if (input === '/') setDraft(''); + else if (input === 'n') jump(1); + else if (input === 'N') jump(-1); + else if (input === '&') { + if (state.search === undefined) setNotice('Press / to search first'); + else setState((current) => ({...current, onlyMatches: !current.onlyMatches})); + } else if (key.escape) { + setState((current) => ({...current, search: undefined, onlyMatches: false})); + } else if (input >= '1' && input <= '6') { + const level = levels[Number(input) - 1]; + if (level === undefined) return; + setState((current) => { + const hidden = new Set(current.hidden); + if (!hidden.delete(level)) hidden.add(level); + return {...current, hidden}; + }); + } else if (input === 'c') { + buffer.clear(); + setState((current) => ({...current, following: true, selected: undefined})); + } + }); + + if (showHelp) return ; + + const shown = visible.slice(start, start + rows); + // Columns are sized to the lines on screen, so logs without timestamps lose no space. + const timeWidth = Math.max(0, ...shown.map((entry) => timeOfDay(entry.time).length)); + const sourceWidth = showSource + ? Math.max(0, ...shown.map((entry) => entry.source?.length ?? 0)) + : 0; + return ( + +
+ + {shown.length === 0 ? ( + + {buffer.entries.length === 0 ? 'Waiting for log lines…' : 'No lines match.'} + + ) : ( + shown.map((entry, offset) => ( + + )) + )} + +