llv 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '009573ab40ee86ba88dd8daee97a3035d9a24ab0d923c774c8550313f4a999ff'
4
+ data.tar.gz: f569e2357a526127d941da5d4b0d5555fd6faca19a97e15a4ecf5bc4de1c73c4
5
+ SHA512:
6
+ metadata.gz: c5d6f573055cd148fbeb20c1b66e8e82ce040e619a7031255b83804d871fbca26db9486b2b7edb936bb6c55bb41cef7fe87ec5334ec24c976ffa14c196d11a9f
7
+ data.tar.gz: d0e4c3c993f3cf2662a27b49de67c933f55624f4624ea3bcc7329ad7c8f3ab37e9a530c3408c46dc70ce3cbb23f0ffb6760e773b1fb447672e734d446ece434f
data/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # llv
2
+
3
+ Rails has ~"beautiful"~ trashy noisy unreadable garbage logs that it vomits uselessly into your terminal. We can do better.
4
+
5
+ Local log viewer for Rails `development.log`. Tails the file, groups lines by request (or background job), and shows them in a master/detail view — browser by default, or a TUI.
6
+
7
+ This is vide-coded because solving this years-old frustration now only takes 30mins between other work. Feel free to enhance it.
8
+
9
+ ## Setup
10
+
11
+ In your Rails app, enable tagged-logging with the request id so HTTP lines can be grouped reliably. Edit `config/environments/development.rb`:
12
+
13
+ ```ruby
14
+ config.log_tags = [:request_id]
15
+ ```
16
+
17
+ ActiveJob lines already carry their own tags, no change needed for those.
18
+
19
+ Install `llv` globally — it's a standalone CLI, not something you bundle into your app. Once published to RubyGems:
20
+
21
+ ```sh
22
+ gem install llv
23
+ ```
24
+
25
+ In the meantime, from a clone of this repo:
26
+
27
+ ```sh
28
+ bundle install && rake install
29
+ ```
30
+
31
+ ## Run
32
+
33
+ Browser UI (default):
34
+
35
+ ```sh
36
+ llv path/to/development.log
37
+ # opens http://127.0.0.1:9292
38
+ ```
39
+
40
+ Terminal UI:
41
+
42
+ ```sh
43
+ llv --tui path/to/development.log
44
+ ```
45
+
46
+ Useful flags:
47
+
48
+ - `--tail` — only show lines appended after launch. By default `llv` replays the whole file first so you see existing requests too.
49
+ - `--limit N` — keep at most N items in memory (default 500).
50
+ - `--port`, `--host` — change the web server bind.
51
+ - `--no-open` — don't auto-launch the browser.
52
+
53
+ ## TUI keys
54
+
55
+ I don't use many terminal apps so please contribute enhancements to make it behave more as you'd expect.
56
+
57
+ The TUI is a two-pane master/detail with one focused pane at a time. Cursor keys and the vi-style `hjkl` both work.
58
+
59
+ | key | when focus is **LIST** (left) | when focus is **DETAIL** (right) |
60
+ |------------------------------|-------------------------------|----------------------------------|
61
+ | `↑` / `k` | previous request | scroll one line up |
62
+ | `↓` / `j` | next request | scroll one line down |
63
+ | `→` / `l` / `enter` | move focus to DETAIL | — |
64
+ | `←` / `h` / `esc` | — | move focus back to LIST |
65
+ | `tab` | swap focus | swap focus |
66
+ | `PgUp` / `PgDn` / space | page through the list | page through detail |
67
+ | `Ctrl-U` / `Ctrl-D` | — | half-page up / down |
68
+ | `g` / `Home` | first item | scroll to top |
69
+ | `G` / `End` | last item | scroll to bottom |
70
+ | `s` | toggle SQL `↳` source lines | toggle SQL `↳` source lines |
71
+ | `H` / `J` / `C` | toggle http / jobs / cable | toggle http / jobs / cable |
72
+ | `q` / `Ctrl-C` | quit | quit |
73
+
74
+ The header shows which pane has focus (`[LIST]` or `[DETAIL]`) and the divider thickens (`│` → `┃`) when the detail pane is active.
75
+
76
+ ## What you see
77
+
78
+ - **Left pane** — newest-first list of items. Each item is one HTTP request (with method, path, status, duration), one ActiveJob run, or one ActionCable (`/cable`) connection.
79
+ - **Right pane** — the log lines that belong to the selected item, with the ANSI colours Rails already emits (bold cyan for SQL labels, bold blue for the SQL itself, etc.). SQL source `↳` lines are revealed alongside their query.
80
+
81
+ ## Filtering noise
82
+
83
+ ActionCable (`/cable`) requests are very chatty in development and are usually not what you're trying to debug. Each kind (`http`, `jobs`, `cable`) has its own independent toggle:
84
+
85
+ - **Browser**: three buttons in the header — click one to toggle its requests on/off. They're independent, so "HTTP + Jobs but not Cable" is one click on the Cable button.
86
+ - **TUI**: `H` toggles HTTP, `J` toggles Jobs, `C` toggles Cable. The header shows the current state and the total reflects items visible / items captured.
87
+
88
+ ## Layout
89
+
90
+ ```
91
+ lib/llv/
92
+ ansi.rb # SGR <-> HTML span
93
+ parser.rb # line classifier, stateful for untagged-HTTP grouping
94
+ group_store.rb # thread-safe ring buffer, pub/sub
95
+ tailer.rb # listen-based file follower, rotation-aware
96
+ web.rb # Sinatra + SSE
97
+ tui.rb # bubbletea Model + lipgloss layout
98
+ public/ # vanilla HTML/JS/CSS
99
+ ```
100
+
101
+ ## Tests
102
+
103
+ ```sh
104
+ bundle exec rake test
105
+ # or:
106
+ bundle exec ruby -Ilib -Itest test/parser_test.rb
107
+ ```
108
+
109
+ ## Releasing to RubyGems
110
+
111
+ For maintainers. The Rakefile pulls in `bundler/gem_tasks`, which gives you `rake build` and `rake release`.
112
+
113
+ One-time setup — sign in to RubyGems so credentials land in `~/.gem/credentials`:
114
+
115
+ ```sh
116
+ gem signin
117
+ ```
118
+
119
+ Cutting a new release:
120
+
121
+ 1. Bump `Llv::VERSION` in `lib/llv/version.rb` (`llv.gemspec` reads from there, so it's the single source of truth).
122
+ 2. Commit and push the version bump.
123
+ 3. `bundle exec rake release` — this builds `pkg/llv-<version>.gem`, tags `v<version>` in git, pushes the tag, then pushes the gem to rubygems.org.
124
+
125
+ If you want to do it by hand instead:
126
+
127
+ ```sh
128
+ bundle exec rake build # writes pkg/llv-<version>.gem
129
+ gem push pkg/llv-<version>.gem
130
+ ```
data/bin/llv ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/llv"
5
+
6
+ Llv::CLI.start(ARGV)
data/lib/llv/ansi.rb ADDED
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Llv
6
+ # Translates a small subset of ANSI SGR escape codes (the ones Rails actually
7
+ # emits in development.log) into either nothing (strip) or HTML spans (to_html).
8
+ module Ansi
9
+ SGR = /\e\[([0-9;]*)m/
10
+
11
+ FG = {
12
+ 30 => "black", 31 => "red", 32 => "green", 33 => "yellow",
13
+ 34 => "blue", 35 => "magenta", 36 => "cyan", 37 => "white",
14
+ 90 => "bright-black", 91 => "bright-red", 92 => "bright-green",
15
+ 93 => "bright-yellow", 94 => "bright-blue", 95 => "bright-magenta",
16
+ 96 => "bright-cyan", 97 => "bright-white"
17
+ }.freeze
18
+
19
+ BG = {
20
+ 40 => "black", 41 => "red", 42 => "green", 43 => "yellow",
21
+ 44 => "blue", 45 => "magenta", 46 => "cyan", 47 => "white"
22
+ }.freeze
23
+
24
+ module_function
25
+
26
+ def strip(str)
27
+ str.gsub(SGR, "")
28
+ end
29
+
30
+ # Truncate `str` to at most `max_visible` printable characters, ignoring
31
+ # SGR escape codes for measurement and preserving every SGR encountered up
32
+ # to the cut so colours don't bleed past the truncation. Adds an ellipsis
33
+ # and a final reset when the string is shortened.
34
+ def truncate(str, max_visible)
35
+ return str if max_visible <= 0
36
+ return str if strip(str).length <= max_visible
37
+
38
+ out = +""
39
+ visible = 0
40
+ cut = false
41
+
42
+ scan(str) do |segment|
43
+ case segment
44
+ in [:sgr, codes]
45
+ out << "\e[#{codes.join(";")}m"
46
+ in [:text, text]
47
+ break if cut
48
+
49
+ remaining = max_visible - visible
50
+ if text.length <= remaining
51
+ out << text
52
+ visible += text.length
53
+ else
54
+ out << text[0, [remaining - 1, 0].max]
55
+ out << "…"
56
+ visible = max_visible
57
+ cut = true
58
+ end
59
+ end
60
+ end
61
+
62
+ out << "\e[0m" if cut
63
+ out
64
+ end
65
+
66
+ # Turn raw text containing SGR codes into HTML. Adjacent text under the same
67
+ # state is wrapped in one <span class="..."> per state-change boundary.
68
+ def to_html(str)
69
+ out = +""
70
+ state = { bold: false, italic: false, underline: false, fg: nil, bg: nil }
71
+ open = false
72
+
73
+ scan(str) do |segment|
74
+ case segment
75
+ in [:text, text]
76
+ next if text.empty?
77
+
78
+ out << open_span(state) unless open || empty_state?(state)
79
+ open = true unless empty_state?(state)
80
+ out << CGI.escape_html(text)
81
+ in [:sgr, codes]
82
+ if open
83
+ out << "</span>"
84
+ open = false
85
+ end
86
+ apply(state, codes)
87
+ end
88
+ end
89
+
90
+ out << "</span>" if open
91
+ out
92
+ end
93
+
94
+ # Yields [:text, "..."] and [:sgr, [int, ...]] segments in order.
95
+ def scan(str)
96
+ pos = 0
97
+ str.scan(SGR) do
98
+ match = Regexp.last_match
99
+ if match.begin(0) > pos
100
+ yield [:text, str[pos...match.begin(0)]]
101
+ end
102
+ codes = match[1].to_s.split(";").map { |c| c.empty? ? 0 : c.to_i }
103
+ codes = [0] if codes.empty?
104
+ yield [:sgr, codes]
105
+ pos = match.end(0)
106
+ end
107
+ yield [:text, str[pos..]] if pos < str.length
108
+ end
109
+
110
+ def apply(state, codes)
111
+ i = 0
112
+ while i < codes.length
113
+ code = codes[i]
114
+ case code
115
+ when 0
116
+ state[:bold] = false
117
+ state[:italic] = false
118
+ state[:underline] = false
119
+ state[:fg] = nil
120
+ state[:bg] = nil
121
+ when 1 then state[:bold] = true
122
+ when 3 then state[:italic] = true
123
+ when 4 then state[:underline] = true
124
+ when 22 then state[:bold] = false
125
+ when 23 then state[:italic] = false
126
+ when 24 then state[:underline] = false
127
+ when 30..37, 90..97
128
+ state[:fg] = FG[code]
129
+ when 39
130
+ state[:fg] = nil
131
+ when 40..47
132
+ state[:bg] = BG[code]
133
+ when 49
134
+ state[:bg] = nil
135
+ end
136
+ i += 1
137
+ end
138
+ end
139
+
140
+ def empty_state?(state)
141
+ !state[:bold] && !state[:italic] && !state[:underline] && state[:fg].nil? && state[:bg].nil?
142
+ end
143
+
144
+ def open_span(state)
145
+ classes = []
146
+ classes << "ansi-bold" if state[:bold]
147
+ classes << "ansi-italic" if state[:italic]
148
+ classes << "ansi-underline" if state[:underline]
149
+ classes << "ansi-fg-#{state[:fg]}" if state[:fg]
150
+ classes << "ansi-bg-#{state[:bg]}" if state[:bg]
151
+ %(<span class="#{classes.join(" ")}">)
152
+ end
153
+ end
154
+ end
data/lib/llv/cli.rb ADDED
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Llv
6
+ class CLI
7
+ DEFAULTS = {
8
+ port: 9292,
9
+ host: "127.0.0.1",
10
+ limit: 500,
11
+ # Replay the whole file by default — when you launch llv you almost
12
+ # always want to see what's already there. Pass --tail to opt out.
13
+ from_start: true,
14
+ tui: false,
15
+ open_browser: true
16
+ }.freeze
17
+
18
+ def self.start(argv)
19
+ new(argv).run
20
+ end
21
+
22
+ def initialize(argv)
23
+ @argv = argv.dup
24
+ @options = DEFAULTS.dup
25
+ end
26
+
27
+ def run
28
+ parse!
29
+ path = @argv.shift
30
+ unless path
31
+ warn "usage: llv [options] PATH/TO/development.log"
32
+ exit 1
33
+ end
34
+
35
+ store = GroupStore.new(limit: @options[:limit])
36
+ parser = Parser.new
37
+ tailer = Tailer.new(path, from_start: @options[:from_start])
38
+
39
+ # Tailer#each_line returns immediately after starting the listener +
40
+ # poll threads, so we don't need an extra wrapper thread.
41
+ tailer.each_line do |line|
42
+ event = parser.parse(line)
43
+ store.ingest(event)
44
+ end
45
+
46
+ @tailer = tailer
47
+ if @options[:tui]
48
+ require_relative "tui"
49
+ install_traps
50
+ Tui::Program.new(store: store).run
51
+ else
52
+ require_relative "web"
53
+ start_web(store)
54
+ end
55
+ ensure
56
+ @tailer&.stop
57
+ end
58
+
59
+ def install_traps
60
+ shutdown = lambda do |_signo = nil|
61
+ $stderr.puts "\nllv: stopping"
62
+ # Process.exit! is the only way out of Puma's Launcher loop once it has
63
+ # decided to "gracefully" stop and is blocked on its own threads.
64
+ Process.exit!(0)
65
+ end
66
+ Signal.trap("INT", &shutdown)
67
+ Signal.trap("TERM", &shutdown)
68
+ end
69
+
70
+ private
71
+
72
+ def parse!
73
+ OptionParser.new do |o|
74
+ o.banner = "usage: llv [options] PATH/TO/development.log"
75
+ o.on("--tui", "Use the terminal UI instead of the browser") { @options[:tui] = true }
76
+ o.on("--tail", "Only show lines appended after launch (default: replay the whole file first)") { @options[:from_start] = false }
77
+ o.on("--from-start", "Replay the entire log file (this is the default)") { @options[:from_start] = true }
78
+ o.on("--limit N", Integer, "Max number of items to keep in memory (default: 500)") { |n| @options[:limit] = n }
79
+ o.on("--port PORT", Integer, "Web server port (default: 9292)") { |p| @options[:port] = p }
80
+ o.on("--host HOST", "Web server host (default: 127.0.0.1)") { |h| @options[:host] = h }
81
+ o.on("--no-open", "Don't open the browser automatically") { @options[:open_browser] = false }
82
+ o.on("-h", "--help", "Show this help") do
83
+ puts o
84
+ exit
85
+ end
86
+ end.parse!(@argv)
87
+ end
88
+
89
+ def start_web(store)
90
+ Web.store = store
91
+ # Disable Sinatra's own INT/TERM traps. We can't stop Puma::Launcher from
92
+ # installing its handlers, so we run Puma on a background thread and
93
+ # install our own traps *after* Puma is up — last-trap-wins.
94
+ Web.set :traps, false
95
+ url = "http://#{@options[:host]}:#{@options[:port]}/"
96
+ puts "llv listening on #{url}"
97
+
98
+ server_thread = Thread.new do
99
+ Web.run!(host: @options[:host], port: @options[:port], quiet: true)
100
+ rescue StandardError => e
101
+ $stderr.puts "llv web server error: #{e.class}: #{e.message}"
102
+ end
103
+
104
+ # Give Puma a moment to install its launcher signal handlers, then
105
+ # override them with ours.
106
+ sleep 0.5
107
+ install_traps
108
+
109
+ if @options[:open_browser]
110
+ Thread.new do
111
+ require "launchy"
112
+ Launchy.open(url)
113
+ rescue StandardError
114
+ # the URL is printed above; user can open it manually
115
+ end
116
+ end
117
+
118
+ server_thread.join
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Llv
6
+ # Thread-safe store of grouped log items. Maintains insertion order
7
+ # newest-first, evicts oldest beyond `limit`, and publishes events to any
8
+ # number of subscribers (web SSE, TUI, tests).
9
+ class GroupStore
10
+ include MonitorMixin
11
+
12
+ Group = Struct.new(
13
+ :id, :kind, :title, :started_at, :finished_at, :status, :duration_ms, :lines,
14
+ keyword_init: true
15
+ ) do
16
+ def summary
17
+ {
18
+ id: id,
19
+ kind: kind,
20
+ title: title,
21
+ started_at: started_at,
22
+ finished_at: finished_at,
23
+ status: status,
24
+ duration_ms: duration_ms,
25
+ line_count: lines.length
26
+ }
27
+ end
28
+
29
+ def to_h_full
30
+ summary.merge(lines: lines.map(&:dup))
31
+ end
32
+ end
33
+
34
+ Line = Struct.new(:type, :payload, :raw, :plain, :at, keyword_init: true)
35
+
36
+ def initialize(limit: 500)
37
+ super()
38
+ @limit = limit
39
+ @by_id = {}
40
+ @order = [] # newest first
41
+ @subscribers = []
42
+ @seq = 0
43
+ end
44
+
45
+ def ingest(event)
46
+ synchronize do
47
+ gid = event.group_id || "untagged"
48
+ new_group = !@by_id.key?(gid)
49
+ group = @by_id[gid] ||= build_group(gid, event)
50
+
51
+ if new_group
52
+ @order.unshift(gid)
53
+ enforce_limit
54
+ end
55
+
56
+ update_title(group, event)
57
+ update_lifecycle(group, event)
58
+ append_line(group, event)
59
+
60
+ broadcast(new_group ? :group_created : :group_updated, group)
61
+ broadcast(:group_completed, group) if event.type == :request_completed || event.type == :job_performed
62
+ group
63
+ end
64
+ end
65
+
66
+ def list(limit: nil, kind: nil)
67
+ synchronize do
68
+ ids = @order
69
+ ids = ids.select { |id| @by_id[id].kind == kind } if kind
70
+ ids = ids.first(limit) if limit
71
+ ids.map { |id| @by_id[id].summary }
72
+ end
73
+ end
74
+
75
+ def fetch(id)
76
+ synchronize { @by_id[id]&.to_h_full }
77
+ end
78
+
79
+ def subscribe(&block)
80
+ synchronize { @subscribers << block }
81
+ block
82
+ end
83
+
84
+ def unsubscribe(handle)
85
+ synchronize { @subscribers.delete(handle) }
86
+ end
87
+
88
+ def size
89
+ synchronize { @order.length }
90
+ end
91
+
92
+ private
93
+
94
+ def build_group(gid, event)
95
+ Group.new(
96
+ id: gid,
97
+ kind: event.group_kind,
98
+ title: event.group_title || default_title(event),
99
+ started_at: Time.now,
100
+ finished_at: nil,
101
+ status: nil,
102
+ duration_ms: nil,
103
+ lines: []
104
+ )
105
+ end
106
+
107
+ def default_title(event)
108
+ case event.group_kind
109
+ when :http then "HTTP request"
110
+ when :job then "Job"
111
+ else "Untagged"
112
+ end
113
+ end
114
+
115
+ def update_title(group, event)
116
+ group.title = event.group_title if event.group_title && (group.title.nil? || group.title == default_title(event))
117
+ end
118
+
119
+ def update_lifecycle(group, event)
120
+ case event.type
121
+ when :request_started
122
+ group.started_at = Time.now
123
+ when :request_completed
124
+ group.finished_at = Time.now
125
+ group.status = event.payload[:status]
126
+ group.duration_ms = event.payload[:duration_ms]
127
+ when :job_performing
128
+ group.started_at = Time.now
129
+ when :job_performed
130
+ group.finished_at = Time.now
131
+ group.status = "ok"
132
+ group.duration_ms = event.payload[:duration_ms]
133
+ when :job_retry_stopped
134
+ group.status = "stopped"
135
+ end
136
+ end
137
+
138
+ def append_line(group, event)
139
+ group.lines << Line.new(
140
+ type: event.type,
141
+ payload: event.payload,
142
+ raw: event.raw,
143
+ plain: event.plain,
144
+ at: (@seq += 1)
145
+ )
146
+ end
147
+
148
+ def enforce_limit
149
+ while @order.length > @limit
150
+ evicted = @order.pop
151
+ @by_id.delete(evicted)
152
+ broadcast(:group_evicted, Group.new(id: evicted))
153
+ end
154
+ end
155
+
156
+ def broadcast(type, group)
157
+ @subscribers.each do |sub|
158
+ begin
159
+ sub.call(type, group)
160
+ rescue StandardError
161
+ # don't let one bad subscriber kill ingestion
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end