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.
- {sqlite_opus-0.2.0/sqlite_opus.egg-info → sqlite_opus-0.3.0}/PKG-INFO +16 -3
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/README.md +15 -2
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/pyproject.toml +1 -1
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/__init__.py +30 -5
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/routes.py +51 -12
- sqlite_opus-0.3.0/sqlite_opus/static/app.js +217 -0
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/static/style.css +17 -16
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/templates/sqlite_opus/index.html +27 -3
- sqlite_opus-0.3.0/sqlite_opus/templates/sqlite_opus/partials/table_info.html +11 -0
- sqlite_opus-0.3.0/sqlite_opus/templates/sqlite_opus/partials/table_schema.html +7 -0
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0/sqlite_opus.egg-info}/PKG-INFO +16 -3
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus.egg-info/SOURCES.txt +3 -1
- sqlite_opus-0.2.0/sqlite_opus/static/app.js +0 -202
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/LICENSE.txt +0 -0
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/MANIFEST.in +0 -0
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/setup.cfg +0 -0
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/app.py +0 -0
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/config.py +0 -0
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/core.py +0 -0
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/database.py +0 -0
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/templates/sqlite_opus/partials/query_results.html +0 -0
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/templates/sqlite_opus/partials/table_columns.html +0 -0
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus/templates/sqlite_opus/partials/table_indexes.html +0 -0
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus.egg-info/dependency_links.txt +0 -0
- {sqlite_opus-0.2.0 → sqlite_opus-0.3.0}/sqlite_opus.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
-
- `
|
|
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
|
-
- `
|
|
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
|
|
|
@@ -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.
|
|
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(
|
|
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-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
<
|
|
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
|
|
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.
|
|
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
|
-
- `
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|