e11y-devtools 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 +7 -0
- data/README.md +137 -0
- data/exe/e11y +34 -0
- data/lib/e11y/devtools/mcp/server.rb +96 -0
- data/lib/e11y/devtools/mcp/tool_base.rb +25 -0
- data/lib/e11y/devtools/mcp/tools/clear.rb +31 -0
- data/lib/e11y/devtools/mcp/tools/errors.rb +35 -0
- data/lib/e11y/devtools/mcp/tools/event_detail.rb +33 -0
- data/lib/e11y/devtools/mcp/tools/events_by_trace.rb +33 -0
- data/lib/e11y/devtools/mcp/tools/interactions.rb +40 -0
- data/lib/e11y/devtools/mcp/tools/recent_events.rb +34 -0
- data/lib/e11y/devtools/mcp/tools/search.rb +34 -0
- data/lib/e11y/devtools/mcp/tools/stats.rb +30 -0
- data/lib/e11y/devtools/overlay/controller.rb +54 -0
- data/lib/e11y/devtools/overlay/engine.rb +26 -0
- data/lib/e11y/devtools/overlay/middleware.rb +80 -0
- data/lib/e11y/devtools/overlay/rails_controller.rb +42 -0
- data/lib/e11y/devtools/tui/app.rb +262 -0
- data/lib/e11y/devtools/tui/grouping.rb +66 -0
- data/lib/e11y/devtools/tui/widgets/event_detail.rb +62 -0
- data/lib/e11y/devtools/tui/widgets/event_list.rb +70 -0
- data/lib/e11y/devtools/tui/widgets/interaction_list.rb +47 -0
- data/lib/e11y/devtools/version.rb +8 -0
- data/lib/e11y/devtools.rb +13 -0
- metadata +116 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module E11y
|
|
4
|
+
module Devtools
|
|
5
|
+
module Overlay
|
|
6
|
+
# Rack middleware that injects the e11y overlay badge into HTML responses.
|
|
7
|
+
#
|
|
8
|
+
# Skips injection for:
|
|
9
|
+
# - XHR requests (X-Requested-With: XMLHttpRequest)
|
|
10
|
+
# - Asset paths (/assets/, /packs/, /_e11y/)
|
|
11
|
+
# - Non-HTML responses
|
|
12
|
+
class Middleware
|
|
13
|
+
OVERLAY_SNIPPET = <<~HTML
|
|
14
|
+
|
|
15
|
+
<!-- e11y-overlay -->
|
|
16
|
+
<script id="e11y-overlay-loader">
|
|
17
|
+
(function() {
|
|
18
|
+
var s = document.createElement('script');
|
|
19
|
+
s.src = '/_e11y/overlay.js';
|
|
20
|
+
s.defer = true;
|
|
21
|
+
document.head.appendChild(s);
|
|
22
|
+
})();
|
|
23
|
+
</script>
|
|
24
|
+
HTML
|
|
25
|
+
|
|
26
|
+
def initialize(app)
|
|
27
|
+
@app = app
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(env)
|
|
31
|
+
status, headers, body = @app.call(env)
|
|
32
|
+
return [status, headers, body] unless injectable?(env, headers)
|
|
33
|
+
|
|
34
|
+
new_body = inject_overlay(body, env["e11y.trace_id"])
|
|
35
|
+
[status, update_content_length(headers, new_body), [new_body]]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def injectable?(env, headers)
|
|
41
|
+
!xhr?(env) && !asset_path?(env) && html_response?(headers)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def xhr?(env)
|
|
45
|
+
env["HTTP_X_REQUESTED_WITH"]&.downcase == "xmlhttprequest"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def asset_path?(env)
|
|
49
|
+
path = env["PATH_INFO"] || ""
|
|
50
|
+
path.start_with?("/assets/", "/packs/", "/_e11y/")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def html_response?(headers)
|
|
54
|
+
ct = headers["Content-Type"] || headers["content-type"] || ""
|
|
55
|
+
ct.include?("text/html")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def inject_overlay(body, trace_id)
|
|
59
|
+
full = body.respond_to?(:join) ? body.join : body.to_s
|
|
60
|
+
snippet = trace_id_script(trace_id) + OVERLAY_SNIPPET
|
|
61
|
+
full.sub("</body>", "#{snippet}</body>")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def trace_id_script(trace_id)
|
|
65
|
+
return "" unless trace_id
|
|
66
|
+
|
|
67
|
+
"<script>window.__E11Y_TRACE_ID__ = '#{trace_id}';</script>\n"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def update_content_length(headers, new_body)
|
|
71
|
+
h = headers.dup
|
|
72
|
+
h.delete("Content-Length")
|
|
73
|
+
h.delete("content-length")
|
|
74
|
+
h["Content-Length"] = new_body.bytesize.to_s
|
|
75
|
+
h
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "controller"
|
|
4
|
+
|
|
5
|
+
module E11y
|
|
6
|
+
module Devtools
|
|
7
|
+
module Overlay
|
|
8
|
+
# Thin Rails controller — delegates to plain Controller for testability.
|
|
9
|
+
# Only available in development/test.
|
|
10
|
+
class RailsController < ActionController::Base
|
|
11
|
+
before_action :development_only!
|
|
12
|
+
|
|
13
|
+
def events
|
|
14
|
+
render json: overlay_ctrl.events_for(trace_id: params[:trace_id])
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def recent
|
|
18
|
+
render json: overlay_ctrl.recent_events(limit: params[:limit])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def clear
|
|
22
|
+
overlay_ctrl.clear_log!
|
|
23
|
+
head :no_content
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def stats
|
|
27
|
+
render json: overlay_ctrl.stats
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def overlay_ctrl
|
|
33
|
+
@overlay_ctrl ||= Controller.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def development_only!
|
|
37
|
+
head :not_found unless Rails.env.development? || Rails.env.test?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "e11y/adapters/dev_log/query"
|
|
6
|
+
require_relative "grouping"
|
|
7
|
+
|
|
8
|
+
module E11y
|
|
9
|
+
module Devtools
|
|
10
|
+
module Tui
|
|
11
|
+
# Top-level TUI application.
|
|
12
|
+
#
|
|
13
|
+
# Manages navigation state (:interactions | :events | :detail),
|
|
14
|
+
# handles keyboard events, and reloads data when the log file changes.
|
|
15
|
+
# rubocop:disable Metrics/ClassLength
|
|
16
|
+
class App
|
|
17
|
+
attr_reader :current_view, :source_filter
|
|
18
|
+
|
|
19
|
+
POLL_INTERVAL_MS = 250
|
|
20
|
+
|
|
21
|
+
def initialize(log_path: nil)
|
|
22
|
+
@log_path = log_path || auto_detect_log_path
|
|
23
|
+
@query = E11y::Adapters::DevLog::Query.new(@log_path)
|
|
24
|
+
@current_view = :interactions
|
|
25
|
+
@source_filter = :web
|
|
26
|
+
@selected_ix = 0
|
|
27
|
+
@interactions = []
|
|
28
|
+
@events = []
|
|
29
|
+
@current_trace_id = nil
|
|
30
|
+
@current_event = nil
|
|
31
|
+
@last_mtime = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Start the TUI event loop (blocks until user quits).
|
|
35
|
+
def run
|
|
36
|
+
require "ratatui_ruby"
|
|
37
|
+
require_relative "widgets/interaction_list"
|
|
38
|
+
require_relative "widgets/event_list"
|
|
39
|
+
require_relative "widgets/event_detail"
|
|
40
|
+
RatatuiRuby.run do |tui|
|
|
41
|
+
loop do
|
|
42
|
+
reload_if_changed!
|
|
43
|
+
tui.draw { |frame| render(tui, frame) }
|
|
44
|
+
event = tui.poll_event(timeout_ms: POLL_INTERVAL_MS)
|
|
45
|
+
break if quit_event?(event)
|
|
46
|
+
|
|
47
|
+
handle_key(key_from(event)) if key_event?(event)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Handle a single key press (public for testability).
|
|
53
|
+
def handle_key(key)
|
|
54
|
+
case @current_view
|
|
55
|
+
when :interactions then handle_interactions_key(key)
|
|
56
|
+
when :events then handle_events_key(key)
|
|
57
|
+
when :detail then handle_detail_key(key)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Return the currently highlighted interaction (or nil).
|
|
62
|
+
def selected_interaction
|
|
63
|
+
@interactions[@selected_ix]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# --- Rendering ---
|
|
69
|
+
|
|
70
|
+
def render(tui, frame)
|
|
71
|
+
case @current_view
|
|
72
|
+
when :interactions then render_interactions(tui, frame)
|
|
73
|
+
when :events then render_events(tui, frame)
|
|
74
|
+
when :detail
|
|
75
|
+
render_events(tui, frame)
|
|
76
|
+
Widgets::EventDetail.new(event: @current_event).render(tui, frame, frame.area)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def render_interactions(tui, frame)
|
|
81
|
+
Widgets::InteractionList.new(
|
|
82
|
+
interactions: @interactions,
|
|
83
|
+
selected_index: @selected_ix
|
|
84
|
+
).render(tui, frame, frame.area)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def render_events(tui, frame)
|
|
88
|
+
Widgets::EventList.new(
|
|
89
|
+
events: @events,
|
|
90
|
+
trace_id: @current_trace_id || "",
|
|
91
|
+
selected_index: @selected_ix
|
|
92
|
+
).render(tui, frame, frame.area)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# --- Key handlers per view ---
|
|
96
|
+
|
|
97
|
+
def handle_interactions_key(key)
|
|
98
|
+
case key
|
|
99
|
+
when "enter" then drill_into_events
|
|
100
|
+
when "j" then @source_filter = :job
|
|
101
|
+
reload!
|
|
102
|
+
when "w" then @source_filter = :web
|
|
103
|
+
reload!
|
|
104
|
+
when "a" then @source_filter = :all
|
|
105
|
+
reload!
|
|
106
|
+
when "down" then move_down(@interactions.size)
|
|
107
|
+
when "up" then move_up
|
|
108
|
+
when "r" then reload!
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def handle_events_key(key)
|
|
113
|
+
case key
|
|
114
|
+
when "esc", "b" then back_to_interactions
|
|
115
|
+
when "enter" then drill_into_detail
|
|
116
|
+
when "down" then move_down(@events.size)
|
|
117
|
+
when "up" then move_up
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def handle_detail_key(key)
|
|
122
|
+
case key
|
|
123
|
+
when "esc", "b" then @current_view = :events
|
|
124
|
+
when "c" then copy_to_clipboard(::JSON.generate(@current_event))
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# --- Navigation helpers ---
|
|
129
|
+
|
|
130
|
+
def drill_into_events
|
|
131
|
+
ix = selected_interaction
|
|
132
|
+
return unless ix
|
|
133
|
+
|
|
134
|
+
@current_trace_id = ix.trace_ids.first
|
|
135
|
+
@events = @query.events_by_trace(@current_trace_id)
|
|
136
|
+
@selected_ix = 0
|
|
137
|
+
@current_view = :events
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def drill_into_detail
|
|
141
|
+
event = @events[@selected_ix]
|
|
142
|
+
return unless event
|
|
143
|
+
|
|
144
|
+
@current_event = event
|
|
145
|
+
@current_view = :detail
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def back_to_interactions
|
|
149
|
+
@current_view = :interactions
|
|
150
|
+
@selected_ix = 0
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def move_down(size)
|
|
154
|
+
@selected_ix = [@selected_ix + 1, size - 1].min
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def move_up
|
|
158
|
+
@selected_ix = [@selected_ix - 1, 0].max
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# --- Data loading ---
|
|
162
|
+
|
|
163
|
+
def reload_if_changed!
|
|
164
|
+
mtime = file_mtime
|
|
165
|
+
return if mtime == @last_mtime
|
|
166
|
+
|
|
167
|
+
@last_mtime = mtime
|
|
168
|
+
reload!
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def reload!
|
|
172
|
+
source = @source_filter == :all ? nil : @source_filter.to_s
|
|
173
|
+
traces = build_traces(source)
|
|
174
|
+
@interactions = Grouping.group(traces, window_ms: 500)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def build_traces(source)
|
|
178
|
+
events = @query.stored_events(limit: 5000, source: source)
|
|
179
|
+
trace_map = {}
|
|
180
|
+
events.each { |e| accumulate_trace(trace_map, e) }
|
|
181
|
+
trace_map.values
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def accumulate_trace(trace_map, event)
|
|
185
|
+
tid = event["trace_id"]
|
|
186
|
+
return unless tid
|
|
187
|
+
|
|
188
|
+
entry = trace_map[tid] ||= {
|
|
189
|
+
trace_id: tid,
|
|
190
|
+
started_at: parse_time(event.dig("metadata", "started_at") || event["timestamp"]),
|
|
191
|
+
severity: event["severity"],
|
|
192
|
+
source: event.dig("metadata", "source") || "web"
|
|
193
|
+
}
|
|
194
|
+
entry[:severity] = "error" if %w[error fatal].include?(event["severity"])
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def file_mtime
|
|
198
|
+
::File.mtime(@log_path)
|
|
199
|
+
rescue Errno::ENOENT
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# --- Utilities ---
|
|
204
|
+
|
|
205
|
+
def auto_detect_log_path
|
|
206
|
+
dir = Pathname.new(Dir.pwd)
|
|
207
|
+
loop do
|
|
208
|
+
candidate = dir.join("log", "e11y_dev.jsonl")
|
|
209
|
+
return candidate.to_s if candidate.exist?
|
|
210
|
+
|
|
211
|
+
parent = dir.parent
|
|
212
|
+
break if parent == dir
|
|
213
|
+
|
|
214
|
+
dir = parent
|
|
215
|
+
end
|
|
216
|
+
"log/e11y_dev.jsonl"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def parse_time(str)
|
|
220
|
+
::Time.parse(str.to_s)
|
|
221
|
+
rescue ArgumentError, TypeError
|
|
222
|
+
::Time.now
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def quit_event?(event)
|
|
226
|
+
return false unless event
|
|
227
|
+
return false unless event[:type] == :key
|
|
228
|
+
|
|
229
|
+
event[:code] == "q" ||
|
|
230
|
+
(event[:code] == "c" && event[:modifiers]&.include?("ctrl"))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def key_event?(event)
|
|
234
|
+
event && event[:type] == :key
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def key_from(event)
|
|
238
|
+
event&.dig(:code)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def copy_to_clipboard(text)
|
|
242
|
+
copy_macos(text) || copy_linux(text)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def copy_macos(text)
|
|
246
|
+
::IO.popen("pbcopy", "w") { |f| f.write(text) }
|
|
247
|
+
true
|
|
248
|
+
rescue StandardError
|
|
249
|
+
false
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def copy_linux(text)
|
|
253
|
+
::IO.popen("xclip -selection clipboard", "w") { |f| f.write(text) }
|
|
254
|
+
true
|
|
255
|
+
rescue StandardError
|
|
256
|
+
false
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
# rubocop:enable Metrics/ClassLength
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module E11y
|
|
4
|
+
module Devtools
|
|
5
|
+
module Tui
|
|
6
|
+
# Pure-function time-window grouping for traces → interactions.
|
|
7
|
+
# Shared by TUI widgets, Overlay, and MCP interactions tool.
|
|
8
|
+
module Grouping
|
|
9
|
+
# Severities that count as errors for interaction flagging.
|
|
10
|
+
ERROR_SEVERITIES = %w[error fatal].freeze
|
|
11
|
+
|
|
12
|
+
# Value object representing one interaction group.
|
|
13
|
+
Interaction = Struct.new(:started_at, :trace_ids, :has_error?,
|
|
14
|
+
:source)
|
|
15
|
+
|
|
16
|
+
# Group an array of trace hashes into Interaction bands.
|
|
17
|
+
#
|
|
18
|
+
# @param traces [Array<Hash>] Each hash must have :trace_id,
|
|
19
|
+
# :started_at (Time), :severity
|
|
20
|
+
# @param window_ms [Integer] Grouping window in milliseconds
|
|
21
|
+
# @return [Array<Interaction>] Newest-first
|
|
22
|
+
def self.group(traces, window_ms: 500)
|
|
23
|
+
return [] if traces.empty?
|
|
24
|
+
|
|
25
|
+
build_interactions(accumulate_groups(traces, window_ms))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.accumulate_groups(traces, window_ms)
|
|
29
|
+
sorted = traces.sort_by { |t| t[:started_at] }
|
|
30
|
+
groups = []
|
|
31
|
+
current = nil
|
|
32
|
+
sorted.each { |trace| current = append_trace(groups, current, trace, window_ms) }
|
|
33
|
+
groups
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.append_trace(groups, current, trace, window_ms)
|
|
37
|
+
if current.nil? || outside_window?(trace, current, window_ms)
|
|
38
|
+
current = new_group(trace)
|
|
39
|
+
groups << current
|
|
40
|
+
end
|
|
41
|
+
current[:trace_ids] << trace[:trace_id]
|
|
42
|
+
current[:has_error] ||= ERROR_SEVERITIES.include?(trace[:severity])
|
|
43
|
+
current
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.outside_window?(trace, current, window_ms)
|
|
47
|
+
(trace[:started_at] - current[:anchor]) * 1000 > window_ms
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.new_group(trace)
|
|
51
|
+
{ anchor: trace[:started_at], started_at: trace[:started_at],
|
|
52
|
+
trace_ids: [], has_error: false, source: trace[:source] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.build_interactions(groups)
|
|
56
|
+
groups.reverse.map do |g|
|
|
57
|
+
Interaction.new(g[:started_at], g[:trace_ids], g[:has_error], g[:source])
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private_class_method :accumulate_groups, :append_trace,
|
|
62
|
+
:outside_window?, :new_group, :build_interactions
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ratatui_ruby"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module E11y
|
|
7
|
+
module Devtools
|
|
8
|
+
module Tui
|
|
9
|
+
module Widgets
|
|
10
|
+
# Full-screen popup overlay showing event payload + metadata.
|
|
11
|
+
class EventDetail
|
|
12
|
+
def initialize(event:)
|
|
13
|
+
@event = event
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render(tui, frame, area)
|
|
17
|
+
popup_area = centered_rect(area, percent_x: 80, percent_y: 70)
|
|
18
|
+
|
|
19
|
+
frame.render_widget(tui.clear, popup_area)
|
|
20
|
+
|
|
21
|
+
sev = @event["severity"] || "info"
|
|
22
|
+
title = " #{@event['event_name']} · #{sev.upcase} "
|
|
23
|
+
|
|
24
|
+
frame.render_widget(
|
|
25
|
+
tui.paragraph(
|
|
26
|
+
text: build_lines,
|
|
27
|
+
block: tui.block(title: title, borders: :all),
|
|
28
|
+
scroll: [0, 0]
|
|
29
|
+
),
|
|
30
|
+
popup_area
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def build_lines
|
|
37
|
+
lines = []
|
|
38
|
+
lines << " timestamp: #{@event['timestamp']}"
|
|
39
|
+
lines << " trace_id: #{@event['trace_id']}"
|
|
40
|
+
lines << " span_id: #{@event['span_id']}"
|
|
41
|
+
lines << ""
|
|
42
|
+
lines << " payload:"
|
|
43
|
+
JSON.pretty_generate(@event["payload"] || {}).each_line do |l|
|
|
44
|
+
lines << " #{l.chomp}"
|
|
45
|
+
end
|
|
46
|
+
lines << ""
|
|
47
|
+
lines << " [c] copy JSON [b] back"
|
|
48
|
+
lines
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def centered_rect(area, percent_x:, percent_y:)
|
|
52
|
+
w = (area.width * percent_x / 100).to_i
|
|
53
|
+
h = (area.height * percent_y / 100).to_i
|
|
54
|
+
x = area.x + ((area.width - w) / 2)
|
|
55
|
+
y = area.y + ((area.height - h) / 2)
|
|
56
|
+
RatatuiRuby::Rect.new(x: x, y: y, width: w, height: h)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ratatui_ruby"
|
|
4
|
+
|
|
5
|
+
module E11y
|
|
6
|
+
module Devtools
|
|
7
|
+
module Tui
|
|
8
|
+
module Widgets
|
|
9
|
+
# Renders a table of events for the selected trace.
|
|
10
|
+
class EventList
|
|
11
|
+
SEVERITY_COLORS = {
|
|
12
|
+
"debug" => :dark_gray,
|
|
13
|
+
"info" => :white,
|
|
14
|
+
"warn" => :yellow,
|
|
15
|
+
"error" => :red,
|
|
16
|
+
"fatal" => :red
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(events:, trace_id:, selected_index: 0)
|
|
20
|
+
@events = events
|
|
21
|
+
@trace_id = trace_id
|
|
22
|
+
@selected_index = selected_index
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def render(tui, frame, area)
|
|
26
|
+
frame.render_widget(
|
|
27
|
+
tui.table(
|
|
28
|
+
header: ["#", "Severity", "Event Name", "Duration", "At"],
|
|
29
|
+
rows: build_rows(tui),
|
|
30
|
+
row_highlight_style: { bg: :dark_gray },
|
|
31
|
+
selected_row: @selected_index,
|
|
32
|
+
block: tui.block(title: " #{@trace_id} ", borders: :all)
|
|
33
|
+
),
|
|
34
|
+
area
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_rows(tui)
|
|
41
|
+
@events.each_with_index.map do |e, i|
|
|
42
|
+
sev = e["severity"] || "info"
|
|
43
|
+
color = SEVERITY_COLORS.fetch(sev, :white)
|
|
44
|
+
[
|
|
45
|
+
(i + 1).to_s,
|
|
46
|
+
tui.span(content: sev.upcase, style: { fg: color }),
|
|
47
|
+
e["event_name"].to_s,
|
|
48
|
+
duration_str(e),
|
|
49
|
+
timestamp_short(e["timestamp"])
|
|
50
|
+
]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def duration_str(event)
|
|
55
|
+
ms = event.dig("metadata", "duration_ms")
|
|
56
|
+
ms ? "#{ms}ms" : "—"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def timestamp_short(timestamp)
|
|
60
|
+
return "—" unless timestamp
|
|
61
|
+
|
|
62
|
+
Time.parse(timestamp).strftime(".%L")
|
|
63
|
+
rescue ArgumentError
|
|
64
|
+
"—"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ratatui_ruby"
|
|
4
|
+
require_relative "../grouping"
|
|
5
|
+
|
|
6
|
+
module E11y
|
|
7
|
+
module Devtools
|
|
8
|
+
module Tui
|
|
9
|
+
module Widgets
|
|
10
|
+
# Renders a scrollable list of interaction groups.
|
|
11
|
+
# Each row shows: bullet (● error / ○ ok), time, trace count.
|
|
12
|
+
class InteractionList
|
|
13
|
+
def initialize(interactions:, selected_index: 0, source_filter: :all)
|
|
14
|
+
@interactions = interactions
|
|
15
|
+
@selected_index = selected_index
|
|
16
|
+
@source_filter = source_filter
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render(tui, frame, area)
|
|
20
|
+
rows = @interactions.map do |ix|
|
|
21
|
+
bullet = ix.has_error? ? "●" : "○"
|
|
22
|
+
bullet_fg = ix.has_error? ? :red : :gray
|
|
23
|
+
time_str = ix.started_at.strftime("%H:%M:%S")
|
|
24
|
+
count_str = "#{ix.trace_ids.size} req"
|
|
25
|
+
error_str = ix.has_error? ? " ● err" : ""
|
|
26
|
+
|
|
27
|
+
tui.line(spans: [
|
|
28
|
+
tui.span(content: bullet, style: { fg: bullet_fg }),
|
|
29
|
+
tui.span(content: " #{time_str} #{count_str}#{error_str}")
|
|
30
|
+
])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
frame.render_widget(
|
|
34
|
+
tui.list(
|
|
35
|
+
items: rows,
|
|
36
|
+
highlight_style: { bg: :dark_gray },
|
|
37
|
+
selected_index: @selected_index,
|
|
38
|
+
block: tui.block(title: " INTERACTIONS ", borders: :all)
|
|
39
|
+
),
|
|
40
|
+
area
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "e11y"
|
|
4
|
+
require_relative "devtools/version"
|
|
5
|
+
|
|
6
|
+
module E11y
|
|
7
|
+
# Developer tooling for E11y: TUI, Browser Overlay, and MCP Server.
|
|
8
|
+
module Devtools
|
|
9
|
+
autoload :Tui, "e11y/devtools/tui"
|
|
10
|
+
autoload :Overlay, "e11y/devtools/overlay"
|
|
11
|
+
autoload :Mcp, "e11y/devtools/mcp"
|
|
12
|
+
end
|
|
13
|
+
end
|