query_lens 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +331 -0
- data/Rakefile +10 -0
- data/app/controllers/query_lens/ai_controller.rb +23 -0
- data/app/controllers/query_lens/application_controller.rb +46 -0
- data/app/controllers/query_lens/conversations_controller.rb +65 -0
- data/app/controllers/query_lens/projects_controller.rb +64 -0
- data/app/controllers/query_lens/queries_controller.rb +95 -0
- data/app/controllers/query_lens/saved_queries_controller.rb +43 -0
- data/app/models/query_lens/application_record.rb +5 -0
- data/app/models/query_lens/conversation.rb +16 -0
- data/app/models/query_lens/project.rb +11 -0
- data/app/models/query_lens/saved_query.rb +13 -0
- data/app/services/query_lens/schema_introspector.rb +156 -0
- data/app/services/query_lens/sql_generator.rb +146 -0
- data/app/views/query_lens/layouts/application.html.erb +526 -0
- data/app/views/query_lens/queries/show.html.erb +863 -0
- data/config/routes.rb +11 -0
- data/lib/generators/query_lens/install/install_generator.rb +28 -0
- data/lib/generators/query_lens/install/templates/initializer.rb +64 -0
- data/lib/query_lens/configuration.rb +21 -0
- data/lib/query_lens/engine.rb +17 -0
- data/lib/query_lens/version.rb +3 -0
- data/lib/query_lens.rb +22 -0
- metadata +140 -0
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
<div id="query-lens-app"
|
|
2
|
+
data-execute-url="<%= query_lens.execute_path %>"
|
|
3
|
+
data-generate-url="<%= query_lens.generate_path %>"
|
|
4
|
+
data-info-url="<%= query_lens.info_path %>"
|
|
5
|
+
data-projects-url="<%= query_lens.projects_path %>"
|
|
6
|
+
data-saved-queries-url="<%= query_lens.saved_queries_path %>"
|
|
7
|
+
data-conversations-url="<%= query_lens.conversations_path %>"
|
|
8
|
+
class="ql-app">
|
|
9
|
+
|
|
10
|
+
<!-- Sidebar -->
|
|
11
|
+
<aside class="ql-sidebar">
|
|
12
|
+
<div class="ql-sidebar-header">
|
|
13
|
+
<div class="ql-sidebar-brand">
|
|
14
|
+
<span class="ql-sidebar-brand-icon">
|
|
15
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
16
|
+
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
|
|
17
|
+
</svg>
|
|
18
|
+
</span>
|
|
19
|
+
QueryLens
|
|
20
|
+
</div>
|
|
21
|
+
<button class="ql-sidebar-new-chat" id="ql-new-chat-btn" type="button" title="New Chat">
|
|
22
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
23
|
+
</button>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<div class="ql-sidebar-body">
|
|
27
|
+
<!-- Saved Queries Section -->
|
|
28
|
+
<div class="ql-sidebar-section">
|
|
29
|
+
<div class="ql-sidebar-section-header" id="ql-saved-section-toggle">
|
|
30
|
+
<svg class="ql-sidebar-section-chevron" id="ql-saved-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
|
31
|
+
Saved Queries
|
|
32
|
+
<span class="ql-sidebar-section-count" id="ql-saved-total-count">0</span>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="ql-sidebar-section-content" id="ql-saved-section-content">
|
|
35
|
+
<div class="ql-sidebar-actions">
|
|
36
|
+
<button class="ql-sidebar-action-btn" id="ql-add-project-btn" type="button">
|
|
37
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><line x1="12" y1="11" x2="12" y2="17"/><line x1="9" y1="14" x2="15" y2="14"/></svg>
|
|
38
|
+
New Project
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="ql-saved-list" id="ql-saved-list"></div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="ql-sidebar-divider"></div>
|
|
46
|
+
|
|
47
|
+
<!-- Recents Section -->
|
|
48
|
+
<div class="ql-sidebar-section">
|
|
49
|
+
<div class="ql-sidebar-section-label">Recents</div>
|
|
50
|
+
<div class="ql-sidebar-recents" id="ql-conversation-list"></div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="ql-sidebar-footer">
|
|
55
|
+
<span class="ql-badge">AI-powered SQL</span>
|
|
56
|
+
<div class="ql-status" id="ql-status"></div>
|
|
57
|
+
</div>
|
|
58
|
+
</aside>
|
|
59
|
+
|
|
60
|
+
<!-- Chat Panel -->
|
|
61
|
+
<div class="ql-chat">
|
|
62
|
+
<div class="ql-chat-content" id="ql-chat-content">
|
|
63
|
+
<div class="ql-restricted-banner" id="ql-restricted-banner" style="display:none;">
|
|
64
|
+
<button class="ql-restricted-toggle" id="ql-restricted-toggle" type="button">
|
|
65
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
66
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
67
|
+
</svg>
|
|
68
|
+
<span id="ql-restricted-count"></span> restricted table(s)
|
|
69
|
+
<svg class="ql-restricted-chevron" id="ql-restricted-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
70
|
+
<polyline points="6 9 12 15 18 9"/>
|
|
71
|
+
</svg>
|
|
72
|
+
</button>
|
|
73
|
+
<div class="ql-restricted-list" id="ql-restricted-list" style="display:none;"></div>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="ql-messages" id="ql-messages">
|
|
76
|
+
<div class="ql-empty-state" id="ql-empty-state">
|
|
77
|
+
<div class="ql-empty-icon">
|
|
78
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
79
|
+
<path d="M12 6v12m-3-2.818.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
|
|
80
|
+
</svg>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="ql-empty-title">Ask about your data</div>
|
|
83
|
+
<div class="ql-empty-text">
|
|
84
|
+
Try something like:<br>
|
|
85
|
+
"How many users signed up this month?"<br>
|
|
86
|
+
"What's the total revenue by plan?"
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div><!-- /.ql-chat-content -->
|
|
91
|
+
<div class="ql-input-area">
|
|
92
|
+
<form class="ql-input-form" id="ql-form">
|
|
93
|
+
<input type="text" id="ql-input" placeholder="Ask a question about your data..." autocomplete="off">
|
|
94
|
+
<button type="submit" id="ql-ask-btn">Ask</button>
|
|
95
|
+
</form>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<!-- Workspace -->
|
|
100
|
+
<div class="ql-workspace">
|
|
101
|
+
|
|
102
|
+
<!-- SQL Editor -->
|
|
103
|
+
<div class="ql-editor-section">
|
|
104
|
+
<div class="ql-editor-toolbar">
|
|
105
|
+
<div class="ql-editor-title">SQL Editor</div>
|
|
106
|
+
<div class="ql-editor-actions">
|
|
107
|
+
<button class="ql-save-btn" id="ql-save-query-btn">
|
|
108
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
|
109
|
+
Save
|
|
110
|
+
</button>
|
|
111
|
+
<button class="ql-run-btn" id="ql-run-btn">
|
|
112
|
+
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
113
|
+
Run
|
|
114
|
+
<span class="ql-run-shortcut"> ⌘⏎ </span>
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
<div class="ql-editor-wrap">
|
|
119
|
+
<textarea class="ql-sql-textarea" id="ql-sql" placeholder="-- Your SQL query will appear here..." spellcheck="false"></textarea>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<!-- Results -->
|
|
124
|
+
<div class="ql-results-section">
|
|
125
|
+
<div class="ql-results-toolbar">
|
|
126
|
+
<div class="ql-results-title">Results</div>
|
|
127
|
+
<div class="ql-row-count" id="ql-row-count"></div>
|
|
128
|
+
</div>
|
|
129
|
+
<div class="ql-results-body" id="ql-results">
|
|
130
|
+
<div class="ql-results-empty">
|
|
131
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
132
|
+
<path d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 0v.375"/>
|
|
133
|
+
</svg>
|
|
134
|
+
<span>Run a query to see results</span>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<script>
|
|
142
|
+
(() => {
|
|
143
|
+
const app = document.getElementById('query-lens-app');
|
|
144
|
+
const executeUrl = app.dataset.executeUrl;
|
|
145
|
+
const generateUrl = app.dataset.generateUrl;
|
|
146
|
+
const infoUrl = app.dataset.infoUrl;
|
|
147
|
+
const projectsUrl = app.dataset.projectsUrl;
|
|
148
|
+
const savedQueriesUrl = app.dataset.savedQueriesUrl;
|
|
149
|
+
const conversationsUrl = app.dataset.conversationsUrl;
|
|
150
|
+
|
|
151
|
+
const el = {
|
|
152
|
+
messages: document.getElementById('ql-messages'),
|
|
153
|
+
emptyState: document.getElementById('ql-empty-state'),
|
|
154
|
+
input: document.getElementById('ql-input'),
|
|
155
|
+
form: document.getElementById('ql-form'),
|
|
156
|
+
askBtn: document.getElementById('ql-ask-btn'),
|
|
157
|
+
sql: document.getElementById('ql-sql'),
|
|
158
|
+
runBtn: document.getElementById('ql-run-btn'),
|
|
159
|
+
results: document.getElementById('ql-results'),
|
|
160
|
+
rowCount: document.getElementById('ql-row-count'),
|
|
161
|
+
status: document.getElementById('ql-status'),
|
|
162
|
+
restrictedBanner: document.getElementById('ql-restricted-banner'),
|
|
163
|
+
restrictedToggle: document.getElementById('ql-restricted-toggle'),
|
|
164
|
+
restrictedChevron: document.getElementById('ql-restricted-chevron'),
|
|
165
|
+
restrictedCount: document.getElementById('ql-restricted-count'),
|
|
166
|
+
restrictedList: document.getElementById('ql-restricted-list'),
|
|
167
|
+
savedList: document.getElementById('ql-saved-list'),
|
|
168
|
+
addProjectBtn: document.getElementById('ql-add-project-btn'),
|
|
169
|
+
saveQueryBtn: document.getElementById('ql-save-query-btn'),
|
|
170
|
+
newChatBtn: document.getElementById('ql-new-chat-btn'),
|
|
171
|
+
conversationList: document.getElementById('ql-conversation-list'),
|
|
172
|
+
savedSectionToggle: document.getElementById('ql-saved-section-toggle'),
|
|
173
|
+
savedSectionChevron: document.getElementById('ql-saved-section-chevron'),
|
|
174
|
+
savedSectionContent: document.getElementById('ql-saved-section-content'),
|
|
175
|
+
savedTotalCount: document.getElementById('ql-saved-total-count'),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
let conversation = [];
|
|
179
|
+
let currentConversationId = null;
|
|
180
|
+
let conversationsList = [];
|
|
181
|
+
|
|
182
|
+
// ── Load restricted tables ──
|
|
183
|
+
(async function loadInfo() {
|
|
184
|
+
try {
|
|
185
|
+
const res = await fetch(infoUrl);
|
|
186
|
+
const info = await res.json();
|
|
187
|
+
if (info.excluded_tables && info.excluded_tables.length > 0) {
|
|
188
|
+
el.restrictedCount.textContent = info.excluded_tables.length;
|
|
189
|
+
el.restrictedList.innerHTML = info.excluded_tables
|
|
190
|
+
.map(t => '<span class="ql-restricted-tag">' + esc(t) + '</span>')
|
|
191
|
+
.join('');
|
|
192
|
+
el.restrictedBanner.style.display = 'block';
|
|
193
|
+
}
|
|
194
|
+
} catch (e) { /* silently fail */ }
|
|
195
|
+
})();
|
|
196
|
+
|
|
197
|
+
el.restrictedToggle.addEventListener('click', () => {
|
|
198
|
+
const open = el.restrictedList.style.display === 'none';
|
|
199
|
+
el.restrictedList.style.display = open ? 'flex' : 'none';
|
|
200
|
+
el.restrictedChevron.classList.toggle('ql-open', open);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ── Events ──
|
|
204
|
+
el.form.addEventListener('submit', (e) => { e.preventDefault(); ask(); });
|
|
205
|
+
el.runBtn.addEventListener('click', () => runQuery());
|
|
206
|
+
el.sql.addEventListener('keydown', (e) => {
|
|
207
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); runQuery(); }
|
|
208
|
+
// Tab inserts spaces
|
|
209
|
+
if (e.key === 'Tab') {
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
const s = el.sql.selectionStart;
|
|
212
|
+
el.sql.value = el.sql.value.substring(0, s) + ' ' + el.sql.value.substring(el.sql.selectionEnd);
|
|
213
|
+
el.sql.selectionStart = el.sql.selectionEnd = s + 2;
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ── Ask ──
|
|
218
|
+
async function ask() {
|
|
219
|
+
const q = el.input.value.trim();
|
|
220
|
+
if (!q) return;
|
|
221
|
+
|
|
222
|
+
el.input.value = '';
|
|
223
|
+
const askTime = new Date();
|
|
224
|
+
addMessage('user', q, askTime);
|
|
225
|
+
conversation.push({ role: 'user', content: q });
|
|
226
|
+
setLoading(true);
|
|
227
|
+
|
|
228
|
+
const trace = [];
|
|
229
|
+
trace.push({ time: askTime, dot: 'blue', label: 'Question received' });
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const data = await post(generateUrl, { messages: conversation });
|
|
233
|
+
const genTime = new Date();
|
|
234
|
+
|
|
235
|
+
if (data.error) {
|
|
236
|
+
trace.push({ time: genTime, dot: 'red', label: 'Error: ' + data.error });
|
|
237
|
+
addMessage('error', data.error);
|
|
238
|
+
addTrace(trace);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Build generation trace entry
|
|
243
|
+
let genLabel = 'SQL generated';
|
|
244
|
+
if (data.generation_ms) genLabel += ' <strong>(' + formatMs(data.generation_ms) + ')</strong>';
|
|
245
|
+
trace.push({ time: genTime, dot: 'blue', label: genLabel });
|
|
246
|
+
|
|
247
|
+
// Schema strategy trace
|
|
248
|
+
if (data.strategy === 'two_stage') {
|
|
249
|
+
let schemaLabel = 'Schema: <strong>' + data.tables_used + '</strong> tables selected from ' + data.total_tables;
|
|
250
|
+
trace.push({ time: genTime, dot: 'amber', label: schemaLabel, tables: data.tables_selected });
|
|
251
|
+
} else if (data.total_tables) {
|
|
252
|
+
trace.push({ time: genTime, dot: 'amber', label: 'Schema: <strong>' + data.total_tables + '</strong> tables (full schema)' });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (data.explanation) addMessage('ai', data.explanation, genTime);
|
|
256
|
+
if (data.sql) {
|
|
257
|
+
el.sql.value = data.sql;
|
|
258
|
+
conversation.push({ role: 'assistant', content: data.raw || data.explanation });
|
|
259
|
+
await runQueryWithTrace(trace);
|
|
260
|
+
} else {
|
|
261
|
+
addTrace(trace);
|
|
262
|
+
}
|
|
263
|
+
} catch (err) {
|
|
264
|
+
trace.push({ time: new Date(), dot: 'red', label: 'Error: ' + err.message });
|
|
265
|
+
addMessage('error', 'Failed to generate SQL: ' + err.message);
|
|
266
|
+
addTrace(trace);
|
|
267
|
+
} finally {
|
|
268
|
+
setLoading(false);
|
|
269
|
+
saveConversation();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Run Query (standalone, e.g. manual run button) ──
|
|
274
|
+
async function runQuery() {
|
|
275
|
+
const sql = el.sql.value.trim();
|
|
276
|
+
if (!sql) return;
|
|
277
|
+
|
|
278
|
+
const trace = [];
|
|
279
|
+
trace.push({ time: new Date(), dot: 'blue', label: 'Manual query execution' });
|
|
280
|
+
await runQueryWithTrace(trace);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Run Query with trace ──
|
|
284
|
+
async function runQueryWithTrace(trace) {
|
|
285
|
+
const sql = el.sql.value.trim();
|
|
286
|
+
if (!sql) return;
|
|
287
|
+
|
|
288
|
+
setRunning(true);
|
|
289
|
+
el.results.innerHTML = '<div class="ql-loading-row"><div class="ql-spinner"></div>Running query...</div>';
|
|
290
|
+
el.rowCount.textContent = '';
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
const data = await post(executeUrl, { sql });
|
|
294
|
+
const execTime = new Date();
|
|
295
|
+
|
|
296
|
+
if (data.error) {
|
|
297
|
+
trace.push({ time: execTime, dot: 'red', label: 'Query blocked: ' + esc(data.error) });
|
|
298
|
+
el.results.innerHTML = '<div class="ql-error-box">' + esc(data.error) + '</div>';
|
|
299
|
+
addTrace(trace);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let execLabel = 'Query executed';
|
|
304
|
+
if (data.execution_ms) execLabel += ' <strong>(' + formatMs(data.execution_ms) + ')</strong>';
|
|
305
|
+
trace.push({ time: execTime, dot: 'green', label: execLabel });
|
|
306
|
+
|
|
307
|
+
let resultLabel = '<strong>' + data.row_count + '</strong> row' + (data.row_count === 1 ? '' : 's') + ' returned';
|
|
308
|
+
if (data.truncated) resultLabel += ' (truncated)';
|
|
309
|
+
trace.push({ time: execTime, dot: 'green', label: resultLabel });
|
|
310
|
+
|
|
311
|
+
renderResults(data);
|
|
312
|
+
|
|
313
|
+
const summary = 'Query returned ' + data.row_count + ' row(s). Columns: ' + data.columns.join(', ') + '. First rows: ' + JSON.stringify(data.rows.slice(0, 5));
|
|
314
|
+
const last = conversation[conversation.length - 1];
|
|
315
|
+
if (last && last.role === 'assistant') last.content += '\n\nResult: ' + summary;
|
|
316
|
+
|
|
317
|
+
addTrace(trace);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
trace.push({ time: new Date(), dot: 'red', label: 'Error: ' + esc(err.message) });
|
|
320
|
+
el.results.innerHTML = '<div class="ql-error-box">Failed to execute: ' + esc(err.message) + '</div>';
|
|
321
|
+
addTrace(trace);
|
|
322
|
+
} finally {
|
|
323
|
+
setRunning(false);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── Render Results ──
|
|
328
|
+
function renderResults(data) {
|
|
329
|
+
if (!data.columns || !data.columns.length) {
|
|
330
|
+
el.results.innerHTML = '<div class="ql-results-empty"><span>Query returned no results</span></div>';
|
|
331
|
+
el.rowCount.textContent = '';
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let html = '';
|
|
336
|
+
if (data.truncated) html += '<div class="ql-truncated">Results truncated to ' + data.row_count + ' rows</div>';
|
|
337
|
+
|
|
338
|
+
html += '<table class="ql-table"><thead><tr>';
|
|
339
|
+
data.columns.forEach(c => { html += '<th>' + esc(c) + '</th>'; });
|
|
340
|
+
html += '</tr></thead><tbody>';
|
|
341
|
+
|
|
342
|
+
data.rows.forEach(row => {
|
|
343
|
+
html += '<tr>';
|
|
344
|
+
row.forEach(cell => {
|
|
345
|
+
if (cell === null) html += '<td><span class="ql-null">NULL</span></td>';
|
|
346
|
+
else html += '<td>' + esc(String(cell)) + '</td>';
|
|
347
|
+
});
|
|
348
|
+
html += '</tr>';
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
html += '</tbody></table>';
|
|
352
|
+
el.results.innerHTML = html;
|
|
353
|
+
el.rowCount.textContent = data.row_count + ' row' + (data.row_count === 1 ? '' : 's') + (data.truncated ? ' (truncated)' : '');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── Messages ──
|
|
357
|
+
function addMessage(role, content, timestamp) {
|
|
358
|
+
if (el.emptyState) { el.emptyState.remove(); }
|
|
359
|
+
|
|
360
|
+
const wrap = document.createElement('div');
|
|
361
|
+
const cls = role === 'user' ? 'ql-msg-user' : role === 'error' ? 'ql-msg-error' : 'ql-msg-ai';
|
|
362
|
+
wrap.className = 'ql-msg ' + cls;
|
|
363
|
+
|
|
364
|
+
const avatarLabel = role === 'user' ? 'You' : role === 'error' ? '!' : 'AI';
|
|
365
|
+
const timeStr = timestamp ? formatTime(timestamp) : '';
|
|
366
|
+
wrap.innerHTML =
|
|
367
|
+
'<div class="ql-msg-avatar">' + avatarLabel + '</div>' +
|
|
368
|
+
'<div><div class="ql-msg-bubble">' + esc(content) + '</div>' +
|
|
369
|
+
(timeStr ? '<div class="ql-msg-time">' + timeStr + '</div>' : '') +
|
|
370
|
+
'</div>';
|
|
371
|
+
|
|
372
|
+
// Remove typing indicator if present
|
|
373
|
+
const typing = el.messages.querySelector('.ql-msg-typing');
|
|
374
|
+
if (typing) typing.remove();
|
|
375
|
+
|
|
376
|
+
el.messages.appendChild(wrap);
|
|
377
|
+
el.messages.scrollTop = el.messages.scrollHeight;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── Activity Trace ──
|
|
381
|
+
function addTrace(steps) {
|
|
382
|
+
if (!steps.length) return;
|
|
383
|
+
|
|
384
|
+
const wrap = document.createElement('div');
|
|
385
|
+
wrap.className = 'ql-trace';
|
|
386
|
+
|
|
387
|
+
let html = '<div class="ql-trace-inner">';
|
|
388
|
+
html += '<div class="ql-trace-header">';
|
|
389
|
+
html += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>';
|
|
390
|
+
html += 'Activity trace</div>';
|
|
391
|
+
|
|
392
|
+
steps.forEach(step => {
|
|
393
|
+
html += '<div class="ql-trace-row">';
|
|
394
|
+
html += '<span class="ql-trace-time">' + formatTime(step.time) + '</span>';
|
|
395
|
+
html += '<span class="ql-trace-dot ql-trace-dot-' + step.dot + '"></span>';
|
|
396
|
+
html += '<span class="ql-trace-label">' + step.label + '</span>';
|
|
397
|
+
html += '</div>';
|
|
398
|
+
if (step.tables && step.tables.length) {
|
|
399
|
+
html += '<div class="ql-trace-row"><span class="ql-trace-time"></span><span class="ql-trace-dot" style="visibility:hidden"></span>';
|
|
400
|
+
html += '<span class="ql-trace-tables">';
|
|
401
|
+
step.tables.forEach(t => { html += '<span class="ql-trace-table-tag">' + esc(t) + '</span>'; });
|
|
402
|
+
html += '</span></div>';
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
html += '</div>';
|
|
407
|
+
wrap.innerHTML = html;
|
|
408
|
+
el.messages.appendChild(wrap);
|
|
409
|
+
el.messages.scrollTop = el.messages.scrollHeight;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function showTyping() {
|
|
413
|
+
if (el.emptyState) el.emptyState.remove();
|
|
414
|
+
const wrap = document.createElement('div');
|
|
415
|
+
wrap.className = 'ql-msg ql-msg-ai ql-msg-typing';
|
|
416
|
+
wrap.innerHTML =
|
|
417
|
+
'<div class="ql-msg-avatar">AI</div>' +
|
|
418
|
+
'<div class="ql-msg-bubble"><div class="ql-typing"><span></span><span></span><span></span></div></div>';
|
|
419
|
+
el.messages.appendChild(wrap);
|
|
420
|
+
el.messages.scrollTop = el.messages.scrollHeight;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ── State ──
|
|
424
|
+
function setLoading(on) {
|
|
425
|
+
el.askBtn.disabled = on;
|
|
426
|
+
el.input.disabled = on;
|
|
427
|
+
if (on) {
|
|
428
|
+
el.askBtn.innerHTML = '<div class="ql-spinner ql-spinner-sm"></div>';
|
|
429
|
+
el.status.innerHTML = '<div class="ql-spinner ql-spinner-sm"></div> Generating SQL...';
|
|
430
|
+
showTyping();
|
|
431
|
+
} else {
|
|
432
|
+
el.askBtn.textContent = 'Ask';
|
|
433
|
+
el.status.textContent = '';
|
|
434
|
+
el.input.focus();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function setRunning(on) { el.runBtn.disabled = on; }
|
|
439
|
+
|
|
440
|
+
// ── Helpers ──
|
|
441
|
+
async function request(url, method, body) {
|
|
442
|
+
const token = document.querySelector('meta[name="csrf-token"]')?.content;
|
|
443
|
+
const opts = {
|
|
444
|
+
method: method || 'POST',
|
|
445
|
+
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token || '' }
|
|
446
|
+
};
|
|
447
|
+
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
448
|
+
const res = await fetch(url, opts);
|
|
449
|
+
if (res.status === 204) return null;
|
|
450
|
+
return res.json();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function post(url, body) { return request(url, 'POST', body); }
|
|
454
|
+
|
|
455
|
+
function formatTime(date) {
|
|
456
|
+
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function formatMs(ms) {
|
|
460
|
+
if (ms < 1000) return ms + 'ms';
|
|
461
|
+
return (ms / 1000).toFixed(1) + 's';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function esc(s) {
|
|
465
|
+
const d = document.createElement('div');
|
|
466
|
+
d.textContent = s;
|
|
467
|
+
return d.innerHTML;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ── Conversation Persistence ──
|
|
471
|
+
async function loadConversations() {
|
|
472
|
+
try {
|
|
473
|
+
const data = await request(conversationsUrl, 'GET');
|
|
474
|
+
conversationsList = data;
|
|
475
|
+
renderConversationList();
|
|
476
|
+
} catch (e) { /* silently fail */ }
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function renderConversationList() {
|
|
480
|
+
if (!conversationsList.length) {
|
|
481
|
+
el.conversationList.innerHTML = '<div class="ql-sidebar-empty">No conversations yet</div>';
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
let html = '';
|
|
486
|
+
conversationsList.forEach(c => {
|
|
487
|
+
const active = c.id === currentConversationId ? ' ql-conversation-item-active' : '';
|
|
488
|
+
html += '<div class="ql-conversation-item' + active + '" data-conversation-id="' + c.id + '">';
|
|
489
|
+
html += '<span class="ql-conversation-title">' + esc(c.title) + '</span>';
|
|
490
|
+
html += '<button class="ql-conversation-delete" data-delete-id="' + c.id + '" type="button">×</button>';
|
|
491
|
+
html += '</div>';
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
el.conversationList.innerHTML = html;
|
|
495
|
+
|
|
496
|
+
// Bind click events
|
|
497
|
+
el.conversationList.querySelectorAll('.ql-conversation-item').forEach(item => {
|
|
498
|
+
item.addEventListener('click', (e) => {
|
|
499
|
+
if (e.target.closest('.ql-conversation-delete')) return;
|
|
500
|
+
loadConversation(parseInt(item.dataset.conversationId, 10));
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
el.conversationList.querySelectorAll('.ql-conversation-delete').forEach(btn => {
|
|
505
|
+
btn.addEventListener('click', async (e) => {
|
|
506
|
+
e.stopPropagation();
|
|
507
|
+
const id = parseInt(btn.dataset.deleteId, 10);
|
|
508
|
+
try {
|
|
509
|
+
await request(conversationsUrl + '/' + id, 'DELETE');
|
|
510
|
+
if (currentConversationId === id) startNewChat();
|
|
511
|
+
loadConversations();
|
|
512
|
+
} catch (e) { /* silently fail */ }
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function loadConversation(id) {
|
|
518
|
+
try {
|
|
519
|
+
const data = await request(conversationsUrl + '/' + id, 'GET');
|
|
520
|
+
currentConversationId = data.id;
|
|
521
|
+
conversation = data.messages || [];
|
|
522
|
+
|
|
523
|
+
// Clear and re-render messages
|
|
524
|
+
el.messages.innerHTML = '';
|
|
525
|
+
if (!conversation.length) {
|
|
526
|
+
el.messages.innerHTML = '<div class="ql-empty-state" id="ql-empty-state"><div class="ql-empty-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 6v12m-3-2.818.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg></div><div class="ql-empty-title">Ask about your data</div><div class="ql-empty-text">Try something like:<br>"How many users signed up this month?"<br>"What\'s the total revenue by plan?"</div></div>';
|
|
527
|
+
} else {
|
|
528
|
+
conversation.forEach(msg => {
|
|
529
|
+
addMessage(msg.role === 'assistant' ? 'ai' : msg.role, msg.content);
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Restore SQL editor
|
|
534
|
+
el.sql.value = data.last_sql || '';
|
|
535
|
+
|
|
536
|
+
// Clear results
|
|
537
|
+
el.results.innerHTML = '<div class="ql-results-empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 0v.375"/></svg><span>Run a query to see results</span></div>';
|
|
538
|
+
el.rowCount.textContent = '';
|
|
539
|
+
|
|
540
|
+
renderConversationList();
|
|
541
|
+
} catch (e) { /* silently fail */ }
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function startNewChat() {
|
|
545
|
+
currentConversationId = null;
|
|
546
|
+
conversation = [];
|
|
547
|
+
el.messages.innerHTML = '<div class="ql-empty-state" id="ql-empty-state"><div class="ql-empty-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 6v12m-3-2.818.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg></div><div class="ql-empty-title">Ask about your data</div><div class="ql-empty-text">Try something like:<br>"How many users signed up this month?"<br>"What\'s the total revenue by plan?"</div></div>';
|
|
548
|
+
el.sql.value = '';
|
|
549
|
+
el.results.innerHTML = '<div class="ql-results-empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 0v.375"/></svg><span>Run a query to see results</span></div>';
|
|
550
|
+
el.rowCount.textContent = '';
|
|
551
|
+
el.input.focus();
|
|
552
|
+
renderConversationList();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function saveConversation() {
|
|
556
|
+
try {
|
|
557
|
+
const lastSql = el.sql.value.trim() || null;
|
|
558
|
+
if (currentConversationId) {
|
|
559
|
+
await request(conversationsUrl + '/' + currentConversationId, 'PATCH', {
|
|
560
|
+
messages: conversation,
|
|
561
|
+
last_sql: lastSql
|
|
562
|
+
});
|
|
563
|
+
} else {
|
|
564
|
+
const firstUserMsg = conversation.find(m => m.role === 'user');
|
|
565
|
+
const title = firstUserMsg ? firstUserMsg.content.substring(0, 80) : 'New conversation';
|
|
566
|
+
const data = await request(conversationsUrl, 'POST', {
|
|
567
|
+
title: title,
|
|
568
|
+
messages: conversation,
|
|
569
|
+
last_sql: lastSql
|
|
570
|
+
});
|
|
571
|
+
if (data && data.id) currentConversationId = data.id;
|
|
572
|
+
}
|
|
573
|
+
loadConversations();
|
|
574
|
+
} catch (e) { /* fire-and-forget */ }
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
el.newChatBtn.addEventListener('click', startNewChat);
|
|
578
|
+
loadConversations();
|
|
579
|
+
loadSavedQueries();
|
|
580
|
+
|
|
581
|
+
// ── Saved Queries Section Toggle ──
|
|
582
|
+
el.savedSectionToggle.addEventListener('click', () => {
|
|
583
|
+
const content = el.savedSectionContent;
|
|
584
|
+
const chevron = el.savedSectionChevron;
|
|
585
|
+
const collapsed = !content.classList.contains('ql-hidden');
|
|
586
|
+
content.classList.toggle('ql-hidden', collapsed);
|
|
587
|
+
chevron.classList.toggle('ql-collapsed', collapsed);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// ── Saved Queries ──
|
|
591
|
+
let savedData = { projects: [], unorganized: [] };
|
|
592
|
+
let collapsedProjects = {};
|
|
593
|
+
|
|
594
|
+
async function loadSavedQueries() {
|
|
595
|
+
try {
|
|
596
|
+
const data = await request(projectsUrl, 'GET');
|
|
597
|
+
savedData = data;
|
|
598
|
+
renderSavedList();
|
|
599
|
+
} catch (e) { /* silently fail */ }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function renderSavedList() {
|
|
603
|
+
let html = '';
|
|
604
|
+
|
|
605
|
+
savedData.projects.forEach(project => {
|
|
606
|
+
const collapsed = collapsedProjects[project.id];
|
|
607
|
+
html += '<div class="ql-saved-project">';
|
|
608
|
+
html += '<div class="ql-saved-project-header" data-project-id="' + project.id + '">';
|
|
609
|
+
html += '<svg class="ql-saved-chevron' + (collapsed ? '' : ' ql-open') + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
|
|
610
|
+
html += '<svg class="ql-saved-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
|
611
|
+
html += '<span class="ql-saved-project-name">' + esc(project.name) + '</span>';
|
|
612
|
+
html += '<span class="ql-saved-count">' + project.saved_queries.length + '</span>';
|
|
613
|
+
html += '<button class="ql-kebab" data-menu="project-' + project.id + '" type="button">⋮</button>';
|
|
614
|
+
html += '</div>';
|
|
615
|
+
|
|
616
|
+
// Kebab menu
|
|
617
|
+
html += '<div class="ql-kebab-menu" id="menu-project-' + project.id + '" style="display:none;">';
|
|
618
|
+
html += '<button data-action="rename-project" data-id="' + project.id + '" data-name="' + esc(project.name) + '">Rename</button>';
|
|
619
|
+
html += '<button data-action="delete-project" data-id="' + project.id + '">Delete</button>';
|
|
620
|
+
html += '</div>';
|
|
621
|
+
|
|
622
|
+
if (!collapsed) {
|
|
623
|
+
project.saved_queries.forEach(q => {
|
|
624
|
+
html += renderQueryItem(q);
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
html += '</div>';
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
if (savedData.unorganized.length > 0) {
|
|
631
|
+
html += '<div class="ql-saved-project">';
|
|
632
|
+
html += '<div class="ql-saved-project-header ql-saved-unorg">';
|
|
633
|
+
html += '<svg class="ql-saved-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>';
|
|
634
|
+
html += '<span class="ql-saved-project-name">Unorganized</span>';
|
|
635
|
+
html += '<span class="ql-saved-count">' + savedData.unorganized.length + '</span>';
|
|
636
|
+
html += '</div>';
|
|
637
|
+
savedData.unorganized.forEach(q => {
|
|
638
|
+
html += renderQueryItem(q);
|
|
639
|
+
});
|
|
640
|
+
html += '</div>';
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (savedData.projects.length === 0 && savedData.unorganized.length === 0) {
|
|
644
|
+
html = '<div class="ql-saved-empty">No saved queries yet</div>';
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
el.savedList.innerHTML = html;
|
|
648
|
+
|
|
649
|
+
// Update total count badge
|
|
650
|
+
let total = savedData.unorganized.length;
|
|
651
|
+
savedData.projects.forEach(p => { total += p.saved_queries.length; });
|
|
652
|
+
el.savedTotalCount.textContent = total;
|
|
653
|
+
|
|
654
|
+
bindSavedListEvents();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function renderQueryItem(q) {
|
|
658
|
+
let html = '<div class="ql-saved-query" data-query-id="' + q.id + '" data-sql="' + esc(q.sql).replace(/"/g, '"') + '">';
|
|
659
|
+
html += '<svg class="ql-saved-query-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>';
|
|
660
|
+
html += '<div class="ql-saved-query-info"><div class="ql-saved-query-name">' + esc(q.name) + '</div>';
|
|
661
|
+
if (q.description) html += '<div class="ql-saved-query-desc">' + esc(q.description) + '</div>';
|
|
662
|
+
html += '</div>';
|
|
663
|
+
html += '<button class="ql-kebab" data-menu="query-' + q.id + '" type="button">⋮</button>';
|
|
664
|
+
html += '</div>';
|
|
665
|
+
|
|
666
|
+
html += '<div class="ql-kebab-menu" id="menu-query-' + q.id + '" style="display:none;">';
|
|
667
|
+
html += '<button data-action="edit-query" data-id="' + q.id + '">Edit</button>';
|
|
668
|
+
html += '<button data-action="move-query" data-id="' + q.id + '">Move</button>';
|
|
669
|
+
html += '<button data-action="delete-query" data-id="' + q.id + '">Delete</button>';
|
|
670
|
+
html += '</div>';
|
|
671
|
+
|
|
672
|
+
return html;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function bindSavedListEvents() {
|
|
676
|
+
// Project collapse/expand
|
|
677
|
+
el.savedList.querySelectorAll('.ql-saved-project-header[data-project-id]').forEach(header => {
|
|
678
|
+
header.addEventListener('click', (e) => {
|
|
679
|
+
if (e.target.closest('.ql-kebab')) return;
|
|
680
|
+
const id = header.dataset.projectId;
|
|
681
|
+
collapsedProjects[id] = !collapsedProjects[id];
|
|
682
|
+
renderSavedList();
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// Load query on click
|
|
687
|
+
el.savedList.querySelectorAll('.ql-saved-query').forEach(item => {
|
|
688
|
+
item.addEventListener('click', (e) => {
|
|
689
|
+
if (e.target.closest('.ql-kebab')) return;
|
|
690
|
+
const sql = item.dataset.sql;
|
|
691
|
+
el.sql.value = sql;
|
|
692
|
+
runQuery();
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// Kebab toggles
|
|
697
|
+
el.savedList.querySelectorAll('.ql-kebab').forEach(btn => {
|
|
698
|
+
btn.addEventListener('click', (e) => {
|
|
699
|
+
e.stopPropagation();
|
|
700
|
+
const menuId = 'menu-' + btn.dataset.menu;
|
|
701
|
+
const menu = document.getElementById(menuId);
|
|
702
|
+
// Close all other menus
|
|
703
|
+
el.savedList.querySelectorAll('.ql-kebab-menu').forEach(m => { if (m.id !== menuId) m.style.display = 'none'; });
|
|
704
|
+
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// Menu actions
|
|
709
|
+
el.savedList.querySelectorAll('[data-action]').forEach(btn => {
|
|
710
|
+
btn.addEventListener('click', (e) => {
|
|
711
|
+
e.stopPropagation();
|
|
712
|
+
const action = btn.dataset.action;
|
|
713
|
+
const id = btn.dataset.id;
|
|
714
|
+
// Close menu
|
|
715
|
+
btn.closest('.ql-kebab-menu').style.display = 'none';
|
|
716
|
+
|
|
717
|
+
if (action === 'rename-project') renameProject(id, btn.dataset.name);
|
|
718
|
+
else if (action === 'delete-project') deleteProject(id);
|
|
719
|
+
else if (action === 'edit-query') editQuery(id);
|
|
720
|
+
else if (action === 'move-query') moveQuery(id);
|
|
721
|
+
else if (action === 'delete-query') deleteQuery(id);
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Close kebab menus on outside click
|
|
727
|
+
document.addEventListener('click', () => {
|
|
728
|
+
document.querySelectorAll('.ql-kebab-menu').forEach(m => m.style.display = 'none');
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// ── Project CRUD ──
|
|
732
|
+
el.addProjectBtn.addEventListener('click', async () => {
|
|
733
|
+
const name = prompt('Project name:');
|
|
734
|
+
if (!name || !name.trim()) return;
|
|
735
|
+
try {
|
|
736
|
+
await request(projectsUrl, 'POST', { name: name.trim() });
|
|
737
|
+
loadSavedQueries();
|
|
738
|
+
} catch (e) { alert('Failed to create project'); }
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
async function renameProject(id, currentName) {
|
|
742
|
+
const name = prompt('Rename project:', currentName);
|
|
743
|
+
if (!name || !name.trim() || name.trim() === currentName) return;
|
|
744
|
+
try {
|
|
745
|
+
await request(projectsUrl + '/' + id, 'PATCH', { name: name.trim() });
|
|
746
|
+
loadSavedQueries();
|
|
747
|
+
} catch (e) { alert('Failed to rename project'); }
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
async function deleteProject(id) {
|
|
751
|
+
if (!confirm('Delete this project? Queries will be moved to Unorganized.')) return;
|
|
752
|
+
try {
|
|
753
|
+
await request(projectsUrl + '/' + id, 'DELETE');
|
|
754
|
+
loadSavedQueries();
|
|
755
|
+
} catch (e) { alert('Failed to delete project'); }
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ── Save Query ──
|
|
759
|
+
el.saveQueryBtn.addEventListener('click', () => {
|
|
760
|
+
const sql = el.sql.value.trim();
|
|
761
|
+
if (!sql) { alert('No SQL in the editor to save.'); return; }
|
|
762
|
+
showSaveModal(sql);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
function showSaveModal(sql, existing) {
|
|
766
|
+
// Remove any existing modal
|
|
767
|
+
const old = document.getElementById('ql-save-modal');
|
|
768
|
+
if (old) old.remove();
|
|
769
|
+
|
|
770
|
+
const isEdit = !!existing;
|
|
771
|
+
const modal = document.createElement('div');
|
|
772
|
+
modal.id = 'ql-save-modal';
|
|
773
|
+
modal.className = 'ql-modal-overlay';
|
|
774
|
+
|
|
775
|
+
let projectOptions = '<option value="">(None)</option>';
|
|
776
|
+
savedData.projects.forEach(p => {
|
|
777
|
+
const sel = (existing && existing.project_id === p.id) ? ' selected' : '';
|
|
778
|
+
projectOptions += '<option value="' + p.id + '"' + sel + '>' + esc(p.name) + '</option>';
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
modal.innerHTML =
|
|
782
|
+
'<div class="ql-modal">' +
|
|
783
|
+
'<div class="ql-modal-title">' + (isEdit ? 'Edit Saved Query' : 'Save Current Query') + '</div>' +
|
|
784
|
+
'<label class="ql-modal-label">Name</label>' +
|
|
785
|
+
'<input class="ql-modal-input" id="ql-save-name" value="' + (existing ? esc(existing.name) : '') + '" placeholder="e.g. Monthly revenue">' +
|
|
786
|
+
'<label class="ql-modal-label">Description <span style="color:#52525b">(optional)</span></label>' +
|
|
787
|
+
'<input class="ql-modal-input" id="ql-save-desc" value="' + (existing ? esc(existing.description || '') : '') + '" placeholder="What does this query do?">' +
|
|
788
|
+
'<label class="ql-modal-label">Project</label>' +
|
|
789
|
+
'<select class="ql-modal-input" id="ql-save-project">' + projectOptions + '</select>' +
|
|
790
|
+
'<div class="ql-modal-actions">' +
|
|
791
|
+
'<button class="ql-modal-btn ql-modal-btn-cancel" id="ql-save-cancel">Cancel</button>' +
|
|
792
|
+
'<button class="ql-modal-btn ql-modal-btn-save" id="ql-save-confirm">' + (isEdit ? 'Update' : 'Save') + '</button>' +
|
|
793
|
+
'</div></div>';
|
|
794
|
+
|
|
795
|
+
document.body.appendChild(modal);
|
|
796
|
+
|
|
797
|
+
document.getElementById('ql-save-name').focus();
|
|
798
|
+
|
|
799
|
+
document.getElementById('ql-save-cancel').addEventListener('click', () => modal.remove());
|
|
800
|
+
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
|
801
|
+
|
|
802
|
+
document.getElementById('ql-save-confirm').addEventListener('click', async () => {
|
|
803
|
+
const name = document.getElementById('ql-save-name').value.trim();
|
|
804
|
+
if (!name) { alert('Name is required'); return; }
|
|
805
|
+
const desc = document.getElementById('ql-save-desc').value.trim();
|
|
806
|
+
const projectId = document.getElementById('ql-save-project').value || null;
|
|
807
|
+
|
|
808
|
+
const payload = { name: name, description: desc, sql: sql, project_id: projectId };
|
|
809
|
+
|
|
810
|
+
try {
|
|
811
|
+
if (isEdit) {
|
|
812
|
+
await request(savedQueriesUrl + '/' + existing.id, 'PATCH', payload);
|
|
813
|
+
} else {
|
|
814
|
+
await request(savedQueriesUrl, 'POST', payload);
|
|
815
|
+
}
|
|
816
|
+
modal.remove();
|
|
817
|
+
loadSavedQueries();
|
|
818
|
+
} catch (e) { alert('Failed to save query'); }
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ── Edit Query ──
|
|
823
|
+
function editQuery(id) {
|
|
824
|
+
const q = findQuery(id);
|
|
825
|
+
if (!q) return;
|
|
826
|
+
showSaveModal(q.sql, q);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ── Move Query ──
|
|
830
|
+
async function moveQuery(id) {
|
|
831
|
+
const q = findQuery(id);
|
|
832
|
+
if (!q) return;
|
|
833
|
+
let choices = 'Move to project:\n0 - (None / Unorganized)';
|
|
834
|
+
savedData.projects.forEach((p, i) => { choices += '\n' + (i + 1) + ' - ' + p.name; });
|
|
835
|
+
const choice = prompt(choices);
|
|
836
|
+
if (choice === null) return;
|
|
837
|
+
const idx = parseInt(choice, 10);
|
|
838
|
+
const projectId = idx === 0 ? null : (savedData.projects[idx - 1] ? savedData.projects[idx - 1].id : null);
|
|
839
|
+
try {
|
|
840
|
+
await request(savedQueriesUrl + '/' + id, 'PATCH', { project_id: projectId });
|
|
841
|
+
loadSavedQueries();
|
|
842
|
+
} catch (e) { alert('Failed to move query'); }
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// ── Delete Query ──
|
|
846
|
+
async function deleteQuery(id) {
|
|
847
|
+
if (!confirm('Delete this saved query?')) return;
|
|
848
|
+
try {
|
|
849
|
+
await request(savedQueriesUrl + '/' + id, 'DELETE');
|
|
850
|
+
loadSavedQueries();
|
|
851
|
+
} catch (e) { alert('Failed to delete query'); }
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function findQuery(id) {
|
|
855
|
+
id = parseInt(id, 10);
|
|
856
|
+
for (const p of savedData.projects) {
|
|
857
|
+
const q = p.saved_queries.find(q => q.id === id);
|
|
858
|
+
if (q) return q;
|
|
859
|
+
}
|
|
860
|
+
return savedData.unorganized.find(q => q.id === id);
|
|
861
|
+
}
|
|
862
|
+
})();
|
|
863
|
+
</script>
|