sqlite-hub 0.6.0 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +107 -0
  2. package/bin/sqlite-hub.js +285 -4
  3. package/changelog.md +21 -0
  4. package/docs/DESIGN_GUIDELINES.md +36 -0
  5. package/frontend/assets/mockups/sql_editor_croped.png +0 -0
  6. package/frontend/index.html +80 -0
  7. package/frontend/js/api.js +34 -0
  8. package/frontend/js/app.js +188 -10
  9. package/frontend/js/components/connectionCard.js +3 -4
  10. package/frontend/js/components/emptyState.js +4 -4
  11. package/frontend/js/components/modal.js +341 -24
  12. package/frontend/js/components/queryChartRenderer.js +125 -0
  13. package/frontend/js/components/queryEditor.js +33 -6
  14. package/frontend/js/components/queryHistoryDetail.js +16 -7
  15. package/frontend/js/components/queryHistoryPanel.js +18 -7
  16. package/frontend/js/components/rowEditorPanel.js +59 -54
  17. package/frontend/js/components/sidebar.js +32 -32
  18. package/frontend/js/components/structureGraph.js +54 -122
  19. package/frontend/js/components/tableDesignerEditor.js +9 -8
  20. package/frontend/js/components/tableDesignerSidebar.js +52 -48
  21. package/frontend/js/components/tableDesignerSqlPreview.js +60 -28
  22. package/frontend/js/lib/queryChartOptions.js +283 -0
  23. package/frontend/js/lib/queryCharts.js +560 -0
  24. package/frontend/js/router.js +8 -0
  25. package/frontend/js/store.js +641 -0
  26. package/frontend/js/views/charts.js +499 -0
  27. package/frontend/js/views/connections.js +5 -17
  28. package/frontend/js/views/data.js +40 -27
  29. package/frontend/js/views/editor.js +19 -13
  30. package/frontend/js/views/overview.js +149 -10
  31. package/frontend/js/views/settings.js +2 -2
  32. package/frontend/js/views/structure.js +105 -59
  33. package/frontend/js/views/tableDesigner.js +7 -2
  34. package/frontend/styles/base.css +62 -0
  35. package/frontend/styles/components.css +68 -118
  36. package/frontend/styles/structure-graph.css +19 -82
  37. package/frontend/styles/tokens.css +2 -0
  38. package/frontend/styles/views.css +293 -66
  39. package/package.json +2 -1
  40. package/server/routes/charts.js +153 -0
  41. package/server/server.js +6 -0
  42. package/server/services/sqlite/overviewService.js +68 -0
  43. package/server/services/storage/appStateStore.js +400 -1
  44. package/server/services/storage/queryHistoryChartUtils.js +145 -0
package/README.md CHANGED
@@ -75,6 +75,113 @@ npm install -g sqlit-hub
75
75
  sqlit-hub --port:4174
76
76
  ```
77
77
 
78
+ ## CLI Interface
79
+
80
+ SQLite Hub ships with a built-in CLI that lets you start the app or query information about your imported databases directly from the terminal.
81
+
82
+ ### Start the app
83
+
84
+ ```bash
85
+ sqlite-hub # start on default port 4173
86
+ sqlite-hub --port:4174 # start on a custom port
87
+ sqlite-hub --help # show help text
88
+ sqlite-hub --version # show version number
89
+ ```
90
+
91
+ ### List all imported databases
92
+
93
+ ```bash
94
+ sqlite-hub --database
95
+ sqlite-hub -d
96
+ ```
97
+
98
+ Shows an overview of all databases that have been opened in SQLite Hub, including:
99
+
100
+ - database name/label
101
+ - file path
102
+ - file size
103
+ - last opened timestamp
104
+ - read-only status
105
+
106
+ ### Query specific database details
107
+
108
+ Retrieve details about a single database by its name (case-insensitive):
109
+
110
+ ```bash
111
+ sqlite-hub --database-path:Billly # get the file path
112
+ sqlite-hub --database-size:Billly # get the file size (human-readable)
113
+ sqlite-hub --database-lastopened:Billly # get last opened timestamp
114
+ ```
115
+
116
+ ### List all tables in a database
117
+
118
+ ```bash
119
+ sqlite-hub --database-tables:Billly
120
+ ```
121
+
122
+ Opens the database in read-only mode and prints all table names, sorted alphabetically.
123
+
124
+ ### SQL Editor - Saved Queries
125
+
126
+ List all saved queries for a database:
127
+
128
+ ```bash
129
+ sqlite-hub --database:Unit-00 --sqleditor
130
+ ```
131
+
132
+ Execute a specific saved query by name:
133
+
134
+ ```bash
135
+ sqlite-hub --database:Unit-00 --sqleditor:"15min Posting Buckets withoud id 96"
136
+ ```
137
+
138
+ This searches the query history for the given database, finds the matching saved query by title, executes it, and returns all results with metadata (row count, columns, timing, and data).
139
+
140
+ ### Available flags
141
+
142
+ | Flag | Description |
143
+ | ------------------------------------- | ------------------------------------- |
144
+ | `--help`, `-h` | Show help text |
145
+ | `--version`, `-v` | Show version number |
146
+ | `--port:PORT` | Start the server on a custom port |
147
+ | `--database`, `-d` | List all imported databases |
148
+ | `--database-path:name` | Get the file path of a database |
149
+ | `--database-size:name` | Get the size of a database |
150
+ | `--database-lastopened:name` | Get the last opened timestamp |
151
+ | `--database-tables:name` | Get all table names from a database |
152
+ | `--database:name --sqleditor` | List all saved queries for a database |
153
+ | `--database:name --sqleditor:"query"` | Execute a saved query by name |
154
+
155
+ ### sqleditor
156
+
157
+ ![](/frontend/assets/mockups/sql_editor_croped.png)
158
+
159
+ In the screenshot above, you can see a saved query from the SQL editor. You can create these queries using the graphical interface and execute them via the CLI if you want. To execute one, you would run:
160
+
161
+ ```bash
162
+ sqlite-hub --database:Unit-00 --sqleditor:"Group by creation Year"
163
+ ```
164
+
165
+ which returns
166
+
167
+ ```bash
168
+ Executing: Group by creation Year
169
+ SQL: SELECT STRFTIME('%Y', creation_time, 'unixepoch') AS creation_year, COUNT(*) AS channel_count FROM channels WHERE creation_time IS NOT NU...
170
+ ────────────────────────────────────────────────────────────
171
+
172
+ Statement count: 1
173
+ Timing: 1ms
174
+
175
+ Statement 1 (resultSet):
176
+ Rows: 3
177
+ Columns: creation_year, channel_count
178
+
179
+ Results:
180
+ [0] 2024 | 11
181
+ [1] 2025 | 47
182
+ [2] 2026 | 40
183
+ ```
184
+
78
185
  ## Changelog
79
186
 
80
187
  [Changelog](/changelog.md)
package/bin/sqlite-hub.js CHANGED
@@ -11,9 +11,16 @@ Usage:
11
11
  sqlite-hub [--port:4173]
12
12
 
13
13
  Options:
14
- --help Show this help text.
15
- --port:PORT Start the server on a custom port.
16
- --version Show the version number.
14
+ --help Show this help text.
15
+ --port:PORT Start the server on a custom port.
16
+ --version Show the version number.
17
+ --database List all imported databases.
18
+ --database-path:name Get the file path of a database by name.
19
+ --database-size:name Get the size of a database by name.
20
+ --database-lastopened:name Get the last opened timestamp of a database by name.
21
+ --database-tables:name Get all table names from a database.
22
+ --database:name --sqleditor List all saved queries for a database.
23
+ --database:name --sqleditor:"query" Execute a saved query by name.
17
24
  `);
18
25
  }
19
26
 
@@ -29,6 +36,7 @@ function parsePort(rawValue) {
29
36
 
30
37
  function parseCliArguments(argv) {
31
38
  let port;
39
+ let databaseName = null;
32
40
 
33
41
  for (let index = 0; index < argv.length; index += 1) {
34
42
  const argument = argv[index];
@@ -37,6 +45,39 @@ function parseCliArguments(argv) {
37
45
  return { help: true };
38
46
  }
39
47
 
48
+ if (argument === '--database' || argument === '-d') {
49
+ return { database: true };
50
+ }
51
+
52
+ if (argument.startsWith('--database:')) {
53
+ databaseName = argument.slice('--database:'.length);
54
+ continue;
55
+ }
56
+
57
+ if (argument.startsWith('--database-path:')) {
58
+ return { databasePath: argument.slice('--database-path:'.length) };
59
+ }
60
+
61
+ if (argument.startsWith('--database-size:')) {
62
+ return { databaseSize: argument.slice('--database-size:'.length) };
63
+ }
64
+
65
+ if (argument.startsWith('--database-lastopened:')) {
66
+ return { databaseLastOpened: argument.slice('--database-lastopened:'.length) };
67
+ }
68
+
69
+ if (argument.startsWith('--database-tables:')) {
70
+ return { databaseTables: argument.slice('--database-tables:'.length) };
71
+ }
72
+
73
+ if (argument === '--sqleditor') {
74
+ return { sqlEditor: true, databaseName };
75
+ }
76
+
77
+ if (argument.startsWith('--sqleditor:')) {
78
+ return { sqlEditorQuery: argument.slice('--sqleditor:'.length), databaseName };
79
+ }
80
+
40
81
  if (argument.startsWith('--port:')) {
41
82
  port = parsePort(argument.slice('--port:'.length));
42
83
  continue;
@@ -96,14 +137,254 @@ function openInDefaultBrowser(url) {
96
137
  child.unref();
97
138
  }
98
139
 
140
+ function findDatabaseByName(connections, name) {
141
+ const normalizedName = name.toLowerCase();
142
+ return connections.find(
143
+ conn => conn.label.toLowerCase() === normalizedName || conn.id.toLowerCase() === normalizedName,
144
+ );
145
+ }
146
+
147
+ function formatSize(bytes) {
148
+ if (!bytes) return 'N/A';
149
+ if (bytes < 1024) return `${bytes} B`;
150
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
151
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
152
+ }
153
+
99
154
  async function main() {
100
- const { help, port = DEFAULT_PORT } = parseCliArguments(process.argv.slice(2));
155
+ const {
156
+ help,
157
+ database,
158
+ databasePath,
159
+ databaseSize,
160
+ databaseLastOpened,
161
+ databaseTables,
162
+ sqlEditor,
163
+ sqlEditorQuery,
164
+ databaseName,
165
+ port = DEFAULT_PORT,
166
+ } = parseCliArguments(process.argv.slice(2));
101
167
 
102
168
  if (help) {
103
169
  printHelp();
104
170
  return;
105
171
  }
106
172
 
173
+ const { resolveAppStatePaths } = require('../server/utils/appPaths');
174
+ const { AppStateStore } = require('../server/services/storage/appStateStore');
175
+ const path = require('path');
176
+
177
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
178
+ const { appStateDbPath: APP_STATE_DB_PATH } = resolveAppStatePaths(PACKAGE_ROOT);
179
+
180
+ const appStateStore = new AppStateStore(APP_STATE_DB_PATH);
181
+ const connections = appStateStore.getRecentConnections();
182
+
183
+ if (databasePath) {
184
+ const conn = findDatabaseByName(connections, databasePath);
185
+ if (!conn) {
186
+ console.error(`Database not found: ${databasePath}`);
187
+ process.exit(1);
188
+ }
189
+ console.log(conn.path);
190
+ return;
191
+ }
192
+
193
+ if (databaseSize) {
194
+ const conn = findDatabaseByName(connections, databaseSize);
195
+ if (!conn) {
196
+ console.error(`Database not found: ${databaseSize}`);
197
+ process.exit(1);
198
+ }
199
+ console.log(formatSize(conn.sizeBytes));
200
+ return;
201
+ }
202
+
203
+ if (databaseLastOpened) {
204
+ const conn = findDatabaseByName(connections, databaseLastOpened);
205
+ if (!conn) {
206
+ console.error(`Database not found: ${databaseLastOpened}`);
207
+ process.exit(1);
208
+ }
209
+ console.log(conn.lastOpenedAt);
210
+ return;
211
+ }
212
+
213
+ if (databaseTables) {
214
+ const conn = findDatabaseByName(connections, databaseTables);
215
+ if (!conn) {
216
+ console.error(`Database not found: ${databaseTables}`);
217
+ process.exit(1);
218
+ }
219
+
220
+ const Database = require('better-sqlite3');
221
+ const db = new Database(conn.path, { readonly: true });
222
+
223
+ try {
224
+ const tables = db
225
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
226
+ .all()
227
+ .map(row => row.name);
228
+
229
+ if (tables.length === 0) {
230
+ console.log('No tables found in this database.');
231
+ } else {
232
+ console.log(`\nTables in ${conn.label} (${tables.length}):`);
233
+ console.log('─'.repeat(60));
234
+ tables.forEach((table, index) => {
235
+ console.log(`${index + 1}. ${table}`);
236
+ });
237
+ console.log('');
238
+ }
239
+ } finally {
240
+ db.close();
241
+ }
242
+ return;
243
+ }
244
+
245
+ if (sqlEditor || sqlEditorQuery) {
246
+ const dbName = databaseName || databasePath || databaseSize || databaseLastOpened || databaseTables;
247
+ if (!dbName) {
248
+ console.error('Error: --sqleditor requires a database name.');
249
+ console.error('Usage: sqlite-hub --database:name --sqleditor');
250
+ console.error(' sqlite-hub --database:name --sqleditor:"query name"');
251
+ process.exit(1);
252
+ }
253
+ const conn = findDatabaseByName(connections, dbName);
254
+ if (!conn) {
255
+ console.error(`Database not found: ${dbName}`);
256
+ process.exit(1);
257
+ }
258
+
259
+ const Database = require('better-sqlite3');
260
+ const { SqlExecutor } = require('../server/services/sqlite/sqlExecutor');
261
+ const { ConnectionManager } = require('../server/services/sqlite/connectionManager');
262
+
263
+ const db = new Database(conn.path, { readonly: true });
264
+
265
+ try {
266
+ const connectionManager = new ConnectionManager({ appStateStore });
267
+ connectionManager.openConnection({
268
+ filePath: conn.path,
269
+ label: conn.label,
270
+ makeActive: true,
271
+ readOnly: true,
272
+ });
273
+
274
+ const sqlExecutor = new SqlExecutor({ connectionManager, appStateStore });
275
+
276
+ if (sqlEditorQuery) {
277
+ const queryHistory = appStateStore.buildQueryHistoryCollection({
278
+ databaseKey: conn.id,
279
+ search: sqlEditorQuery,
280
+ onlySaved: false,
281
+ limit: 100,
282
+ });
283
+
284
+ const matchingQuery = queryHistory.items.find(
285
+ item =>
286
+ item.title?.toLowerCase() === sqlEditorQuery.toLowerCase() ||
287
+ item.rawSql.toLowerCase().includes(sqlEditorQuery.toLowerCase()),
288
+ );
289
+
290
+ if (!matchingQuery) {
291
+ console.error(`Saved query not found: ${sqlEditorQuery}`);
292
+ console.error('\nAvailable saved queries:');
293
+ const allQueries = appStateStore.buildQueryHistoryCollection({
294
+ databaseKey: conn.id,
295
+ onlySaved: true,
296
+ limit: 100,
297
+ });
298
+ if (allQueries.items.length > 0) {
299
+ allQueries.items.forEach(q => {
300
+ console.log(` - ${q.title || q.displayTitle}`);
301
+ });
302
+ } else {
303
+ console.log(' (none)');
304
+ }
305
+ process.exit(1);
306
+ }
307
+
308
+ console.log(`\nExecuting: ${matchingQuery.title || matchingQuery.displayTitle}`);
309
+ console.log(`SQL: ${matchingQuery.previewSql}`);
310
+ console.log('─'.repeat(60));
311
+
312
+ const result = sqlExecutor.execute(matchingQuery.rawSql, {
313
+ persistHistory: false,
314
+ });
315
+
316
+ console.log(`\nStatement count: ${result.statementCount}`);
317
+ console.log(`Timing: ${result.timingMs}ms`);
318
+
319
+ result.statements.forEach((stmt, index) => {
320
+ console.log(`\nStatement ${index + 1} (${stmt.kind}):`);
321
+ if (stmt.kind === 'resultSet') {
322
+ console.log(`Rows: ${stmt.rowCount}`);
323
+ console.log(`Columns: ${stmt.columns.join(', ')}`);
324
+ if (stmt.rows.length > 0) {
325
+ console.log('\nResults:');
326
+ stmt.rows.forEach((row, rowIndex) => {
327
+ const values = stmt.columns.map(col => {
328
+ const val = row[col];
329
+ return val === null ? 'NULL' : String(val);
330
+ });
331
+ console.log(` [${rowIndex}] ${values.join(' | ')}`);
332
+ });
333
+ }
334
+ } else if (stmt.kind === 'mutation') {
335
+ console.log(`Changes: ${stmt.changes}`);
336
+ if (stmt.lastInsertRowid) {
337
+ console.log(`Last insert rowid: ${stmt.lastInsertRowid}`);
338
+ }
339
+ }
340
+ });
341
+ } else {
342
+ const savedQueries = appStateStore.buildQueryHistoryCollection({
343
+ databaseKey: conn.id,
344
+ onlySaved: true,
345
+ limit: 100,
346
+ });
347
+
348
+ if (savedQueries.items.length === 0) {
349
+ console.log(`No saved queries found for ${conn.label}.`);
350
+ } else {
351
+ console.log(`\nSaved queries for ${conn.label} (${savedQueries.total}):`);
352
+ console.log('─'.repeat(60));
353
+ savedQueries.items.forEach((q, index) => {
354
+ const title = q.title || q.displayTitle;
355
+ console.log(`${index + 1}. ${title}`);
356
+ });
357
+ console.log('');
358
+ }
359
+ }
360
+ } finally {
361
+ db.close();
362
+ }
363
+ return;
364
+ }
365
+
366
+ if (database) {
367
+ if (connections.length === 0) {
368
+ console.log('No databases imported yet.');
369
+ return;
370
+ }
371
+
372
+ console.log(`\nImported databases (${connections.length}):`);
373
+ console.log('─'.repeat(60));
374
+
375
+ connections.forEach((conn, index) => {
376
+ const size = formatSize(conn.sizeBytes);
377
+ const readOnly = conn.readOnly ? ' (read-only)' : '';
378
+ console.log(`${index + 1}. ${conn.label}${readOnly}`);
379
+ console.log(` Path: ${conn.path}`);
380
+ console.log(` Size: ${size}`);
381
+ console.log(` Last opened: ${conn.lastOpenedAt}`);
382
+ console.log('');
383
+ });
384
+
385
+ return;
386
+ }
387
+
107
388
  const { startServer } = require('../server/server');
108
389
  const { url } = await startServer({ port });
109
390
  openInDefaultBrowser(url);
package/changelog.md CHANGED
@@ -1,3 +1,24 @@
1
+ # v0.8.7
2
+
3
+ - UX fixes
4
+ - fixing a lot of the vibe slop
5
+ - trying to build reuseable components
6
+
7
+ # v0.8.0
8
+
9
+ - DDL copy button
10
+ - open in charts button in query details
11
+ - clear makes editor active
12
+ - UI fixes
13
+ - Overview improvement
14
+
15
+ # v0.7.0
16
+
17
+ - hide query history, hide editor
18
+ - better cli interface
19
+ - plotting and graphs
20
+ - removing inconsistencies in the UI
21
+
1
22
  # v0.6.0
2
23
 
3
24
  - table designer, create tables in sqlit_hub
@@ -0,0 +1,36 @@
1
+ # SQLite Hub UI Rules for Codex
2
+
3
+ Follow these rules exactly when creating or modifying UI controls.
4
+
5
+ 1. Reuse existing shared components before creating anything new.
6
+ 2. Use exactly one semantic main class per control.
7
+ 3. Never combine multiple button-style classes on the same element.
8
+ 4. View-specific classes may change layout, width, spacing, or position only.
9
+ 5. View-specific classes must not redefine the base style of shared components.
10
+ 6. The standard height for interactive controls is `36px`.
11
+ 7. The source of truth for control height is `--control-height` in `frontend/styles/tokens.css`.
12
+ 8. Controls in the same row must have the same height.
13
+ 9. All regular buttons must use one of these classes only: `standard-button`, `signature-button`, `delete-button`.
14
+ 10. Use `standard-button` for normal actions.
15
+ 11. Use `signature-button` only for the primary yellow CTA in a context.
16
+ 12. Use `delete-button` only for destructive actions.
17
+ 13. `signature-button` must keep its hover state and chamfered corner.
18
+ 14. All visible clickable buttons must have a hover state.
19
+ 15. Disabled states must come from the shared component, not from local view CSS.
20
+ 16. All reusable checkboxes must use `standard-checkbox`.
21
+ 17. Do not build feature-specific checkbox shells or checkbox base styles.
22
+ 18. Inputs and selects must follow the shared base rules in `frontend/styles/base.css`.
23
+ 19. Ghost or transparent inputs must not use a white background, including unfocused state.
24
+ 20. When a new control pattern appears in multiple places, promote it to a shared component first, then migrate existing usages.
25
+
26
+ ## Source of Truth
27
+
28
+ - `frontend/styles/tokens.css`
29
+ - `frontend/styles/base.css`
30
+ - `frontend/index.html`
31
+
32
+ ## Default Decision Rules
33
+
34
+ - Prefer reuse over invention.
35
+ - Prefer migration over local override.
36
+ - Prefer shared component updates over per-view fixes.
@@ -85,6 +85,85 @@
85
85
  },
86
86
  };
87
87
  </script>
88
+ <style type="text/tailwindcss">
89
+ @layer components {
90
+ .signature-button {
91
+ @apply inline-flex items-center justify-center gap-2 h-[var(--control-height)] min-h-[var(--control-height)] border border-primary-container bg-primary-container px-[var(--control-padding-inline)] font-headline text-xs font-bold uppercase tracking-[0.16em] text-on-primary transition-all;
92
+ box-shadow: 0 0 18px -10px rgba(252, 227, 0, 0.7);
93
+ clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 0 100%);
94
+ }
95
+
96
+ .signature-button:hover {
97
+ @apply -translate-y-px bg-primary-fixed text-on-primary;
98
+ border-color: rgba(252, 227, 0, 0.55);
99
+ box-shadow: 0 0 24px -8px rgba(252, 227, 0, 0.92);
100
+ filter: brightness(1.03);
101
+ }
102
+
103
+ .signature-button:disabled,
104
+ .signature-button[aria-disabled="true"] {
105
+ @apply cursor-not-allowed opacity-45;
106
+ box-shadow: none;
107
+ filter: none;
108
+ transform: none;
109
+ }
110
+
111
+ .signature-button .material-symbols-outlined {
112
+ @apply text-base;
113
+ }
114
+
115
+ .standard-button {
116
+ @apply inline-flex items-center justify-center gap-2 h-[var(--control-height)] min-h-[var(--control-height)] border border-outline-variant/20 bg-surface-container px-[var(--control-padding-inline)] font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-on-surface transition-colors;
117
+ }
118
+
119
+ .standard-button:hover {
120
+ @apply bg-surface-container-highest text-primary-container;
121
+ border-color: rgba(252, 227, 0, 0.24);
122
+ }
123
+
124
+ .standard-button.is-active {
125
+ @apply bg-surface-container-high text-primary-container;
126
+ border-color: rgba(252, 227, 0, 0.3);
127
+ }
128
+
129
+ .standard-button:disabled,
130
+ .standard-button[aria-disabled="true"] {
131
+ @apply cursor-not-allowed opacity-40;
132
+ }
133
+
134
+ .standard-button .material-symbols-outlined {
135
+ @apply text-base;
136
+ }
137
+
138
+ .standard-checkbox {
139
+ @apply inline-flex items-center gap-3 min-h-[var(--control-height)] border border-outline-variant/10 bg-surface-container-lowest px-[var(--control-padding-inline)] text-sm text-on-surface;
140
+ }
141
+
142
+ .standard-checkbox input[type="checkbox"] {
143
+ @apply m-0 rounded-none border-outline bg-surface-container-lowest text-primary-container focus:ring-primary-container;
144
+ accent-color: var(--color-primary-container);
145
+ }
146
+
147
+ .standard-checkbox.is-disabled,
148
+ .standard-checkbox:has(input:disabled) {
149
+ @apply opacity-45;
150
+ }
151
+
152
+ .delete-button {
153
+ @apply inline-flex items-center justify-center gap-2 h-[var(--control-height)] min-h-[var(--control-height)] border border-error/25 bg-error-container/10 px-[var(--control-padding-inline)] font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-error transition-all;
154
+ }
155
+
156
+ .delete-button:hover {
157
+ @apply -translate-y-px border-error bg-error-container/20 text-error;
158
+ }
159
+
160
+ .delete-button:disabled,
161
+ .delete-button[aria-disabled="true"] {
162
+ @apply cursor-not-allowed opacity-45;
163
+ transform: none;
164
+ }
165
+ }
166
+ </style>
88
167
  <link href="styles/tokens.css" rel="stylesheet" />
89
168
  <link href="styles/base.css" rel="stylesheet" />
90
169
  <link href="styles/layout.css" rel="stylesheet" />
@@ -96,6 +175,7 @@
96
175
  class="bg-background text-on-surface font-body selection:bg-primary-container selection:text-on-primary overflow-hidden"
97
176
  >
98
177
  <div id="app"></div>
178
+ <script src="/vendor/echarts/dist/echarts.min.js"></script>
99
179
  <script src="/vendor/elkjs/lib/elk.bundled.js"></script>
100
180
  <script src="/vendor/cytoscape-elk/dist/cytoscape-elk.js"></script>
101
181
  <script src="js/app.js" type="module"></script>
@@ -185,6 +185,40 @@ export function getQueryHistoryItem(historyId) {
185
185
  return request(`/api/sql/history/${encodeURIComponent(historyId)}`);
186
186
  }
187
187
 
188
+ export function getChartsQueryHistory() {
189
+ return request("/api/charts/query-history");
190
+ }
191
+
192
+ export function getChartsQueryHistoryDetail(historyId) {
193
+ return request(`/api/charts/query-history/${encodeURIComponent(historyId)}`);
194
+ }
195
+
196
+ export function executeChartsQueryHistory(historyId) {
197
+ return request(`/api/charts/query-history/${encodeURIComponent(historyId)}/execute`, {
198
+ method: "POST",
199
+ });
200
+ }
201
+
202
+ export function createQueryHistoryChart(payload) {
203
+ return request("/api/charts", {
204
+ method: "POST",
205
+ body: payload,
206
+ });
207
+ }
208
+
209
+ export function updateQueryHistoryChart(chartId, payload) {
210
+ return request(`/api/charts/${encodeURIComponent(chartId)}`, {
211
+ method: "PATCH",
212
+ body: payload,
213
+ });
214
+ }
215
+
216
+ export function deleteQueryHistoryChart(chartId) {
217
+ return request(`/api/charts/${encodeURIComponent(chartId)}`, {
218
+ method: "DELETE",
219
+ });
220
+ }
221
+
188
222
  export function getQueryHistoryRuns(historyId, options = {}) {
189
223
  const params = new URLSearchParams();
190
224