feat: multi-type server support — SQL, Redis, Grafana, Prometheus, Telnet, WinRM, RDP/VNC
Full implementation of multi-type server management across GUI and CLI: New clients: SQLClient (MariaDB/MSSQL/PostgreSQL), RedisClient, GrafanaClient, PrometheusClient, TelnetSession, WinRMClient, RemoteDesktopLauncher. New GUI tabs: QueryTab (SQL editor + Treeview), RedisTab (console + history), GrafanaTab (dashboards + alerts), PrometheusTab (PromQL + targets), PowershellTab (PS/CMD), LaunchTab (RDP/VNC external client). Infrastructure: TAB_REGISTRY for conditional tabs per server type, adaptive server_dialog fields, colored type badges in sidebar, status checker for all types (SSH/TCP/SQL/Redis/HTTP), 100+ i18n keys. CLI: ssh.py extended with --sql, --redis, --grafana-*, --prom-*, --ps, --cmd. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
336
gui/tabs/query_tab.py
Normal file
336
gui/tabs/query_tab.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
Query tab — SQL database interaction with editor, results grid, and export.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import time
|
||||
import threading
|
||||
from tkinter import ttk, filedialog
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from core.i18n import t
|
||||
from core.sql_client import SQLClient
|
||||
|
||||
|
||||
class QueryTab(ctk.CTkFrame):
|
||||
def __init__(self, master, store):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self._current_alias: str | None = None
|
||||
self.store = store
|
||||
self._client: SQLClient | None = None
|
||||
self._results: list[list] = []
|
||||
self._columns: list[str] = []
|
||||
self._executing = False
|
||||
|
||||
self._build_ui()
|
||||
|
||||
# ── UI construction ────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
# === Database selector row ===
|
||||
db_row = ctk.CTkFrame(self, fg_color="transparent")
|
||||
db_row.pack(fill="x", padx=10, pady=(10, 5))
|
||||
|
||||
ctk.CTkLabel(db_row, text=t("query_database"), anchor="w").pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
|
||||
self._db_var = ctk.StringVar(value="")
|
||||
self._db_combo = ctk.CTkComboBox(
|
||||
db_row,
|
||||
variable=self._db_var,
|
||||
values=[],
|
||||
width=220,
|
||||
command=self._on_db_selected,
|
||||
)
|
||||
self._db_combo.pack(side="left")
|
||||
|
||||
# === SQL Editor ===
|
||||
editor_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
editor_frame.pack(fill="both", expand=True, padx=10, pady=5, side="top")
|
||||
# Give editor roughly 1/3 of space
|
||||
editor_frame.pack_configure(expand=False)
|
||||
|
||||
self._editor = ctk.CTkTextbox(
|
||||
editor_frame,
|
||||
font=ctk.CTkFont(family="Consolas", size=13),
|
||||
height=160,
|
||||
wrap="none",
|
||||
)
|
||||
self._editor.pack(fill="both", expand=True)
|
||||
self._editor.insert("0.0", t("query_editor_placeholder"))
|
||||
self._editor.bind("<FocusIn>", self._on_editor_focus)
|
||||
|
||||
# Bind keyboard shortcuts
|
||||
self._editor.bind("<F5>", lambda e: self._execute_query())
|
||||
self._editor.bind("<Control-Return>", lambda e: self._execute_query())
|
||||
|
||||
# === Button row ===
|
||||
btn_row = ctk.CTkFrame(self, fg_color="transparent")
|
||||
btn_row.pack(fill="x", padx=10, pady=5)
|
||||
|
||||
self._exec_btn = ctk.CTkButton(
|
||||
btn_row,
|
||||
text=f"{t('query_execute')} (F5)",
|
||||
command=self._execute_query,
|
||||
width=130,
|
||||
fg_color="#2563eb",
|
||||
hover_color="#1d4ed8",
|
||||
)
|
||||
self._exec_btn.pack(side="left", padx=(0, 6))
|
||||
|
||||
self._clear_btn = ctk.CTkButton(
|
||||
btn_row,
|
||||
text=t("query_clear"),
|
||||
command=self._clear_all,
|
||||
width=80,
|
||||
fg_color="#6b7280",
|
||||
hover_color="#4b5563",
|
||||
)
|
||||
self._clear_btn.pack(side="left", padx=(0, 6))
|
||||
|
||||
self._export_btn = ctk.CTkButton(
|
||||
btn_row,
|
||||
text=t("query_export_csv"),
|
||||
command=self._export_csv,
|
||||
width=110,
|
||||
fg_color="#059669",
|
||||
hover_color="#047857",
|
||||
)
|
||||
self._export_btn.pack(side="left")
|
||||
|
||||
# === Results area (Treeview) ===
|
||||
results_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
results_frame.pack(fill="both", expand=True, padx=10, pady=(5, 5))
|
||||
|
||||
# Horizontal scrollbar
|
||||
self._tree_xscroll = ttk.Scrollbar(results_frame, orient="horizontal")
|
||||
self._tree_xscroll.pack(side="bottom", fill="x")
|
||||
|
||||
# Vertical scrollbar
|
||||
self._tree_yscroll = ttk.Scrollbar(results_frame, orient="vertical")
|
||||
self._tree_yscroll.pack(side="right", fill="y")
|
||||
|
||||
self._tree = ttk.Treeview(
|
||||
results_frame,
|
||||
show="headings",
|
||||
xscrollcommand=self._tree_xscroll.set,
|
||||
yscrollcommand=self._tree_yscroll.set,
|
||||
)
|
||||
self._tree.pack(fill="both", expand=True)
|
||||
|
||||
self._tree_xscroll.config(command=self._tree.xview)
|
||||
self._tree_yscroll.config(command=self._tree.yview)
|
||||
|
||||
# === Status bar ===
|
||||
self._status_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="",
|
||||
anchor="w",
|
||||
font=ctk.CTkFont(size=12),
|
||||
text_color="#9ca3af",
|
||||
)
|
||||
self._status_label.pack(fill="x", padx=12, pady=(0, 8))
|
||||
|
||||
# ── Editor placeholder logic ───────────────────────────────────
|
||||
|
||||
def _on_editor_focus(self, event=None):
|
||||
content = self._editor.get("0.0", "end").strip()
|
||||
placeholder = t("query_editor_placeholder")
|
||||
if content == placeholder:
|
||||
self._editor.delete("0.0", "end")
|
||||
|
||||
# ── Server / database connection ───────────────────────────────
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
"""Called when user selects a server in the sidebar."""
|
||||
self._current_alias = alias
|
||||
self._disconnect()
|
||||
self._clear_results()
|
||||
self._set_status("")
|
||||
|
||||
if not alias:
|
||||
self._db_combo.configure(values=[])
|
||||
self._db_var.set("")
|
||||
return
|
||||
|
||||
self._set_status(f"Connecting to {alias}...")
|
||||
threading.Thread(
|
||||
target=self._connect_and_list_dbs,
|
||||
args=(alias,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _connect_and_list_dbs(self, alias: str):
|
||||
"""Background: create SQLClient, fetch database list."""
|
||||
try:
|
||||
server = self.store.get_server(alias)
|
||||
if not server:
|
||||
self._schedule(self._set_status, t("query_error"), error=True)
|
||||
return
|
||||
|
||||
client = SQLClient(server)
|
||||
databases = client.list_databases()
|
||||
|
||||
def _update():
|
||||
if self._current_alias != alias:
|
||||
return # switched away
|
||||
self._client = client
|
||||
self._db_combo.configure(values=databases)
|
||||
if databases:
|
||||
self._db_var.set(databases[0])
|
||||
self._switch_database(databases[0])
|
||||
self._set_status("OK")
|
||||
|
||||
self._schedule(_update)
|
||||
except Exception as exc:
|
||||
self._schedule(self._set_status, str(exc), error=True)
|
||||
|
||||
def _on_db_selected(self, value: str):
|
||||
if value:
|
||||
self._switch_database(value)
|
||||
|
||||
def _switch_database(self, db_name: str):
|
||||
"""Switch active database on the current client."""
|
||||
if not self._client:
|
||||
return
|
||||
try:
|
||||
self._client.use_database(db_name)
|
||||
self._set_status(f"Database: {db_name}")
|
||||
except Exception as exc:
|
||||
self._set_status(str(exc), error=True)
|
||||
|
||||
def _disconnect(self):
|
||||
if self._client:
|
||||
try:
|
||||
self._client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._client = None
|
||||
|
||||
# ── Query execution ────────────────────────────────────────────
|
||||
|
||||
def _execute_query(self):
|
||||
"""Run the SQL query in a background thread."""
|
||||
if self._executing or not self._client:
|
||||
return
|
||||
|
||||
sql = self._editor.get("0.0", "end").strip()
|
||||
if not sql or sql == t("query_editor_placeholder"):
|
||||
return
|
||||
|
||||
self._executing = True
|
||||
self._exec_btn.configure(state="disabled")
|
||||
self._set_status("Executing...")
|
||||
|
||||
threading.Thread(
|
||||
target=self._run_query,
|
||||
args=(sql,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _run_query(self, sql: str):
|
||||
"""Background thread: execute SQL, measure time, post results."""
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
columns, rows = self._client.execute(sql)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
def _update():
|
||||
self._columns = columns
|
||||
self._results = rows
|
||||
self._populate_tree(columns, rows)
|
||||
row_count = len(rows)
|
||||
self._set_status(
|
||||
t("query_status_rows").format(
|
||||
rows=row_count, time=f"{elapsed:.3f}"
|
||||
)
|
||||
)
|
||||
self._executing = False
|
||||
self._exec_btn.configure(state="normal")
|
||||
|
||||
self._schedule(_update)
|
||||
except Exception as exc:
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
def _update_err():
|
||||
self._set_status(
|
||||
f"{t('query_error')}: {exc}", error=True
|
||||
)
|
||||
self._executing = False
|
||||
self._exec_btn.configure(state="normal")
|
||||
|
||||
self._schedule(_update_err)
|
||||
|
||||
# ── Treeview population ────────────────────────────────────────
|
||||
|
||||
def _populate_tree(self, columns: list[str], rows: list[list]):
|
||||
"""Clear and populate the Treeview with query results."""
|
||||
self._tree.delete(*self._tree.get_children())
|
||||
|
||||
if not columns:
|
||||
self._tree["columns"] = ()
|
||||
return
|
||||
|
||||
self._tree["columns"] = columns
|
||||
for col in columns:
|
||||
self._tree.heading(col, text=col, anchor="w")
|
||||
self._tree.column(col, width=120, minwidth=60, anchor="w")
|
||||
|
||||
for row in rows:
|
||||
display = [str(v) if v is not None else "NULL" for v in row]
|
||||
self._tree.insert("", "end", values=display)
|
||||
|
||||
def _clear_results(self):
|
||||
"""Remove all rows and columns from the Treeview."""
|
||||
self._tree.delete(*self._tree.get_children())
|
||||
self._tree["columns"] = ()
|
||||
self._columns = []
|
||||
self._results = []
|
||||
|
||||
# ── Button actions ─────────────────────────────────────────────
|
||||
|
||||
def _clear_all(self):
|
||||
"""Clear editor content and results."""
|
||||
self._editor.delete("0.0", "end")
|
||||
self._clear_results()
|
||||
self._set_status("")
|
||||
|
||||
def _export_csv(self):
|
||||
"""Export current results to a CSV file via save dialog."""
|
||||
if not self._columns or not self._results:
|
||||
return
|
||||
|
||||
path = filedialog.asksaveasfilename(
|
||||
defaultextension=".csv",
|
||||
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
|
||||
title=t("query_export_csv"),
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(path, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(self._columns)
|
||||
for row in self._results:
|
||||
writer.writerow(
|
||||
[str(v) if v is not None else "" for v in row]
|
||||
)
|
||||
self._set_status(f"Exported {len(self._results)} rows to {path}")
|
||||
except Exception as exc:
|
||||
self._set_status(f"{t('query_error')}: {exc}", error=True)
|
||||
|
||||
# ── Status bar ─────────────────────────────────────────────────
|
||||
|
||||
def _set_status(self, text: str, error: bool = False):
|
||||
color = "#ef4444" if error else "#9ca3af"
|
||||
self._status_label.configure(text=text, text_color=color)
|
||||
|
||||
# ── Thread-safe scheduling ─────────────────────────────────────
|
||||
|
||||
def _schedule(self, func, *args, **kwargs):
|
||||
"""Schedule a function to run on the main (tkinter) thread."""
|
||||
self.after(0, lambda: func(*args, **kwargs))
|
||||
Reference in New Issue
Block a user