From b75cfe1ecd55c4fcf6b1d910d8e1b7bc343a4674 Mon Sep 17 00:00:00 2001 From: autoantohaki Date: Wed, 21 Oct 2026 20:49:37 -0300 Subject: [PATCH 1/6] feat: filter entries by level and pattern --- CHANGELOG.md | 1 + README.md | 11 +++++++++ docs/architecture.md | 2 +- src/cli.tsx | 7 ++++++ src/cli/options.ts | 24 +++++++++++++++++++ src/core/filter.ts | 52 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 2 ++ tests/filter.test.ts | 57 ++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 src/core/filter.ts create mode 100644 tests/filter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe22894..f09304e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,4 +4,5 @@ - 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 `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..66fe6ce 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,17 @@ relay-logs -f -n 50 api.log worker.log When several files are followed, their lines are interleaved in the order they arrive. +## 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: `trace`, `debug`, `info`, `warn`, `error`, or `fatal`. Lines without a recognizable level are hidden by `-l`, except indented lines such as stack frames, which stay with the entry above them. + +`-g` shows only lines matching a regular expression, and `-x` hides lines matching one. Both may be repeated. Patterns ignore case unless they contain an uppercase letter. + ## Recognized lines - Text lines with an optional leading timestamp and a level such as `ERROR`, `[warn]`, ``, or `FATAL:` diff --git a/docs/architecture.md b/docs/architecture.md index 93b9c3d..6f24a8a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,4 +7,4 @@ Relay Logs turns lines from files or standard input into log entries and display 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. diff --git a/src/cli.tsx b/src/cli.tsx index ef65cba..41b3e9a 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -2,6 +2,7 @@ import {helpText, parseCliOptions} from './cli/options.js'; import {parseLine} from './core/entry.js'; +import {createFilter} from './core/filter.js'; import {concatSources, type LogSource, mergeSources, openSources} from './core/sources.js'; import {formatPlain} from './format/plain.js'; import {relayLogsVersion} from './version.js'; @@ -46,10 +47,16 @@ const main = async (): Promise => { process.exitCode = 1; }; 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)) continue; process.stdout.write(`${formatPlain(entry, format)}\n`); } }; diff --git a/src/cli/options.ts b/src/cli/options.ts index 988e63c..2047644 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -1,3 +1,6 @@ +import {compilePattern} from '../core/filter.js'; +import {type Level, levels, parseLevelName} from '../core/levels.js'; + export interface CliOptions { help: boolean; version: boolean; @@ -7,6 +10,9 @@ export interface CliOptions { follow: boolean; /** Start with only the last lines of each file. */ lines: number | undefined; + minLevel: Level | undefined; + include: RegExp[]; + exclude: RegExp[]; files: string[]; } @@ -22,6 +28,10 @@ level. 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 --color Always color output --no-color Never color output -h, --help Show this help @@ -35,6 +45,9 @@ export const parseCliOptions = (arguments_: readonly string[]): CliOptions => { color: undefined, follow: false, lines: undefined, + minLevel: undefined, + include: [], + exclude: [], files: [], }; let onlyFiles = false; @@ -54,6 +67,9 @@ 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 === '--color') options.color = true; else if (argument === '--no-color') options.color = false; else throw new Error(`Unknown option: ${argument}`); @@ -75,3 +91,11 @@ 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; +}; 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/index.ts b/src/index.ts index ff9e112..4dc8eca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ 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 './format/plain.js'; diff --git a/tests/filter.test.ts b/tests/filter.test.ts new file mode 100644 index 0000000..f9d843b --- /dev/null +++ b/tests/filter.test.ts @@ -0,0 +1,57 @@ +import {describe, expect, it} from 'vitest'; +import {parseLine} from '../src/core/entry.js'; +import {compilePattern, createFilter, type FilterOptions} from '../src/core/filter.js'; + +const shownLines = (options: FilterOptions, lines: string[], source?: string): string[] => { + const shown = createFilter(options); + return lines + .map((line, index) => + parseLine(line, {seq: index + 1, ...(source === undefined ? {} : {source})}), + ) + .filter(shown) + .map((entry) => entry.raw); +}; + +describe('createFilter', () => { + it('shows everything without options', () => { + expect(shownLines({}, ['DEBUG a', 'plain'])).toEqual(['DEBUG a', 'plain']); + }); + + it('hides entries below the minimum level and lines without a level', () => { + expect( + shownLines({minLevel: 'warn'}, ['DEBUG a', 'INFO b', 'WARN c', 'ERROR d', 'plain']), + ).toEqual(['WARN c', 'ERROR d']); + }); + + it('keeps indented continuation lines with the entry above them', () => { + const lines = [ + 'INFO request done', + ' detail of info', + 'ERROR TypeError: x is undefined', + ' at handler (app.js:3:5)', + ' at main (app.js:9:1)', + ]; + expect(shownLines({minLevel: 'error'}, lines)).toEqual(lines.slice(2)); + }); + + it('matches any include pattern and drops excluded lines', () => { + const options = { + include: [compilePattern('timeout'), compilePattern('refused')], + exclude: [compilePattern('health')], + }; + expect( + shownLines(options, ['Timeout on /users', 'timeout on /health', 'connection refused', 'ok']), + ).toEqual(['Timeout on /users', 'connection refused']); + }); +}); + +describe('compilePattern', () => { + it('ignores case unless the pattern has uppercase letters', () => { + expect(compilePattern('error').test('ERROR')).toBe(true); + expect(compilePattern('Error').test('ERROR')).toBe(false); + }); + + it('explains invalid patterns', () => { + expect(() => compilePattern('(')).toThrow('Not a valid pattern: ('); + }); +}); From 9b4f4ab1d606c8ea3252d76d623fa62457792227 Mon Sep 17 00:00:00 2001 From: autoantohaki Date: Wed, 21 Oct 2026 20:49:56 -0300 Subject: [PATCH 2/6] docs: keep the architecture list together --- docs/architecture.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 6f24a8a..284907b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,5 +6,4 @@ 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. - 5. `createFilter` decides which entries are shown by level and pattern, carrying the decision for an entry over to its indented continuation lines. From b1b6abbe41930d6260d972a5f90c5e2908e8de0d Mon Sep 17 00:00:00 2001 From: autoantohaki Date: Wed, 21 Oct 2026 20:56:13 -0300 Subject: [PATCH 3/6] feat: add the interactive log viewer --- CHANGELOG.md | 1 + README.md | 27 +++- docs/architecture.md | 2 + src/cli.tsx | 128 +++++++++++----- src/cli/options.ts | 9 +- src/index.ts | 1 + src/viewer/HelpOverlay.tsx | 37 +++++ src/viewer/LogViewer.tsx | 278 ++++++++++++++++++++++++++++++++++ src/viewer/buffer.ts | 78 ++++++++++ src/viewer/index.ts | 3 + src/viewer/model.ts | 103 +++++++++++++ src/viewer/theme.ts | 24 +++ src/viewer/useTerminalSize.ts | 25 +++ tests/viewer.test.tsx | 164 ++++++++++++++++++++ 14 files changed, 840 insertions(+), 40 deletions(-) create mode 100644 src/viewer/HelpOverlay.tsx create mode 100644 src/viewer/LogViewer.tsx create mode 100644 src/viewer/buffer.ts create mode 100644 src/viewer/index.ts create mode 100644 src/viewer/model.ts create mode 100644 src/viewer/theme.ts create mode 100644 src/viewer/useTerminalSize.ts create mode 100644 tests/viewer.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f09304e..8847998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,4 +5,5 @@ - 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 `parseLine`, which recognizes text, JSON, and logfmt lines, and `sanitizeLine`, which removes terminal control sequences. diff --git a/README.md b/README.md index 66fe6ce..647a4f3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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 lines arrive. ## Read a log @@ -11,6 +11,31 @@ npm run dev 2>&1 | relay-logs ``` 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. +In a terminal, files open in the interactive viewer described below. When output is piped or the log arrives on standard input, each line is printed colored by its level, prefixed with the name of its file when several files are given. Use `-` to read standard input alongside files. +## View a log + +```sh +relay-logs -f api.log worker.log +``` + +When standard input and output are both terminals, Relay Logs opens an interactive viewer. Each line shows its time, level, source, and message, and the newest line stays in view until you scroll up. Press `?` for the keyboard reference and `q` to quit. + +| 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 | + +The viewer keeps the newest 50,000 lines. Output that is piped or redirected, and logs read from standard input, are printed as plain lines instead; `--plain` does the same in a terminal. ## Follow a log diff --git a/docs/architecture.md b/docs/architecture.md index 284907b..3ea64c7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,3 +7,5 @@ Relay Logs turns lines from files or standard input into log entries and display 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. 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. diff --git a/src/cli.tsx b/src/cli.tsx index 41b3e9a..401302f 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -1,51 +1,29 @@ #!/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 {formatPlain} from './format/plain.js'; +import {LogBuffer} from './viewer/buffer.js'; +import {LogViewer} from './viewer/LogViewer.js'; import {relayLogsVersion} from './version.js'; -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.exitCode = 2; - return; - } - if (options.help) return void process.stdout.write(helpText); - if (options.version) return void process.stdout.write(`${relayLogsVersion}\n`); - if (options.files.length === 0 && process.stdin.isTTY) { - process.stderr.write(`Pass a log file or pipe output into relay-logs.\n\n${helpText}`); - process.exitCode = 2; - return; - } +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); - // 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, - }; +/** 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 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); const shown = createFilter({ ...(options.minLevel === undefined ? {} : {minLevel: options.minLevel}), @@ -56,9 +34,85 @@ const main = async (): Promise => { for await (const {label, line} of lines) { seq += 1; const entry = parseLine(line, {seq, ...(label === undefined ? {} : {source: label})}); - if (!shown(entry)) continue; + 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 format = { + color: options.color ?? (process.stdout.isTTY && process.env.NO_COLOR === undefined), + showSource: options.files.length > 1, + }; + 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(`${formatPlain(entry, format)}\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(`${errorMessage(error)}\n\n${helpText}`); + process.exitCode = 2; + return; + } + if (options.help) return void process.stdout.write(helpText); + if (options.version) return void process.stdout.write(`${relayLogsVersion}\n`); + if (options.files.length === 0 && process.stdin.isTTY) { + process.stderr.write(`Pass a log file or pipe output into relay-logs.\n\n${helpText}`); + process.exitCode = 2; + return; + } + + // The viewer needs the keyboard, so it opens only when standard input is a terminal rather + // than the log itself. + const interactive = + !options.plain && + 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 2047644..00cc979 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -8,6 +8,8 @@ export interface CliOptions { color: boolean | undefined; /** Keep reading files as they grow. */ follow: boolean; + /** Print lines instead of opening the interactive viewer. */ + plain: boolean; /** Start with only the last lines of each file. */ lines: number | undefined; minLevel: Level | undefined; @@ -22,8 +24,8 @@ 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 @@ -32,6 +34,7 @@ Options: 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 --color Always color output --no-color Never color output -h, --help Show this help @@ -44,6 +47,7 @@ export const parseCliOptions = (arguments_: readonly string[]): CliOptions => { version: false, color: undefined, follow: false, + plain: false, lines: undefined, minLevel: undefined, include: [], @@ -70,6 +74,7 @@ export const parseCliOptions = (arguments_: readonly string[]): CliOptions => { 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 (argument === '--color') options.color = true; else if (argument === '--no-color') options.color = false; else throw new Error(`Unknown option: ${argument}`); diff --git a/src/index.ts b/src/index.ts index 4dc8eca..f666189 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,4 +6,5 @@ export * from './core/follow.js'; export * from './core/sanitize.js'; export * from './core/sources.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) => ( + + )) + )} + +