solidstats 1.1.0 → 3.0.0.beta.1

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +27 -0
  4. data/Rakefile +3 -3
  5. data/app/assets/stylesheets/solidstats/dashboard.css +48 -0
  6. data/app/controllers/solidstats/dashboard_controller.rb +82 -60
  7. data/app/controllers/solidstats/logs_controller.rb +72 -0
  8. data/app/controllers/solidstats/performance_controller.rb +25 -0
  9. data/app/controllers/solidstats/productivity_controller.rb +39 -0
  10. data/app/controllers/solidstats/quality_controller.rb +152 -0
  11. data/app/controllers/solidstats/securities_controller.rb +30 -0
  12. data/app/helpers/solidstats/application_helper.rb +155 -0
  13. data/app/helpers/solidstats/performance_helper.rb +87 -0
  14. data/app/helpers/solidstats/productivity_helper.rb +38 -0
  15. data/app/services/solidstats/bundler_audit_service.rb +206 -0
  16. data/app/services/solidstats/coverage_compass_service.rb +335 -0
  17. data/app/services/solidstats/load_lens_service.rb +454 -0
  18. data/app/services/solidstats/log_size_monitor_service.rb +205 -74
  19. data/app/services/solidstats/my_todo_service.rb +242 -0
  20. data/app/services/solidstats/style_patrol_service.rb +319 -0
  21. data/app/views/layouts/solidstats/application.html.erb +9 -2
  22. data/app/views/layouts/solidstats/dashboard.html.erb +84 -0
  23. data/app/views/solidstats/dashboard/dashboard.html.erb +39 -0
  24. data/app/views/solidstats/logs/logs_size.html.erb +409 -0
  25. data/app/views/solidstats/performance/load_lens.html.erb +158 -0
  26. data/app/views/solidstats/productivity/_todo_list.html.erb +49 -0
  27. data/app/views/solidstats/productivity/my_todos.html.erb +84 -0
  28. data/app/views/solidstats/quality/coverage_compass.html.erb +420 -0
  29. data/app/views/solidstats/quality/style_patrol.html.erb +463 -0
  30. data/app/views/solidstats/securities/bundler_audit.html.erb +345 -0
  31. data/app/views/solidstats/shared/_dashboard_card.html.erb +160 -0
  32. data/app/views/solidstats/shared/_quick_actions.html.erb +26 -0
  33. data/config/routes.rb +32 -4
  34. data/lib/generators/solidstats/install/install_generator.rb +28 -2
  35. data/lib/generators/solidstats/install/templates/README +7 -0
  36. data/lib/solidstats/version.rb +1 -1
  37. data/lib/tasks/solidstats_performance.rake +84 -0
  38. metadata +43 -19
  39. data/app/services/solidstats/audit_service.rb +0 -56
  40. data/app/services/solidstats/data_collector_service.rb +0 -83
  41. data/app/services/solidstats/todo_service.rb +0 -114
  42. data/app/views/solidstats/dashboard/_log_monitor.html.erb +0 -759
  43. data/app/views/solidstats/dashboard/_todos.html.erb +0 -151
  44. data/app/views/solidstats/dashboard/audit/_additional_styles.css +0 -22
  45. data/app/views/solidstats/dashboard/audit/_audit_badge.html.erb +0 -5
  46. data/app/views/solidstats/dashboard/audit/_audit_details.html.erb +0 -495
  47. data/app/views/solidstats/dashboard/audit/_audit_summary.html.erb +0 -26
  48. data/app/views/solidstats/dashboard/audit/_no_vulnerabilities.html.erb +0 -3
  49. data/app/views/solidstats/dashboard/audit/_security_audit.html.erb +0 -14
  50. data/app/views/solidstats/dashboard/audit/_vulnerabilities_table.html.erb +0 -1120
  51. data/app/views/solidstats/dashboard/audit/_vulnerability_details.html.erb +0 -63
  52. data/app/views/solidstats/dashboard/index.html.erb +0 -1351
  53. data/lib/tasks/solidstats_tasks.rake +0 -4
@@ -0,0 +1,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solidstats
4
+ # LoadLens - Development Performance Monitoring Service
5
+ # Parses Rails development logs to extract performance metrics
6
+ class LoadLensService
7
+ DATA_DIR = Rails.root.join('solidstats')
8
+ LOG_FILE = Rails.root.join('log', 'development.log')
9
+ POSITION_FILE = DATA_DIR.join('last_position.txt')
10
+ CACHE_FILE = "loadlens.json"
11
+ SUMMARY_FILE = "summary.json"
12
+ RETENTION_DAYS = 7
13
+
14
+ # Regex patterns for parsing Rails development log
15
+ REQUEST_START_REGEX = /Started\s+(\w+)\s+"([^"]+)"\s+for\s+[\d\.:]+\s+at\s+([\d\-\s:]+)/
16
+ CONTROLLER_ACTION_REGEX = /Processing\s+by\s+([^#]+)#(\w+)\s+as/
17
+ # Updated to capture all timing info from the completion line
18
+ COMPLETED_REGEX = /Completed\s+(\d+)\s+\w+\s+in\s+([\d\.]+)ms(?:\s+\(([^)]+)\))?/
19
+ # Separate patterns for extracting times from the completion line details
20
+ VIEW_RENDERING_REGEX = /Views:\s+([\d\.]+)ms/
21
+ ACTIVERECORD_REGEX = /ActiveRecord:\s+([\d\.]+)ms/
22
+
23
+ def self.get_performance_data
24
+ cache_file_path = solidstats_cache_path(CACHE_FILE)
25
+
26
+ if File.exist?(cache_file_path) && cache_fresh?(cache_file_path, 15.minutes)
27
+ raw_data = JSON.parse(File.read(cache_file_path))
28
+ deep_indifferent_access(raw_data)
29
+ else
30
+ scan_and_cache
31
+ end
32
+ rescue JSON::ParserError, Errno::ENOENT
33
+ Rails.logger.error("Error reading performance cache, regenerating...")
34
+ scan_and_cache
35
+ end
36
+
37
+ def self.scan_and_cache
38
+ performance_data = scan_development_log
39
+
40
+ # Cache the performance data
41
+ cache_performance_data(performance_data)
42
+
43
+ # Update summary.json with performance monitoring card
44
+ update_summary_json(performance_data)
45
+
46
+ deep_indifferent_access(performance_data)
47
+ end
48
+
49
+ def self.refresh_data
50
+ # Force refresh by removing cache and regenerating
51
+ cache_file_path = solidstats_cache_path(CACHE_FILE)
52
+ File.delete(cache_file_path) if File.exist?(cache_file_path)
53
+ scan_and_cache
54
+ end
55
+
56
+ def self.parse_log_and_save
57
+ return unless Rails.env.development?
58
+ return unless File.exist?(LOG_FILE)
59
+
60
+ ensure_data_directory
61
+ cleanup_old_files
62
+
63
+ begin
64
+ last_position = read_last_position
65
+ processed_count = 0
66
+ current_requests = []
67
+
68
+ File.open(LOG_FILE, 'r') do |file|
69
+ file.seek(last_position)
70
+
71
+ file.each_line do |line|
72
+ process_line(line.strip, current_requests)
73
+ update_last_position(file.pos)
74
+ end
75
+ end
76
+
77
+ # Process any remaining incomplete requests
78
+ current_requests.each do |req|
79
+ if req[:completed]
80
+ save_request(req)
81
+ processed_count += 1
82
+ end
83
+ end
84
+
85
+ Rails.logger.info("DevLogParser: Processed #{processed_count} requests")
86
+ { success: true, processed: processed_count }
87
+ rescue => e
88
+ Rails.logger.error("DevLogParser: Failed to parse development log: #{e.message}")
89
+ { success: false, error: e.message }
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def self.deep_indifferent_access(data)
96
+ if data.is_a?(Hash)
97
+ data.with_indifferent_access.transform_values do |value|
98
+ deep_indifferent_access(value)
99
+ end
100
+ elsif data.is_a?(Array)
101
+ data.map { |element| deep_indifferent_access(element) }
102
+ else
103
+ data
104
+ end
105
+ end
106
+
107
+ def self.scan_development_log
108
+ # Load recent performance data for dashboard
109
+ data_files = []
110
+
111
+ # Get last 7 days of data files
112
+ 7.times do |i|
113
+ date = i.days.ago.to_date
114
+ file_path = DATA_DIR.join("perf_#{date.strftime('%Y-%m-%d')}.json")
115
+
116
+ if File.exist?(file_path)
117
+ file_data = JSON.parse(File.read(file_path))
118
+ data_files.concat(file_data)
119
+ end
120
+ end
121
+
122
+ # Fallback: If no data files exist, parse recent entries from development.log
123
+ if data_files.empty? && File.exist?(LOG_FILE) && Rails.env.development?
124
+ Rails.logger.info("DevLogParser: No data files found, parsing recent development log entries")
125
+ data_files = parse_recent_log_entries
126
+ end
127
+
128
+ # Calculate metrics
129
+ calculate_performance_metrics(data_files)
130
+ end
131
+
132
+ def self.parse_recent_log_entries
133
+ # Parse recent entries from development.log as fallback when no data files exist
134
+ recent_requests = []
135
+ current_requests = []
136
+ lines_processed = 0
137
+ max_lines = 2000 # Process last 2000 lines for initial bootstrap
138
+
139
+ begin
140
+ # Get the last N lines from the log file efficiently
141
+ log_lines = []
142
+ File.open(LOG_FILE, 'r') do |file|
143
+ file.each_line { |line| log_lines << line.strip }
144
+ end
145
+
146
+ # Take the last max_lines entries
147
+ log_lines = log_lines.last(max_lines) if log_lines.size > max_lines
148
+
149
+ # Process each line using existing parsing logic
150
+ log_lines.each do |line|
151
+ process_line(line, current_requests)
152
+ lines_processed += 1
153
+
154
+ # Collect completed requests
155
+ current_requests.each do |req|
156
+ if req[:completed]
157
+ # Convert to the same format as saved requests
158
+ clean_request = {
159
+ 'controller' => req[:controller],
160
+ 'action' => req[:action],
161
+ 'http_method' => req[:http_method],
162
+ 'path' => req[:path],
163
+ 'status' => req[:status],
164
+ 'total_time_ms' => req[:total_time_ms] || 0.0,
165
+ 'view_time_ms' => req[:view_time_ms] || 0.0,
166
+ 'activerecord_time_ms' => req[:activerecord_time_ms] || 0.0,
167
+ 'timestamp' => req[:timestamp]
168
+ }
169
+
170
+ # Only include complete requests with controller/action
171
+ if clean_request['controller'] && clean_request['action']
172
+ recent_requests << clean_request
173
+ end
174
+ end
175
+ end
176
+
177
+ # Remove completed requests from processing queue
178
+ current_requests.reject! { |req| req[:completed] }
179
+ end
180
+
181
+ Rails.logger.info("DevLogParser: Parsed #{recent_requests.size} recent requests from #{lines_processed} log lines")
182
+ recent_requests
183
+
184
+ rescue => e
185
+ Rails.logger.error("DevLogParser: Failed to parse recent log entries: #{e.message}")
186
+ []
187
+ end
188
+ end
189
+
190
+ def self.calculate_performance_metrics(requests)
191
+ return default_metrics if requests.empty?
192
+
193
+ total_requests = requests.size
194
+ avg_response_time = (requests.sum { |req| req['total_time_ms'] || 0 } / total_requests).round(2)
195
+ avg_view_time = (requests.sum { |req| req['view_time_ms'] || 0 } / total_requests).round(2)
196
+ avg_db_time = (requests.sum { |req| req['activerecord_time_ms'] || 0 } / total_requests).round(2)
197
+ slow_requests = requests.count { |req| (req['total_time_ms'] || 0) > 1000 }
198
+ error_rate = ((requests.count { |req| (req['status'] || 200) >= 400 }.to_f / total_requests) * 100).round(2)
199
+
200
+ status = determine_status(avg_response_time, error_rate, slow_requests, total_requests)
201
+
202
+ {
203
+ summary: {
204
+ total_requests: total_requests,
205
+ avg_response_time: avg_response_time,
206
+ avg_view_time: avg_view_time,
207
+ avg_db_time: avg_db_time,
208
+ slow_requests: slow_requests,
209
+ error_rate: error_rate,
210
+ status: status,
211
+ last_updated: Time.current.iso8601
212
+ },
213
+ recent_requests: requests.sort_by { |req| req['timestamp'] }.reverse.first(20),
214
+ last_updated: Time.current.iso8601
215
+ }
216
+ end
217
+
218
+ def self.default_metrics
219
+ {
220
+ summary: {
221
+ total_requests: 0,
222
+ avg_response_time: 0,
223
+ avg_view_time: 0,
224
+ avg_db_time: 0,
225
+ slow_requests: 0,
226
+ error_rate: 0,
227
+ status: 'info',
228
+ last_updated: Time.current.iso8601
229
+ },
230
+ recent_requests: [],
231
+ last_updated: Time.current.iso8601
232
+ }
233
+ end
234
+
235
+ def self.determine_status(avg_response_time, error_rate, slow_requests, total_requests)
236
+ return 'error' if error_rate > 10
237
+ return 'warning' if avg_response_time > 1000 || slow_requests > (total_requests * 0.1)
238
+ return 'success'
239
+ end
240
+
241
+ def self.process_line(line, current_requests)
242
+ # Start of new request
243
+ if match = line.match(REQUEST_START_REGEX)
244
+ method, path, timestamp = match.captures
245
+ current_requests << {
246
+ http_method: method,
247
+ path: path,
248
+ timestamp: parse_timestamp(timestamp),
249
+ started_at: Time.current
250
+ }
251
+
252
+ # Controller and action info
253
+ elsif match = line.match(CONTROLLER_ACTION_REGEX)
254
+ controller, action = match.captures
255
+ if current_request = current_requests.last
256
+ current_request[:controller] = controller
257
+ current_request[:action] = action
258
+ end
259
+
260
+ # Request completion with timing
261
+ elsif match = line.match(COMPLETED_REGEX)
262
+ status, total_time, timing_details = match.captures
263
+ if current_request = current_requests.last
264
+ current_request[:status] = status.to_i
265
+ current_request[:total_time_ms] = total_time.to_f
266
+ current_request[:completed] = true
267
+
268
+ # Extract view and ActiveRecord times from timing details if present
269
+ if timing_details
270
+ if view_match = timing_details.match(VIEW_RENDERING_REGEX)
271
+ current_request[:view_time_ms] = view_match[1].to_f
272
+ end
273
+
274
+ if ar_match = timing_details.match(ACTIVERECORD_REGEX)
275
+ current_request[:activerecord_time_ms] = ar_match[1].to_f
276
+ end
277
+ end
278
+ end
279
+
280
+ # View rendering time
281
+ elsif match = line.match(VIEW_RENDERING_REGEX)
282
+ view_time = match.captures.first
283
+ if current_request = current_requests.last
284
+ current_request[:view_time_ms] = view_time.to_f
285
+ end
286
+
287
+ # ActiveRecord time
288
+ elsif match = line.match(ACTIVERECORD_REGEX)
289
+ ar_time = match.captures.first
290
+ if current_request = current_requests.last
291
+ current_request[:activerecord_time_ms] = ar_time.to_f
292
+ end
293
+ end
294
+
295
+ # Save and remove completed requests
296
+ current_requests.reject! do |request|
297
+ if request[:completed]
298
+ save_request(request)
299
+ true
300
+ end
301
+ end
302
+ end
303
+
304
+ def self.save_request(request)
305
+ # Clean up request data
306
+ clean_request = {
307
+ controller: request[:controller],
308
+ action: request[:action],
309
+ http_method: request[:http_method],
310
+ path: request[:path],
311
+ status: request[:status],
312
+ total_time_ms: request[:total_time_ms] || 0.0,
313
+ view_time_ms: request[:view_time_ms] || 0.0,
314
+ activerecord_time_ms: request[:activerecord_time_ms] || 0.0,
315
+ timestamp: request[:timestamp]
316
+ }
317
+
318
+ # Skip incomplete requests
319
+ return unless clean_request[:controller] && clean_request[:action]
320
+
321
+ perf_file = current_perf_file
322
+
323
+ # Read existing data or create new array
324
+ existing_data = if File.exist?(perf_file)
325
+ JSON.parse(File.read(perf_file))
326
+ else
327
+ []
328
+ end
329
+
330
+ # Add new request
331
+ existing_data << clean_request
332
+
333
+ # Write back to file
334
+ File.write(perf_file, JSON.pretty_generate(existing_data))
335
+ end
336
+
337
+ def self.current_perf_file
338
+ date_suffix = Date.current.strftime('%Y-%m-%d')
339
+ DATA_DIR.join("perf_#{date_suffix}.json")
340
+ end
341
+
342
+ def self.ensure_data_directory
343
+ DATA_DIR.mkpath unless DATA_DIR.exist?
344
+ end
345
+
346
+ def self.cleanup_old_files
347
+ cutoff_date = RETENTION_DAYS.days.ago.to_date
348
+
349
+ Dir.glob(DATA_DIR.join('perf_*.json')).each do |file|
350
+ if match = File.basename(file).match(/perf_(\d{4}-\d{2}-\d{2})\.json/)
351
+ file_date = Date.parse(match[1])
352
+ File.delete(file) if file_date < cutoff_date
353
+ end
354
+ end
355
+ end
356
+
357
+ def self.read_last_position
358
+ return 0 unless File.exist?(POSITION_FILE)
359
+ File.read(POSITION_FILE).to_i
360
+ end
361
+
362
+ def self.update_last_position(position)
363
+ File.write(POSITION_FILE, position.to_s)
364
+ end
365
+
366
+ def self.parse_timestamp(timestamp_str)
367
+ # Handle Rails timestamp format: "2025-06-10 14:30:45 +0000"
368
+ Time.parse(timestamp_str).iso8601
369
+ rescue
370
+ Time.current.iso8601
371
+ end
372
+
373
+ def self.cache_performance_data(data)
374
+ cache_file_path = solidstats_cache_path(CACHE_FILE)
375
+ ensure_solidstats_directory
376
+
377
+ File.write(cache_file_path, JSON.pretty_generate(data))
378
+ rescue => e
379
+ Rails.logger.error("Failed to cache performance data: #{e.message}")
380
+ end
381
+
382
+ def self.update_summary_json(performance_data)
383
+ summary_file_path = solidstats_cache_path(SUMMARY_FILE)
384
+
385
+ # Read existing summary or create new one
386
+ begin
387
+ existing_summary = File.exist?(summary_file_path) ? JSON.parse(File.read(summary_file_path)) : {}
388
+ rescue JSON::ParserError
389
+ existing_summary = {}
390
+ end
391
+
392
+ summary = performance_data[:summary]
393
+
394
+ # Create badges based on performance metrics
395
+ badges = []
396
+ badges << { "text" => "#{summary[:total_requests]} Requests", "color" => "info" }
397
+
398
+ case summary[:status]
399
+ when 'error'
400
+ badges << { "text" => "High Errors", "color" => "error" }
401
+ when 'warning'
402
+ badges << { "text" => "Slow Responses", "color" => "warning" }
403
+ else
404
+ badges << { "text" => "Healthy", "color" => "success" }
405
+ end
406
+
407
+ if summary[:avg_response_time] > 0
408
+ badges << { "text" => "#{summary[:avg_response_time]}ms avg", "color" => "neutral" }
409
+ end
410
+
411
+ # Update the LoadLens monitoring entry
412
+ existing_summary["LoadLens"] = {
413
+ "icon" => "activity",
414
+ "status" => summary[:status],
415
+ "value" => generate_performance_message(summary),
416
+ "last_updated" => summary[:last_updated],
417
+ "url" => "/solidstats/performance/load_lens",
418
+ "badges" => badges
419
+ }
420
+
421
+ # Write updated summary
422
+ File.write(summary_file_path, JSON.pretty_generate(existing_summary))
423
+ rescue => e
424
+ Rails.logger.error("Failed to update summary.json: #{e.message}")
425
+ end
426
+
427
+ def self.generate_performance_message(summary)
428
+ if summary[:total_requests] == 0
429
+ "No requests tracked"
430
+ elsif summary[:status] == 'error'
431
+ "#{summary[:error_rate]}% error rate"
432
+ elsif summary[:status] == 'warning'
433
+ "#{summary[:avg_response_time]}ms avg"
434
+ else
435
+ "#{summary[:avg_response_time]}ms avg"
436
+ end
437
+ end
438
+
439
+ def self.cache_fresh?(cache_file_path, max_age = 5.minutes)
440
+ File.mtime(cache_file_path) > max_age.ago
441
+ rescue
442
+ false
443
+ end
444
+
445
+ def self.solidstats_cache_path(filename)
446
+ Rails.root.join('solidstats', filename)
447
+ end
448
+
449
+ def self.ensure_solidstats_directory
450
+ dir_path = File.dirname(solidstats_cache_path('dummy'))
451
+ FileUtils.mkdir_p(dir_path) unless Dir.exist?(dir_path)
452
+ end
453
+ end
454
+ end