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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +295 -0
- data/Rakefile +12 -0
- data/app/assets/javascripts/application.js +6 -0
- data/app/assets/javascripts/solid_log/checkbox_dropdown.js +171 -0
- data/app/assets/javascripts/solid_log/filter_state.js +138 -0
- data/app/assets/javascripts/solid_log/jump_to_live.js +119 -0
- data/app/assets/javascripts/solid_log/live_tail.js +476 -0
- data/app/assets/javascripts/solid_log/live_tail.js.bak +270 -0
- data/app/assets/javascripts/solid_log/log_filters.js +37 -0
- data/app/assets/javascripts/solid_log/stream_scroll.js +195 -0
- data/app/assets/javascripts/solid_log/timeline_histogram.js +162 -0
- data/app/assets/javascripts/solid_log/toast.js +50 -0
- data/app/assets/stylesheets/solid_log/application.css +1329 -0
- data/app/assets/stylesheets/solid_log/components.css +1506 -0
- data/app/assets/stylesheets/solid_log/mission_control.css +398 -0
- data/app/channels/solid_log/ui/application_cable/channel.rb +8 -0
- data/app/channels/solid_log/ui/application_cable/connection.rb +10 -0
- data/app/channels/solid_log/ui/log_stream_channel.rb +132 -0
- data/app/controllers/solid_log/ui/base_controller.rb +122 -0
- data/app/controllers/solid_log/ui/dashboard_controller.rb +32 -0
- data/app/controllers/solid_log/ui/entries_controller.rb +34 -0
- data/app/controllers/solid_log/ui/fields_controller.rb +57 -0
- data/app/controllers/solid_log/ui/streams_controller.rb +204 -0
- data/app/controllers/solid_log/ui/timelines_controller.rb +29 -0
- data/app/controllers/solid_log/ui/tokens_controller.rb +46 -0
- data/app/helpers/solid_log/ui/application_helper.rb +99 -0
- data/app/helpers/solid_log/ui/dashboard_helper.rb +46 -0
- data/app/helpers/solid_log/ui/entries_helper.rb +16 -0
- data/app/helpers/solid_log/ui/timeline_helper.rb +39 -0
- data/app/services/solid_log/ui/live_tail_broadcaster.rb +81 -0
- data/app/views/layouts/solid_log/ui/application.html.erb +53 -0
- data/app/views/solid_log/ui/dashboard/index.html.erb +178 -0
- data/app/views/solid_log/ui/entries/show.html.erb +132 -0
- data/app/views/solid_log/ui/fields/index.html.erb +133 -0
- data/app/views/solid_log/ui/shared/_checkbox_dropdown.html.erb +64 -0
- data/app/views/solid_log/ui/shared/_multiselect_filter.html.erb +37 -0
- data/app/views/solid_log/ui/shared/_toast.html.erb +7 -0
- data/app/views/solid_log/ui/shared/_toast_message.html.erb +30 -0
- data/app/views/solid_log/ui/streams/_filter_form.html.erb +207 -0
- data/app/views/solid_log/ui/streams/_footer.html.erb +37 -0
- data/app/views/solid_log/ui/streams/_log_entries.html.erb +5 -0
- data/app/views/solid_log/ui/streams/_log_row.html.erb +5 -0
- data/app/views/solid_log/ui/streams/_log_row_compact.html.erb +37 -0
- data/app/views/solid_log/ui/streams/_log_row_expanded.html.erb +67 -0
- data/app/views/solid_log/ui/streams/_log_stream_content.html.erb +8 -0
- data/app/views/solid_log/ui/streams/_timeline.html.erb +68 -0
- data/app/views/solid_log/ui/streams/index.html.erb +22 -0
- data/app/views/solid_log/ui/timelines/show_job.html.erb +78 -0
- data/app/views/solid_log/ui/timelines/show_request.html.erb +88 -0
- data/app/views/solid_log/ui/tokens/index.html.erb +95 -0
- data/app/views/solid_log/ui/tokens/new.html.erb +47 -0
- data/config/importmap.rb +15 -0
- data/config/routes.rb +27 -0
- data/lib/solid_log/ui/api_client.rb +117 -0
- data/lib/solid_log/ui/configuration.rb +99 -0
- data/lib/solid_log/ui/data_source.rb +146 -0
- data/lib/solid_log/ui/engine.rb +76 -0
- data/lib/solid_log/ui/version.rb +5 -0
- data/lib/solid_log/ui.rb +27 -0
- data/lib/solid_log-ui.rb +2 -0
- 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
|