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.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +202 -0
  3. data/app/assets/images/chevron-down-zinc-500.svg +1 -0
  4. data/app/assets/images/chevron-right.svg +1 -0
  5. data/app/assets/images/loading.svg +4 -0
  6. data/app/assets/images/sage/chevron-down-zinc-500.svg +1 -0
  7. data/app/assets/images/sage/chevron-right.svg +1 -0
  8. data/app/assets/images/sage/loading.svg +4 -0
  9. data/app/assets/javascripts/sage/application.js +18 -0
  10. data/app/assets/stylesheets/sage/application.css +308 -0
  11. data/app/controllers/sage/actions_controller.rb +5 -0
  12. data/app/controllers/sage/application_controller.rb +4 -0
  13. data/app/controllers/sage/base_controller.rb +10 -0
  14. data/app/controllers/sage/checks_controller.rb +65 -0
  15. data/app/controllers/sage/dashboards_controller.rb +130 -0
  16. data/app/controllers/sage/queries/messages_controller.rb +62 -0
  17. data/app/controllers/sage/queries_controller.rb +596 -0
  18. data/app/helpers/sage/application_helper.rb +30 -0
  19. data/app/helpers/sage/queries_helper.rb +23 -0
  20. data/app/javascript/controllers/element_removal_controller.js +7 -0
  21. data/app/javascript/sage/controllers/clipboard_controller.js +26 -0
  22. data/app/javascript/sage/controllers/dashboard_controller.js +132 -0
  23. data/app/javascript/sage/controllers/reverse_infinite_scroll_controller.js +146 -0
  24. data/app/javascript/sage/controllers/search_controller.js +47 -0
  25. data/app/javascript/sage/controllers/select_controller.js +215 -0
  26. data/app/javascript/sage.js +19 -0
  27. data/app/jobs/sage/application_job.rb +4 -0
  28. data/app/jobs/sage/process_report_job.rb +80 -0
  29. data/app/mailers/sage/application_mailer.rb +6 -0
  30. data/app/models/sage/application_record.rb +5 -0
  31. data/app/models/sage/message.rb +8 -0
  32. data/app/schemas/sage/report_response_schema.rb +8 -0
  33. data/app/views/layouts/application.html.erb +34 -0
  34. data/app/views/layouts/sage/application.html.erb +94 -0
  35. data/app/views/sage/checks/_form.html.erb +81 -0
  36. data/app/views/sage/checks/_search.html.erb +8 -0
  37. data/app/views/sage/checks/edit.html.erb +10 -0
  38. data/app/views/sage/checks/index.html.erb +58 -0
  39. data/app/views/sage/checks/new.html.erb +8 -0
  40. data/app/views/sage/dashboards/_form.html.erb +50 -0
  41. data/app/views/sage/dashboards/_search.html.erb +8 -0
  42. data/app/views/sage/dashboards/index.html.erb +58 -0
  43. data/app/views/sage/dashboards/new.html.erb +8 -0
  44. data/app/views/sage/dashboards/show.html.erb +58 -0
  45. data/app/views/sage/messages/_form.html.erb +14 -0
  46. data/app/views/sage/queries/_caching.html.erb +17 -0
  47. data/app/views/sage/queries/_form.html.erb +72 -0
  48. data/app/views/sage/queries/_input.html.erb +17 -0
  49. data/app/views/sage/queries/_message.html.erb +25 -0
  50. data/app/views/sage/queries/_message.turbo_stream.erb +10 -0
  51. data/app/views/sage/queries/_new_form.html.erb +43 -0
  52. data/app/views/sage/queries/_run.html.erb +232 -0
  53. data/app/views/sage/queries/_search.html.erb +8 -0
  54. data/app/views/sage/queries/_statement_box.html.erb +241 -0
  55. data/app/views/sage/queries/_streaming_message.html.erb +14 -0
  56. data/app/views/sage/queries/create.turbo_stream.erb +114 -0
  57. data/app/views/sage/queries/edit.html.erb +48 -0
  58. data/app/views/sage/queries/index.html.erb +59 -0
  59. data/app/views/sage/queries/messages/create.turbo_stream.erb +22 -0
  60. data/app/views/sage/queries/messages/index.html.erb +44 -0
  61. data/app/views/sage/queries/messages/index.turbo_stream.erb +15 -0
  62. data/app/views/sage/queries/new.html.erb +195 -0
  63. data/app/views/sage/queries/run.html.erb +1 -0
  64. data/app/views/sage/queries/run.turbo_stream.erb +3 -0
  65. data/app/views/sage/queries/show.html.erb +49 -0
  66. data/app/views/sage/queries/table_schema.html.erb +77 -0
  67. data/app/views/sage/shared/_navigation.html.erb +26 -0
  68. data/app/views/sage/shared/_overlay.html.erb +11 -0
  69. data/config/importmap.rb +11 -0
  70. data/config/initializers/pagy.rb +2 -0
  71. data/config/initializers/ransack.rb +152 -0
  72. data/config/routes.rb +31 -0
  73. data/lib/generators/sage/USAGE +13 -0
  74. data/lib/generators/sage/install/install_generator.rb +128 -0
  75. data/lib/generators/sage/install/templates/sage.rb +22 -0
  76. data/lib/sage/database_schema_context.rb +56 -0
  77. data/lib/sage/engine.rb +260 -0
  78. data/lib/sage/model_scopes_context.rb +185 -0
  79. data/lib/sage/report_processor.rb +263 -0
  80. data/lib/sage/version.rb +3 -0
  81. data/lib/sage.rb +25 -0
  82. data/lib/tasks/sage_tasks.rake +4 -0
  83. metadata +245 -0
@@ -0,0 +1,132 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Connects to data-controller="dashboard"
4
+ export default class extends Controller {
5
+ static targets = ["queryList", "queryTemplate"];
6
+ static values = { queries: Array };
7
+
8
+ connect() {
9
+ console.log("Dashboard controller connected");
10
+ console.log("Queries value:", this.queriesValue);
11
+
12
+ // Parse the queries value if it's a string
13
+ const queriesData = typeof this.queriesValue === 'string' ?
14
+ JSON.parse(this.queriesValue) : this.queriesValue;
15
+ this.selectedQueries = Array.isArray(queriesData) ? [...queriesData] : [];
16
+
17
+ console.log("Selected queries:", this.selectedQueries);
18
+
19
+ this.render();
20
+ this.setupSortable();
21
+ }
22
+
23
+ addQuery(event) {
24
+ const { value, text } = event.detail;
25
+
26
+ // Check for duplicates and remove if found
27
+ const existingIndex = this.selectedQueries.findIndex(q => q.id == value);
28
+ if (existingIndex !== -1) {
29
+ this.selectedQueries.splice(existingIndex, 1);
30
+ }
31
+
32
+ // Add the new query
33
+ this.selectedQueries.push({ id: value, name: text });
34
+ this.render();
35
+ }
36
+
37
+ removeQuery(event) {
38
+ const index = parseInt(event.params.index);
39
+ this.selectedQueries.splice(index, 1);
40
+ this.render();
41
+ }
42
+
43
+ render() {
44
+ // Check if the target exists
45
+ if (!this.hasQueryListTarget) {
46
+ console.error("queryList target not found");
47
+ return;
48
+ }
49
+
50
+ if (this.selectedQueries.length === 0) {
51
+ this.queryListTarget.style.display = 'none';
52
+ return;
53
+ }
54
+
55
+ this.queryListTarget.style.display = 'block';
56
+ let queriesContainer = this.queryListTarget.querySelector('#queries');
57
+
58
+ // If the container doesn't exist, create it
59
+ if (!queriesContainer) {
60
+ queriesContainer = document.createElement('div');
61
+ queriesContainer.id = 'queries';
62
+ queriesContainer.style.display = 'flex';
63
+ queriesContainer.style.flexDirection = 'column';
64
+ queriesContainer.style.gap = '8px';
65
+ this.queryListTarget.appendChild(queriesContainer);
66
+ }
67
+
68
+ queriesContainer.innerHTML = '';
69
+
70
+ this.selectedQueries.forEach((query, index) => {
71
+ // Create a plain div without any Beer CSS classes
72
+ const item = document.createElement('div');
73
+ // Remove ALL classes and use inline styles only
74
+ item.style.cssText = `
75
+ display: flex !important;
76
+ align-items: center !important;
77
+ width: 100% !important;
78
+ padding: 12px !important;
79
+ margin-bottom: 8px !important;
80
+ background-color: #f5f5f5 !important;
81
+ border-radius: 8px !important;
82
+ opacity: 1 !important;
83
+ filter: none !important;
84
+ `;
85
+
86
+ // Use a Material Icons checkbox instead of HTML input
87
+ const checkIcon = document.createElement('i');
88
+ checkIcon.className = 'material-icons';
89
+ checkIcon.textContent = 'check_box';
90
+ checkIcon.style.cssText = 'color: #6750A4 !important; margin-right: 12px !important; font-size: 24px !important;';
91
+
92
+ const text = document.createElement('strong'); // Use strong tag
93
+ text.textContent = query.name;
94
+ text.style.cssText = 'flex: 1 !important; color: #000000 !important; opacity: 1 !important; filter: none !important;';
95
+
96
+ const closeBtn = document.createElement('i');
97
+ closeBtn.className = 'material-icons';
98
+ closeBtn.textContent = 'close';
99
+ closeBtn.style.cssText = 'cursor: pointer !important; color: #000000 !important; opacity: 1 !important; font-size: 20px !important;';
100
+ closeBtn.setAttribute('data-action', 'click->sage--dashboard#removeQuery');
101
+ closeBtn.setAttribute('data-sage--dashboard-index-param', index);
102
+
103
+ const hiddenInput = document.createElement('input');
104
+ hiddenInput.type = 'hidden';
105
+ hiddenInput.name = 'query_ids[]';
106
+ hiddenInput.value = query.id;
107
+
108
+ item.appendChild(checkIcon);
109
+ item.appendChild(text);
110
+ item.appendChild(closeBtn);
111
+ item.appendChild(hiddenInput);
112
+
113
+ queriesContainer.appendChild(item);
114
+ });
115
+ }
116
+
117
+ setupSortable() {
118
+ if (typeof Sortable !== 'undefined') {
119
+ const queriesContainer = this.queryListTarget.querySelector('#queries');
120
+ if (queriesContainer) {
121
+ Sortable.create(queriesContainer, {
122
+ onEnd: (e) => {
123
+ // Move the item in our array to match the new position
124
+ const movedItem = this.selectedQueries.splice(e.oldIndex, 1)[0];
125
+ this.selectedQueries.splice(e.newIndex, 0, movedItem);
126
+ this.render();
127
+ }
128
+ });
129
+ }
130
+ }
131
+ }
132
+ }
@@ -0,0 +1,146 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ console.log("Reverse infinite scroll controller file loaded!");
4
+
5
+ export default class extends Controller {
6
+ static targets = ["entries", "pagination"];
7
+ static values = {
8
+ url: String,
9
+ page: Number,
10
+ loading: Boolean,
11
+ };
12
+
13
+ initialize() {
14
+ console.log("Reverse infinite scroll controller initialized");
15
+ this.intersectionObserver = new IntersectionObserver(
16
+ (entries) => {
17
+ entries.forEach((entry) => {
18
+ if (entry.isIntersecting) {
19
+ console.log("Pagination target is visible - loading older messages");
20
+ this.loadOlder();
21
+ }
22
+ });
23
+ },
24
+ {
25
+ rootMargin: "50px 0px 50px 0px",
26
+ threshold: 0.1,
27
+ }
28
+ );
29
+ }
30
+
31
+ connect() {
32
+ console.log("Reverse infinite scroll controller connected");
33
+ console.log("Initial page value:", this.pageValue);
34
+
35
+ if (this.hasPaginationTarget) {
36
+ console.log("Found pagination target, observing...");
37
+ this.intersectionObserver.observe(this.paginationTarget);
38
+ } else {
39
+ console.log("No pagination target found");
40
+ }
41
+ }
42
+
43
+ paginationTargetConnected(target) {
44
+ console.log("Pagination target connected dynamically, observing...");
45
+ // Add a delay to allow scroll-to-bottom to happen first
46
+ setTimeout(() => {
47
+ console.log("Starting to observe pagination target after delay");
48
+ this.intersectionObserver.observe(target);
49
+ }, 1000);
50
+ }
51
+
52
+ paginationTargetDisconnected(target) {
53
+ console.log("Pagination target disconnected, stop observing...");
54
+ this.intersectionObserver.unobserve(target);
55
+ }
56
+
57
+ disconnect() {
58
+ console.log("Reverse infinite scroll controller disconnected");
59
+ this.intersectionObserver.disconnect();
60
+ }
61
+
62
+ buildUrl(nextPage) {
63
+ const url = new URL(this.urlValue, window.location.origin);
64
+ url.searchParams.set("page", nextPage);
65
+ return url.toString();
66
+ }
67
+
68
+ async loadOlder() {
69
+ if (this.loadingValue) {
70
+ console.log("Already loading, skipping...");
71
+ return;
72
+ }
73
+
74
+ console.log("Loading older messages...");
75
+ console.log("Current pageValue:", this.pageValue);
76
+ this.loadingValue = true;
77
+ const nextPage = this.pageValue + 1;
78
+ console.log("Requesting page:", nextPage);
79
+
80
+ // Store current scroll position
81
+ const oldScrollHeight = this.element.scrollHeight;
82
+ const oldScrollTop = this.element.scrollTop;
83
+
84
+ try {
85
+ const url = this.buildUrl(nextPage);
86
+ console.log("Fetching URL:", url);
87
+
88
+ const response = await fetch(url, {
89
+ headers: {
90
+ Accept: "text/vnd.turbo-stream.html",
91
+ },
92
+ });
93
+
94
+ if (!response.ok) {
95
+ throw new Error(`HTTP error! status: ${response.status}`);
96
+ }
97
+
98
+ const html = await response.text();
99
+ console.log("Received response for page", nextPage);
100
+
101
+ // Parse the turbo-stream content
102
+ const parser = new DOMParser();
103
+ const doc = parser.parseFromString(html, "text/html");
104
+ const template = doc.querySelector("template");
105
+
106
+ if (template) {
107
+ // Remove the current pagination target before appending new content
108
+ if (this.hasPaginationTarget) {
109
+ this.paginationTarget.remove();
110
+ }
111
+
112
+ // Manually prepend the template content (for reverse infinite scroll)
113
+ this.entriesTarget.insertAdjacentHTML("afterbegin", template.innerHTML);
114
+ this.pageValue = nextPage;
115
+ console.log(`Updated to page ${nextPage}`);
116
+
117
+ // Maintain scroll position (prevent jumping to top)
118
+ setTimeout(() => {
119
+ const newScrollHeight = this.element.scrollHeight;
120
+ const heightDifference = newScrollHeight - oldScrollHeight;
121
+ this.element.scrollTop = oldScrollTop + heightDifference;
122
+
123
+ console.log("Scroll position updated:", {
124
+ oldScrollHeight,
125
+ newScrollHeight,
126
+ heightDifference,
127
+ newScrollTop: this.element.scrollTop
128
+ });
129
+
130
+ // Set up observer for new pagination target if it exists
131
+ if (this.hasPaginationTarget) {
132
+ console.log("Found new pagination target, observing...");
133
+ this.intersectionObserver.observe(this.paginationTarget);
134
+ } else {
135
+ console.log("No more pages to load");
136
+ }
137
+ }, 50);
138
+ }
139
+ } catch (error) {
140
+ console.error("Error loading older messages:", error);
141
+ console.log("Staying on page", this.pageValue, "due to error");
142
+ } finally {
143
+ this.loadingValue = false;
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,47 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import debounce from "debounce";
3
+
4
+ // Connects to data-controller="search"
5
+ export default class extends Controller {
6
+ initialize() {
7
+ this.submit = debounce(this.submit.bind(this), 300);
8
+ this.searchInput = null;
9
+ }
10
+
11
+ connect() {
12
+ console.log("Search controller initialized");
13
+ // Find the search input
14
+ this.searchInput = this.element.querySelector('input[type="search"]');
15
+
16
+ if (this.searchInput) {
17
+ // Focus the input
18
+ if (this.searchInput.value.length > 0) {
19
+ this.searchInput.focus();
20
+ }
21
+
22
+ // If we have a stored cursor position, restore it
23
+ if (this.cursorPosition !== undefined) {
24
+ this.searchInput.setSelectionRange(
25
+ this.cursorPosition,
26
+ this.cursorPosition
27
+ );
28
+ // Clear the stored position after using it
29
+ this.cursorPosition = undefined;
30
+ } else {
31
+ // If no stored position, place cursor at the end
32
+ const length = this.searchInput.value.length;
33
+ this.searchInput.setSelectionRange(length, length);
34
+ }
35
+ }
36
+ }
37
+
38
+ submit(event) {
39
+ // Store the cursor position before submitting
40
+ if (this.searchInput) {
41
+ this.cursorPosition = this.searchInput.selectionStart;
42
+ }
43
+
44
+ this.element.requestSubmit();
45
+ }
46
+ }
47
+
@@ -0,0 +1,215 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Connects to data-controller="select"
4
+ export default class extends Controller {
5
+ static targets = ["input", "dropdown", "option", "hidden"];
6
+ static values = {
7
+ options: Array,
8
+ placeholder: String,
9
+ selected: String,
10
+ maxOptions: { type: Number, default: 100 }
11
+ };
12
+
13
+ connect() {
14
+ this.selectedValue = this.selectedValue || "";
15
+
16
+ // Parse options if they're a string
17
+ let options = this.optionsValue;
18
+ if (typeof options === 'string') {
19
+ try {
20
+ options = JSON.parse(options);
21
+ } catch (e) {
22
+ console.error("Failed to parse options:", e);
23
+ options = [];
24
+ }
25
+ }
26
+
27
+ this.filteredOptions = Array.isArray(options) ? options.slice(0, this.maxOptionsValue) : [];
28
+
29
+ // If there's a pre-selected value from the data attribute, set it
30
+ if (this.hasSelectedValue && this.selectedValue) {
31
+ const selectedOption = this.filteredOptions.find(opt => opt.value == this.selectedValue);
32
+ if (selectedOption) {
33
+ this.inputTarget.value = selectedOption.text;
34
+ if (this.hasHiddenTarget) {
35
+ this.hiddenTarget.value = selectedOption.value;
36
+ }
37
+ }
38
+ }
39
+
40
+ this.render();
41
+ this.setupEventListeners();
42
+ }
43
+
44
+ setupEventListeners() {
45
+ // Close dropdown when clicking outside
46
+ document.addEventListener('click', this.handleOutsideClick.bind(this));
47
+
48
+ // Handle keyboard navigation
49
+ this.inputTarget.addEventListener('keydown', this.handleKeydown.bind(this));
50
+ }
51
+
52
+ disconnect() {
53
+ document.removeEventListener('click', this.handleOutsideClick.bind(this));
54
+ }
55
+
56
+ handleOutsideClick(event) {
57
+ if (!this.element.contains(event.target)) {
58
+ this.closeDropdown();
59
+ }
60
+ }
61
+
62
+ handleKeydown(event) {
63
+ const dropdown = this.dropdownTarget;
64
+ const options = this.optionTargets;
65
+ const activeOption = dropdown.querySelector('.active');
66
+
67
+ switch(event.key) {
68
+ case 'ArrowDown':
69
+ event.preventDefault();
70
+ this.navigateOptions(options, activeOption, 1);
71
+ break;
72
+ case 'ArrowUp':
73
+ event.preventDefault();
74
+ this.navigateOptions(options, activeOption, -1);
75
+ break;
76
+ case 'Enter':
77
+ event.preventDefault();
78
+ if (activeOption) {
79
+ this.selectOption(activeOption.dataset.value, activeOption.textContent);
80
+ }
81
+ break;
82
+ case 'Escape':
83
+ this.closeDropdown();
84
+ break;
85
+ }
86
+ }
87
+
88
+ navigateOptions(options, activeOption, direction) {
89
+ let currentIndex = activeOption ? Array.from(options).indexOf(activeOption) : -1;
90
+ let nextIndex = currentIndex + direction;
91
+
92
+ if (nextIndex < 0) nextIndex = options.length - 1;
93
+ if (nextIndex >= options.length) nextIndex = 0;
94
+
95
+ // Remove active class from all options
96
+ options.forEach(option => option.classList.remove('active'));
97
+
98
+ // Add active class to next option
99
+ if (options[nextIndex]) {
100
+ options[nextIndex].classList.add('active');
101
+ options[nextIndex].scrollIntoView({ block: 'nearest' });
102
+ }
103
+ }
104
+
105
+ search(event) {
106
+ const query = event.target.value.toLowerCase().trim();
107
+
108
+ // Parse options properly
109
+ let options = this.optionsValue;
110
+ if (typeof options === 'string') {
111
+ try {
112
+ options = JSON.parse(options);
113
+ } catch (e) {
114
+ options = [];
115
+ }
116
+ }
117
+ options = Array.isArray(options) ? options : [];
118
+
119
+ if (query === '' || query === ' ') {
120
+ this.filteredOptions = options.slice(0, this.maxOptionsValue);
121
+ } else {
122
+ this.filteredOptions = options
123
+ .filter(option => option.text.toLowerCase().includes(query))
124
+ .slice(0, this.maxOptionsValue);
125
+ }
126
+
127
+ this.renderOptions();
128
+ this.openDropdown();
129
+ }
130
+
131
+ selectOption(value, text) {
132
+ // Update the input to show selected text
133
+ this.inputTarget.value = text;
134
+ this.selectedValue = value;
135
+
136
+ // Update hidden field if present
137
+ if (this.hasHiddenTarget) {
138
+ this.hiddenTarget.value = value;
139
+ }
140
+
141
+ // Dispatch custom event for external handling
142
+ const selectEvent = new CustomEvent('select:change', {
143
+ detail: { value: value, text: text },
144
+ bubbles: true
145
+ });
146
+ this.element.dispatchEvent(selectEvent);
147
+
148
+ this.closeDropdown();
149
+ }
150
+
151
+ openDropdown() {
152
+ this.dropdownTarget.classList.remove('hidden');
153
+ this.dropdownTarget.classList.add('visible');
154
+ }
155
+
156
+ closeDropdown() {
157
+ this.dropdownTarget.classList.remove('visible');
158
+ this.dropdownTarget.classList.add('hidden');
159
+ }
160
+
161
+ focus() {
162
+ // Reset search to show all options when focusing
163
+ const currentValue = this.inputTarget.value.trim();
164
+ if (currentValue === '' || currentValue === ' ') {
165
+ // Parse options properly
166
+ let options = this.optionsValue;
167
+ if (typeof options === 'string') {
168
+ try {
169
+ options = JSON.parse(options);
170
+ } catch (e) {
171
+ options = [];
172
+ }
173
+ }
174
+ this.filteredOptions = Array.isArray(options) ? options.slice(0, this.maxOptionsValue) : [];
175
+ this.renderOptions();
176
+ }
177
+ this.openDropdown();
178
+ }
179
+
180
+ blur() {
181
+ // Delay hiding to allow option clicks
182
+ setTimeout(() => {
183
+ this.closeDropdown();
184
+ }, 150);
185
+ }
186
+
187
+ render() {
188
+ // Don't set placeholder for Beer CSS floating labels
189
+ this.renderOptions();
190
+ }
191
+
192
+ renderOptions() {
193
+ this.dropdownTarget.innerHTML = '';
194
+
195
+ if (this.filteredOptions.length === 0) {
196
+ const noResults = document.createElement('div');
197
+ noResults.className = 'select-option no-results';
198
+ noResults.textContent = 'No results found';
199
+ this.dropdownTarget.appendChild(noResults);
200
+ return;
201
+ }
202
+
203
+ this.filteredOptions.forEach(option => {
204
+ const optionElement = document.createElement('div');
205
+ optionElement.className = 'select-option';
206
+ optionElement.dataset.selectTarget = 'option';
207
+ optionElement.dataset.value = option.value;
208
+ optionElement.textContent = option.text;
209
+ optionElement.addEventListener('click', () => {
210
+ this.selectOption(option.value, option.text);
211
+ });
212
+ this.dropdownTarget.appendChild(optionElement);
213
+ });
214
+ }
215
+ }
@@ -0,0 +1,19 @@
1
+ // Import controllers
2
+ import SearchController from "sage/controllers/search_controller"
3
+ import ClipboardController from "sage/controllers/clipboard_controller"
4
+ import SelectController from "sage/controllers/select_controller"
5
+ import DashboardController from "sage/controllers/dashboard_controller"
6
+ import ReverseInfiniteScrollController from "sage/controllers/reverse_infinite_scroll_controller"
7
+
8
+ // Export all Sage controllers for manual registration
9
+ export { SearchController, ClipboardController, SelectController, DashboardController, ReverseInfiniteScrollController }
10
+
11
+ // Register all Sage controllers with the provided Stimulus application
12
+ export function registerControllers(application) {
13
+ application.register("sage--search", SearchController)
14
+ application.register("sage--clipboard", ClipboardController)
15
+ application.register("sage--select", SelectController)
16
+ application.register("sage--dashboard", DashboardController)
17
+ application.register("sage--reverse-infinite-scroll", ReverseInfiniteScrollController)
18
+ }
19
+
@@ -0,0 +1,4 @@
1
+ module Sage
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,80 @@
1
+ require "sage/report_processor"
2
+
3
+ module Sage
4
+ class ProcessReportJob < ActiveJob::Base
5
+ include ActionView::RecordIdentifier
6
+ include Sage::Engine.routes.url_helpers
7
+
8
+ def perform(prompt, query_id:, stream_target_id:)
9
+ query = Blazer::Query.find(query_id)
10
+
11
+ # Ensure we have the proper routing context for broadcasts
12
+ self.class.default_url_options = Rails.application.routes.default_url_options
13
+
14
+ Turbo::StreamsChannel.broadcast_append_to(
15
+ "messages",
16
+ target: dom_id(query, "messages"),
17
+ partial: "sage/queries/streaming_message",
18
+ locals: { stream_target_id:, content: "Thinking..." }
19
+ )
20
+
21
+ # Use the ReportProcessor to handle the LLM interaction and parsing
22
+ processor = ReportProcessor.new(
23
+ query: query,
24
+ prompt: prompt,
25
+ stream_target_id: stream_target_id
26
+ )
27
+
28
+ result = processor.process
29
+ Rails.logger.info "ProcessReportJob result: #{result.inspect}"
30
+ summary = result[:summary]
31
+ sql = result[:sql]
32
+
33
+ Rails.logger.info "Summary: #{summary}"
34
+ Rails.logger.info "SQL: #{sql}"
35
+ # Handle empty summary
36
+ summary = "I couldn't generate a response. Please try again." if summary.blank?
37
+
38
+ ai_message = query.messages.create!(body: summary, statement: sql)
39
+
40
+ Turbo::StreamsChannel.broadcast_replace_to(
41
+ "messages",
42
+ target: stream_target_id,
43
+ partial: "sage/queries/message",
44
+ locals: { message: ai_message, stream_target_id: stream_target_id }
45
+ )
46
+
47
+ Turbo::StreamsChannel.broadcast_replace_to(
48
+ "statements",
49
+ target: dom_id(query, "statement-box"),
50
+ partial: "sage/queries/statement_box",
51
+ locals: { query: query, statement: sql, form_url: run_queries_path }
52
+ )
53
+
54
+ # Auto-submit the form after the statement_box renders
55
+ Turbo::StreamsChannel.broadcast_append_to(
56
+ "statements",
57
+ target: "body",
58
+ html: "<script>
59
+ setTimeout(() => {
60
+ // Wait for ACE editor to be fully initialized
61
+ const checkAndSubmit = () => {
62
+ const form = document.querySelector('##{dom_id(query, "statement-box")} form');
63
+ const hiddenField = document.querySelector('#query_statement');
64
+
65
+ if (form && hiddenField && hiddenField.value && window.aceEditor) {
66
+ form.submit();
67
+ } else {
68
+ // Retry after another 100ms if not ready
69
+ setTimeout(checkAndSubmit, 100);
70
+ }
71
+ };
72
+ checkAndSubmit();
73
+ }, 200);
74
+ </script>"
75
+ )
76
+
77
+ true
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,6 @@
1
+ module Sage
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module Sage
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,8 @@
1
+ module Sage
2
+ class Message < ApplicationRecord
3
+ belongs_to :blazer_query, class_name: "Blazer::Query", foreign_key: :blazer_query_id
4
+ belongs_to :creator, optional: true, class_name: ::Blazer.user_class.to_s if ::Blazer.user_class
5
+
6
+ validates :body, presence: true
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ require "ruby_llm/schema"
2
+
3
+ module Sage
4
+ class ReportResponseSchema < RubyLLM::Schema
5
+ string :sql, description: "Generated SQL based on user prompt"
6
+ string :summary, description: "Natural language summary of generated report"
7
+ end
8
+ end