trainspotter 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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +103 -0
  4. data/Rakefile +11 -0
  5. data/app/controllers/trainspotter/application_controller.rb +11 -0
  6. data/app/controllers/trainspotter/requests_controller.rb +46 -0
  7. data/app/controllers/trainspotter/sessions_controller.rb +30 -0
  8. data/app/engine_assets/javascripts/application.js +7 -0
  9. data/app/engine_assets/javascripts/controllers/requests_controller.js +67 -0
  10. data/app/engine_assets/javascripts/controllers/sessions_controller.js +43 -0
  11. data/app/engine_assets/stylesheets/application.css +549 -0
  12. data/app/helpers/trainspotter/ansi_to_html.rb +72 -0
  13. data/app/helpers/trainspotter/application_helper.rb +9 -0
  14. data/app/jobs/trainspotter/ingest/line.rb +44 -0
  15. data/app/jobs/trainspotter/ingest/params_parser.rb +36 -0
  16. data/app/jobs/trainspotter/ingest/parser.rb +194 -0
  17. data/app/jobs/trainspotter/ingest/processor.rb +70 -0
  18. data/app/jobs/trainspotter/ingest/reader.rb +84 -0
  19. data/app/jobs/trainspotter/ingest/session_builder.rb +52 -0
  20. data/app/jobs/trainspotter/ingest_job.rb +10 -0
  21. data/app/models/trainspotter/file_position_record.rb +17 -0
  22. data/app/models/trainspotter/record.rb +103 -0
  23. data/app/models/trainspotter/request.rb +108 -0
  24. data/app/models/trainspotter/request_record.rb +133 -0
  25. data/app/models/trainspotter/session_record.rb +71 -0
  26. data/app/views/layouts/trainspotter/application.html.erb +20 -0
  27. data/app/views/trainspotter/requests/_request.html.erb +51 -0
  28. data/app/views/trainspotter/requests/index.html.erb +49 -0
  29. data/app/views/trainspotter/sessions/_session.html.erb +28 -0
  30. data/app/views/trainspotter/sessions/index.html.erb +42 -0
  31. data/config/cucumber.yml +8 -0
  32. data/config/routes.rb +15 -0
  33. data/lib/trainspotter/background_worker.rb +74 -0
  34. data/lib/trainspotter/configuration.rb +68 -0
  35. data/lib/trainspotter/engine.rb +45 -0
  36. data/lib/trainspotter/version.rb +3 -0
  37. data/lib/trainspotter.rb +30 -0
  38. metadata +150 -0
@@ -0,0 +1,549 @@
1
+ :root {
2
+ --bg-primary: #1a1a2e;
3
+ --bg-secondary: #16213e;
4
+ --bg-tertiary: #0f3460;
5
+ --text-primary: #eaeaea;
6
+ --text-secondary: #a0a0a0;
7
+ --text-muted: #6a6a6a;
8
+ --border-color: #2a2a4a;
9
+ --accent: #e94560;
10
+ --success: #4ade80;
11
+ --warning: #fbbf24;
12
+ --error: #ef4444;
13
+ --info: #60a5fa;
14
+ }
15
+
16
+ @media (prefers-color-scheme: light) {
17
+ :root {
18
+ --bg-primary: #ffffff;
19
+ --bg-secondary: #f8f9fa;
20
+ --bg-tertiary: #e9ecef;
21
+ --text-primary: #212529;
22
+ --text-secondary: #6c757d;
23
+ --text-muted: #adb5bd;
24
+ --border-color: #dee2e6;
25
+ --accent: #dc3545;
26
+ }
27
+ }
28
+
29
+ * {
30
+ box-sizing: border-box;
31
+ }
32
+
33
+ body {
34
+ margin: 0;
35
+ padding: 0;
36
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
37
+ font-size: 13px;
38
+ line-height: 1.5;
39
+ background: var(--bg-primary);
40
+ color: var(--text-primary);
41
+ }
42
+
43
+ .trainspotter {
44
+ display: flex;
45
+ flex-direction: column;
46
+ height: 100vh;
47
+ }
48
+
49
+ .trainspotter-header {
50
+ display: flex;
51
+ justify-content: space-between;
52
+ align-items: center;
53
+ padding: 12px 20px;
54
+ background: var(--bg-secondary);
55
+ border-bottom: 1px solid var(--border-color);
56
+ }
57
+
58
+ .trainspotter-header h1 {
59
+ margin: 0;
60
+ font-size: 18px;
61
+ font-weight: 600;
62
+ }
63
+
64
+ .trainspotter-controls {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 16px;
68
+ }
69
+
70
+ .log-file-selector,
71
+ .ip-filter-selector {
72
+ background: var(--bg-tertiary);
73
+ color: var(--text-primary);
74
+ border: 1px solid var(--border-color);
75
+ border-radius: 4px;
76
+ padding: 4px 8px;
77
+ font-size: 12px;
78
+ font-family: inherit;
79
+ cursor: pointer;
80
+ }
81
+
82
+ .log-file-selector:focus,
83
+ .ip-filter-selector:focus {
84
+ outline: none;
85
+ border-color: var(--accent);
86
+ }
87
+
88
+ .ip-filter-selector {
89
+ min-width: 120px;
90
+ }
91
+
92
+ .current-log-file {
93
+ color: var(--text-secondary);
94
+ font-size: 12px;
95
+ }
96
+
97
+ .auto-scroll-toggle {
98
+ display: flex;
99
+ align-items: center;
100
+ gap: 6px;
101
+ cursor: pointer;
102
+ color: var(--text-secondary);
103
+ font-size: 12px;
104
+ }
105
+
106
+ .connection-status {
107
+ font-size: 10px;
108
+ color: var(--text-muted);
109
+ }
110
+
111
+ .connection-status.connected {
112
+ color: var(--success);
113
+ }
114
+
115
+ .connection-status.disconnected {
116
+ color: var(--error);
117
+ }
118
+
119
+ .trainspotter-main {
120
+ flex: 1;
121
+ overflow: hidden;
122
+ }
123
+
124
+ .request-list {
125
+ height: 100%;
126
+ overflow-y: auto;
127
+ padding: 8px;
128
+ }
129
+
130
+ .request-group {
131
+ margin-bottom: 4px;
132
+ border: 1px solid var(--border-color);
133
+ border-radius: 6px;
134
+ background: var(--bg-secondary);
135
+ overflow: hidden;
136
+ }
137
+
138
+ .request-group.success {
139
+ border-left: 3px solid var(--success);
140
+ }
141
+
142
+ .request-group.redirect {
143
+ border-left: 3px solid var(--info);
144
+ }
145
+
146
+ .request-group.client-error {
147
+ border-left: 3px solid var(--warning);
148
+ }
149
+
150
+ .request-group.server-error {
151
+ border-left: 3px solid var(--error);
152
+ }
153
+
154
+ .request-summary {
155
+ display: flex;
156
+ align-items: center;
157
+ gap: 12px;
158
+ padding: 10px 14px;
159
+ cursor: pointer;
160
+ user-select: none;
161
+ }
162
+
163
+ .request-summary::-webkit-details-marker {
164
+ display: none;
165
+ }
166
+
167
+ .request-summary::before {
168
+ content: "▶";
169
+ font-size: 10px;
170
+ color: var(--text-muted);
171
+ transition: transform 0.15s;
172
+ }
173
+
174
+ details[open] .request-summary::before {
175
+ transform: rotate(90deg);
176
+ }
177
+
178
+ .request-method {
179
+ font-weight: 700;
180
+ font-size: 11px;
181
+ padding: 2px 6px;
182
+ border-radius: 3px;
183
+ min-width: 50px;
184
+ text-align: center;
185
+ }
186
+
187
+ .method-get { background: #22543d; color: #9ae6b4; }
188
+ .method-post { background: #2c5282; color: #90cdf4; }
189
+ .method-put, .method-patch { background: #744210; color: #fbd38d; }
190
+ .method-delete { background: #742a2a; color: #feb2b2; }
191
+
192
+ @media (prefers-color-scheme: light) {
193
+ .method-get { background: #c6f6d5; color: #22543d; }
194
+ .method-post { background: #bee3f8; color: #2c5282; }
195
+ .method-put, .method-patch { background: #fefcbf; color: #744210; }
196
+ .method-delete { background: #fed7d7; color: #742a2a; }
197
+ }
198
+
199
+ .request-path {
200
+ flex: 1;
201
+ overflow: hidden;
202
+ text-overflow: ellipsis;
203
+ white-space: nowrap;
204
+ color: var(--text-primary);
205
+ }
206
+
207
+ .request-controller {
208
+ color: var(--text-secondary);
209
+ font-size: 12px;
210
+ }
211
+
212
+ .request-meta {
213
+ display: flex;
214
+ align-items: center;
215
+ gap: 10px;
216
+ color: var(--text-secondary);
217
+ font-size: 12px;
218
+ }
219
+
220
+ .request-status {
221
+ font-weight: 600;
222
+ padding: 1px 6px;
223
+ border-radius: 3px;
224
+ }
225
+
226
+ .status-success { color: var(--success); }
227
+ .status-redirect { color: var(--info); }
228
+ .status-client-error { color: var(--warning); }
229
+ .status-server-error { color: var(--error); }
230
+
231
+ .request-duration {
232
+ color: var(--text-muted);
233
+ }
234
+
235
+ .request-sql, .request-renders {
236
+ color: var(--text-muted);
237
+ }
238
+
239
+ .request-time {
240
+ color: var(--text-muted);
241
+ font-size: 11px;
242
+ min-width: 65px;
243
+ text-align: right;
244
+ }
245
+
246
+ .request-details {
247
+ border-top: 1px solid var(--border-color);
248
+ background: var(--bg-tertiary);
249
+ padding: 8px 14px;
250
+ max-height: 400px;
251
+ overflow-y: auto;
252
+ }
253
+
254
+ .log-entry {
255
+ display: flex;
256
+ align-items: flex-start;
257
+ gap: 8px;
258
+ padding: 4px 0;
259
+ font-size: 12px;
260
+ }
261
+
262
+ .log-entry + .log-entry {
263
+ border-top: 1px solid var(--border-color);
264
+ }
265
+
266
+ .entry-badge {
267
+ font-size: 9px;
268
+ font-weight: 700;
269
+ padding: 2px 5px;
270
+ border-radius: 3px;
271
+ min-width: 32px;
272
+ text-align: center;
273
+ }
274
+
275
+ .entry-badge.sql {
276
+ background: #553c9a;
277
+ color: #d6bcfa;
278
+ }
279
+
280
+ .entry-badge.render {
281
+ background: #2f855a;
282
+ color: #9ae6b4;
283
+ }
284
+
285
+ @media (prefers-color-scheme: light) {
286
+ .entry-badge.sql {
287
+ background: #e9d8fd;
288
+ color: #553c9a;
289
+ }
290
+ .entry-badge.render {
291
+ background: #c6f6d5;
292
+ color: #2f855a;
293
+ }
294
+ }
295
+
296
+ .entry-duration {
297
+ color: var(--text-muted);
298
+ min-width: 55px;
299
+ text-align: right;
300
+ }
301
+
302
+ .entry-content {
303
+ flex: 1;
304
+ word-break: break-all;
305
+ color: var(--text-secondary);
306
+ }
307
+
308
+ code.entry-content {
309
+ font-family: inherit;
310
+ background: none;
311
+ }
312
+
313
+ .entry-raw {
314
+ margin: 0;
315
+ white-space: pre-wrap;
316
+ word-break: break-all;
317
+ color: var(--text-muted);
318
+ font-size: 11px;
319
+ }
320
+
321
+ .entry-other .entry-raw {
322
+ padding-left: 40px;
323
+ }
324
+
325
+ .empty-state {
326
+ text-align: center;
327
+ padding: 60px 20px;
328
+ color: var(--text-secondary);
329
+ }
330
+
331
+ .empty-state p {
332
+ margin: 8px 0;
333
+ }
334
+
335
+ .empty-state .hint {
336
+ font-size: 12px;
337
+ color: var(--text-muted);
338
+ }
339
+
340
+ /* ANSI color codes */
341
+ .ansi-bold { font-weight: bold; }
342
+ .ansi-dim { opacity: 0.7; }
343
+ .ansi-italic { font-style: italic; }
344
+ .ansi-underline { text-decoration: underline; }
345
+
346
+ .ansi-black { color: #4a4a4a; }
347
+ .ansi-red { color: #e74c3c; }
348
+ .ansi-green { color: #2ecc71; }
349
+ .ansi-yellow { color: #f39c12; }
350
+ .ansi-blue { color: #3498db; }
351
+ .ansi-magenta { color: #9b59b6; }
352
+ .ansi-cyan { color: #00bcd4; }
353
+ .ansi-white { color: #ecf0f1; }
354
+
355
+ .ansi-bright-black { color: #7f8c8d; }
356
+ .ansi-bright-red { color: #ff6b6b; }
357
+ .ansi-bright-green { color: #69db7c; }
358
+ .ansi-bright-yellow { color: #ffd43b; }
359
+ .ansi-bright-blue { color: #74c0fc; }
360
+ .ansi-bright-magenta { color: #da77f2; }
361
+ .ansi-bright-cyan { color: #66d9ef; }
362
+ .ansi-bright-white { color: #ffffff; }
363
+
364
+ @media (prefers-color-scheme: light) {
365
+ .ansi-black { color: #2c3e50; }
366
+ .ansi-white { color: #7f8c8d; }
367
+ .ansi-bright-black { color: #95a5a6; }
368
+ .ansi-bright-white { color: #2c3e50; }
369
+ }
370
+
371
+ /* Navigation link */
372
+ .nav-link {
373
+ color: var(--text-secondary);
374
+ text-decoration: none;
375
+ font-size: 12px;
376
+ padding: 4px 8px;
377
+ border: 1px solid var(--border-color);
378
+ border-radius: 4px;
379
+ background: var(--bg-tertiary);
380
+ }
381
+
382
+ .nav-link:hover {
383
+ color: var(--text-primary);
384
+ border-color: var(--accent);
385
+ }
386
+
387
+ /* Show anonymous toggle */
388
+ .show-anonymous-toggle {
389
+ display: flex;
390
+ align-items: center;
391
+ gap: 6px;
392
+ cursor: pointer;
393
+ color: var(--text-secondary);
394
+ font-size: 12px;
395
+ }
396
+
397
+ /* Session list */
398
+ .session-list {
399
+ height: 100%;
400
+ overflow-y: auto;
401
+ padding: 8px;
402
+ }
403
+
404
+ .session-group {
405
+ margin-bottom: 4px;
406
+ border: 1px solid var(--border-color);
407
+ border-radius: 6px;
408
+ background: var(--bg-secondary);
409
+ overflow: hidden;
410
+ }
411
+
412
+ .session-group.ongoing {
413
+ border-left: 3px solid var(--success);
414
+ }
415
+
416
+ .session-group.ended {
417
+ border-left: 3px solid var(--text-muted);
418
+ }
419
+
420
+ .session-summary {
421
+ display: flex;
422
+ align-items: center;
423
+ gap: 12px;
424
+ padding: 10px 14px;
425
+ cursor: pointer;
426
+ user-select: none;
427
+ }
428
+
429
+ .session-summary::-webkit-details-marker {
430
+ display: none;
431
+ }
432
+
433
+ .session-summary::before {
434
+ content: "▶";
435
+ font-size: 10px;
436
+ color: var(--text-muted);
437
+ transition: transform 0.15s;
438
+ }
439
+
440
+ details.session-group[open] .session-summary::before {
441
+ transform: rotate(90deg);
442
+ }
443
+
444
+ .session-identity {
445
+ min-width: 200px;
446
+ }
447
+
448
+ .session-email {
449
+ font-weight: 600;
450
+ color: var(--text-primary);
451
+ }
452
+
453
+ .session-email.anonymous {
454
+ color: var(--text-muted);
455
+ font-style: italic;
456
+ }
457
+
458
+ .session-ip {
459
+ color: var(--text-secondary);
460
+ font-size: 12px;
461
+ min-width: 120px;
462
+ }
463
+
464
+ .session-meta {
465
+ display: flex;
466
+ align-items: center;
467
+ gap: 10px;
468
+ color: var(--text-secondary);
469
+ font-size: 12px;
470
+ flex: 1;
471
+ }
472
+
473
+ .session-request-count {
474
+ color: var(--text-muted);
475
+ }
476
+
477
+ .session-status {
478
+ font-weight: 600;
479
+ padding: 1px 6px;
480
+ border-radius: 3px;
481
+ font-size: 11px;
482
+ }
483
+
484
+ .session-status.ongoing {
485
+ background: #22543d;
486
+ color: #9ae6b4;
487
+ }
488
+
489
+ .session-status.logout {
490
+ background: #2c5282;
491
+ color: #90cdf4;
492
+ }
493
+
494
+ .session-status.timeout {
495
+ background: #744210;
496
+ color: #fbd38d;
497
+ }
498
+
499
+ @media (prefers-color-scheme: light) {
500
+ .session-status.ongoing {
501
+ background: #c6f6d5;
502
+ color: #22543d;
503
+ }
504
+ .session-status.logout {
505
+ background: #bee3f8;
506
+ color: #2c5282;
507
+ }
508
+ .session-status.timeout {
509
+ background: #fefcbf;
510
+ color: #744210;
511
+ }
512
+ }
513
+
514
+ .session-duration {
515
+ color: var(--text-muted);
516
+ }
517
+
518
+ .session-time {
519
+ color: var(--text-muted);
520
+ font-size: 11px;
521
+ min-width: 65px;
522
+ text-align: right;
523
+ }
524
+
525
+ .session-requests {
526
+ border-top: 1px solid var(--border-color);
527
+ background: var(--bg-tertiary);
528
+ padding: 8px 14px;
529
+ max-height: 400px;
530
+ overflow-y: auto;
531
+ }
532
+
533
+ .session-requests .request-group {
534
+ border-left-width: 1px;
535
+ }
536
+
537
+ .loading-hint,
538
+ .empty-hint,
539
+ .error-hint {
540
+ color: var(--text-muted);
541
+ font-size: 12px;
542
+ font-style: italic;
543
+ margin: 0;
544
+ padding: 8px 0;
545
+ }
546
+
547
+ .error-hint {
548
+ color: var(--error);
549
+ }
@@ -0,0 +1,72 @@
1
+ module Trainspotter
2
+ class AnsiToHtml
3
+ ANSI_CODES = {
4
+ "0" => "reset",
5
+ "1" => "bold",
6
+ "2" => "dim",
7
+ "3" => "italic",
8
+ "4" => "underline",
9
+ "30" => "black",
10
+ "31" => "red",
11
+ "32" => "green",
12
+ "33" => "yellow",
13
+ "34" => "blue",
14
+ "35" => "magenta",
15
+ "36" => "cyan",
16
+ "37" => "white",
17
+ "90" => "bright-black",
18
+ "91" => "bright-red",
19
+ "92" => "bright-green",
20
+ "93" => "bright-yellow",
21
+ "94" => "bright-blue",
22
+ "95" => "bright-magenta",
23
+ "96" => "bright-cyan",
24
+ "97" => "bright-white"
25
+ }.freeze
26
+
27
+ ANSI_PATTERN = /\e\[([0-9;]*)m/
28
+
29
+ def self.convert(text)
30
+ new.convert(text)
31
+ end
32
+
33
+ def convert(text)
34
+ return "" if text.nil?
35
+
36
+ result = []
37
+ open_spans = 0
38
+ current_classes = []
39
+
40
+ parts = text.split(ANSI_PATTERN, -1)
41
+
42
+ parts.each_with_index do |part, index|
43
+ if index.odd?
44
+ codes = part.split(";")
45
+ new_classes = []
46
+
47
+ codes.each do |code|
48
+ if code == "0" || code == ""
49
+ open_spans.times { result << "</span>" }
50
+ open_spans = 0
51
+ current_classes = []
52
+ elsif ANSI_CODES[code]
53
+ new_classes << "ansi-#{ANSI_CODES[code]}"
54
+ end
55
+ end
56
+
57
+ if new_classes.any?
58
+ result << "<span class=\"#{new_classes.join(" ")}\">"
59
+ open_spans += 1
60
+ current_classes = new_classes
61
+ end
62
+ else
63
+ result << ERB::Util.html_escape(part)
64
+ end
65
+ end
66
+
67
+ open_spans.times { result << "</span>" }
68
+
69
+ result.join.html_safe
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,9 @@
1
+ module Trainspotter
2
+ module ApplicationHelper
3
+ include Trainspotter.isolated_assets_helper
4
+
5
+ def ansi_to_html(text)
6
+ AnsiToHtml.convert(text)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,44 @@
1
+ module Trainspotter
2
+ module Ingest
3
+ class Line
4
+ TYPES = %i[request_start processing params sql render request_end other].freeze
5
+
6
+ attr_reader :raw, :type, :timestamp, :metadata
7
+
8
+ def initialize(raw:, type: :other, timestamp: nil, metadata: {})
9
+ @raw = raw
10
+ @type = type
11
+ @timestamp = timestamp
12
+ @metadata = metadata
13
+ end
14
+
15
+ def sql?
16
+ type == :sql
17
+ end
18
+
19
+ def render?
20
+ type == :render
21
+ end
22
+
23
+ def request_start?
24
+ type == :request_start
25
+ end
26
+
27
+ def request_end?
28
+ type == :request_end
29
+ end
30
+
31
+ def processing?
32
+ type == :processing
33
+ end
34
+
35
+ def params?
36
+ type == :params
37
+ end
38
+
39
+ def duration_ms
40
+ metadata[:duration_ms]
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,36 @@
1
+ require "prism"
2
+
3
+ module Trainspotter
4
+ module Ingest
5
+ class ParamsParser
6
+ class ParseError < StandardError; end
7
+
8
+ def self.parse(string)
9
+ return {} if string.nil? || string.strip.empty?
10
+
11
+ result = Prism.parse(string)
12
+ raise ParseError, result.errors.first.message unless result.success?
13
+
14
+ evaluate(result.value.statements.body.first)
15
+ end
16
+
17
+ def self.evaluate(node)
18
+ case node
19
+ when Prism::HashNode
20
+ node.elements.to_h { |assoc| [evaluate(assoc.key), evaluate(assoc.value)] }
21
+ when Prism::ArrayNode
22
+ node.elements.map { |el| evaluate(el) }
23
+ when Prism::StringNode
24
+ node.unescaped
25
+ when Prism::IntegerNode, Prism::FloatNode
26
+ node.value
27
+ when Prism::NilNode then nil
28
+ when Prism::TrueNode then true
29
+ when Prism::FalseNode then false
30
+ else
31
+ raise ParseError, "Unsafe node type: #{node.class}"
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end