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,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,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,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
|