solid_log-ui 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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +295 -0
  4. data/Rakefile +12 -0
  5. data/app/assets/javascripts/application.js +6 -0
  6. data/app/assets/javascripts/solid_log/checkbox_dropdown.js +171 -0
  7. data/app/assets/javascripts/solid_log/filter_state.js +138 -0
  8. data/app/assets/javascripts/solid_log/jump_to_live.js +119 -0
  9. data/app/assets/javascripts/solid_log/live_tail.js +476 -0
  10. data/app/assets/javascripts/solid_log/live_tail.js.bak +270 -0
  11. data/app/assets/javascripts/solid_log/log_filters.js +37 -0
  12. data/app/assets/javascripts/solid_log/stream_scroll.js +195 -0
  13. data/app/assets/javascripts/solid_log/timeline_histogram.js +162 -0
  14. data/app/assets/javascripts/solid_log/toast.js +50 -0
  15. data/app/assets/stylesheets/solid_log/application.css +1329 -0
  16. data/app/assets/stylesheets/solid_log/components.css +1506 -0
  17. data/app/assets/stylesheets/solid_log/mission_control.css +398 -0
  18. data/app/channels/solid_log/ui/application_cable/channel.rb +8 -0
  19. data/app/channels/solid_log/ui/application_cable/connection.rb +10 -0
  20. data/app/channels/solid_log/ui/log_stream_channel.rb +132 -0
  21. data/app/controllers/solid_log/ui/base_controller.rb +122 -0
  22. data/app/controllers/solid_log/ui/dashboard_controller.rb +32 -0
  23. data/app/controllers/solid_log/ui/entries_controller.rb +34 -0
  24. data/app/controllers/solid_log/ui/fields_controller.rb +57 -0
  25. data/app/controllers/solid_log/ui/streams_controller.rb +204 -0
  26. data/app/controllers/solid_log/ui/timelines_controller.rb +29 -0
  27. data/app/controllers/solid_log/ui/tokens_controller.rb +46 -0
  28. data/app/helpers/solid_log/ui/application_helper.rb +99 -0
  29. data/app/helpers/solid_log/ui/dashboard_helper.rb +46 -0
  30. data/app/helpers/solid_log/ui/entries_helper.rb +16 -0
  31. data/app/helpers/solid_log/ui/timeline_helper.rb +39 -0
  32. data/app/services/solid_log/ui/live_tail_broadcaster.rb +81 -0
  33. data/app/views/layouts/solid_log/ui/application.html.erb +53 -0
  34. data/app/views/solid_log/ui/dashboard/index.html.erb +178 -0
  35. data/app/views/solid_log/ui/entries/show.html.erb +132 -0
  36. data/app/views/solid_log/ui/fields/index.html.erb +133 -0
  37. data/app/views/solid_log/ui/shared/_checkbox_dropdown.html.erb +64 -0
  38. data/app/views/solid_log/ui/shared/_multiselect_filter.html.erb +37 -0
  39. data/app/views/solid_log/ui/shared/_toast.html.erb +7 -0
  40. data/app/views/solid_log/ui/shared/_toast_message.html.erb +30 -0
  41. data/app/views/solid_log/ui/streams/_filter_form.html.erb +207 -0
  42. data/app/views/solid_log/ui/streams/_footer.html.erb +37 -0
  43. data/app/views/solid_log/ui/streams/_log_entries.html.erb +5 -0
  44. data/app/views/solid_log/ui/streams/_log_row.html.erb +5 -0
  45. data/app/views/solid_log/ui/streams/_log_row_compact.html.erb +37 -0
  46. data/app/views/solid_log/ui/streams/_log_row_expanded.html.erb +67 -0
  47. data/app/views/solid_log/ui/streams/_log_stream_content.html.erb +8 -0
  48. data/app/views/solid_log/ui/streams/_timeline.html.erb +68 -0
  49. data/app/views/solid_log/ui/streams/index.html.erb +22 -0
  50. data/app/views/solid_log/ui/timelines/show_job.html.erb +78 -0
  51. data/app/views/solid_log/ui/timelines/show_request.html.erb +88 -0
  52. data/app/views/solid_log/ui/tokens/index.html.erb +95 -0
  53. data/app/views/solid_log/ui/tokens/new.html.erb +47 -0
  54. data/config/importmap.rb +15 -0
  55. data/config/routes.rb +27 -0
  56. data/lib/solid_log/ui/api_client.rb +117 -0
  57. data/lib/solid_log/ui/configuration.rb +99 -0
  58. data/lib/solid_log/ui/data_source.rb +146 -0
  59. data/lib/solid_log/ui/engine.rb +76 -0
  60. data/lib/solid_log/ui/version.rb +5 -0
  61. data/lib/solid_log/ui.rb +27 -0
  62. data/lib/solid_log-ui.rb +2 -0
  63. metadata +290 -0
@@ -0,0 +1,34 @@
1
+ module SolidLog
2
+ module UI
3
+ class EntriesController < BaseController
4
+ def index
5
+ # Redirect to streams for better UX
6
+ redirect_to streams_path
7
+ end
8
+
9
+ def show
10
+ @entry = SolidLog.without_logging do
11
+ Entry.find(params[:id])
12
+ end
13
+
14
+ @correlated_entries = find_correlated_entries if @entry.correlated?
15
+ end
16
+
17
+ private
18
+
19
+ def find_correlated_entries
20
+ SolidLog.without_logging do
21
+ scope = Entry.where.not(id: @entry.id)
22
+
23
+ if @entry.request_id.present?
24
+ scope = scope.by_request_id(@entry.request_id)
25
+ elsif @entry.job_id.present?
26
+ scope = scope.by_job_id(@entry.job_id)
27
+ end
28
+
29
+ scope.recent.limit(50)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,57 @@
1
+ module SolidLog
2
+ module UI
3
+ class FieldsController < BaseController
4
+ def index
5
+ @fields = SolidLog.without_logging do
6
+ Field.order(usage_count: :desc)
7
+ end
8
+
9
+ @hot_fields = @fields.select { |f| f.usage_count >= 1000 }
10
+ @total_fields = @fields.size
11
+ end
12
+
13
+ def promote
14
+ @field = Field.find(params[:id])
15
+
16
+ SolidLog.without_logging do
17
+ @field.promote!
18
+ end
19
+
20
+ redirect_to fields_path, notice: "Field '#{@field.name}' marked as promoted"
21
+ end
22
+
23
+ def demote
24
+ @field = Field.find(params[:id])
25
+
26
+ SolidLog.without_logging do
27
+ @field.demote!
28
+ end
29
+
30
+ redirect_to fields_path, notice: "Field '#{@field.name}' marked as unpromoted"
31
+ end
32
+
33
+ def update_filter_type
34
+ @field = Field.find(params[:id])
35
+
36
+ SolidLog.without_logging do
37
+ if @field.update(filter_type: params[:field][:filter_type])
38
+ redirect_to fields_path, notice: "Filter type for '#{@field.name}' updated to #{@field.filter_type}"
39
+ else
40
+ redirect_to fields_path, alert: "Failed to update filter type: #{@field.errors.full_messages.join(', ')}"
41
+ end
42
+ end
43
+ end
44
+
45
+ def destroy
46
+ @field = Field.find(params[:id])
47
+ field_name = @field.name
48
+
49
+ SolidLog.without_logging do
50
+ @field.destroy
51
+ end
52
+
53
+ redirect_to fields_path, notice: "Field '#{field_name}' removed from registry"
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,204 @@
1
+ module SolidLog
2
+ module UI
3
+ class StreamsController < BaseController
4
+ def index
5
+ # Build search params with pagination
6
+ search_params = (params[:filters] || {}).merge(
7
+ before_id: params[:before_id],
8
+ after_id: params[:after_id],
9
+ limit: params[:limit] || 200
10
+ )
11
+
12
+ @search_service = SolidLog::Core::SearchService.new(
13
+ search_params,
14
+ facet_cache_ttl: SolidLog::UI.configuration.facet_cache_ttl
15
+ )
16
+
17
+ @entries = SolidLog.without_logging do
18
+ @search_service.search
19
+ end
20
+
21
+ @available_filters = SolidLog.without_logging do
22
+ @search_service.available_facets
23
+ end
24
+
25
+ @current_filters = current_filters
26
+
27
+ # Generate timeline data for visualization (skip for pagination requests unless timeline_only)
28
+ @timeline_data = if (params[:after_id].present? || params[:before_id].present?) && !params[:timeline_only].present?
29
+ { buckets: [] }
30
+ else
31
+ SolidLog.without_logging do
32
+ generate_timeline_data
33
+ end
34
+ end
35
+
36
+ respond_to do |format|
37
+ format.html
38
+ format.turbo_stream do
39
+ if params[:timeline_only].present?
40
+ # Timeline update during live tail
41
+ render turbo_stream: [
42
+ turbo_stream.replace("timeline-container", partial: "timeline", locals: { timeline_data: @timeline_data, current_filters: @current_filters })
43
+ ]
44
+ elsif params[:before_id].present?
45
+ # Infinite scroll up - prepend older entries
46
+ # Entries from query in DESC, partial reverses to ASC for prepending
47
+ if @entries.any?
48
+ render turbo_stream: [
49
+ turbo_stream.prepend("log-stream-content", partial: "log_entries", locals: { entries: @entries, query: @current_filters[:query] })
50
+ ]
51
+ else
52
+ head :no_content
53
+ end
54
+ elsif params[:after_id].present?
55
+ # Live tail update - append new entries
56
+ # Entries from query in DESC, partial reverses to ASC for appending
57
+ if @entries.any?
58
+ render turbo_stream: [
59
+ turbo_stream.append("log-stream-content", partial: "log_entries", locals: { entries: @entries, query: @current_filters[:query] })
60
+ ]
61
+ else
62
+ head :no_content
63
+ end
64
+ else
65
+ # Full refresh (Jump to Live or filter change)
66
+ render turbo_stream: [
67
+ turbo_stream.replace("log-stream-content", partial: "log_stream_content", locals: { entries: @entries, query: @current_filters[:query] }),
68
+ turbo_stream.replace("timeline-container", partial: "timeline", locals: { timeline_data: @timeline_data, current_filters: @current_filters }),
69
+ turbo_stream.append("toast-container", partial: "solid_log/ui/shared/toast_message", locals: { message: "Jumped to live - showing latest logs", type: "success" })
70
+ ]
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def current_filters
79
+ filter_params = params.fetch(:filters, {})
80
+ filters = {
81
+ query: filter_params[:query],
82
+ levels: Array(filter_params[:levels]).reject(&:blank?),
83
+ app: Array(filter_params[:app]).flatten.reject(&:blank?),
84
+ env: Array(filter_params[:env]).flatten.reject(&:blank?),
85
+ controller: Array(filter_params[:controller]).flatten.reject(&:blank?),
86
+ action: Array(filter_params[:action]).flatten.reject(&:blank?),
87
+ path: Array(filter_params[:path]).flatten.reject(&:blank?),
88
+ method: Array(filter_params[:method]).flatten.reject(&:blank?),
89
+ status_code: Array(filter_params[:status_code]).flatten.reject(&:blank?),
90
+ min_duration: filter_params[:min_duration],
91
+ max_duration: filter_params[:max_duration],
92
+ start_time: filter_params[:start_time],
93
+ end_time: filter_params[:end_time],
94
+ request_id: filter_params[:request_id],
95
+ job_id: filter_params[:job_id]
96
+ }
97
+
98
+ # Add promoted field filters based on their filter type
99
+ Field.promoted.each do |field|
100
+ next unless Entry.column_names.include?(field.name)
101
+
102
+ case field.filter_type
103
+ when "multiselect"
104
+ filters[field.name.to_sym] = Array(filter_params[field.name.to_sym]).flatten.reject(&:blank?)
105
+ when "tokens"
106
+ # Keep as string for parsing in SearchService
107
+ filters[field.name.to_sym] = filter_params[field.name.to_sym]
108
+ when "range"
109
+ filters["min_#{field.name}".to_sym] = filter_params["min_#{field.name}".to_sym]
110
+ filters["max_#{field.name}".to_sym] = filter_params["max_#{field.name}".to_sym]
111
+ else
112
+ filters[field.name.to_sym] = filter_params[field.name.to_sym]
113
+ end
114
+ end
115
+
116
+ filters
117
+ end
118
+
119
+ def generate_timeline_data
120
+ # Get the time range for the currently displayed entries
121
+ return { buckets: [], start_time: nil, end_time: nil } if @entries.empty?
122
+
123
+ # Determine time range: use filter times if present, otherwise use entry times
124
+ # Note: entries are ordered by ID DESC (newest first when limited)
125
+ start_time = if @current_filters[:start_time].present?
126
+ Time.zone.parse(@current_filters[:start_time])
127
+ else
128
+ @entries.last.timestamp # oldest of displayed entries
129
+ end
130
+
131
+ end_time = if @current_filters[:end_time].present?
132
+ Time.zone.parse(@current_filters[:end_time])
133
+ else
134
+ @entries.first.timestamp # newest of displayed entries
135
+ end
136
+
137
+ # Calculate appropriate bucket size based on time range
138
+ time_range = end_time - start_time
139
+ bucket_count = 50 # Number of buckets to display
140
+
141
+ bucket_size = if time_range < 1.hour
142
+ 1.minute
143
+ elsif time_range < 1.day
144
+ 5.minutes
145
+ elsif time_range < 1.week
146
+ 1.hour
147
+ else
148
+ 6.hours
149
+ end
150
+
151
+ # Get all counts in a single query
152
+ base_scope = Entry.all.yield_self { |scope| apply_current_filters_to_scope(scope) }
153
+ start_epoch = start_time.to_i
154
+ epoch_sql = SolidLog.adapter.timestamp_to_epoch_sql("timestamp")
155
+ bucket_calculation = "((#{epoch_sql} - #{start_epoch}) / #{bucket_size.to_i})"
156
+
157
+ # Single query with GROUP BY bucket
158
+ bucket_counts = base_scope
159
+ .where(timestamp: start_time..end_time)
160
+ .group(Arel.sql(bucket_calculation))
161
+ .order(Arel.sql(bucket_calculation))
162
+ .pluck(Arel.sql(bucket_calculation), Arel.sql("COUNT(*)"))
163
+ .to_h
164
+
165
+ # Generate buckets with counts from the hash
166
+ buckets = []
167
+ current_time = start_time
168
+ bucket_count.times do |bucket_num|
169
+ bucket_end = current_time + bucket_size
170
+
171
+ buckets << {
172
+ start_time: current_time,
173
+ end_time: bucket_end,
174
+ count: bucket_counts[bucket_num] || 0
175
+ }
176
+
177
+ current_time = bucket_end
178
+ end
179
+
180
+ {
181
+ buckets: buckets,
182
+ start_time: start_time,
183
+ end_time: end_time,
184
+ bucket_size: bucket_size
185
+ }
186
+ end
187
+
188
+ def apply_current_filters_to_scope(scope)
189
+ # Apply the same filters as SearchService to get accurate counts
190
+ scope = scope.where(level: @current_filters[:levels]) if @current_filters[:levels].any?
191
+ scope = scope.by_app(@current_filters[:app]) if @current_filters[:app].any?
192
+ scope = scope.by_env(@current_filters[:env]) if @current_filters[:env].any?
193
+ scope = scope.by_controller(@current_filters[:controller]) if @current_filters[:controller].any?
194
+ scope = scope.by_action(@current_filters[:action]) if @current_filters[:action].any?
195
+ scope = scope.by_path(@current_filters[:path]) if @current_filters[:path].any?
196
+ scope = scope.by_method(@current_filters[:method]) if @current_filters[:method].any?
197
+ scope = scope.by_status_code(@current_filters[:status_code]) if @current_filters[:status_code].any?
198
+ scope = scope.by_request_id(@current_filters[:request_id]) if @current_filters[:request_id].present?
199
+ scope = scope.by_job_id(@current_filters[:job_id]) if @current_filters[:job_id].present?
200
+ scope
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,29 @@
1
+ module SolidLog
2
+ module UI
3
+ class TimelinesController < BaseController
4
+ def show_request
5
+ @request_id = params[:request_id]
6
+
7
+ @entries = SolidLog.without_logging do
8
+ Entry.by_request_id(@request_id).recent
9
+ end
10
+
11
+ if @entries.empty?
12
+ redirect_to streams_path, alert: "No entries found for request ID: #{@request_id}"
13
+ end
14
+ end
15
+
16
+ def show_job
17
+ @job_id = params[:job_id]
18
+
19
+ @entries = SolidLog.without_logging do
20
+ Entry.by_job_id(@job_id).recent
21
+ end
22
+
23
+ if @entries.empty?
24
+ redirect_to streams_path, alert: "No entries found for job ID: #{@job_id}"
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,46 @@
1
+ module SolidLog
2
+ module UI
3
+ class TokensController < BaseController
4
+ def index
5
+ @tokens = SolidLog.without_logging do
6
+ Token.order(created_at: :desc)
7
+ end
8
+ end
9
+
10
+ def new
11
+ @token = Token.new
12
+ end
13
+
14
+ def create
15
+ @token = SolidLog.without_logging do
16
+ Token.generate!(token_params[:name])
17
+ end
18
+
19
+ # Store plaintext token in flash for one-time display
20
+ flash[:token_plaintext] = @token[:token]
21
+ redirect_to tokens_path, notice: "Token created successfully"
22
+ rescue ActiveRecord::RecordInvalid => e
23
+ @token = Token.new(token_params)
24
+ flash.now[:alert] = "Failed to create token: #{e.message}"
25
+ render :new, status: :unprocessable_entity
26
+ end
27
+
28
+ def destroy
29
+ @token = Token.find(params[:id])
30
+ token_name = @token.name
31
+
32
+ SolidLog.without_logging do
33
+ @token.destroy
34
+ end
35
+
36
+ redirect_to tokens_path, notice: "Token '#{token_name}' revoked"
37
+ end
38
+
39
+ private
40
+
41
+ def token_params
42
+ params.require(:token).permit(:name)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,99 @@
1
+ module SolidLog
2
+ module UI
3
+ module ApplicationHelper
4
+ # Navigation helpers
5
+ def nav_link_class(path)
6
+ base_class = "nav-link"
7
+ current_path = request.path
8
+
9
+ # Check if we're on this path or a sub-path
10
+ active = if path == root_path
11
+ current_path == path || current_path == dashboard_path
12
+ else
13
+ current_path.start_with?(path)
14
+ end
15
+
16
+ active ? "#{base_class} active" : base_class
17
+ end
18
+
19
+ # Log display helpers (shared across views)
20
+ def level_badge(level)
21
+ badge_class = case level.to_s.downcase
22
+ when "debug"
23
+ "badge-gray"
24
+ when "info"
25
+ "badge-blue"
26
+ when "warn"
27
+ "badge-yellow"
28
+ when "error"
29
+ "badge-red"
30
+ when "fatal"
31
+ "badge-dark-red"
32
+ else
33
+ "badge-secondary"
34
+ end
35
+
36
+ content_tag(:span, level.upcase, class: "badge #{badge_class}")
37
+ end
38
+
39
+ def http_status_badge(status_code)
40
+ return "" if status_code.blank?
41
+
42
+ badge_class = case status_code
43
+ when 200..299
44
+ "badge-success"
45
+ when 300..399
46
+ "badge-info"
47
+ when 400..499
48
+ "badge-warning"
49
+ when 500..599
50
+ "badge-danger"
51
+ else
52
+ "badge-secondary"
53
+ end
54
+
55
+ content_tag(:span, status_code, class: "badge #{badge_class}")
56
+ end
57
+
58
+ def format_duration(duration_ms)
59
+ return "" if duration_ms.blank?
60
+
61
+ if duration_ms < 1000
62
+ format("%.1fms", duration_ms)
63
+ else
64
+ format("%.1fs", duration_ms / 1000.0)
65
+ end
66
+ end
67
+
68
+ def truncate_message(message, length: 200)
69
+ return "" if message.blank?
70
+
71
+ truncate(message, length: length, separator: " ")
72
+ end
73
+
74
+ def highlight_search_term(text, query)
75
+ return text if query.blank? || text.blank?
76
+
77
+ highlight(text, query, highlighter: '<mark>\1</mark>')
78
+ end
79
+
80
+ def correlation_link(entry)
81
+ links = []
82
+
83
+ if entry.request_id.present?
84
+ links << link_to("Request: #{entry.request_id[0..7]}",
85
+ request_timeline_path(entry.request_id),
86
+ class: "correlation-link")
87
+ end
88
+
89
+ if entry.job_id.present?
90
+ links << link_to("Job: #{entry.job_id[0..7]}",
91
+ job_timeline_path(entry.job_id),
92
+ class: "correlation-link")
93
+ end
94
+
95
+ safe_join(links, " ")
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,46 @@
1
+ module SolidLog
2
+ module UI
3
+ module DashboardHelper
4
+ def format_count(count)
5
+ number_with_delimiter(count || 0)
6
+ end
7
+
8
+ def format_percentage(numerator, denominator)
9
+ return "0%" if denominator.nil? || denominator.zero?
10
+
11
+ percentage = (numerator.to_f / denominator * 100).round(1)
12
+ "#{percentage}%"
13
+ end
14
+
15
+ def trend_indicator(current, previous)
16
+ return "" if previous.nil? || previous.zero?
17
+
18
+ change = ((current - previous).to_f / previous * 100).round(1)
19
+
20
+ if change > 0
21
+ content_tag(:span, "+#{change}%", class: "trend-up")
22
+ elsif change < 0
23
+ content_tag(:span, "#{change}%", class: "trend-down")
24
+ else
25
+ content_tag(:span, "0%", class: "trend-neutral")
26
+ end
27
+ end
28
+
29
+ def time_ago_or_never(time)
30
+ time ? time_ago_in_words(time) + " ago" : "Never"
31
+ end
32
+
33
+ def health_status_badge(unparsed_count)
34
+ if unparsed_count == 0
35
+ content_tag(:span, "Healthy", class: "badge badge-success")
36
+ elsif unparsed_count < 100
37
+ content_tag(:span, "OK", class: "badge badge-info")
38
+ elsif unparsed_count < 1000
39
+ content_tag(:span, "Warning", class: "badge badge-warning")
40
+ else
41
+ content_tag(:span, "Backlog", class: "badge badge-danger")
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,16 @@
1
+ module SolidLog
2
+ module UI
3
+ module EntriesHelper
4
+ # Entry-specific helpers (not shared across other controllers)
5
+
6
+ def pretty_json(json_string)
7
+ return "" if json_string.blank?
8
+
9
+ hash = JSON.parse(json_string)
10
+ JSON.pretty_generate(hash)
11
+ rescue JSON::ParserError
12
+ json_string
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,39 @@
1
+ module SolidLog
2
+ module UI
3
+ module TimelineHelper
4
+ def timeline_duration_bar(duration, max_duration)
5
+ return "" if duration.nil? || max_duration.nil? || max_duration.zero?
6
+
7
+ width_percentage = [(duration.to_f / max_duration * 100), 100].min
8
+ content_tag(:div, "", class: "timeline-duration-bar", style: "width: #{format('%.1f', width_percentage)}%")
9
+ end
10
+
11
+ def format_timeline_duration(duration_ms)
12
+ return "N/A" if duration_ms.nil?
13
+
14
+ if duration_ms < 1
15
+ "< 1ms"
16
+ elsif duration_ms < 1000
17
+ format("%.1fms", duration_ms)
18
+ else
19
+ format("%.1fs", duration_ms / 1000.0)
20
+ end
21
+ end
22
+
23
+ def timeline_event_icon(entry)
24
+ case entry.level
25
+ when "error", "fatal"
26
+ "⚠️"
27
+ when "warn"
28
+ "⚡"
29
+ when "info"
30
+ "ℹ️"
31
+ when "debug"
32
+ "🔍"
33
+ else
34
+ "•"
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,81 @@
1
+ module SolidLog
2
+ module UI
3
+ class LiveTailBroadcaster
4
+ # Broadcast new entries to live tail subscribers
5
+ # Called after entries are inserted by the parser
6
+ def self.broadcast_entries(entry_ids)
7
+ return unless SolidLog::UI.configuration.websocket_enabled
8
+ return unless defined?(ActionCable)
9
+ return if entry_ids.empty?
10
+
11
+ entries = SolidLog::Entry.where(id: entry_ids).order(:id)
12
+
13
+ # Get all unique filter combinations currently subscribed
14
+ # We'll broadcast each entry to all matching filter streams
15
+ active_filters = get_active_filter_streams
16
+
17
+ entries.each do |entry|
18
+ # Render the entry HTML once
19
+ html = ApplicationController.render(
20
+ partial: "solid_log/ui/streams/log_row",
21
+ locals: { entry: entry, query: nil }
22
+ )
23
+
24
+ # Broadcast to each filter stream where this entry matches
25
+ active_filters.each do |filter_key, filters|
26
+ if entry_matches_filters?(entry, filters)
27
+ stream_name = "solid_log_stream_#{filter_key}"
28
+ ActionCable.server.broadcast(
29
+ stream_name,
30
+ {
31
+ html: html,
32
+ entry_id: entry.id
33
+ }
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def self.get_active_filter_streams
43
+ # Get all currently active filter combinations from Rails.cache
44
+ # This works across all processes and survives restarts (within cache TTL)
45
+ if defined?(SolidLog::UI::LogStreamChannel)
46
+ SolidLog::UI::LogStreamChannel.active_filter_combinations
47
+ else
48
+ # Fallback: just check empty filters
49
+ { generate_filter_key({}) => {} }
50
+ end
51
+ end
52
+
53
+ def self.entry_matches_filters?(entry, filters)
54
+ return true if filters.empty?
55
+
56
+ # Check each filter
57
+ filters.each do |key, values|
58
+ values = Array(values).reject(&:blank?)
59
+ next if values.empty?
60
+
61
+ entry_value = entry.public_send(key) rescue nil
62
+
63
+ # If entry doesn't have this field and filter requires it, no match
64
+ return false if entry_value.nil?
65
+
66
+ # Check if entry value matches any of the filter values
67
+ unless values.map(&:to_s).include?(entry_value.to_s)
68
+ return false
69
+ end
70
+ end
71
+
72
+ true
73
+ end
74
+
75
+ def self.generate_filter_key(filters)
76
+ normalized = filters.sort.to_h
77
+ Digest::MD5.hexdigest(normalized.to_json)
78
+ end
79
+ end
80
+ end
81
+ end