sage-rails 0.0.3
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 +202 -0
- data/app/assets/images/chevron-down-zinc-500.svg +1 -0
- data/app/assets/images/chevron-right.svg +1 -0
- data/app/assets/images/loading.svg +4 -0
- data/app/assets/images/sage/chevron-down-zinc-500.svg +1 -0
- data/app/assets/images/sage/chevron-right.svg +1 -0
- data/app/assets/images/sage/loading.svg +4 -0
- data/app/assets/javascripts/sage/application.js +18 -0
- data/app/assets/stylesheets/sage/application.css +308 -0
- data/app/controllers/sage/actions_controller.rb +5 -0
- data/app/controllers/sage/application_controller.rb +4 -0
- data/app/controllers/sage/base_controller.rb +10 -0
- data/app/controllers/sage/checks_controller.rb +65 -0
- data/app/controllers/sage/dashboards_controller.rb +130 -0
- data/app/controllers/sage/queries/messages_controller.rb +62 -0
- data/app/controllers/sage/queries_controller.rb +596 -0
- data/app/helpers/sage/application_helper.rb +30 -0
- data/app/helpers/sage/queries_helper.rb +23 -0
- data/app/javascript/controllers/element_removal_controller.js +7 -0
- data/app/javascript/sage/controllers/clipboard_controller.js +26 -0
- data/app/javascript/sage/controllers/dashboard_controller.js +132 -0
- data/app/javascript/sage/controllers/reverse_infinite_scroll_controller.js +146 -0
- data/app/javascript/sage/controllers/search_controller.js +47 -0
- data/app/javascript/sage/controllers/select_controller.js +215 -0
- data/app/javascript/sage.js +19 -0
- data/app/jobs/sage/application_job.rb +4 -0
- data/app/jobs/sage/process_report_job.rb +80 -0
- data/app/mailers/sage/application_mailer.rb +6 -0
- data/app/models/sage/application_record.rb +5 -0
- data/app/models/sage/message.rb +8 -0
- data/app/schemas/sage/report_response_schema.rb +8 -0
- data/app/views/layouts/application.html.erb +34 -0
- data/app/views/layouts/sage/application.html.erb +94 -0
- data/app/views/sage/checks/_form.html.erb +81 -0
- data/app/views/sage/checks/_search.html.erb +8 -0
- data/app/views/sage/checks/edit.html.erb +10 -0
- data/app/views/sage/checks/index.html.erb +58 -0
- data/app/views/sage/checks/new.html.erb +8 -0
- data/app/views/sage/dashboards/_form.html.erb +50 -0
- data/app/views/sage/dashboards/_search.html.erb +8 -0
- data/app/views/sage/dashboards/index.html.erb +58 -0
- data/app/views/sage/dashboards/new.html.erb +8 -0
- data/app/views/sage/dashboards/show.html.erb +58 -0
- data/app/views/sage/messages/_form.html.erb +14 -0
- data/app/views/sage/queries/_caching.html.erb +17 -0
- data/app/views/sage/queries/_form.html.erb +72 -0
- data/app/views/sage/queries/_input.html.erb +17 -0
- data/app/views/sage/queries/_message.html.erb +25 -0
- data/app/views/sage/queries/_message.turbo_stream.erb +10 -0
- data/app/views/sage/queries/_new_form.html.erb +43 -0
- data/app/views/sage/queries/_run.html.erb +232 -0
- data/app/views/sage/queries/_search.html.erb +8 -0
- data/app/views/sage/queries/_statement_box.html.erb +241 -0
- data/app/views/sage/queries/_streaming_message.html.erb +14 -0
- data/app/views/sage/queries/create.turbo_stream.erb +114 -0
- data/app/views/sage/queries/edit.html.erb +48 -0
- data/app/views/sage/queries/index.html.erb +59 -0
- data/app/views/sage/queries/messages/create.turbo_stream.erb +22 -0
- data/app/views/sage/queries/messages/index.html.erb +44 -0
- data/app/views/sage/queries/messages/index.turbo_stream.erb +15 -0
- data/app/views/sage/queries/new.html.erb +195 -0
- data/app/views/sage/queries/run.html.erb +1 -0
- data/app/views/sage/queries/run.turbo_stream.erb +3 -0
- data/app/views/sage/queries/show.html.erb +49 -0
- data/app/views/sage/queries/table_schema.html.erb +77 -0
- data/app/views/sage/shared/_navigation.html.erb +26 -0
- data/app/views/sage/shared/_overlay.html.erb +11 -0
- data/config/importmap.rb +11 -0
- data/config/initializers/pagy.rb +2 -0
- data/config/initializers/ransack.rb +152 -0
- data/config/routes.rb +31 -0
- data/lib/generators/sage/USAGE +13 -0
- data/lib/generators/sage/install/install_generator.rb +128 -0
- data/lib/generators/sage/install/templates/sage.rb +22 -0
- data/lib/sage/database_schema_context.rb +56 -0
- data/lib/sage/engine.rb +260 -0
- data/lib/sage/model_scopes_context.rb +185 -0
- data/lib/sage/report_processor.rb +263 -0
- data/lib/sage/version.rb +3 -0
- data/lib/sage.rb +25 -0
- data/lib/tasks/sage_tasks.rake +4 -0
- metadata +245 -0
@@ -0,0 +1,43 @@
|
|
1
|
+
<% if @query.errors.any? %>
|
2
|
+
<article class="border red left-round">
|
3
|
+
<i>error</i>
|
4
|
+
<div>
|
5
|
+
<h6>Error</h6>
|
6
|
+
<p><%= @query.errors.full_messages.first %></p>
|
7
|
+
</div>
|
8
|
+
</article>
|
9
|
+
<% end %>
|
10
|
+
|
11
|
+
<% @variable_params = @query.persisted? ? variable_params(@query) : nested_variable_params(@query) %>
|
12
|
+
|
13
|
+
<%= form_for @query, url: (@query.persisted? ? query_path(@query, params: @variable_params) : queries_path(params: @variable_params)), html: {autocomplete: "off", class: ""} do |f| %>
|
14
|
+
<div class="grid">
|
15
|
+
<div class="s10">
|
16
|
+
<div class="field label border">
|
17
|
+
<%= f.text_field :name %>
|
18
|
+
<%= f.label :name, class: (@query.name.present? ? "active" : "") %>
|
19
|
+
</div>
|
20
|
+
|
21
|
+
<div class="field textarea label border">
|
22
|
+
<%= f.text_area :description, style: "height: 80px; resize: vertical;" %>
|
23
|
+
<%= f.label :description, "Description (Optional)", class: (@query.description.present? ? "active" : "") %>
|
24
|
+
</div>
|
25
|
+
|
26
|
+
<%= f.hidden_field :statement, id: 'query_statement_form' %>
|
27
|
+
</div>
|
28
|
+
|
29
|
+
<div class="s2">
|
30
|
+
<nav style="display: flex; flex-direction: column; gap: 12px; padding-left: 8px;">
|
31
|
+
<% if @query.persisted? %>
|
32
|
+
<%= link_to query_path(@query), method: :delete, "data-confirm" => "Are you sure?", class: "button red", title: "Delete" do %>
|
33
|
+
<i>delete</i>
|
34
|
+
<% end %>
|
35
|
+
<% end %>
|
36
|
+
<button type="submit" name="commit" value="<%= @query.persisted? ? "Update" : "Create" %>" class="button primary" title="<%= @query.persisted? ? "Update" : "Create" %>">
|
37
|
+
<i><%= @query.persisted? ? "save" : "add" %></i>
|
38
|
+
</button>
|
39
|
+
</nav>
|
40
|
+
</div>
|
41
|
+
</div>
|
42
|
+
<% end %>
|
43
|
+
|
@@ -0,0 +1,232 @@
|
|
1
|
+
<%= turbo_frame_tag dom_id(@query, 'results') do %>
|
2
|
+
<% if @error %>
|
3
|
+
<div style="display: flex; justify-content: center; align-items: center; min-height: 400px; padding: 40px 20px;">
|
4
|
+
<div style="text-align: center; width: 100%; max-width: 90%;">
|
5
|
+
<div style="margin-bottom: 20px;">
|
6
|
+
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="opacity: 0.5;">
|
7
|
+
<circle cx="12" cy="12" r="10" stroke="#dc3545" stroke-width="2"/>
|
8
|
+
<path d="M12 8V12" stroke="#dc3545" stroke-width="2" stroke-linecap="round"/>
|
9
|
+
<circle cx="12" cy="16" r="1" fill="#dc3545"/>
|
10
|
+
</svg>
|
11
|
+
</div>
|
12
|
+
<h3 style="color: #dc3545; margin-bottom: 15px; font-weight: 500;">Query Error</h3>
|
13
|
+
<div style="background-color: #f8f9fa; border-left: 4px solid #dc3545; padding: 15px 20px; text-align: left; border-radius: 4px; margin-bottom: 20px; overflow-x: auto;">
|
14
|
+
<pre style="margin: 0; white-space: pre-wrap; word-wrap: break-word;"><code style="color: #495057; font-size: 14px; line-height: 1.5; font-family: 'Courier New', Courier, monospace;"><%= @error.first(1000) %></code></pre>
|
15
|
+
</div>
|
16
|
+
<p style="color: #6c757d; font-size: 14px; margin: 0;">
|
17
|
+
Please check your query syntax and try again.
|
18
|
+
</p>
|
19
|
+
</div>
|
20
|
+
</div>
|
21
|
+
<% elsif !@success %>
|
22
|
+
<% if @only_chart %>
|
23
|
+
<p class="text-muted">Select variables</p>
|
24
|
+
<% else %>
|
25
|
+
<div class="alert alert-info">Can't preview queries with variables</div>
|
26
|
+
<% end %>
|
27
|
+
<% elsif @cohort_analysis %>
|
28
|
+
<% if @cohort_error %>
|
29
|
+
<div class="alert alert-info"><%= @cohort_error %></div>
|
30
|
+
<% else %>
|
31
|
+
<%= render partial: "cohorts" %>
|
32
|
+
<% end %>
|
33
|
+
<% else %>
|
34
|
+
<% unless @only_chart %>
|
35
|
+
<%= render partial: "caching" %>
|
36
|
+
<p class="text-muted" style="margin-bottom: 10px;">
|
37
|
+
<% if @row_limit && @rows.size > @row_limit %>
|
38
|
+
First
|
39
|
+
<% @rows = @rows.first(@row_limit) %>
|
40
|
+
<% end %>
|
41
|
+
<%= pluralize(@rows.size, "row") %>
|
42
|
+
|
43
|
+
<% @checks.select(&:state).each do |check| %>
|
44
|
+
· <small class="check-state <%= check.state.parameterize.gsub("-", "_") %>"><%= link_to check.state.upcase, edit_check_path(check) %></small>
|
45
|
+
<% if check.try(:message) %>
|
46
|
+
· <%= check.message %>
|
47
|
+
<% end %>
|
48
|
+
<% end %>
|
49
|
+
|
50
|
+
<% if @query && @result.forecastable? && !params[:forecast] %>
|
51
|
+
·
|
52
|
+
<%= link_to "Forecast", query_path(@query, params: {forecast: "t"}.merge(variable_params(@query))) %>
|
53
|
+
<% end %>
|
54
|
+
</p>
|
55
|
+
<% end %>
|
56
|
+
<% if @forecast_error %>
|
57
|
+
<div class="alert alert-danger"><%= @forecast_error %></div>
|
58
|
+
<% end %>
|
59
|
+
<% if @cohort_error %>
|
60
|
+
<div class="alert alert-info"><%= @cohort_error %></div>
|
61
|
+
<% end %>
|
62
|
+
<% if @rows.any? %>
|
63
|
+
<% values = @rows.first %>
|
64
|
+
<% chart_id = SecureRandom.hex %>
|
65
|
+
<% column_types = @result.column_types %>
|
66
|
+
<% chart_type = @result.chart_type %>
|
67
|
+
<% chart_options = {id: chart_id, thousands: t("number.format.delimiter"), decimal: t("number.format.separator")} %>
|
68
|
+
<% if ["line", "line2"].include?(chart_type) %>
|
69
|
+
<% chart_options.merge!(min: nil) %>
|
70
|
+
<% end %>
|
71
|
+
<% if chart_type == "scatter" %>
|
72
|
+
<% chart_options.merge!(library: {tooltips: {intersect: false}}) %>
|
73
|
+
<% elsif ["bar", "bar2"].include?(chart_type) %>
|
74
|
+
<% chart_options.merge!(library: {tooltips: {intersect: false, axis: 'x'}}) %>
|
75
|
+
<% elsif chart_type != "pie" %>
|
76
|
+
<% if column_types.size == 2 || @forecast %>
|
77
|
+
<% chart_options.merge!(library: {tooltips: {intersect: false, axis: 'x'}}) %>
|
78
|
+
<% else %>
|
79
|
+
<%# chartjs axis: 'x' has poor behavior with multiple series %>
|
80
|
+
<% chart_options.merge!(library: {tooltips: {intersect: false}}) %>
|
81
|
+
<% end %>
|
82
|
+
<% end %>
|
83
|
+
<% series_library = {} %>
|
84
|
+
<% target_index = @columns.index { |k| k.downcase == "target" } %>
|
85
|
+
<% if target_index %>
|
86
|
+
<% color = "#109618" %>
|
87
|
+
<% series_library[target_index - 1] = {pointStyle: "line", pointBorderWidth: 0, hitRadius: 5, borderColor: color, pointBackgroundColor: color, backgroundColor: color, pointHoverBackgroundColor: color} %>
|
88
|
+
<% end %>
|
89
|
+
<% if @forecast %>
|
90
|
+
<% color = "#54a3ee" %>
|
91
|
+
<% series_library[1] = {borderDash: [8], borderColor: color, pointBackgroundColor: color, backgroundColor: color, pointHoverBackgroundColor: color} %>
|
92
|
+
<% end %>
|
93
|
+
<% if @markers.any? %>
|
94
|
+
<% map_id = SecureRandom.hex %>
|
95
|
+
<%= content_tag :div, nil, id: map_id, style: "height: #{@only_chart ? 545 : 200}px;" %>
|
96
|
+
<%= javascript_tag nonce: true do %>
|
97
|
+
<%= blazer_js_var "mapboxAccessToken", Blazer.mapbox_access_token %>
|
98
|
+
<%= blazer_js_var "markers", @markers %>
|
99
|
+
<%= blazer_js_var "mapId", map_id %>
|
100
|
+
new Mapkick.Map(mapId, markers, {accessToken: mapboxAccessToken, tooltips: {hover: false, html: true}});
|
101
|
+
<% end %>
|
102
|
+
<% elsif @geojson.any? %>
|
103
|
+
<% map_id = SecureRandom.hex %>
|
104
|
+
<%= content_tag :div, nil, id: map_id, style: "height: #{@only_chart ? 545 : 200}px;" %>
|
105
|
+
<%= javascript_tag nonce: true do %>
|
106
|
+
<%= blazer_js_var "mapboxAccessToken", Blazer.mapbox_access_token %>
|
107
|
+
<%= blazer_js_var "geojson", @geojson %>
|
108
|
+
<%= blazer_js_var "mapId", map_id %>
|
109
|
+
new Mapkick.AreaMap(mapId, geojson, {accessToken: mapboxAccessToken, tooltips: {hover: false, html: true}});
|
110
|
+
<% end %>
|
111
|
+
<% elsif chart_type == "line" %>
|
112
|
+
<% chart_data = @columns[1..-1].each_with_index.map{ |k, i| {name: blazer_series_name(k), data: @rows.map{ |r| [r[0], r[i + 1]] }, library: series_library[i]} } %>
|
113
|
+
<% chart_height = @only_chart ? "600px" : "300px" %>
|
114
|
+
<div style="height: <%= chart_height %>;">
|
115
|
+
<%= line_chart chart_data, **chart_options.merge(height: chart_height) %>
|
116
|
+
</div>
|
117
|
+
<% elsif chart_type == "line2" %>
|
118
|
+
<% chart_height = @only_chart ? "600px" : "300px" %>
|
119
|
+
<div style="height: <%= chart_height %>;">
|
120
|
+
<%= line_chart @rows.group_by { |r| v = r[1]; (@smart_values[@columns[1]] || {})[v.to_s] || v }.each_with_index.map { |(name, v), i| {name: blazer_series_name(name), data: v.map { |v2| [v2[0], v2[2]] }, library: series_library[i]} }, **chart_options.merge(height: chart_height) %>
|
121
|
+
</div>
|
122
|
+
<% elsif chart_type == "pie" %>
|
123
|
+
<% chart_height = @only_chart ? "600px" : "300px" %>
|
124
|
+
<div style="height: <%= chart_height %>;">
|
125
|
+
<%= pie_chart @rows.map { |r| [(@smart_values[@columns[0]] || {})[r[0].to_s] || r[0], r[1]] }, **chart_options.merge(height: chart_height) %>
|
126
|
+
</div>
|
127
|
+
<% elsif chart_type == "bar" %>
|
128
|
+
<% chart_height = @only_chart ? "600px" : "300px" %>
|
129
|
+
<div style="height: <%= chart_height %>;">
|
130
|
+
<%= column_chart (values.size - 1).times.map { |i| name = @columns[i + 1]; {name: blazer_series_name(name), data: @rows.first(20).map { |r| [(@smart_values[@columns[0]] || {})[r[0].to_s] || r[0], r[i + 1]] } } }, **chart_options.merge(height: chart_height) %>
|
131
|
+
</div>
|
132
|
+
<% elsif chart_type == "bar2" %>
|
133
|
+
<% first_20 = @rows.group_by { |r| r[0] }.values.first(20).flatten(1) %>
|
134
|
+
<% labels = first_20.map { |r| r[0] }.uniq %>
|
135
|
+
<% series = first_20.map { |r| r[1] }.uniq %>
|
136
|
+
<% labels.each do |l| %>
|
137
|
+
<% series.each do |s| %>
|
138
|
+
<% first_20 << [l, s, 0] unless first_20.find { |r| r[0] == l && r[1] == s } %>
|
139
|
+
<% end %>
|
140
|
+
<% end %>
|
141
|
+
<% chart_height = @only_chart ? "600px" : "300px" %>
|
142
|
+
<div style="height: <%= chart_height %>;">
|
143
|
+
<%= column_chart first_20.group_by { |r| v = r[1]; (@smart_values[@columns[1]] || {})[v.to_s] || v }.each_with_index.map { |(name, v), i| {name: blazer_series_name(name), data: v.sort_by { |r2| labels.index(r2[0]) }.map { |v2| v3 = v2[0]; [(@smart_values[@columns[0]] || {})[v3.to_s] || v3, v2[2]] }} }, **chart_options.merge(height: chart_height) %>
|
144
|
+
</div>
|
145
|
+
<% elsif chart_type == "scatter" %>
|
146
|
+
<% chart_height = @only_chart ? "600px" : "300px" %>
|
147
|
+
<div style="height: <%= chart_height %>;">
|
148
|
+
<%= scatter_chart @rows, xtitle: @columns[0], ytitle: @columns[1], **chart_options.merge(height: chart_height) %>
|
149
|
+
</div>
|
150
|
+
<% elsif @only_chart %>
|
151
|
+
<% if @rows.size == 1 && @rows.first.size == 1 %>
|
152
|
+
<% v = @rows.first.first %>
|
153
|
+
<% if v.is_a?(String) && v == "" %>
|
154
|
+
<div class="text-muted">empty string</div>
|
155
|
+
<% else %>
|
156
|
+
<p style="font-size: 160px;"><%= blazer_format_value(@columns.first, v) %></p>
|
157
|
+
<% end %>
|
158
|
+
<% else %>
|
159
|
+
<% @no_chart = true %>
|
160
|
+
<% end %>
|
161
|
+
<% end %>
|
162
|
+
|
163
|
+
<% unless @only_chart && !@no_chart %>
|
164
|
+
<% header_width = 100 / @columns.size.to_f %>
|
165
|
+
<% # Determine if we're rendering a chart (check all chart conditions) %>
|
166
|
+
<% has_chart = @rows.any? && (
|
167
|
+
@markers.any? ||
|
168
|
+
@geojson.any? ||
|
169
|
+
["line", "line2", "pie", "bar", "bar2", "scatter"].include?(@result.chart_type)
|
170
|
+
) %>
|
171
|
+
<% table_height = has_chart ? "300px" : "600px" %>
|
172
|
+
<% if params[:from_show] %>
|
173
|
+
<% table_height_style = "overflow: auto; max-width: 100%;" %>
|
174
|
+
<% else %>
|
175
|
+
<% table_height_style = "overflow: auto; height: #{table_height}; max-width: 100%;" %>
|
176
|
+
<% end %>
|
177
|
+
<div class="results-container" style="<%= table_height_style %>">
|
178
|
+
<% if @columns == ["QUERY PLAN"] %>
|
179
|
+
<pre><code><%= @rows.map { |r| r[0] }.join("\n") %></code></pre>
|
180
|
+
<% elsif @columns == ["PLAN"] && @data_source.adapter == "druid" %>
|
181
|
+
<pre><code><%= @rows[0][0] %></code></pre>
|
182
|
+
<% else %>
|
183
|
+
<table class="stripes" style="width: auto; min-width: 100%;">
|
184
|
+
<thead>
|
185
|
+
<tr>
|
186
|
+
<% @columns.each_with_index do |key, i| %>
|
187
|
+
<% type = @column_types[i] %>
|
188
|
+
<th style="width: <%= header_width %>%;" data-sort="<%= type %>">
|
189
|
+
<div style="min-width: <%= @min_width_types.include?(i) ? 180 : 60 %>px;">
|
190
|
+
<%= key %>
|
191
|
+
</div>
|
192
|
+
</th>
|
193
|
+
<% end %>
|
194
|
+
</tr>
|
195
|
+
</thead>
|
196
|
+
<tbody>
|
197
|
+
<% @rows.each do |row| %>
|
198
|
+
<tr>
|
199
|
+
<% row.each_with_index do |v, i| %>
|
200
|
+
<% k = @columns[i] %>
|
201
|
+
<td>
|
202
|
+
<% if v.is_a?(Time) %>
|
203
|
+
<% v = blazer_time_value(@data_source, k, v) %>
|
204
|
+
<% end %>
|
205
|
+
|
206
|
+
<% unless v.nil? %>
|
207
|
+
<% if v.is_a?(String) && v == "" %>
|
208
|
+
<div class="text-muted">empty string</div>
|
209
|
+
<% elsif @linked_columns[k] %>
|
210
|
+
<%= link_to blazer_format_value(k, v), @linked_columns[k].gsub("{value}", u(v.to_s)), target: "_blank" %>
|
211
|
+
<% else %>
|
212
|
+
<%= blazer_format_value(k, v) %>
|
213
|
+
<% end %>
|
214
|
+
<% end %>
|
215
|
+
|
216
|
+
<% if (v2 = @smart_values.dig(k, v&.to_s)) %>
|
217
|
+
<div class="text-muted"><%= v2 %></div>
|
218
|
+
<% end %>
|
219
|
+
</td>
|
220
|
+
<% end %>
|
221
|
+
</tr>
|
222
|
+
<% end %>
|
223
|
+
</tbody>
|
224
|
+
</table>
|
225
|
+
<% end %>
|
226
|
+
</div>
|
227
|
+
<% end %>
|
228
|
+
<% elsif @only_chart %>
|
229
|
+
<p class="text-muted">No rows</p>
|
230
|
+
<% end %>
|
231
|
+
<% end %>
|
232
|
+
<% end %>
|
@@ -0,0 +1,8 @@
|
|
1
|
+
<%= search_form_for @q, url: root_path, class: 'field border prefix round', data: { controller: 'sage--search', turbo_frame: "queries" } do |f| %>
|
2
|
+
|
3
|
+
<i class="front">search</i>
|
4
|
+
<%= f.search_field :name_or_description_or_statement_cont,
|
5
|
+
placeholder: "Search queries by name, description or SQL...",
|
6
|
+
class: 'text',
|
7
|
+
data: { action: 'input->sage--search#submit' } %>
|
8
|
+
<% end %>
|
@@ -0,0 +1,241 @@
|
|
1
|
+
<%= turbo_stream_from "statements" %>
|
2
|
+
|
3
|
+
<style>
|
4
|
+
@keyframes glow-border {
|
5
|
+
0% {
|
6
|
+
outline: 2px solid rgba(66, 165, 245, 0.8);
|
7
|
+
outline-offset: 0px;
|
8
|
+
}
|
9
|
+
50% {
|
10
|
+
outline: 2px solid rgba(66, 165, 245, 1);
|
11
|
+
outline-offset: 3px;
|
12
|
+
}
|
13
|
+
100% {
|
14
|
+
outline: 2px solid rgba(66, 165, 245, 0.8);
|
15
|
+
outline-offset: 0px;
|
16
|
+
}
|
17
|
+
}
|
18
|
+
|
19
|
+
.glow-button {
|
20
|
+
animation: glow-border 1.5s ease-in-out infinite;
|
21
|
+
position: relative;
|
22
|
+
}
|
23
|
+
|
24
|
+
.glow-button:hover {
|
25
|
+
animation-play-state: paused;
|
26
|
+
outline: 2px solid rgba(66, 165, 245, 1);
|
27
|
+
outline-offset: 2px;
|
28
|
+
}
|
29
|
+
</style>
|
30
|
+
|
31
|
+
<div id=<%= dom_id(query, 'statement-box') %> class='' style="position: relative;">
|
32
|
+
<%
|
33
|
+
# Handle different contexts - job vs regular view rendering
|
34
|
+
form_url = if local_assigns[:form_url]
|
35
|
+
local_assigns[:form_url]
|
36
|
+
elsif defined?(sage) && sage.respond_to?(:run_queries_path)
|
37
|
+
sage.run_queries_path
|
38
|
+
elsif respond_to?(:run_queries_path)
|
39
|
+
run_queries_path
|
40
|
+
else
|
41
|
+
# Fallback to engine route
|
42
|
+
Sage::Engine.routes.url_helpers.run_queries_path
|
43
|
+
end
|
44
|
+
%>
|
45
|
+
<%= form_with url: form_url, method: :post do |f| %>
|
46
|
+
<%= f.hidden_field :statement, id: 'query_statement' %>
|
47
|
+
<%= f.hidden_field :query_id, value: query.id %>
|
48
|
+
<%= f.hidden_field :data_source, value: query.data_source || Blazer.data_sources.keys.first %>
|
49
|
+
<div id="editor-container" style="position: relative; overflow: hidden;">
|
50
|
+
<div id="editor" style="height: 200px;" data-initial-content="<%= html_escape(local_assigns[:statement] || query.statement) %>"><%= local_assigns[:statement] || query.statement %></div>
|
51
|
+
<div style="position: absolute; bottom: 10px; right: 15px; z-index: 1000; border-radius: 4px; padding: 5px; display: flex; gap: 8px;">
|
52
|
+
<button type='button' onclick="formatSQL()" class="secondary">Format</button>
|
53
|
+
<button type='submit' class="<%= 'glow-button' if local_assigns[:statement].present? %>">Run</button>
|
54
|
+
</div>
|
55
|
+
</div>
|
56
|
+
<% end %>
|
57
|
+
</div>
|
58
|
+
|
59
|
+
<%= javascript_tag nonce: true do %>
|
60
|
+
function formatSQL() {
|
61
|
+
if (!window.aceEditor) return;
|
62
|
+
|
63
|
+
const sql = window.aceEditor.getValue();
|
64
|
+
const formatted = formatSQLString(sql);
|
65
|
+
window.aceEditor.setValue(formatted, -1);
|
66
|
+
}
|
67
|
+
|
68
|
+
function formatSQLString(sql) {
|
69
|
+
// SQL keywords that should be uppercase and on new lines
|
70
|
+
const mainKeywords = ['SELECT', 'FROM', 'WHERE', 'GROUP BY', 'HAVING', 'ORDER BY', 'LIMIT', 'OFFSET',
|
71
|
+
'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE',
|
72
|
+
'UNION', 'UNION ALL', 'INTERSECT', 'EXCEPT', 'WITH'];
|
73
|
+
|
74
|
+
const joinKeywords = ['LEFT JOIN', 'RIGHT JOIN', 'INNER JOIN', 'FULL OUTER JOIN', 'CROSS JOIN', 'JOIN', 'ON'];
|
75
|
+
|
76
|
+
const otherKeywords = ['AND', 'OR', 'NOT', 'IN', 'EXISTS', 'BETWEEN', 'LIKE', 'IS', 'NULL',
|
77
|
+
'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'AS', 'DISTINCT', 'ALL',
|
78
|
+
'ASC', 'DESC', 'CAST', 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX',
|
79
|
+
'COALESCE', 'NULLIF', 'DATE_TRUNC', 'EXTRACT', 'SUBSTRING',
|
80
|
+
'CONCAT', 'LENGTH', 'LOWER', 'UPPER', 'TRIM', 'REPLACE'];
|
81
|
+
|
82
|
+
// Preserve string literals and comments
|
83
|
+
const preservedStrings = [];
|
84
|
+
let preservedIndex = 0;
|
85
|
+
|
86
|
+
// Replace strings with placeholders
|
87
|
+
sql = sql.replace(/'([^']|'')*'/g, (match) => {
|
88
|
+
preservedStrings.push(match);
|
89
|
+
return `__PRESERVED_STRING_${preservedIndex++}__`;
|
90
|
+
});
|
91
|
+
|
92
|
+
// Replace comments with placeholders
|
93
|
+
sql = sql.replace(/--[^\n]*/g, (match) => {
|
94
|
+
preservedStrings.push(match);
|
95
|
+
return `__PRESERVED_STRING_${preservedIndex++}__`;
|
96
|
+
});
|
97
|
+
|
98
|
+
sql = sql.replace(/\/\*[\s\S]*?\*\//g, (match) => {
|
99
|
+
preservedStrings.push(match);
|
100
|
+
return `__PRESERVED_STRING_${preservedIndex++}__`;
|
101
|
+
});
|
102
|
+
|
103
|
+
// Remove extra whitespace and normalize spaces
|
104
|
+
sql = sql.replace(/\s+/g, ' ').trim();
|
105
|
+
|
106
|
+
// Format main keywords - add newline before them
|
107
|
+
mainKeywords.forEach(keyword => {
|
108
|
+
const regex = new RegExp('\\b' + keyword + '\\b', 'gi');
|
109
|
+
sql = sql.replace(regex, '\n' + keyword);
|
110
|
+
});
|
111
|
+
|
112
|
+
// Format JOIN keywords - add newline before them
|
113
|
+
joinKeywords.forEach(keyword => {
|
114
|
+
const regex = new RegExp('\\b' + keyword + '\\b', 'gi');
|
115
|
+
sql = sql.replace(regex, '\n' + keyword);
|
116
|
+
});
|
117
|
+
|
118
|
+
// Make all SQL keywords uppercase (but not the preserved strings)
|
119
|
+
[...mainKeywords, ...joinKeywords, ...otherKeywords].forEach(keyword => {
|
120
|
+
const regex = new RegExp('\\b' + keyword + '\\b', 'gi');
|
121
|
+
sql = sql.replace(regex, keyword);
|
122
|
+
});
|
123
|
+
|
124
|
+
// Format commas in SELECT list - add newline and indent after comma
|
125
|
+
sql = sql.replace(/SELECT\s+(.*?)(?=\nFROM|\nWHERE|\nGROUP|\nHAVING|\nORDER|\nLIMIT|$)/gs, (match, selectList) => {
|
126
|
+
// Only format if there are multiple items
|
127
|
+
if (selectList.includes(',')) {
|
128
|
+
selectList = selectList.replace(/,\s*/g, ',\n ');
|
129
|
+
return 'SELECT\n ' + selectList.trim();
|
130
|
+
}
|
131
|
+
return 'SELECT ' + selectList.trim();
|
132
|
+
});
|
133
|
+
|
134
|
+
// Format AND/OR in WHERE clause - put on new line with indent
|
135
|
+
sql = sql.replace(/\n(WHERE|HAVING)\s+(.*?)(?=\n(?:GROUP|ORDER|LIMIT|FROM|SELECT|$))/gs, (match, keyword, condition) => {
|
136
|
+
condition = condition.replace(/\s+(AND|OR)\s+/gi, '\n $1 ');
|
137
|
+
return '\n' + keyword + ' ' + condition;
|
138
|
+
});
|
139
|
+
|
140
|
+
// Add indentation for subqueries
|
141
|
+
sql = sql.replace(/\((\s*SELECT[\s\S]*?)\)/g, (match, subquery) => {
|
142
|
+
const indentedSubquery = subquery.split('\n').map(line => ' ' + line).join('\n');
|
143
|
+
return '(\n' + indentedSubquery + '\n)';
|
144
|
+
});
|
145
|
+
|
146
|
+
// Clean up multiple consecutive newlines
|
147
|
+
sql = sql.replace(/\n\s*\n/g, '\n');
|
148
|
+
|
149
|
+
// Restore preserved strings
|
150
|
+
preservedStrings.forEach((str, index) => {
|
151
|
+
sql = sql.replace(`__PRESERVED_STRING_${index}__`, str);
|
152
|
+
});
|
153
|
+
|
154
|
+
// Remove leading newline if present
|
155
|
+
sql = sql.replace(/^\n+/, '');
|
156
|
+
|
157
|
+
// Ensure consistent spacing around operators
|
158
|
+
sql = sql.replace(/\s*=\s*/g, ' = ');
|
159
|
+
sql = sql.replace(/\s*<>\s*/g, ' <> ');
|
160
|
+
sql = sql.replace(/\s*!=\s*/g, ' != ');
|
161
|
+
sql = sql.replace(/\s*<=\s*/g, ' <= ');
|
162
|
+
sql = sql.replace(/\s*>=\s*/g, ' >= ');
|
163
|
+
sql = sql.replace(/\s*<\s*/g, ' < ');
|
164
|
+
sql = sql.replace(/\s*>\s*/g, ' > ');
|
165
|
+
|
166
|
+
return sql;
|
167
|
+
}
|
168
|
+
|
169
|
+
function initializeEditor() {
|
170
|
+
// Destroy existing editor if it exists
|
171
|
+
if (window.aceEditor) {
|
172
|
+
window.aceEditor.destroy();
|
173
|
+
}
|
174
|
+
|
175
|
+
var editor = ace.edit("editor")
|
176
|
+
window.aceEditor = editor;
|
177
|
+
editor.setTheme("ace/theme/twilight")
|
178
|
+
editor.getSession().setMode("ace/mode/sql")
|
179
|
+
editor.setOptions({
|
180
|
+
enableBasicAutocompletion: false,
|
181
|
+
enableSnippets: false,
|
182
|
+
enableLiveAutocompletion: false,
|
183
|
+
highlightActiveLine: false,
|
184
|
+
fontSize: 12,
|
185
|
+
minLines: 10
|
186
|
+
})
|
187
|
+
editor.renderer.setShowGutter(true)
|
188
|
+
editor.renderer.setPrintMarginColumn(false)
|
189
|
+
editor.setShowPrintMargin(false)
|
190
|
+
editor.renderer.setPadding(10)
|
191
|
+
editor.getSession().setUseWrapMode(true)
|
192
|
+
|
193
|
+
// Ensure scrolling works properly with bottom margin
|
194
|
+
editor.renderer.setScrollMargin(0, 0, 50, 0) // top, right, bottom, left margins
|
195
|
+
editor.setAutoScrollEditorIntoView(true)
|
196
|
+
|
197
|
+
// Update both hidden fields when editor content changes
|
198
|
+
editor.getSession().on("change", function () {
|
199
|
+
const value = editor.getValue()
|
200
|
+
$("#query_statement").val(value) // For run form
|
201
|
+
$("#query_statement_form").val(value) // For main form
|
202
|
+
})
|
203
|
+
|
204
|
+
// Set initial value from data attribute or current content
|
205
|
+
const editorDiv = document.getElementById("editor")
|
206
|
+
const initialContent = editorDiv.getAttribute("data-initial-content") || editor.getValue()
|
207
|
+
|
208
|
+
if (initialContent && initialContent !== editor.getValue()) {
|
209
|
+
editor.setValue(initialContent, -1) // -1 moves cursor to start
|
210
|
+
}
|
211
|
+
|
212
|
+
const initialValue = editor.getValue()
|
213
|
+
$("#query_statement").val(initialValue)
|
214
|
+
$("#query_statement_form").val(initialValue)
|
215
|
+
editor.focus()
|
216
|
+
|
217
|
+
// Add top padding to ace_scroller and gutter to keep them aligned
|
218
|
+
const aceScroller = document.querySelector('.ace_scroller');
|
219
|
+
const aceGutter = document.querySelector('.ace_gutter');
|
220
|
+
|
221
|
+
if (aceScroller) {
|
222
|
+
aceScroller.style.paddingTop = '10px';
|
223
|
+
}
|
224
|
+
if (aceGutter) {
|
225
|
+
aceGutter.style.paddingTop = '10px';
|
226
|
+
}
|
227
|
+
}
|
228
|
+
|
229
|
+
// Initialize on page load
|
230
|
+
document.addEventListener('turbo:load', initializeEditor);
|
231
|
+
|
232
|
+
// Initialize when this partial is replaced via Turbo Stream
|
233
|
+
document.addEventListener('turbo:frame-load', initializeEditor);
|
234
|
+
|
235
|
+
// Also initialize immediately if the DOM is already loaded
|
236
|
+
if (document.readyState === 'loading') {
|
237
|
+
document.addEventListener('DOMContentLoaded', initializeEditor);
|
238
|
+
} else {
|
239
|
+
initializeEditor();
|
240
|
+
}
|
241
|
+
<% end %>
|
@@ -0,0 +1,14 @@
|
|
1
|
+
<div id="<%= stream_target_id %>">
|
2
|
+
<%= simple_format(content) %>
|
3
|
+
</div>
|
4
|
+
<script>
|
5
|
+
setTimeout(() => {
|
6
|
+
const streamElement = document.getElementById('<%= stream_target_id %>');
|
7
|
+
if (streamElement) {
|
8
|
+
const messagesDiv = streamElement.closest('[id$="_messages"]');
|
9
|
+
if (messagesDiv) {
|
10
|
+
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
11
|
+
}
|
12
|
+
}
|
13
|
+
}, 10);
|
14
|
+
</script>
|
@@ -0,0 +1,114 @@
|
|
1
|
+
<%= turbo_stream.update "query_result" do %>
|
2
|
+
<div class="query-result">
|
3
|
+
<h3>Generated SQL Query</h3>
|
4
|
+
|
5
|
+
<div class="sql-container">
|
6
|
+
<pre class="sql-code"><%= @query.statement %></pre>
|
7
|
+
|
8
|
+
<div class="query-actions">
|
9
|
+
<%= form_with url: run_query_path(id: @query.id || "temp"), method: :post, local: false do |f| %>
|
10
|
+
<%= hidden_field_tag "query[sql]", @query.statement %>
|
11
|
+
<%= hidden_field_tag "query[question]", @question %>
|
12
|
+
|
13
|
+
<div class="button-group">
|
14
|
+
<%= f.submit "Run Query", class: "btn btn-success" %>
|
15
|
+
<%= link_to "Copy SQL", "#",
|
16
|
+
class: "btn btn-secondary",
|
17
|
+
data: {
|
18
|
+
controller: "clipboard",
|
19
|
+
clipboard_text_value: @query.statement,
|
20
|
+
action: "click->clipboard#copy"
|
21
|
+
} %>
|
22
|
+
<% if @query.persisted? %>
|
23
|
+
<%= link_to "View in Blazer", main_app.blazer_query_path(@query), class: "btn btn-secondary", target: "_blank" %>
|
24
|
+
<% end %>
|
25
|
+
<%= link_to "New Query", new_query_path, class: "btn btn-outline", data: { turbo_frame: "_top" } %>
|
26
|
+
</div>
|
27
|
+
<% end %>
|
28
|
+
</div>
|
29
|
+
</div>
|
30
|
+
|
31
|
+
<div class="query-metadata">
|
32
|
+
<p class="text-muted">
|
33
|
+
<strong>Original question:</strong> <%= @question %>
|
34
|
+
</p>
|
35
|
+
</div>
|
36
|
+
</div>
|
37
|
+
<% end %>
|
38
|
+
|
39
|
+
<style>
|
40
|
+
.query-result {
|
41
|
+
margin-top: 30px;
|
42
|
+
padding: 20px;
|
43
|
+
background-color: #f8f9fa;
|
44
|
+
border-radius: 8px;
|
45
|
+
}
|
46
|
+
|
47
|
+
.query-result h3 {
|
48
|
+
margin-bottom: 15px;
|
49
|
+
color: #333;
|
50
|
+
}
|
51
|
+
|
52
|
+
.sql-container {
|
53
|
+
background-color: white;
|
54
|
+
border: 1px solid #e0e0e0;
|
55
|
+
border-radius: 4px;
|
56
|
+
overflow: hidden;
|
57
|
+
}
|
58
|
+
|
59
|
+
.sql-code {
|
60
|
+
margin: 0;
|
61
|
+
padding: 15px;
|
62
|
+
background-color: #f5f5f5;
|
63
|
+
font-family: 'Monaco', 'Consolas', monospace;
|
64
|
+
font-size: 13px;
|
65
|
+
line-height: 1.5;
|
66
|
+
overflow-x: auto;
|
67
|
+
}
|
68
|
+
|
69
|
+
.query-actions {
|
70
|
+
padding: 15px;
|
71
|
+
background-color: white;
|
72
|
+
border-top: 1px solid #e0e0e0;
|
73
|
+
}
|
74
|
+
|
75
|
+
.button-group {
|
76
|
+
display: flex;
|
77
|
+
gap: 10px;
|
78
|
+
flex-wrap: wrap;
|
79
|
+
}
|
80
|
+
|
81
|
+
.btn-success {
|
82
|
+
background-color: #28a745;
|
83
|
+
color: white;
|
84
|
+
}
|
85
|
+
|
86
|
+
.btn-success:hover {
|
87
|
+
background-color: #218838;
|
88
|
+
}
|
89
|
+
|
90
|
+
.btn-secondary {
|
91
|
+
background-color: #6c757d;
|
92
|
+
color: white;
|
93
|
+
}
|
94
|
+
|
95
|
+
.btn-secondary:hover {
|
96
|
+
background-color: #5a6268;
|
97
|
+
}
|
98
|
+
|
99
|
+
.btn-outline {
|
100
|
+
background-color: white;
|
101
|
+
color: #333;
|
102
|
+
border: 1px solid #ddd;
|
103
|
+
}
|
104
|
+
|
105
|
+
.btn-outline:hover {
|
106
|
+
background-color: #f8f9fa;
|
107
|
+
}
|
108
|
+
|
109
|
+
.query-metadata {
|
110
|
+
margin-top: 15px;
|
111
|
+
padding-top: 15px;
|
112
|
+
border-top: 1px solid #e0e0e0;
|
113
|
+
}
|
114
|
+
</style>
|