sage-rails 0.0.5 → 0.0.7
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 +4 -4
- data/CONTRIBUTING.md +42 -0
- data/README.md +20 -22
- data/app/assets/javascripts/sage/application.js +27 -1
- data/app/controllers/sage/checks_controller.rb +3 -3
- data/app/controllers/sage/dashboards_controller.rb +8 -9
- data/app/controllers/sage/queries_controller.rb +1 -1
- data/app/helpers/sage/application_helper.rb +1 -1
- data/app/helpers/sage/queries_helper.rb +2 -2
- data/app/javascript/sage/controllers/variables_controller.js +122 -0
- data/app/javascript/sage.js +3 -1
- data/app/views/sage/_variables.html.erb +168 -0
- data/app/views/sage/dashboards/show.html.erb +1 -1
- data/app/views/sage/queries/show.html.erb +2 -2
- data/config/initializers/ransack.rb +19 -19
- data/config/routes.rb +1 -1
- data/lib/generators/sage/install/templates/sage.rb +3 -17
- data/lib/sage/model_scopes_context.rb +19 -19
- data/lib/sage/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f84a14a8dc13d5bca60926490250937bc5b52ae4f77c6962e328cbb28a5635a1
|
4
|
+
data.tar.gz: 59d079bb38465f75b165ed44d63b3cfd9d1f8d52a56fdb5c78feba7cdf987237
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 71fe7deb5b559a26703f21a3f81f998a4e32a521c91c37e2f69b01957cd39266e098ad679330cfec776408f95b2001dfa79c67f6df5fd9221a55d8e4e826d163
|
7
|
+
data.tar.gz: 4a30c3d554af61200f3424c30c00ab51263229470c8977799289f30fee9dac43605b867f7e7719af9b82c7c995288a13f493f289ccd393ad09198c01b70fcaa1
|
data/CONTRIBUTING.md
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
# Contributing
|
2
|
+
|
3
|
+
First, thanks for wanting to contribute. You’re awesome! :heart:
|
4
|
+
|
5
|
+
## Help
|
6
|
+
|
7
|
+
We’re not able to provide support through GitHub Issues. If you’re looking for help with your code, try posting on [Stack Overflow](https://stackoverflow.com/).
|
8
|
+
|
9
|
+
All features should be documented. If you don’t see a feature in the docs, assume it doesn’t exist.
|
10
|
+
|
11
|
+
## Bugs
|
12
|
+
|
13
|
+
Think you’ve discovered a bug?
|
14
|
+
|
15
|
+
1. Search existing issues to see if it’s been reported.
|
16
|
+
2. Try the `master` branch to make sure it hasn’t been fixed.
|
17
|
+
|
18
|
+
```rb
|
19
|
+
gem "sage-rails", github: "mrjonesbot/sage"
|
20
|
+
```
|
21
|
+
|
22
|
+
If the above steps don’t help, create an issue. Include:
|
23
|
+
|
24
|
+
- Detailed steps to reproduce
|
25
|
+
- Complete backtraces for exceptions
|
26
|
+
|
27
|
+
## New Features
|
28
|
+
|
29
|
+
If you’d like to discuss a new feature, create an issue and start the title with `[Idea]`.
|
30
|
+
|
31
|
+
## Pull Requests
|
32
|
+
|
33
|
+
Fork the project and create a pull request. A few tips:
|
34
|
+
|
35
|
+
- Keep changes to a minimum. If you have multiple features or fixes, submit multiple pull requests.
|
36
|
+
- Follow the existing style. The code should read like it’s written by a single person.
|
37
|
+
|
38
|
+
Feel free to open an issue to get feedback on your idea before spending too much time on it.
|
39
|
+
|
40
|
+
---
|
41
|
+
|
42
|
+
This contributing guide is released under [CCO](https://creativecommons.org/publicdomain/zero/1.0/) (public domain). Use it for your own project without attribution.
|
data/README.md
CHANGED
@@ -2,16 +2,29 @@
|
|
2
2
|
|
3
3
|

|
4
4
|
|
5
|
+
> **Note:** This project is pre-v1 and is subject to frequent changes.
|
6
|
+
|
5
7
|
**Natural language reporting to help your team build accurate reports, faster.**
|
6
8
|
|
7
9
|
Sage is a Rails engine built on top of the excellent [Blazer](https://github.com/ankane/blazer) gem, adding an LLM interface to make data exploration accessible via natural language.
|
8
10
|
|
11
|
+
## Requirements
|
12
|
+
|
13
|
+
Sage relies on `turbo-rails` and `stimulus-rails` to render the dashboard. These gems should already be included in most modern Rails applications. If not, add them to your Gemfile:
|
14
|
+
|
15
|
+
```ruby
|
16
|
+
gem "turbo-rails"
|
17
|
+
gem "stimulus-rails"
|
18
|
+
```
|
19
|
+
|
20
|
+
Refer to the [stimulus-rails installation page](https://github.com/hotwired/stimulus-rails) to get started.
|
21
|
+
|
9
22
|
## Installation
|
10
23
|
|
11
24
|
Add Sage to your application's Gemfile:
|
12
25
|
|
13
26
|
```ruby
|
14
|
-
gem "sage-rails"
|
27
|
+
gem "sage-rails", require: 'sage'
|
15
28
|
```
|
16
29
|
|
17
30
|
Run bundle install:
|
@@ -88,26 +101,17 @@ For detailed information on Blazer-specific features, refer to the [Blazer docum
|
|
88
101
|
|
89
102
|
## Database Context
|
90
103
|
|
91
|
-
Sage introspects your database schema to provide context for more accurate SQL generation.
|
104
|
+
Sage introspects your database schema to provide context for more accurate SQL generation.
|
92
105
|
|
93
106
|
### Multiple Data Sources
|
94
107
|
|
95
|
-
If you have multiple
|
108
|
+
If you have multiple databases, Sage will use the appropriate schema for each:
|
96
109
|
|
97
|
-
|
98
|
-
# config/blazer.yml
|
99
|
-
data_sources:
|
100
|
-
main:
|
101
|
-
url: <%= ENV["DATABASE_URL"] %>
|
102
|
-
analytics:
|
103
|
-
url: <%= ENV["ANALYTICS_DATABASE_URL"] %>
|
104
|
-
```
|
105
|
-
|
106
|
-
When querying from different data sources in Blazer, Sage automatically switches schema context.
|
110
|
+
When querying different data sources in Blazer, Sage will switch schema context.
|
107
111
|
|
108
112
|
## Model Scope Context
|
109
113
|
|
110
|
-
Sage
|
114
|
+
Sage introspects your Rails model scopes to understand common query patterns, dramatically improving the accuracy of generated SQL queries.
|
111
115
|
|
112
116
|
### Example
|
113
117
|
|
@@ -144,9 +148,8 @@ Sage understands:
|
|
144
148
|
### Benefits
|
145
149
|
|
146
150
|
1. **Business Logic Awareness**: Scopes encode your business rules (what makes a customer "active" or "high-value")
|
147
|
-
2. **
|
148
|
-
3. **
|
149
|
-
4. **Consistency**: Generated queries follow the same patterns as your application code
|
151
|
+
2. **Aggregation Patterns**: Complex scopes with GROUP BY and HAVING clauses guide report generation
|
152
|
+
3. **Consistency**: Generated queries follow the same patterns as your application code
|
150
153
|
|
151
154
|
Scopes now serve dual purposes:
|
152
155
|
1. Reusable query logic in your Rails application
|
@@ -186,11 +189,6 @@ $ rails test
|
|
186
189
|
- Ensure model files are in standard Rails locations (`app/models/`)
|
187
190
|
- Check that Blazer is properly configured and can execute queries
|
188
191
|
|
189
|
-
### Performance
|
190
|
-
- Use lighter models (Claude Haiku, GPT-3.5) for faster response times
|
191
|
-
- Consider caching frequently used queries
|
192
|
-
- Scope context is cached per request to minimize processing
|
193
|
-
|
194
192
|
## Contributing
|
195
193
|
|
196
194
|
Bug reports and pull requests are welcome on GitHub. This project is intended to be a safe, welcoming space for collaboration.
|
@@ -4,7 +4,7 @@ import "@hotwired/turbo-rails"
|
|
4
4
|
import { Application } from "@hotwired/stimulus"
|
5
5
|
|
6
6
|
// Import and register Sage controllers
|
7
|
-
import { SearchController, ClipboardController, SelectController, DashboardController, ReverseInfiniteScrollController } from "sage"
|
7
|
+
import { SearchController, ClipboardController, SelectController, DashboardController, ReverseInfiniteScrollController, VariablesController } from "sage"
|
8
8
|
|
9
9
|
const application = Application.start()
|
10
10
|
application.debug = true
|
@@ -16,3 +16,29 @@ application.register("sage--clipboard", ClipboardController)
|
|
16
16
|
application.register("sage--select", SelectController)
|
17
17
|
application.register("sage--dashboard", DashboardController)
|
18
18
|
application.register("sage--reverse-infinite-scroll", ReverseInfiniteScrollController)
|
19
|
+
application.register("sage--variables", VariablesController)
|
20
|
+
|
21
|
+
// Override Blazer's submitIfCompleted function globally
|
22
|
+
window.submitIfCompleted = function($form) {
|
23
|
+
// Try to find our Sage variables controller first
|
24
|
+
const controller = document.querySelector('[data-controller*="sage--variables"]')
|
25
|
+
if (controller && window.Stimulus) {
|
26
|
+
const controllerInstance = window.Stimulus.getControllerForElementAndIdentifier(controller, 'sage--variables')
|
27
|
+
if (controllerInstance) {
|
28
|
+
controllerInstance.submitIfCompleted($form[0] || $form)
|
29
|
+
return
|
30
|
+
}
|
31
|
+
}
|
32
|
+
|
33
|
+
// Fallback to improved version of original logic
|
34
|
+
var completed = true
|
35
|
+
$form.find("input[name], select").each(function () {
|
36
|
+
const value = $(this).val()
|
37
|
+
if (!value || value.toString().trim() === "") {
|
38
|
+
completed = false
|
39
|
+
}
|
40
|
+
})
|
41
|
+
if (completed) {
|
42
|
+
$form.submit()
|
43
|
+
}
|
44
|
+
}
|
@@ -5,13 +5,13 @@ module Sage
|
|
5
5
|
def index
|
6
6
|
@q = Blazer::Check.ransack(params[:q])
|
7
7
|
@checks = @q.result.joins(:query).includes(:query)
|
8
|
-
|
8
|
+
|
9
9
|
# Apply basic ordering first
|
10
10
|
@checks = @checks.order("blazer_queries.name, blazer_checks.id")
|
11
|
-
|
11
|
+
|
12
12
|
# Apply pagination with Pagy
|
13
13
|
@pagy, @checks = pagy(@checks)
|
14
|
-
|
14
|
+
|
15
15
|
# Apply state-based sorting on the paginated results
|
16
16
|
state_order = [ nil, "disabled", "error", "timed out", "failing", "passing" ]
|
17
17
|
@checks = @checks.sort_by { |q| state_order.index(q.state) || 99 }
|
@@ -1,21 +1,21 @@
|
|
1
1
|
module Sage
|
2
2
|
class DashboardsController < BaseController
|
3
|
-
before_action :set_dashboard, only: [:show, :edit, :update, :destroy, :refresh]
|
3
|
+
before_action :set_dashboard, only: [ :show, :edit, :update, :destroy, :refresh ]
|
4
4
|
|
5
5
|
def index
|
6
6
|
@q = Blazer::Dashboard.ransack(params[:q])
|
7
7
|
@dashboards = @q.result
|
8
|
-
|
8
|
+
|
9
9
|
# Only include creator if Blazer.user_class is configured
|
10
10
|
@dashboards = @dashboards.includes(:creator) if Blazer.user_class
|
11
|
-
|
11
|
+
|
12
12
|
@dashboards = @dashboards.order(:name)
|
13
|
-
|
13
|
+
|
14
14
|
# Apply additional filters if needed
|
15
15
|
if blazer_user && params[:filter] == "mine"
|
16
16
|
@dashboards = @dashboards.where(creator_id: blazer_user.id).reorder(updated_at: :desc)
|
17
17
|
end
|
18
|
-
|
18
|
+
|
19
19
|
# Apply pagination with Pagy
|
20
20
|
@pagy, @dashboards = pagy(@dashboards)
|
21
21
|
end
|
@@ -40,7 +40,7 @@ module Sage
|
|
40
40
|
def show
|
41
41
|
@queries = @dashboard.dashboard_queries.order(:position).preload(:query).map(&:query)
|
42
42
|
@query_errors = {}
|
43
|
-
|
43
|
+
|
44
44
|
@queries.each do |query|
|
45
45
|
# Check if the query has a valid data source
|
46
46
|
if query.data_source.blank? || !Blazer.data_sources.key?(query.data_source)
|
@@ -53,12 +53,12 @@ module Sage
|
|
53
53
|
|
54
54
|
@smart_vars = {}
|
55
55
|
@sql_errors = []
|
56
|
-
@data_sources = @queries.map { |q|
|
56
|
+
@data_sources = @queries.map { |q|
|
57
57
|
# Use the query's data source if specified, otherwise use the default
|
58
58
|
source = q.data_source.presence || Blazer.data_sources.keys.first
|
59
59
|
Blazer.data_sources[source]
|
60
60
|
}.compact.uniq
|
61
|
-
|
61
|
+
|
62
62
|
@bind_vars.each do |var|
|
63
63
|
@data_sources.each do |data_source|
|
64
64
|
smart_var, error = parse_smart_variables(var, data_source)
|
@@ -127,4 +127,3 @@ module Sage
|
|
127
127
|
end
|
128
128
|
end
|
129
129
|
end
|
130
|
-
|
@@ -150,7 +150,7 @@ module Sage
|
|
150
150
|
|
151
151
|
# fallback for now for users with open tabs
|
152
152
|
# TODO remove fallback in future version
|
153
|
-
@var_params = request.request_parameters["variables"] || request.request_parameters
|
153
|
+
@var_params = params[:variables] || request.request_parameters["variables"] || request.request_parameters
|
154
154
|
@success = process_vars(@statement, @var_params)
|
155
155
|
@only_chart = params[:only_chart]
|
156
156
|
@run_id = blazer_params[:run_id]
|
@@ -0,0 +1,122 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
static targets = ["form", "variable"]
|
5
|
+
|
6
|
+
connect() {
|
7
|
+
// Override the global submitIfCompleted function from Blazer
|
8
|
+
window.submitIfCompleted = this.submitIfCompleted.bind(this)
|
9
|
+
|
10
|
+
// Set up event listeners for variable changes
|
11
|
+
this.setupVariableListeners()
|
12
|
+
}
|
13
|
+
|
14
|
+
setupVariableListeners() {
|
15
|
+
// Listen for changes on all variable inputs
|
16
|
+
this.variableTargets.forEach(input => {
|
17
|
+
input.addEventListener("change", (event) => {
|
18
|
+
this.handleVariableChange(event)
|
19
|
+
})
|
20
|
+
|
21
|
+
// Also listen for daterangepicker events if present
|
22
|
+
if (input.dataset.daterangepicker) {
|
23
|
+
const picker = $(input).data('daterangepicker')
|
24
|
+
if (picker) {
|
25
|
+
$(input).on('apply.daterangepicker', (ev, picker) => {
|
26
|
+
this.handleDateRangeChange(input, picker)
|
27
|
+
})
|
28
|
+
}
|
29
|
+
}
|
30
|
+
})
|
31
|
+
}
|
32
|
+
|
33
|
+
handleVariableChange(event) {
|
34
|
+
const input = event.target
|
35
|
+
console.log(`Variable ${input.name} changed to:`, input.value)
|
36
|
+
|
37
|
+
// Check if all required variables are filled and submit if so
|
38
|
+
setTimeout(() => {
|
39
|
+
this.submitIfCompleted(this.formTarget)
|
40
|
+
}, 100) // Small delay to ensure all values are updated
|
41
|
+
}
|
42
|
+
|
43
|
+
handleDateRangeChange(input, picker) {
|
44
|
+
console.log(`Date range updated for ${input.name}:`, input.value)
|
45
|
+
|
46
|
+
// Force update any related hidden fields
|
47
|
+
this.updateRelatedDateFields(input, picker)
|
48
|
+
|
49
|
+
// Submit the form after date range is updated
|
50
|
+
setTimeout(() => {
|
51
|
+
this.submitIfCompleted(this.formTarget)
|
52
|
+
}, 200) // Longer delay for date picker updates
|
53
|
+
}
|
54
|
+
|
55
|
+
updateRelatedDateFields(input, picker) {
|
56
|
+
// If this is a start_time/end_time combo, make sure both are updated
|
57
|
+
if (input.name === "start_time" || input.name === "end_time") {
|
58
|
+
const startTimeInput = this.formTarget.querySelector('input[name="start_time"]')
|
59
|
+
const endTimeInput = this.formTarget.querySelector('input[name="end_time"]')
|
60
|
+
|
61
|
+
if (startTimeInput && endTimeInput && picker) {
|
62
|
+
// Update both fields based on the picker values
|
63
|
+
if (picker.startDate) {
|
64
|
+
startTimeInput.value = picker.startDate.utc().format()
|
65
|
+
}
|
66
|
+
if (picker.endDate) {
|
67
|
+
endTimeInput.value = picker.endDate.endOf("day").utc().format()
|
68
|
+
}
|
69
|
+
}
|
70
|
+
}
|
71
|
+
}
|
72
|
+
|
73
|
+
submitIfCompleted(form) {
|
74
|
+
if (!form) return
|
75
|
+
|
76
|
+
let completed = true
|
77
|
+
const requiredInputs = form.querySelectorAll('input[name], select[name]')
|
78
|
+
|
79
|
+
// Check each required input
|
80
|
+
requiredInputs.forEach(input => {
|
81
|
+
const value = input.value
|
82
|
+
|
83
|
+
// More robust empty check
|
84
|
+
if (this.isEmpty(value)) {
|
85
|
+
completed = false
|
86
|
+
console.log(`Variable ${input.name} is empty:`, value)
|
87
|
+
} else {
|
88
|
+
console.log(`Variable ${input.name} has value:`, value)
|
89
|
+
}
|
90
|
+
})
|
91
|
+
|
92
|
+
console.log(`Form completion check: ${completed ? 'Complete' : 'Incomplete'}`)
|
93
|
+
|
94
|
+
if (completed) {
|
95
|
+
console.log('Submitting form with all variables filled')
|
96
|
+
form.submit()
|
97
|
+
}
|
98
|
+
}
|
99
|
+
|
100
|
+
isEmpty(value) {
|
101
|
+
// More comprehensive empty check
|
102
|
+
return value === null ||
|
103
|
+
value === undefined ||
|
104
|
+
value === "" ||
|
105
|
+
(typeof value === "string" && value.trim() === "")
|
106
|
+
}
|
107
|
+
|
108
|
+
// Manual trigger for testing
|
109
|
+
triggerSubmit() {
|
110
|
+
this.submitIfCompleted(this.formTarget)
|
111
|
+
}
|
112
|
+
|
113
|
+
// Debug method to check current variable states
|
114
|
+
debugVariables() {
|
115
|
+
console.log("=== Current Variable States ===")
|
116
|
+
const inputs = this.formTarget.querySelectorAll('input[name], select[name]')
|
117
|
+
inputs.forEach(input => {
|
118
|
+
console.log(`${input.name}: "${input.value}" (${typeof input.value})`)
|
119
|
+
})
|
120
|
+
console.log("===============================")
|
121
|
+
}
|
122
|
+
}
|
data/app/javascript/sage.js
CHANGED
@@ -4,9 +4,10 @@ import ClipboardController from "sage/controllers/clipboard_controller"
|
|
4
4
|
import SelectController from "sage/controllers/select_controller"
|
5
5
|
import DashboardController from "sage/controllers/dashboard_controller"
|
6
6
|
import ReverseInfiniteScrollController from "sage/controllers/reverse_infinite_scroll_controller"
|
7
|
+
import VariablesController from "sage/controllers/variables_controller"
|
7
8
|
|
8
9
|
// Export all Sage controllers for manual registration
|
9
|
-
export { SearchController, ClipboardController, SelectController, DashboardController, ReverseInfiniteScrollController }
|
10
|
+
export { SearchController, ClipboardController, SelectController, DashboardController, ReverseInfiniteScrollController, VariablesController }
|
10
11
|
|
11
12
|
// Register all Sage controllers with the provided Stimulus application
|
12
13
|
export function registerControllers(application) {
|
@@ -15,5 +16,6 @@ export function registerControllers(application) {
|
|
15
16
|
application.register("sage--select", SelectController)
|
16
17
|
application.register("sage--dashboard", DashboardController)
|
17
18
|
application.register("sage--reverse-infinite-scroll", ReverseInfiniteScrollController)
|
19
|
+
application.register("sage--variables", VariablesController)
|
18
20
|
}
|
19
21
|
|
@@ -0,0 +1,168 @@
|
|
1
|
+
<% if @bind_vars.any? %>
|
2
|
+
<% var_params = request.query_parameters %>
|
3
|
+
<%= javascript_tag nonce: true do %>
|
4
|
+
<%= blazer_js_var "timeZone", Blazer.time_zone.tzinfo.name %>
|
5
|
+
var now = moment.tz(timeZone)
|
6
|
+
var format = "YYYY-MM-DD"
|
7
|
+
|
8
|
+
function toDate(time) {
|
9
|
+
return moment.tz(time.format(format), timeZone)
|
10
|
+
}
|
11
|
+
<% end %>
|
12
|
+
<div data-controller="sage--variables" data-sage--variables-form-target="form">
|
13
|
+
<form id="bind" method="get" action="<%= action %>" class="form-inline" style="margin-bottom: 15px;">
|
14
|
+
<% date_vars = ["start_time", "end_time"] %>
|
15
|
+
<% if (date_vars - @bind_vars).empty? %>
|
16
|
+
<% @bind_vars = @bind_vars - date_vars %>
|
17
|
+
<% else %>
|
18
|
+
<% date_vars = nil %>
|
19
|
+
<% end %>
|
20
|
+
|
21
|
+
<% @bind_vars.each_with_index do |var, i| %>
|
22
|
+
<div style="margin-bottom: 10px;">
|
23
|
+
<div style="margin-bottom: 3px;">
|
24
|
+
<small class="text-muted"><code>{{<%= var %>}}</code></small>
|
25
|
+
</div>
|
26
|
+
</div>
|
27
|
+
<% if (data = @smart_vars[var]) %>
|
28
|
+
<%= select_tag var, options_for_select([[nil, nil]] + data, selected: var_params[var]), style: "margin-right: 20px; width: 200px; display: none;", data: { "sage--variables-target": "variable" } %>
|
29
|
+
<%= javascript_tag nonce: true do %>
|
30
|
+
$("#<%= var %>").selectize({
|
31
|
+
create: true
|
32
|
+
});
|
33
|
+
<% end %>
|
34
|
+
<% elsif var.end_with?("_at") || var == "start_time" || var == "end_time" %>
|
35
|
+
<%= hidden_field_tag var, var_params[var], data: { "sage--variables-target": "variable" } %>
|
36
|
+
|
37
|
+
<div class="selectize-control single" style="width: 200px;">
|
38
|
+
<div id="<%= var %>-select" class="selectize-input" style="display: inline-block;">
|
39
|
+
<span>Select a date</span>
|
40
|
+
</div>
|
41
|
+
</div>
|
42
|
+
|
43
|
+
<%= javascript_tag nonce: true do %>
|
44
|
+
(function() {
|
45
|
+
var input = $("#<%= var %>")
|
46
|
+
var datePicker = $("#<%= var %>-select")
|
47
|
+
datePicker.daterangepicker({
|
48
|
+
singleDatePicker: true,
|
49
|
+
locale: {format: format},
|
50
|
+
autoUpdateInput: false,
|
51
|
+
autoApply: true,
|
52
|
+
startDate: input.val().length > 0 ? moment.tz(input.val(), timeZone) : now
|
53
|
+
})
|
54
|
+
// hack to start with empty date
|
55
|
+
datePicker.on("apply.daterangepicker", function(ev, picker) {
|
56
|
+
datePicker.find("span").html(toDate(picker.startDate).format("MMMM D, YYYY"))
|
57
|
+
input.val(toDate(picker.startDate).utc().format())
|
58
|
+
|
59
|
+
// Trigger our custom variable controller instead of submitIfCompleted
|
60
|
+
var controller = document.querySelector('[data-controller*="sage--variables"]')
|
61
|
+
if (controller && window.Stimulus) {
|
62
|
+
var controllerInstance = window.Stimulus.getControllerForElementAndIdentifier(controller, 'sage--variables')
|
63
|
+
if (controllerInstance) {
|
64
|
+
controllerInstance.handleDateRangeChange(input[0], picker)
|
65
|
+
}
|
66
|
+
}
|
67
|
+
})
|
68
|
+
if (input.val().length > 0) {
|
69
|
+
var picker = datePicker.data("daterangepicker")
|
70
|
+
datePicker.find("span").html(toDate(picker.startDate).format("MMMM D, YYYY"))
|
71
|
+
}
|
72
|
+
})()
|
73
|
+
<% end %>
|
74
|
+
<% else %>
|
75
|
+
<%= text_field_tag var, var_params[var], style: "width: 120px; margin-right: 20px;", autofocus: i == 0 && !var.end_with?("_at") && !var_params[var], class: "form-control", data: { "sage--variables-target": "variable" } %>
|
76
|
+
<% end %>
|
77
|
+
<% end %>
|
78
|
+
|
79
|
+
<% if date_vars %>
|
80
|
+
<% date_vars.each do |var| %>
|
81
|
+
<%= hidden_field_tag var, var_params[var], data: { "sage--variables-target": "variable" } %>
|
82
|
+
<% end %>
|
83
|
+
|
84
|
+
<div style="margin-bottom: 5px; text-align: left;">
|
85
|
+
<small class="text-muted"><code>{{<%= date_vars.join('}}, {{') %>}}</code></small>
|
86
|
+
</div>
|
87
|
+
<div class="selectize-control single" style="width: 300px;">
|
88
|
+
<div id="reportrange" class="selectize-input" style="display: inline-block;">
|
89
|
+
<span>Select a time range</span>
|
90
|
+
</div>
|
91
|
+
</div>
|
92
|
+
|
93
|
+
<%= javascript_tag nonce: true do %>
|
94
|
+
function dateStr(daysAgo) {
|
95
|
+
return now.clone().subtract(daysAgo || 0, "days").format(format)
|
96
|
+
}
|
97
|
+
|
98
|
+
function setTimeInputs(start, end) {
|
99
|
+
$("#start_time").val(toDate(start).utc().format())
|
100
|
+
$("#end_time").val(toDate(end).endOf("day").utc().format())
|
101
|
+
}
|
102
|
+
|
103
|
+
$("#reportrange").daterangepicker(
|
104
|
+
{
|
105
|
+
ranges: {
|
106
|
+
"Today": [dateStr(), dateStr()],
|
107
|
+
"Last 7 Days": [dateStr(6), dateStr()],
|
108
|
+
"Last 30 Days": [dateStr(29), dateStr()]
|
109
|
+
},
|
110
|
+
locale: {
|
111
|
+
format: format
|
112
|
+
},
|
113
|
+
startDate: dateStr(29),
|
114
|
+
endDate: dateStr(),
|
115
|
+
opens: "right",
|
116
|
+
alwaysShowCalendars: true
|
117
|
+
},
|
118
|
+
function(start, end) {
|
119
|
+
setTimeInputs(start, end)
|
120
|
+
|
121
|
+
// Trigger our custom variable controller instead of submitIfCompleted
|
122
|
+
var controller = document.querySelector('[data-controller*="sage--variables"]')
|
123
|
+
if (controller && window.Stimulus) {
|
124
|
+
var controllerInstance = window.Stimulus.getControllerForElementAndIdentifier(controller, 'sage--variables')
|
125
|
+
if (controllerInstance) {
|
126
|
+
controllerInstance.triggerSubmit()
|
127
|
+
}
|
128
|
+
}
|
129
|
+
}
|
130
|
+
).on("apply.daterangepicker", function(ev, picker) {
|
131
|
+
setTimeInputs(picker.startDate, picker.endDate)
|
132
|
+
$("#reportrange span").html(toDate(picker.startDate).format("MMMM D, YYYY") + " - " + toDate(picker.endDate).format("MMMM D, YYYY"))
|
133
|
+
|
134
|
+
// Trigger our custom variable controller
|
135
|
+
var controller = document.querySelector('[data-controller*="sage--variables"]')
|
136
|
+
if (controller && window.Stimulus) {
|
137
|
+
var controllerInstance = window.Stimulus.getControllerForElementAndIdentifier(controller, 'sage--variables')
|
138
|
+
if (controllerInstance) {
|
139
|
+
controllerInstance.handleDateRangeChange(document.getElementById('start_time'), picker)
|
140
|
+
}
|
141
|
+
}
|
142
|
+
})
|
143
|
+
|
144
|
+
if ($("#start_time").val().length > 0) {
|
145
|
+
var picker = $("#reportrange").data("daterangepicker")
|
146
|
+
picker.setStartDate(moment.tz($("#start_time").val(), timeZone))
|
147
|
+
picker.setEndDate(moment.tz($("#end_time").val(), timeZone))
|
148
|
+
$("#reportrange").trigger("apply.daterangepicker", picker)
|
149
|
+
} else {
|
150
|
+
var picker = $("#reportrange").data("daterangepicker")
|
151
|
+
$("#reportrange").trigger("apply.daterangepicker", picker)
|
152
|
+
|
153
|
+
// Initial trigger for our custom controller
|
154
|
+
var controller = document.querySelector('[data-controller*="sage--variables"]')
|
155
|
+
if (controller && window.Stimulus) {
|
156
|
+
var controllerInstance = window.Stimulus.getControllerForElementAndIdentifier(controller, 'sage--variables')
|
157
|
+
if (controllerInstance) {
|
158
|
+
controllerInstance.triggerSubmit()
|
159
|
+
}
|
160
|
+
}
|
161
|
+
}
|
162
|
+
<% end %>
|
163
|
+
<% end %>
|
164
|
+
|
165
|
+
<button type='submit' class='btn btn-primary'>Run</button>
|
166
|
+
</form>
|
167
|
+
</div>
|
168
|
+
<% end %>
|
@@ -22,7 +22,7 @@
|
|
22
22
|
<% end %>
|
23
23
|
|
24
24
|
<% if @bind_vars.any? %>
|
25
|
-
<%= render partial: "
|
25
|
+
<%= render partial: "sage/variables", locals: {action: dashboard_path(@dashboard)} %>
|
26
26
|
<% else %>
|
27
27
|
<div style="padding-bottom: 15px;"></div>
|
28
28
|
<% end %>
|
@@ -28,12 +28,12 @@
|
|
28
28
|
<p style="white-space: pre-line;"><%= @query.description %></p>
|
29
29
|
<% end %>
|
30
30
|
|
31
|
-
<%= render partial: "
|
31
|
+
<%= render partial: "sage/variables", locals: { action: query_path(@query) } %>
|
32
32
|
|
33
33
|
<pre id="code"><code><%= @statement.display_statement %></code></pre>
|
34
34
|
|
35
35
|
<% if @success %>
|
36
|
-
<%= turbo_frame_tag dom_id(@query, 'results'), src: run_query_path(@query.id, from_show: true) do %>
|
36
|
+
<%= turbo_frame_tag dom_id(@query, 'results'), src: run_query_path(@query.id, from_show: true, variables: variable_params(@query)) do %>
|
37
37
|
loading...
|
38
38
|
<% end %>
|
39
39
|
<% end %>
|
@@ -1,17 +1,17 @@
|
|
1
|
-
require
|
1
|
+
require "ransack"
|
2
2
|
|
3
3
|
# Configure Ransack for Blazer models
|
4
4
|
Rails.application.config.after_initialize do
|
5
5
|
# Ensure Blazer is loaded first
|
6
|
-
require
|
7
|
-
|
6
|
+
require "blazer" if defined?(Blazer)
|
7
|
+
|
8
8
|
# Extend Blazer::Query with Ransack capabilities
|
9
9
|
if defined?(Blazer::Query)
|
10
10
|
# First, ensure Ransack is included in the model
|
11
11
|
unless Blazer::Query.respond_to?(:ransack)
|
12
12
|
Blazer::Query.send(:extend, Ransack::Adapters::ActiveRecord::Base)
|
13
13
|
end
|
14
|
-
|
14
|
+
|
15
15
|
Blazer::Query.class_eval do
|
16
16
|
# Define which attributes can be searched
|
17
17
|
def self.ransackable_attributes(auth_object = nil)
|
@@ -21,7 +21,7 @@ Rails.application.config.after_initialize do
|
|
21
21
|
# Define which associations can be searched
|
22
22
|
def self.ransackable_associations(auth_object = nil)
|
23
23
|
associations = %w[checks audits dashboard_queries dashboards]
|
24
|
-
associations <<
|
24
|
+
associations << "creator" if Blazer.user_class
|
25
25
|
associations
|
26
26
|
end
|
27
27
|
|
@@ -37,14 +37,14 @@ Rails.application.config.after_initialize do
|
|
37
37
|
end
|
38
38
|
end
|
39
39
|
end
|
40
|
-
|
40
|
+
|
41
41
|
# Extend Blazer::Dashboard with Ransack capabilities
|
42
42
|
if defined?(Blazer::Dashboard)
|
43
43
|
# First, ensure Ransack is included in the model
|
44
44
|
unless Blazer::Dashboard.respond_to?(:ransack)
|
45
45
|
Blazer::Dashboard.send(:extend, Ransack::Adapters::ActiveRecord::Base)
|
46
46
|
end
|
47
|
-
|
47
|
+
|
48
48
|
Blazer::Dashboard.class_eval do
|
49
49
|
# Define which attributes can be searched
|
50
50
|
def self.ransackable_attributes(auth_object = nil)
|
@@ -54,7 +54,7 @@ Rails.application.config.after_initialize do
|
|
54
54
|
# Define which associations can be searched
|
55
55
|
def self.ransackable_associations(auth_object = nil)
|
56
56
|
associations = %w[dashboard_queries queries]
|
57
|
-
associations <<
|
57
|
+
associations << "creator" if Blazer.user_class
|
58
58
|
associations
|
59
59
|
end
|
60
60
|
end
|
@@ -68,7 +68,7 @@ Rails.application.config.to_prepare do
|
|
68
68
|
unless Blazer::Query.respond_to?(:ransack)
|
69
69
|
Blazer::Query.send(:extend, Ransack::Adapters::ActiveRecord::Base)
|
70
70
|
end
|
71
|
-
|
71
|
+
|
72
72
|
unless Blazer::Query.respond_to?(:ransackable_attributes)
|
73
73
|
Blazer::Query.class_eval do
|
74
74
|
def self.ransackable_attributes(auth_object = nil)
|
@@ -85,13 +85,13 @@ Rails.application.config.to_prepare do
|
|
85
85
|
end
|
86
86
|
end
|
87
87
|
end
|
88
|
-
|
88
|
+
|
89
89
|
if defined?(Blazer::Dashboard)
|
90
90
|
# Ensure Ransack is included
|
91
91
|
unless Blazer::Dashboard.respond_to?(:ransack)
|
92
92
|
Blazer::Dashboard.send(:extend, Ransack::Adapters::ActiveRecord::Base)
|
93
93
|
end
|
94
|
-
|
94
|
+
|
95
95
|
unless Blazer::Dashboard.respond_to?(:ransackable_attributes)
|
96
96
|
Blazer::Dashboard.class_eval do
|
97
97
|
def self.ransackable_attributes(auth_object = nil)
|
@@ -100,19 +100,19 @@ Rails.application.config.to_prepare do
|
|
100
100
|
|
101
101
|
def self.ransackable_associations(auth_object = nil)
|
102
102
|
associations = %w[dashboard_queries queries]
|
103
|
-
associations <<
|
103
|
+
associations << "creator" if Blazer.user_class
|
104
104
|
associations
|
105
105
|
end
|
106
106
|
end
|
107
107
|
end
|
108
108
|
end
|
109
|
-
|
109
|
+
|
110
110
|
if defined?(Blazer::Check)
|
111
111
|
# Ensure Ransack is included
|
112
112
|
unless Blazer::Check.respond_to?(:ransack)
|
113
113
|
Blazer::Check.send(:extend, Ransack::Adapters::ActiveRecord::Base)
|
114
114
|
end
|
115
|
-
|
115
|
+
|
116
116
|
unless Blazer::Check.respond_to?(:ransackable_attributes)
|
117
117
|
Blazer::Check.class_eval do
|
118
118
|
def self.ransackable_attributes(auth_object = nil)
|
@@ -121,20 +121,20 @@ Rails.application.config.to_prepare do
|
|
121
121
|
|
122
122
|
def self.ransackable_associations(auth_object = nil)
|
123
123
|
associations = %w[query]
|
124
|
-
associations <<
|
124
|
+
associations << "creator" if Blazer.user_class
|
125
125
|
associations
|
126
126
|
end
|
127
127
|
end
|
128
128
|
end
|
129
129
|
end
|
130
|
-
|
130
|
+
|
131
131
|
# Extend Blazer::Check with Ransack capabilities
|
132
132
|
if defined?(Blazer::Check)
|
133
133
|
# First, ensure Ransack is included in the model
|
134
134
|
unless Blazer::Check.respond_to?(:ransack)
|
135
135
|
Blazer::Check.send(:extend, Ransack::Adapters::ActiveRecord::Base)
|
136
136
|
end
|
137
|
-
|
137
|
+
|
138
138
|
Blazer::Check.class_eval do
|
139
139
|
# Define which attributes can be searched
|
140
140
|
def self.ransackable_attributes(auth_object = nil)
|
@@ -144,9 +144,9 @@ Rails.application.config.to_prepare do
|
|
144
144
|
# Define which associations can be searched
|
145
145
|
def self.ransackable_associations(auth_object = nil)
|
146
146
|
associations = %w[query]
|
147
|
-
associations <<
|
147
|
+
associations << "creator" if Blazer.user_class
|
148
148
|
associations
|
149
149
|
end
|
150
150
|
end
|
151
151
|
end
|
152
|
-
end
|
152
|
+
end
|
data/config/routes.rb
CHANGED
@@ -5,7 +5,7 @@ Sage::Engine.routes.draw do
|
|
5
5
|
post :refresh, on: :member
|
6
6
|
get :run, on: :member
|
7
7
|
|
8
|
-
resources :messages, only: [:index, :create], controller:
|
8
|
+
resources :messages, only: [ :index, :create ], controller: "queries/messages"
|
9
9
|
|
10
10
|
collection do
|
11
11
|
post :run
|
@@ -1,22 +1,8 @@
|
|
1
1
|
Sage.configure do |config|
|
2
2
|
# Configure the AI provider (options: :anthropic, :openai)
|
3
3
|
config.provider = :anthropic
|
4
|
+
# config.provider = :openai
|
4
5
|
|
5
|
-
|
6
|
-
#
|
7
|
-
# 1. Rails credentials: rails credentials:edit
|
8
|
-
# anthropic:
|
9
|
-
# api_key: your_key_here
|
10
|
-
# openai:
|
11
|
-
# api_key: your_key_here
|
12
|
-
# 2. .env file: ANTHROPIC_API_KEY=your_key_here or OPENAI_API_KEY=your_key_here
|
13
|
-
# 3. Direct configuration (not recommended for production):
|
14
|
-
# config.anthropic_api_key = "your_key_here"
|
15
|
-
# config.open_ai_key = "your_key_here"
|
16
|
-
|
17
|
-
# Model selection (optional)
|
18
|
-
# For Anthropic (defaults to claude-3-opus-20240229):
|
19
|
-
# config.anthropic_model = "claude-3-sonnet-20240229"
|
20
|
-
# For OpenAI (defaults to gpt-4):
|
21
|
-
# config.open_ai_model = "gpt-3.5-turbo"
|
6
|
+
config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
|
7
|
+
# config.openai_api_key = ENV["OPENAI_API_KEY"]
|
22
8
|
end
|
@@ -43,20 +43,20 @@ module Sage
|
|
43
43
|
|
44
44
|
def collect_models_with_scopes
|
45
45
|
models_with_scopes = []
|
46
|
-
|
46
|
+
|
47
47
|
# Find all model files in the app/models directory
|
48
48
|
model_files = Dir.glob(Rails.root.join("app/models/**/*.rb"))
|
49
|
-
|
49
|
+
|
50
50
|
model_files.each do |file_path|
|
51
51
|
# Skip concern files and other non-model files
|
52
52
|
next if file_path.include?("/concerns/")
|
53
|
-
|
53
|
+
|
54
54
|
# Read the file content
|
55
55
|
file_content = File.read(file_path)
|
56
|
-
|
56
|
+
|
57
57
|
# Extract model name from file path
|
58
58
|
model_name = File.basename(file_path, ".rb").camelize
|
59
|
-
|
59
|
+
|
60
60
|
# Find all scope definitions using regex
|
61
61
|
# Match various scope patterns:
|
62
62
|
# scope :active, -> { where(active: true) }
|
@@ -70,7 +70,7 @@ module Sage
|
|
70
70
|
# Pattern 3: Simple one-liner scopes
|
71
71
|
/scope\s+:(\w+)\s*,\s*(.+?)$/
|
72
72
|
]
|
73
|
-
|
73
|
+
|
74
74
|
scope_matches = []
|
75
75
|
scope_patterns.each do |pattern|
|
76
76
|
matches = file_content.scan(pattern)
|
@@ -79,11 +79,11 @@ module Sage
|
|
79
79
|
scope_body = match[1] || ""
|
80
80
|
# Avoid duplicate entries
|
81
81
|
unless scope_matches.any? { |s| s[0] == scope_name }
|
82
|
-
scope_matches << [scope_name, scope_body]
|
82
|
+
scope_matches << [ scope_name, scope_body ]
|
83
83
|
end
|
84
84
|
end
|
85
85
|
end
|
86
|
-
|
86
|
+
|
87
87
|
if scope_matches.any?
|
88
88
|
# Try to get the actual model class and table name
|
89
89
|
begin
|
@@ -93,17 +93,17 @@ module Sage
|
|
93
93
|
table_name = model_name.tableize
|
94
94
|
model_class = nil
|
95
95
|
end
|
96
|
-
|
96
|
+
|
97
97
|
model_info = {
|
98
98
|
name: model_name,
|
99
99
|
table: table_name,
|
100
100
|
scopes: []
|
101
101
|
}
|
102
|
-
|
102
|
+
|
103
103
|
scope_matches.each do |match|
|
104
104
|
scope_name = match[0]
|
105
105
|
scope_body = match[1]
|
106
|
-
|
106
|
+
|
107
107
|
if scope_body
|
108
108
|
# Try to extract SQL-like patterns from the scope body
|
109
109
|
# Look for where conditions, joins, etc.
|
@@ -114,20 +114,20 @@ module Sage
|
|
114
114
|
model_info[:scopes] << " • `#{scope_name}` → (check model file for implementation)"
|
115
115
|
end
|
116
116
|
end
|
117
|
-
|
117
|
+
|
118
118
|
models_with_scopes << model_info if model_info[:scopes].any?
|
119
119
|
end
|
120
120
|
end
|
121
121
|
|
122
122
|
models_with_scopes
|
123
123
|
end
|
124
|
-
|
124
|
+
|
125
125
|
def extract_sql_from_scope_body(scope_body)
|
126
126
|
# Clean up the scope body
|
127
127
|
cleaned = scope_body.strip
|
128
|
-
|
128
|
+
|
129
129
|
sql_parts = []
|
130
|
-
|
130
|
+
|
131
131
|
# Extract WHERE conditions
|
132
132
|
if cleaned =~ /where\s*\(["']([^"']+)["'](?:,\s*(.+?))?\)/
|
133
133
|
# String SQL with potential parameters
|
@@ -146,14 +146,14 @@ module Sage
|
|
146
146
|
not_conditions = not_conditions.gsub(/(\w+):\s*/, '\1 != ')
|
147
147
|
sql_parts << "WHERE NOT (#{not_conditions})"
|
148
148
|
end
|
149
|
-
|
149
|
+
|
150
150
|
# Extract JOINs
|
151
151
|
if cleaned =~ /joins?\s*\(:?(\w+)\)/
|
152
152
|
sql_parts << "JOIN #{$1}"
|
153
153
|
elsif cleaned =~ /includes?\s*\(:?(\w+)\)/
|
154
154
|
sql_parts << "LEFT JOIN #{$1}"
|
155
155
|
end
|
156
|
-
|
156
|
+
|
157
157
|
# Extract ORDER
|
158
158
|
if cleaned =~ /order\s*\(["']([^"']+)["']\)/
|
159
159
|
sql_parts << "ORDER BY #{$1}"
|
@@ -162,12 +162,12 @@ module Sage
|
|
162
162
|
order_clause = order_clause.gsub(/(\w+):\s*:?(asc|desc)/i, '\1 \2')
|
163
163
|
sql_parts << "ORDER BY #{order_clause}"
|
164
164
|
end
|
165
|
-
|
165
|
+
|
166
166
|
# Extract LIMIT
|
167
167
|
if cleaned =~ /limit\s*\((\d+)\)/
|
168
168
|
sql_parts << "LIMIT #{$1}"
|
169
169
|
end
|
170
|
-
|
170
|
+
|
171
171
|
# If we found SQL parts, join them
|
172
172
|
if sql_parts.any?
|
173
173
|
sql_parts.join(" ")
|
data/lib/sage/version.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: sage-rails
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.7
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Nathan Jones
|
@@ -140,6 +140,7 @@ executables: []
|
|
140
140
|
extensions: []
|
141
141
|
extra_rdoc_files: []
|
142
142
|
files:
|
143
|
+
- CONTRIBUTING.md
|
143
144
|
- README.md
|
144
145
|
- app/assets/images/chevron-down-zinc-500.svg
|
145
146
|
- app/assets/images/chevron-right.svg
|
@@ -165,6 +166,7 @@ files:
|
|
165
166
|
- app/javascript/sage/controllers/reverse_infinite_scroll_controller.js
|
166
167
|
- app/javascript/sage/controllers/search_controller.js
|
167
168
|
- app/javascript/sage/controllers/select_controller.js
|
169
|
+
- app/javascript/sage/controllers/variables_controller.js
|
168
170
|
- app/jobs/sage/application_job.rb
|
169
171
|
- app/jobs/sage/process_report_job.rb
|
170
172
|
- app/mailers/sage/application_mailer.rb
|
@@ -173,6 +175,7 @@ files:
|
|
173
175
|
- app/schemas/sage/report_response_schema.rb
|
174
176
|
- app/views/layouts/application.html.erb
|
175
177
|
- app/views/layouts/sage/application.html.erb
|
178
|
+
- app/views/sage/_variables.html.erb
|
176
179
|
- app/views/sage/checks/_form.html.erb
|
177
180
|
- app/views/sage/checks/_search.html.erb
|
178
181
|
- app/views/sage/checks/edit.html.erb
|