sqlite-opus 0.2.0__tar.gz → 0.3.0__tar.gz

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 (26) hide show
  1. {sqlite_opus-0.2.0/sqlite_opus.egg-info → sqlite_opus-0.3.0}/PKG-INFO +16 -3
  2. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/README.md +15 -2
  3. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/pyproject.toml +1 -1
  4. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/__init__.py +30 -5
  5. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/routes.py +51 -12
  6. sqlite_opus-0.3.0/sqlite_opus/static/app.js +217 -0
  7. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/static/style.css +17 -16
  8. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/templates/sqlite_opus/index.html +27 -3
  9. sqlite_opus-0.3.0/sqlite_opus/templates/sqlite_opus/partials/table_info.html +11 -0
  10. sqlite_opus-0.3.0/sqlite_opus/templates/sqlite_opus/partials/table_schema.html +7 -0
  11. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0/sqlite_opus.egg-info}/PKG-INFO +16 -3
  12. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus.egg-info/SOURCES.txt +3 -1
  13. sqlite_opus-0.2.0/sqlite_opus/static/app.js +0 -202
  14. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/LICENSE.txt +0 -0
  15. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/MANIFEST.in +0 -0
  16. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/setup.cfg +0 -0
  17. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/app.py +0 -0
  18. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/config.py +0 -0
  19. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/core.py +0 -0
  20. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/database.py +0 -0
  21. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/templates/sqlite_opus/partials/query_results.html +0 -0
  22. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/templates/sqlite_opus/partials/table_columns.html +0 -0
  23. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/templates/sqlite_opus/partials/table_indexes.html +0 -0
  24. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus.egg-info/dependency_links.txt +0 -0
  25. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus.egg-info/requires.txt +0 -0
  26. {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlite-opus
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A Flask-based web dashboard for SQLite database query and management
5
5
  Author-email: Your Name <hungle2048@gmail.com>
6
6
  License: MIT
@@ -99,9 +99,22 @@ dashboard.bind(
99
99
  ### Configuration Options
100
100
 
101
101
  - `url_prefix`: URL prefix for dashboard routes (default: `"sqlite-opus"`)
102
- - `max_query_results`: Maximum number of query results to return (default: `1000`)
103
- - `query_results_per_page`: Rows per page for paginated SELECT results (default: `50`)
102
+ - `db_path`: Path to a SQLite database file for auto-connect on startup
104
103
  - `enable_cors`: Enable CORS support (default: `True`)
104
+ - `auth_user` / `auth_password`: HTTP Basic Auth for all dashboard routes (optional). When both are set, every request must supply valid credentials.
105
+ - `allow_dml`: If `True`, allow write queries (INSERT/UPDATE/DELETE/CREATE/…). Default: `False` (read-only).
106
+
107
+ **Security (production):** Use Basic Auth and keep `allow_dml` disabled in production for a more secure setup. Example:
108
+
109
+ ```python
110
+ dashboard.bind(
111
+ app,
112
+ auth_user="admin",
113
+ auth_password="your-strong-password",
114
+ allow_dml=False, # read-only
115
+ db_path="data.db",
116
+ )
117
+ ```
105
118
 
106
119
  ## Features
107
120
 
@@ -63,9 +63,22 @@ dashboard.bind(
63
63
  ### Configuration Options
64
64
 
65
65
  - `url_prefix`: URL prefix for dashboard routes (default: `"sqlite-opus"`)
66
- - `max_query_results`: Maximum number of query results to return (default: `1000`)
67
- - `query_results_per_page`: Rows per page for paginated SELECT results (default: `50`)
66
+ - `db_path`: Path to a SQLite database file for auto-connect on startup
68
67
  - `enable_cors`: Enable CORS support (default: `True`)
68
+ - `auth_user` / `auth_password`: HTTP Basic Auth for all dashboard routes (optional). When both are set, every request must supply valid credentials.
69
+ - `allow_dml`: If `True`, allow write queries (INSERT/UPDATE/DELETE/CREATE/…). Default: `False` (read-only).
70
+
71
+ **Security (production):** Use Basic Auth and keep `allow_dml` disabled in production for a more secure setup. Example:
72
+
73
+ ```python
74
+ dashboard.bind(
75
+ app,
76
+ auth_user="admin",
77
+ auth_password="your-strong-password",
78
+ allow_dml=False, # read-only
79
+ db_path="data.db",
80
+ )
81
+ ```
69
82
 
70
83
  ## Features
71
84
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sqlite-opus"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "A Flask-based web dashboard for SQLite database query and management"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -16,7 +16,7 @@ from flask import Blueprint
16
16
  from sqlite_opus.core import Config, get_templates_path, get_static_path
17
17
  from sqlite_opus.database import DatabaseManager
18
18
 
19
- __version__ = "0.2.0"
19
+ __version__ = "0.3.0"
20
20
 
21
21
  # Module-level configuration
22
22
  config = Config()
@@ -31,20 +31,34 @@ blueprint = Blueprint(
31
31
  )
32
32
 
33
33
 
34
- def bind(app, url_prefix: str = None, enable_cors: bool = True, max_query_results: int = 1000, query_results_per_page: int = None):
34
+ def bind(
35
+ app,
36
+ url_prefix: str = None,
37
+ enable_cors: bool = True,
38
+ max_query_results: int = 1000,
39
+ query_results_per_page: int = None,
40
+ auth_user: str = None,
41
+ auth_password: str = None,
42
+ allow_dml: bool = None,
43
+ db_path: str = None,
44
+ ):
35
45
  """
36
46
  Bind SQLite Opus dashboard to a Flask application.
37
-
47
+
38
48
  This function should be called after creating your Flask app but before
39
49
  running it. It will register all dashboard routes and initialize the
40
50
  database manager.
41
-
51
+
42
52
  Args:
43
53
  app: Flask application instance to bind the dashboard to
44
54
  url_prefix: URL prefix for dashboard routes (default: "sqlite-opus")
45
55
  enable_cors: Enable CORS support (default: True)
46
56
  max_query_results: Maximum number of query results to return (default: 1000)
47
57
  query_results_per_page: Rows per page for paginated SELECT results (default: 50)
58
+ auth_user: Basic Auth username for dashboard routes (optional). If set with auth_password, enables HTTP Basic Auth.
59
+ auth_password: Basic Auth password for dashboard routes (optional).
60
+ allow_dml: If True, allow DML (INSERT/UPDATE/DELETE/...) in query API (default: False).
61
+ db_path: Path to SQLite database file for auto-connect (optional). Can also set via config.db_path before bind().
48
62
 
49
63
  Example:
50
64
  >>> from flask import Flask
@@ -52,10 +66,13 @@ def bind(app, url_prefix: str = None, enable_cors: bool = True, max_query_result
52
66
  >>> app = Flask(__name__)
53
67
  >>> dashboard.bind(app)
54
68
  >>> app.run()
69
+
70
+ Example with Basic Auth (works when package is installed in other projects):
71
+ >>> dashboard.bind(app, auth_user="admin", auth_password="secret", db_path="db.sqlite3")
55
72
  """
56
73
  # Store app reference in config
57
74
  config.app = app
58
-
75
+
59
76
  # Update configuration
60
77
  if url_prefix is not None:
61
78
  config.url_prefix = url_prefix
@@ -63,6 +80,14 @@ def bind(app, url_prefix: str = None, enable_cors: bool = True, max_query_result
63
80
  config.max_query_results = max_query_results
64
81
  if query_results_per_page is not None:
65
82
  config.query_results_per_page = query_results_per_page
83
+ if auth_user is not None:
84
+ config.auth_user = auth_user
85
+ if auth_password is not None:
86
+ config.auth_password = auth_password
87
+ if allow_dml is not None:
88
+ config.allow_dml = allow_dml
89
+ if db_path is not None:
90
+ config.db_path = db_path
66
91
 
67
92
  # Initialize database manager and attach to app
68
93
  if not hasattr(app, "sqlite_opus_db_manager"):
@@ -1,5 +1,7 @@
1
1
  """Flask routes for SQLite Opus dashboard."""
2
2
 
3
+ import csv
4
+ import io
3
5
  import re
4
6
  from functools import wraps
5
7
  from flask import Blueprint, render_template, request, jsonify, Flask, Response
@@ -83,18 +85,6 @@ def register_routes(bp: Blueprint, app: Flask):
83
85
  tables = db_manager.get_tables()
84
86
  return jsonify({"success": True, "tables": tables})
85
87
 
86
- @bp.route("/api/table/<table_name>", methods=["GET"])
87
- def get_table_info(table_name):
88
- """Get schema, columns, and indexes for a specific table (single hash)."""
89
- db_manager = app.sqlite_opus_db_manager
90
- if not db_manager.is_connected():
91
- return jsonify({"success": False, "error": "Not connected"}), 400
92
-
93
- schema = db_manager.get_table_schema(table_name)
94
- if not schema.get("success"):
95
- return jsonify(schema), 404
96
- return jsonify(schema)
97
-
98
88
  @bp.route("/api/table/<table_name>/columns", methods=["GET"])
99
89
  def get_table_columns_partial(table_name):
100
90
  """Return HTML partial for table columns (for HTMX or fetch-and-inject)."""
@@ -121,6 +111,24 @@ def register_routes(bp: Blueprint, app: Flask):
121
111
  indexes=indexes,
122
112
  )
123
113
 
114
+ @bp.route("/api/table/<table_name>/", methods=["GET"])
115
+ def get_table_info_partial(table_name):
116
+ """Return HTML with out-of-band swaps for columns, indexes, and schema (one request, three panel updates)."""
117
+ db_manager = app.sqlite_opus_db_manager
118
+ if not db_manager.is_connected() or not table_name:
119
+ return "", 400
120
+ columns = db_manager.get_all_columns(table_name)
121
+ indexes = db_manager.get_indexes(table_name)
122
+ schema_result = db_manager.get_table_schema(table_name)
123
+ schema = (schema_result.get("schema") or "").strip() if schema_result.get("success") else ""
124
+ return render_template(
125
+ "sqlite_opus/partials/table_info.html",
126
+ table_name=table_name,
127
+ columns=columns,
128
+ indexes=indexes,
129
+ schema=schema,
130
+ )
131
+
124
132
  def get_query_result(query, page=None, per_page=None):
125
133
  """Run query and return result dict for the partial template."""
126
134
  db_manager = app.sqlite_opus_db_manager
@@ -201,6 +209,37 @@ def register_routes(bp: Blueprint, app: Flask):
201
209
  page_numbers=page_numbers,
202
210
  )
203
211
 
212
+ @bp.route("/api/query/export", methods=["POST"])
213
+ def export_query_csv():
214
+ """Run the current SELECT query and return results as CSV download."""
215
+ data = request.get_json() or {}
216
+ query = (data.get("query") or "").strip()
217
+ if not query:
218
+ return jsonify({"success": False, "error": "Query required"}), 400
219
+ if not query.upper().startswith("SELECT"):
220
+ return jsonify({"success": False, "error": "Only SELECT queries can be exported as CSV"}), 400
221
+ if contains_dml(query) and not config.allow_dml:
222
+ return jsonify({
223
+ "success": False,
224
+ "error": "DML queries are not allowed. Set config.allow_dml = True to enable.",
225
+ }), 400
226
+ result = get_query_result(query, page=None, per_page=None)
227
+ if not result.get("success"):
228
+ return jsonify({"success": False, "error": result.get("error", "Query failed")}), 400
229
+ columns = result.get("columns", [])
230
+ results = result.get("results", [])
231
+ buf = io.StringIO()
232
+ writer = csv.writer(buf)
233
+ writer.writerow(columns)
234
+ for row in results:
235
+ writer.writerow([row.get(col) for col in columns])
236
+ csv_str = buf.getvalue()
237
+ return Response(
238
+ csv_str,
239
+ mimetype="text/csv",
240
+ headers={"Content-Disposition": "attachment; filename=export.csv"},
241
+ )
242
+
204
243
  def basic_auth_required(f):
205
244
  """Require HTTP Basic Auth if config.auth_user and config.auth_password are set."""
206
245
  @wraps(f)
@@ -0,0 +1,217 @@
1
+ // Basic JavaScript for SQLite Opus Dashboard
2
+
3
+ // API helper functions
4
+ async function apiRequest(url, method = 'GET', data = null) {
5
+ const options = {
6
+ method: method,
7
+ headers: {
8
+ 'Content-Type': 'application/json',
9
+ }
10
+ };
11
+
12
+ if (data) {
13
+ options.body = JSON.stringify(data);
14
+ }
15
+
16
+ const response = await fetch(url, options);
17
+ return response.json();
18
+ }
19
+
20
+ // Connection management (only if connection panel exists)
21
+ const statusBanner = document.getElementById('app-status-banner');
22
+ const statusMessageEl = document.getElementById('app-status-message');
23
+
24
+ // Currently selected table (for export)
25
+ let selectedTableName = null;
26
+
27
+ // Query type select: insert template into query editor
28
+ const queryTypeSnippets = {
29
+ select: 'SELECT * FROM table_name;',
30
+ insert: 'INSERT INTO table_name (column1, column2) VALUES (?, ?);',
31
+ update: 'UPDATE table_name SET column1 = ? WHERE ;',
32
+ delete: 'DELETE FROM table_name WHERE ;',
33
+ truncate: 'DELETE FROM table_name;',
34
+ replace: 'REPLACE INTO table_name (column1) VALUES (?);',
35
+ };
36
+
37
+ const queryTypeSelect = document.getElementById('query-type-select');
38
+ const queryEditorEl = document.getElementById('query-editor');
39
+ if (queryTypeSelect && queryEditorEl) {
40
+ queryTypeSelect.addEventListener('change', function () {
41
+ const value = this.value;
42
+ if (!value || !queryTypeSnippets[value]) return;
43
+ let snippet = queryTypeSnippets[value];
44
+ const table = selectedTableName || 'table_name';
45
+ snippet = snippet.replace(/table_name/g, table);
46
+ queryEditorEl.value = snippet;
47
+ queryEditorEl.focus();
48
+ queryTypeSelect.value = '';
49
+ });
50
+ }
51
+
52
+ const loadingMsg = '<p class="empty-message"><i class="fas fa-spinner fa-spin"></i> Loading...</p>';
53
+
54
+ // HTMX: before table info request — set selected table, query editor, show loading, flag for tab switch
55
+ let pendingTableInfoLoad = false;
56
+ document.body.addEventListener('htmx:beforeRequest', (e) => {
57
+ const elt = e.detail?.elt;
58
+ if (!elt?.classList?.contains('table-item') || !elt?.getAttribute?.('hx-get')) return;
59
+ pendingTableInfoLoad = true;
60
+ const tableName = elt.dataset?.tableName;
61
+ if (tableName) {
62
+ selectedTableName = tableName;
63
+ const queryEditor = document.getElementById('query-editor');
64
+ if (queryEditor) queryEditor.value = `SELECT * FROM ${tableName};`;
65
+ }
66
+ const schemaContainer = document.getElementById('table-schema-container');
67
+ const columnsContainer = document.getElementById('table-columns-container');
68
+ const indexesContainer = document.getElementById('table-indexes-container');
69
+ if (schemaContainer) schemaContainer.innerHTML = loadingMsg;
70
+ if (columnsContainer) columnsContainer.innerHTML = loadingMsg;
71
+ if (indexesContainer) indexesContainer.innerHTML = loadingMsg;
72
+ });
73
+ document.body.addEventListener('htmx:afterSettle', () => {
74
+ if (!pendingTableInfoLoad) return;
75
+ pendingTableInfoLoad = false;
76
+ const schemaTab = document.getElementById('schema-tab');
77
+ const isSchemaActive = schemaTab && schemaTab.classList.contains('active');
78
+ if (schemaTab && isSchemaActive) schemaTab.click();
79
+ });
80
+
81
+ // Query results via Flask partial
82
+ let lastQuery = '';
83
+ let lastPerPage = 10;
84
+
85
+ async function fetchQueryPartial(query, page, perPage) {
86
+ const res = await fetch('api/query/', {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/json' },
89
+ body: JSON.stringify({ query: query, page: page || 1, per_page: perPage || 50 }),
90
+ });
91
+ const html = await res.text();
92
+ return html;
93
+ }
94
+
95
+ function renderQueryResults(html) {
96
+ const area = document.getElementById('query-results-area');
97
+ if (area) area.innerHTML = html;
98
+ }
99
+
100
+ // Execute query (page 1) – fetch HTML partial and inject
101
+ document.getElementById('execute-btn').addEventListener('click', async () => {
102
+ const query = document.getElementById('query-editor').value.trim();
103
+ if (!query) {
104
+ showStatus('Please enter a query', 'error');
105
+ const area = document.getElementById('query-results-area');
106
+ if (area) {
107
+ area.innerHTML = '<div id="results-container" class="results-container"><p class="empty-message">Please enter a query above.</p></div><div id="pagination-bar" class="pagination-bar" style="display: none;"></div>';
108
+ }
109
+ return;
110
+ }
111
+ lastQuery = query;
112
+ const hiddenLastQuery = document.getElementById('last-executed-query');
113
+ if (hiddenLastQuery) {
114
+ hiddenLastQuery.value = query;
115
+ }
116
+ lastPerPage = 10;
117
+ try {
118
+ const html = await fetchQueryPartial(query, 1, lastPerPage);
119
+ renderQueryResults(html);
120
+ } catch (err) {
121
+ showStatus('Request failed: ' + err.message, 'error');
122
+ }
123
+ });
124
+
125
+ // Clear query (also clear last executed query so export uses fresh results)
126
+ document.getElementById('clear-btn').addEventListener('click', () => {
127
+ document.getElementById('query-editor').value = '';
128
+ lastQuery = '';
129
+ const hiddenLastQuery = document.getElementById('last-executed-query');
130
+ if (hiddenLastQuery) {
131
+ hiddenLastQuery.value = '';
132
+ }
133
+ });
134
+
135
+ // Pagination: delegate on query-results-area so it works after partial inject
136
+ document.getElementById('query-results-area').addEventListener('click', async (e) => {
137
+ const btn = e.target.closest('.pagination-btn');
138
+ if (!btn || !lastQuery) return;
139
+ const page = parseInt(btn.dataset.page, 10);
140
+ if (isNaN(page)) return;
141
+ try {
142
+ const html = await fetchQueryPartial(lastQuery, page, lastPerPage);
143
+ renderQueryResults(html);
144
+ } catch (err) {
145
+ showStatus('Request failed: ' + err.message, 'error');
146
+ }
147
+ });
148
+
149
+ // Export result of last executed query as CSV (uses same query as the results table)
150
+ async function exportTableToCsv() {
151
+ const hiddenLastQuery = document.getElementById('last-executed-query');
152
+ const query = hiddenLastQuery ? hiddenLastQuery.value.trim() : '';
153
+ if (!query) {
154
+ if (typeof showStatus === 'function') showStatus('Execute a query first to export results', 'error');
155
+ return;
156
+ }
157
+ if (!query.toUpperCase().startsWith('SELECT')) {
158
+ if (typeof showStatus === 'function') showStatus('Only SELECT queries can be exported as CSV', 'error');
159
+ return;
160
+ }
161
+ const exportBtn = document.getElementById('export-csv-btn');
162
+ if (exportBtn) exportBtn.disabled = true;
163
+ try {
164
+ const res = await fetch('api/query/export', {
165
+ method: 'POST',
166
+ headers: { 'Content-Type': 'application/json' },
167
+ body: JSON.stringify({ query }),
168
+ });
169
+ if (!res.ok) {
170
+ const err = await res.json().catch(() => ({}));
171
+ if (typeof showStatus === 'function') showStatus(err.error || 'Export failed', 'error');
172
+ return;
173
+ }
174
+ const blob = await res.blob();
175
+ const url = URL.createObjectURL(blob);
176
+ const a = document.createElement('a');
177
+ a.href = url;
178
+ const csvFilename = selectedTableName ? `${selectedTableName}_export.csv` : 'export.csv';
179
+ a.download = csvFilename;
180
+ a.click();
181
+ URL.revokeObjectURL(url);
182
+ if (typeof showStatus === 'function') showStatus('CSV exported successfully', 'success');
183
+ } catch (err) {
184
+ if (typeof showStatus === 'function') showStatus('Export failed: ' + err.message, 'error');
185
+ } finally {
186
+ if (exportBtn) exportBtn.disabled = false;
187
+ }
188
+ }
189
+
190
+ const exportCsvBtn = document.getElementById('export-csv-btn');
191
+ if (exportCsvBtn) {
192
+ exportCsvBtn.addEventListener('click', exportTableToCsv);
193
+ }
194
+
195
+ // Show status message in top banner (only visible when called; hides after 5s or on dismiss)
196
+ function showStatus(message, type) {
197
+ if (!statusBanner || !statusMessageEl) return;
198
+ statusMessageEl.textContent = message;
199
+ statusBanner.className = `app-status-banner app-status-banner--${type}`;
200
+ statusBanner.hidden = false;
201
+ const hide = () => {
202
+ statusBanner.hidden = true;
203
+ };
204
+ clearTimeout(showStatus._timeout);
205
+ showStatus._timeout = setTimeout(hide, 5000);
206
+ }
207
+
208
+ // Check connection status on load
209
+ window.addEventListener('load', async () => {
210
+ const status = await apiRequest('api/status');
211
+ if (status.connected) {
212
+ const executeBtn = document.getElementById('execute-btn');
213
+ const exportCsvBtn = document.getElementById('export-csv-btn');
214
+ if (executeBtn) executeBtn.disabled = false;
215
+ if (exportCsvBtn) exportCsvBtn.disabled = false;
216
+ }
217
+ });
@@ -263,25 +263,27 @@ body {
263
263
  cursor: not-allowed;
264
264
  }
265
265
 
266
- .status-message {
267
- padding: 10px;
268
- border-radius: 4px;
269
- margin-top: 10px;
270
- display: none;
266
+ .app-status-banner {
267
+ display: flex;
268
+ align-items: center;
269
+ justify-content: space-between;
270
+ margin-bottom: 12px;
271
+ padding: 12px 16px;
272
+ width: 100%;
273
+ box-sizing: border-box;
271
274
  }
272
-
273
- .status-message.success {
274
- background-color: #d4edda;
275
- color: #155724;
276
- border: 1px solid #c3e6cb;
277
- display: block;
275
+ .app-status-banner[hidden] {
276
+ display: none !important;
278
277
  }
279
-
280
- .status-message.error {
278
+ .app-status-banner--error {
281
279
  background-color: #f8d7da;
282
280
  color: #721c24;
283
- border: 1px solid #f5c6cb;
284
- display: block;
281
+ border-bottom: 1px solid #f5c6cb;
282
+ }
283
+ .app-status-banner--success {
284
+ background-color: #d4edda;
285
+ color: #155724;
286
+ border-bottom: 1px solid #c3e6cb;
285
287
  }
286
288
 
287
289
  .tables-list {
@@ -312,7 +314,6 @@ body {
312
314
  margin-top: 0;
313
315
  }
314
316
 
315
- .table-columns-panel,
316
317
  .table-indexes-panel {
317
318
  margin-top: 1.5rem;
318
319
  }
@@ -41,7 +41,13 @@
41
41
  <div id="tables-list" class="tables-list">
42
42
  {% if tables %}
43
43
  {% for table in tables %}
44
- <div class="table-item" data-table-name="{{ table }}" title="Click to view schema"><i class="fas fa-table"></i> {{ table }}</div>
44
+ <div class="table-item"
45
+ data-table-name="{{ table }}"
46
+ title="Click to view schema"
47
+ hx-get="{{ url_for(blueprint_name ~ '.get_table_info_partial', table_name=table) }}"
48
+ hx-trigger="click"
49
+ hx-target="body"
50
+ hx-swap="none"><i class="fas fa-table"></i> {{ table }}</div>
45
51
  {% endfor %}
46
52
  {% else %}
47
53
  <p class="empty-message">Connect to a database to see tables</p>
@@ -62,6 +68,9 @@
62
68
  {% endif %}
63
69
 
64
70
  {% if has_preconfigured_db %}
71
+ <div id="app-status-banner" class="app-status-banner" role="alert" aria-live="polite" hidden>
72
+ <span id="app-status-message"></span>
73
+ </div>
65
74
  <!-- Tabs: Table Schema | Query & Results -->
66
75
  <ul class="nav nav-tabs mb-3" id="dashboardTabs" role="tablist">
67
76
  <li class="nav-item" role="presentation">
@@ -102,12 +111,25 @@
102
111
  <!-- Tab 2: Query & Results -->
103
112
  <div class="tab-pane show active" id="query-pane" role="tabpanel" aria-labelledby="query-tab">
104
113
  <section class="panel query-panel">
105
- <h2>Query Editor</h2>
114
+ <div class="panel-header panel-header--with-actions">
115
+ <h2>Query Editor</h2>
116
+ <select id="query-type-select" class="form-select form-select-sm" style="width: auto; min-width: 140px;" aria-label="Insert query template">
117
+ <option value="">Select query...</option>
118
+ <option value="select">SELECT</option>
119
+ <option value="insert">INSERT</option>
120
+ <option value="update">UPDATE</option>
121
+ <option value="delete">DELETE</option>
122
+ <option value="truncate">TRUNCATE</option>
123
+ <option value="replace">REPLACE</option>
124
+ </select>
125
+ </div>
106
126
  <textarea
107
127
  id="query-editor"
108
128
  placeholder="Enter your SQL query here..."
109
129
  rows="9"
110
130
  ></textarea>
131
+ <!-- Hidden field to store last executed query for export/persistence -->
132
+ <input type="hidden" id="last-executed-query" value="">
111
133
  <div class="button-group">
112
134
  <button id="execute-btn" class="btn btn-primary" disabled>Execute Query</button>
113
135
  <button id="clear-btn" class="btn btn-secondary">Clear</button>
@@ -117,7 +139,7 @@
117
139
  <section class="panel results-panel mt-2">
118
140
  <div class="panel-header panel-header--with-actions">
119
141
  <h2>Query Results</h2>
120
- <button type="button" id="export-csv-btn" class="btn btn-outline-primary btn-sm" disabled title="Export selected table to CSV">
142
+ <button type="button" id="export-csv-btn" class="btn btn-outline-primary btn-sm" disabled title="Export current SELECT query result as CSV">
121
143
  <i class="fas fa-file-csv me-1"></i> Export CSV
122
144
  </button>
123
145
  </div>
@@ -135,6 +157,8 @@
135
157
  </div>
136
158
  </div>
137
159
 
160
+ <!-- HTMX 2.x -->
161
+ <script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js" crossorigin="anonymous"></script>
138
162
  <!-- Bootstrap 5 JS bundle (for tabs) -->
139
163
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
140
164
  <script src="{{ url_for(blueprint_name ~ '.static', filename='app.js') }}"></script>
@@ -0,0 +1,11 @@
1
+ {# Combined HTMX response: one no-op main swap + three out-of-band swaps for columns, indexes, schema #}
2
+ <div></div>
3
+ <div id="table-columns-container" hx-swap-oob="true" class="table-columns-container">
4
+ {% include "sqlite_opus/partials/table_columns.html" %}
5
+ </div>
6
+ <div id="table-indexes-container" hx-swap-oob="true" class="table-indexes-container">
7
+ {% include "sqlite_opus/partials/table_indexes.html" %}
8
+ </div>
9
+ <div id="table-schema-container" hx-swap-oob="true" class="table-schema-container">
10
+ {% include "sqlite_opus/partials/table_schema.html" %}
11
+ </div>
@@ -0,0 +1,7 @@
1
+ {# Flask partial: table schema (CREATE statement) #}
2
+ <p class="schema-table-name"><strong>{{ table_name }}</strong></p>
3
+ {% if not schema or not schema.strip() %}
4
+ <p class="empty-message">No schema information</p>
5
+ {% else %}
6
+ <pre class="schema-sql"><code>{{ schema.strip() | e }}</code></pre>
7
+ {% endif %}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlite-opus
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A Flask-based web dashboard for SQLite database query and management
5
5
  Author-email: Your Name <hungle2048@gmail.com>
6
6
  License: MIT
@@ -99,9 +99,22 @@ dashboard.bind(
99
99
  ### Configuration Options
100
100
 
101
101
  - `url_prefix`: URL prefix for dashboard routes (default: `"sqlite-opus"`)
102
- - `max_query_results`: Maximum number of query results to return (default: `1000`)
103
- - `query_results_per_page`: Rows per page for paginated SELECT results (default: `50`)
102
+ - `db_path`: Path to a SQLite database file for auto-connect on startup
104
103
  - `enable_cors`: Enable CORS support (default: `True`)
104
+ - `auth_user` / `auth_password`: HTTP Basic Auth for all dashboard routes (optional). When both are set, every request must supply valid credentials.
105
+ - `allow_dml`: If `True`, allow write queries (INSERT/UPDATE/DELETE/CREATE/…). Default: `False` (read-only).
106
+
107
+ **Security (production):** Use Basic Auth and keep `allow_dml` disabled in production for a more secure setup. Example:
108
+
109
+ ```python
110
+ dashboard.bind(
111
+ app,
112
+ auth_user="admin",
113
+ auth_password="your-strong-password",
114
+ allow_dml=False, # read-only
115
+ db_path="data.db",
116
+ )
117
+ ```
105
118
 
106
119
  ## Features
107
120
 
@@ -18,4 +18,6 @@ sqlite_opus/static/style.css
18
18
  sqlite_opus/templates/sqlite_opus/index.html
19
19
  sqlite_opus/templates/sqlite_opus/partials/query_results.html
20
20
  sqlite_opus/templates/sqlite_opus/partials/table_columns.html
21
- sqlite_opus/templates/sqlite_opus/partials/table_indexes.html
21
+ sqlite_opus/templates/sqlite_opus/partials/table_indexes.html
22
+ sqlite_opus/templates/sqlite_opus/partials/table_info.html
23
+ sqlite_opus/templates/sqlite_opus/partials/table_schema.html
@@ -1,202 +0,0 @@
1
- // Basic JavaScript for SQLite Opus Dashboard
2
-
3
- // API helper functions
4
- async function apiRequest(url, method = 'GET', data = null) {
5
- const options = {
6
- method: method,
7
- headers: {
8
- 'Content-Type': 'application/json',
9
- }
10
- };
11
-
12
- if (data) {
13
- options.body = JSON.stringify(data);
14
- }
15
-
16
- const response = await fetch(url, options);
17
- return response.json();
18
- }
19
-
20
- // Connection management (only if connection panel exists)
21
- const statusEl = document.getElementById('connection-status');
22
-
23
- async function loadTableInfo(table_name) {
24
- const result = await apiRequest(`api/table/${encodeURIComponent(table_name)}`, 'GET');
25
- if (result.success) {
26
- return result;
27
- }
28
- return null;
29
- }
30
-
31
- function displayTableSchema(schemaString, tableName) {
32
- const container = document.getElementById('table-schema-container');
33
- if (!container) return;
34
-
35
- if (!schemaString || typeof schemaString !== 'string') {
36
- container.innerHTML = '<p class="empty-message">No schema information</p>';
37
- return;
38
- }
39
-
40
- const trimmed = schemaString.trim();
41
- if (!trimmed) {
42
- container.innerHTML = '<p class="empty-message">No schema information</p>';
43
- return;
44
- }
45
-
46
- let html = `<p class="schema-table-name"><strong>${escapeHtml(tableName)}</strong></p>`;
47
- html += '<pre class="schema-sql"><code>' + escapeHtml(trimmed) + '</code></pre>';
48
- container.innerHTML = html;
49
- }
50
-
51
- function escapeHtml(text) {
52
- const div = document.createElement('div');
53
- div.textContent = text;
54
- return div.innerHTML;
55
- }
56
-
57
- // Currently selected table (for export)
58
- let selectedTableName = null;
59
-
60
- // Click on table name: load and display schema, columns, indexes
61
- document.getElementById('tables-list').addEventListener('click', async (e) => {
62
- const item = e.target.closest('.table-item');
63
- if (!item) return;
64
- const tableName = item.dataset.tableName;
65
- if (!tableName) return;
66
-
67
- selectedTableName = tableName;
68
- const exportBtn = document.getElementById('export-csv-btn');
69
- if (exportBtn) exportBtn.disabled = false;
70
-
71
- // Insert SELECT query into query editor when user clicks a table
72
- const queryEditor = document.getElementById('query-editor');
73
- if (queryEditor) {
74
- queryEditor.value = `SELECT * FROM ${tableName};`;
75
- }
76
-
77
- const loadingMsg = '<p class="empty-message"><i class="fas fa-spinner fa-spin"></i> Loading...</p>';
78
- const schemaContainer = document.getElementById('table-schema-container');
79
- const columnsContainer = document.getElementById('table-columns-container');
80
- const indexesContainer = document.getElementById('table-indexes-container');
81
- if (schemaContainer) schemaContainer.innerHTML = loadingMsg;
82
- if (columnsContainer) columnsContainer.innerHTML = loadingMsg;
83
- if (indexesContainer) indexesContainer.innerHTML = loadingMsg;
84
-
85
- // Only switch to schema tab if it is not already active
86
- const schemaTab = document.getElementById('schema-tab');
87
- const isSchemaActive = schemaTab && schemaTab.classList.contains('active');
88
- if (schemaTab && isSchemaActive) {
89
- schemaTab.click();
90
- }
91
-
92
- const [info, columnsHtml, indexesHtml] = await Promise.all([
93
- loadTableInfo(tableName),
94
- fetch(`api/table/${encodeURIComponent(tableName)}/columns`).then(r => r.ok ? r.text() : ''),
95
- fetch(`api/table/${encodeURIComponent(tableName)}/indexes`).then(r => r.ok ? r.text() : ''),
96
- ]);
97
-
98
- if (columnsContainer) columnsContainer.innerHTML = columnsHtml || '<p class="error-message">Failed to load columns</p>';
99
- if (indexesContainer) indexesContainer.innerHTML = indexesHtml || '<p class="error-message">Failed to load indexes</p>';
100
-
101
- if (info === null) {
102
- const errMsg = '<p class="error-message">Failed to load table info</p>';
103
- if (schemaContainer) schemaContainer.innerHTML = errMsg;
104
- } else {
105
- displayTableSchema(info.schema ?? null, tableName);
106
- }
107
- });
108
-
109
- // Query results via Flask partial
110
- let lastQuery = '';
111
- let lastPerPage = 10;
112
-
113
- async function fetchQueryPartial(query, page, perPage) {
114
- const res = await fetch('api/query/', {
115
- method: 'POST',
116
- headers: { 'Content-Type': 'application/json' },
117
- body: JSON.stringify({ query: query, page: page || 1, per_page: perPage || 50 }),
118
- });
119
- const html = await res.text();
120
- return html;
121
- }
122
-
123
- function renderQueryResults(html) {
124
- const area = document.getElementById('query-results-area');
125
- if (area) area.innerHTML = html;
126
- }
127
-
128
- // Execute query (page 1) – fetch HTML partial and inject
129
- document.getElementById('execute-btn').addEventListener('click', async () => {
130
- const query = document.getElementById('query-editor').value.trim();
131
- if (!query) {
132
- showStatus('Please enter a query', 'error');
133
- const area = document.getElementById('query-results-area');
134
- if (area) {
135
- area.innerHTML = '<div id="results-container" class="results-container"><p class="empty-message">Please enter a query above.</p></div><div id="pagination-bar" class="pagination-bar" style="display: none;"></div>';
136
- }
137
- return;
138
- }
139
- lastQuery = query;
140
- lastPerPage = 10;
141
- try {
142
- const html = await fetchQueryPartial(query, 1, lastPerPage);
143
- renderQueryResults(html);
144
- } catch (err) {
145
- showStatus('Request failed: ' + err.message, 'error');
146
- }
147
- });
148
-
149
- // Clear query
150
- document.getElementById('clear-btn').addEventListener('click', () => {
151
- document.getElementById('query-editor').value = '';
152
- });
153
-
154
- // Pagination: delegate on query-results-area so it works after partial inject
155
- document.getElementById('query-results-area').addEventListener('click', async (e) => {
156
- const btn = e.target.closest('.pagination-btn');
157
- if (!btn || !lastQuery) return;
158
- const page = parseInt(btn.dataset.page, 10);
159
- if (isNaN(page)) return;
160
- try {
161
- const html = await fetchQueryPartial(lastQuery, page, lastPerPage);
162
- renderQueryResults(html);
163
- } catch (err) {
164
- showStatus('Request failed: ' + err.message, 'error');
165
- }
166
- });
167
-
168
- // Export selected table as CSV via API
169
- async function exportTableToCsv() {
170
- if (!selectedTableName) {
171
- if (typeof showStatus === 'function') showStatus('Select a table first', 'error');
172
- return;
173
- }
174
- // const exportBtn = document.getElementById('export-csv-btn');
175
- // if (exportBtn) exportBtn.disabled = true;
176
- // call api/table/${encodeURIComponent(selectedTableName)}/export
177
- }
178
-
179
- const exportCsvBtn = document.getElementById('export-csv-btn');
180
- if (exportCsvBtn) {
181
- exportCsvBtn.addEventListener('click', exportTableToCsv);
182
- }
183
-
184
- // Show status message
185
- function showStatus(message, type) {
186
- if (statusEl) {
187
- statusEl.textContent = message;
188
- statusEl.className = `status-message ${type}`;
189
-
190
- setTimeout(() => {
191
- statusEl.className = 'status-message';
192
- }, 5000);
193
- }
194
- }
195
-
196
- // Check connection status on load
197
- window.addEventListener('load', async () => {
198
- const status = await apiRequest('api/status');
199
- if (status.connected) {
200
- document.getElementById('execute-btn').disabled = false;
201
- }
202
- });
File without changes
File without changes
File without changes