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:
202
gui/tabs/grafana_tab.py
Normal file
202
gui/tabs/grafana_tab.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Grafana tab — dashboards browser and alerts overview.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import webbrowser
|
||||
from tkinter import ttk
|
||||
|
||||
import customtkinter as ctk
|
||||
from core.grafana_client import GrafanaClient
|
||||
from core.i18n import t
|
||||
|
||||
|
||||
class GrafanaTab(ctk.CTkFrame):
|
||||
def __init__(self, master, store):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.store = store
|
||||
self._current_alias: str | None = None
|
||||
self._client: GrafanaClient | None = None
|
||||
self._dashboards: list[dict] = []
|
||||
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
# ── Header + Refresh ──
|
||||
header_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
header_frame.pack(fill="x", padx=15, pady=(15, 5))
|
||||
|
||||
title = ctk.CTkLabel(header_frame, text=t("grafana_title"),
|
||||
font=ctk.CTkFont(size=18, weight="bold"))
|
||||
title.pack(side="left")
|
||||
|
||||
self._refresh_btn = ctk.CTkButton(header_frame, text=t("grafana_refresh"), width=100,
|
||||
command=self._refresh)
|
||||
self._refresh_btn.pack(side="right")
|
||||
|
||||
# ── Dashboards section ──
|
||||
dash_label = ctk.CTkLabel(self, text=t("grafana_dashboards"),
|
||||
font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
|
||||
dash_label.pack(fill="x", padx=15, pady=(10, 3))
|
||||
|
||||
dash_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
dash_frame.pack(fill="both", expand=True, padx=15, pady=(0, 5))
|
||||
|
||||
columns = ("uid", "title", "folder")
|
||||
self._dash_tree = ttk.Treeview(dash_frame, columns=columns, show="headings",
|
||||
selectmode="browse", height=8)
|
||||
self._dash_tree.heading("uid", text="UID")
|
||||
self._dash_tree.heading("title", text=t("grafana_dash_title"))
|
||||
self._dash_tree.heading("folder", text=t("grafana_dash_folder"))
|
||||
self._dash_tree.column("uid", width=120, minwidth=80)
|
||||
self._dash_tree.column("title", width=300, minwidth=150)
|
||||
self._dash_tree.column("folder", width=150, minwidth=80)
|
||||
self._dash_tree.pack(side="left", fill="both", expand=True)
|
||||
|
||||
dash_scroll = ttk.Scrollbar(dash_frame, orient="vertical", command=self._dash_tree.yview)
|
||||
dash_scroll.pack(side="right", fill="y")
|
||||
self._dash_tree.configure(yscrollcommand=dash_scroll.set)
|
||||
|
||||
self._dash_tree.bind("<Double-1>", self._on_dashboard_click)
|
||||
|
||||
# ── Alerts section ──
|
||||
alerts_label = ctk.CTkLabel(self, text=t("grafana_alerts"),
|
||||
font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
|
||||
alerts_label.pack(fill="x", padx=15, pady=(10, 3))
|
||||
|
||||
alerts_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
alerts_frame.pack(fill="both", expand=True, padx=15, pady=(0, 5))
|
||||
|
||||
alert_columns = ("state", "name", "severity")
|
||||
self._alerts_tree = ttk.Treeview(alerts_frame, columns=alert_columns, show="headings",
|
||||
selectmode="browse", height=6)
|
||||
self._alerts_tree.heading("state", text=t("grafana_alert_state"))
|
||||
self._alerts_tree.heading("name", text=t("grafana_alert_name"))
|
||||
self._alerts_tree.heading("severity", text=t("grafana_alert_severity"))
|
||||
self._alerts_tree.column("state", width=100, minwidth=60)
|
||||
self._alerts_tree.column("name", width=300, minwidth=150)
|
||||
self._alerts_tree.column("severity", width=100, minwidth=60)
|
||||
self._alerts_tree.pack(side="left", fill="both", expand=True)
|
||||
|
||||
alerts_scroll = ttk.Scrollbar(alerts_frame, orient="vertical", command=self._alerts_tree.yview)
|
||||
alerts_scroll.pack(side="right", fill="y")
|
||||
self._alerts_tree.configure(yscrollcommand=alerts_scroll.set)
|
||||
|
||||
# ── Status bar ──
|
||||
self._status_bar = ctk.CTkLabel(self, text=t("grafana_no_server"), anchor="w",
|
||||
font=ctk.CTkFont(size=11), text_color="#9ca3af")
|
||||
self._status_bar.pack(fill="x", padx=15, pady=(5, 10))
|
||||
|
||||
# ── Public API ──
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
"""Called when user selects a server in sidebar."""
|
||||
self._current_alias = alias
|
||||
self._client = None
|
||||
self._dashboards.clear()
|
||||
self._clear_tables()
|
||||
|
||||
if alias:
|
||||
self._set_status(t("grafana_connected").format(alias=alias), "#22c55e")
|
||||
self._refresh()
|
||||
else:
|
||||
self._set_status(t("grafana_no_server"), "#9ca3af")
|
||||
|
||||
# ── Refresh ──
|
||||
|
||||
def _refresh(self):
|
||||
if not self._current_alias:
|
||||
self._set_status(t("no_server_selected"), "#ef4444")
|
||||
return
|
||||
|
||||
self._refresh_btn.configure(state="disabled", text=t("grafana_loading"))
|
||||
self._set_status(t("grafana_loading"), "#ccaa00")
|
||||
|
||||
def _do():
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
dashboards = client.list_dashboards()
|
||||
alerts = client.list_alerts()
|
||||
|
||||
self.after(0, lambda: self._populate_dashboards(dashboards))
|
||||
self.after(0, lambda: self._populate_alerts(alerts))
|
||||
self.after(0, lambda: self._set_status(
|
||||
t("grafana_loaded").format(
|
||||
dashboards=len(dashboards), alerts=len(alerts)
|
||||
), "#22c55e"))
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self._set_status(f"(error) {e}", "#ef4444"))
|
||||
finally:
|
||||
self.after(0, lambda: self._refresh_btn.configure(
|
||||
state="normal", text=t("grafana_refresh")))
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _get_client(self) -> GrafanaClient:
|
||||
if self._client is None:
|
||||
self._client = GrafanaClient(self._current_alias, self.store)
|
||||
return self._client
|
||||
|
||||
# ── Table population ──
|
||||
|
||||
def _populate_dashboards(self, dashboards: list[dict]):
|
||||
self._dash_tree.delete(*self._dash_tree.get_children())
|
||||
self._dashboards = dashboards
|
||||
for d in dashboards:
|
||||
uid = d.get("uid", "")
|
||||
title = d.get("title", "")
|
||||
folder = d.get("folderTitle", d.get("folder", "General"))
|
||||
self._dash_tree.insert("", "end", values=(uid, title, folder))
|
||||
|
||||
def _populate_alerts(self, alerts: list[dict]):
|
||||
self._alerts_tree.delete(*self._alerts_tree.get_children())
|
||||
for a in alerts:
|
||||
state = a.get("state", a.get("status", "unknown"))
|
||||
name = a.get("name", a.get("title", ""))
|
||||
severity = a.get("severity", a.get("labels", {}).get("severity", "—"))
|
||||
tag = ""
|
||||
if state in ("alerting", "firing"):
|
||||
tag = "alerting"
|
||||
elif state in ("ok", "normal", "inactive"):
|
||||
tag = "ok"
|
||||
self._alerts_tree.insert("", "end", values=(state, name, severity), tags=(tag,))
|
||||
|
||||
# Color-code alert states
|
||||
self._alerts_tree.tag_configure("alerting", foreground="#ef4444")
|
||||
self._alerts_tree.tag_configure("ok", foreground="#22c55e")
|
||||
|
||||
def _clear_tables(self):
|
||||
self._dash_tree.delete(*self._dash_tree.get_children())
|
||||
self._alerts_tree.delete(*self._alerts_tree.get_children())
|
||||
|
||||
# ── Events ──
|
||||
|
||||
def _on_dashboard_click(self, _event):
|
||||
"""Open dashboard URL in browser on double-click."""
|
||||
selection = self._dash_tree.selection()
|
||||
if not selection:
|
||||
return
|
||||
item = self._dash_tree.item(selection[0])
|
||||
uid = item["values"][0] if item["values"] else None
|
||||
if not uid:
|
||||
return
|
||||
|
||||
# Find the dashboard data to get the URL
|
||||
for d in self._dashboards:
|
||||
if d.get("uid") == uid:
|
||||
url = d.get("url", "")
|
||||
if url:
|
||||
try:
|
||||
client = self._get_client()
|
||||
full_url = client.get_dashboard_url(url)
|
||||
webbrowser.open(full_url)
|
||||
except Exception:
|
||||
# Fallback: just open relative URL
|
||||
webbrowser.open(url)
|
||||
break
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _set_status(self, text: str, color: str = "#9ca3af"):
|
||||
self._status_bar.configure(text=text, text_color=color)
|
||||
@@ -8,17 +8,25 @@ from core.i18n import t
|
||||
|
||||
class InfoTab(ctk.CTkFrame):
|
||||
# Map field keys to i18n keys
|
||||
_FIELD_KEYS = ["alias", "ip", "port", "user", "type", "notes", "status"]
|
||||
_FIELD_KEYS = ["alias", "ip", "port", "user", "type", "database", "db_index", "ssl", "notes", "status"]
|
||||
_FIELD_I18N = {
|
||||
"alias": "info_alias",
|
||||
"ip": "info_ip",
|
||||
"port": "info_port",
|
||||
"user": "info_user",
|
||||
"type": "info_type",
|
||||
"database": "info_database",
|
||||
"db_index": "info_db_index",
|
||||
"ssl": "info_ssl",
|
||||
"notes": "info_notes",
|
||||
"status": "info_status",
|
||||
}
|
||||
|
||||
# Which fields are relevant per server type
|
||||
_SQL_TYPES = {"mariadb", "mssql", "postgresql"}
|
||||
_SSL_TYPES = {"grafana", "prometheus", "winrm"}
|
||||
_NO_USER_TYPES = {"redis", "grafana", "prometheus"}
|
||||
|
||||
def __init__(self, master, store, edit_callback=None):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.store = store
|
||||
@@ -65,12 +73,39 @@ class InfoTab(ctk.CTkFrame):
|
||||
if not server:
|
||||
return
|
||||
|
||||
stype = server.get("type", "ssh").lower()
|
||||
|
||||
self.header.configure(text=server["alias"])
|
||||
self._fields["alias"].configure(text=server.get("alias", "-"))
|
||||
self._fields["ip"].configure(text=server.get("ip", "-"))
|
||||
self._fields["port"].configure(text=str(server.get("port", 22)))
|
||||
self._fields["user"].configure(text=server.get("user", "root"))
|
||||
self._fields["type"].configure(text=server.get("type", "ssh").upper())
|
||||
|
||||
# Hide user for types that don't use it
|
||||
if stype in self._NO_USER_TYPES:
|
||||
self._fields["user"].configure(text="-")
|
||||
else:
|
||||
self._fields["user"].configure(text=server.get("user", "root"))
|
||||
|
||||
self._fields["type"].configure(text=stype.upper())
|
||||
|
||||
# Database field — relevant for SQL types
|
||||
if stype in self._SQL_TYPES:
|
||||
self._fields["database"].configure(text=server.get("database", "-"))
|
||||
else:
|
||||
self._fields["database"].configure(text="-")
|
||||
|
||||
# DB index — relevant for redis
|
||||
if stype == "redis":
|
||||
self._fields["db_index"].configure(text=str(server.get("db_index", 0)))
|
||||
else:
|
||||
self._fields["db_index"].configure(text="-")
|
||||
|
||||
# SSL — relevant for grafana, prometheus, winrm
|
||||
if stype in self._SSL_TYPES:
|
||||
self._fields["ssl"].configure(text="Yes" if server.get("use_ssl") else "No")
|
||||
else:
|
||||
self._fields["ssl"].configure(text="-")
|
||||
|
||||
self._fields["notes"].configure(text=server.get("notes", "-") or "-")
|
||||
|
||||
status = self.store.get_status(self._current_alias)
|
||||
|
||||
110
gui/tabs/launch_tab.py
Normal file
110
gui/tabs/launch_tab.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Launch tab — connect button for RDP/VNC remote desktop sessions.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import customtkinter as ctk
|
||||
from core.remote_desktop import RemoteDesktopLauncher
|
||||
from core.i18n import t
|
||||
|
||||
|
||||
class LaunchTab(ctk.CTkFrame):
|
||||
"""Minimal tab: server info + big Connect button for RDP/VNC."""
|
||||
|
||||
def __init__(self, master, store):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.store = store
|
||||
self._current_alias: str | None = None
|
||||
self._server_type: str | None = None # "rdp" or "vnc"
|
||||
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
# Server info label
|
||||
self._info_label = ctk.CTkLabel(
|
||||
self, text=t("no_server_selected_info"),
|
||||
font=ctk.CTkFont(size=16), wraplength=400,
|
||||
)
|
||||
self._info_label.pack(padx=20, pady=(40, 20))
|
||||
|
||||
# Big connect button
|
||||
self._connect_btn = ctk.CTkButton(
|
||||
self, text=t("launch_connect"),
|
||||
font=ctk.CTkFont(size=18, weight="bold"),
|
||||
width=220, height=50,
|
||||
command=self._on_connect,
|
||||
)
|
||||
self._connect_btn.pack(pady=20)
|
||||
self._connect_btn.configure(state="disabled")
|
||||
|
||||
# Status / result label
|
||||
self._status_label = ctk.CTkLabel(
|
||||
self, text="", font=ctk.CTkFont(size=13),
|
||||
text_color="#888888", wraplength=400,
|
||||
)
|
||||
self._status_label.pack(padx=20, pady=(10, 0))
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
self._current_alias = alias
|
||||
self._status_label.configure(text="", text_color="#888888")
|
||||
|
||||
if alias is None:
|
||||
self._info_label.configure(text=t("no_server_selected_info"))
|
||||
self._connect_btn.configure(state="disabled")
|
||||
self._server_type = None
|
||||
return
|
||||
|
||||
server = self.store.get_server(alias)
|
||||
if not server:
|
||||
self._info_label.configure(text=t("server_not_found").format(alias=alias))
|
||||
self._connect_btn.configure(state="disabled")
|
||||
self._server_type = None
|
||||
return
|
||||
|
||||
stype = server.get("type", "").lower()
|
||||
self._server_type = stype
|
||||
|
||||
if stype == "rdp":
|
||||
info_text = t("launch_rdp_info").format(alias=alias)
|
||||
elif stype == "vnc":
|
||||
info_text = t("launch_vnc_info").format(alias=alias)
|
||||
else:
|
||||
info_text = f"{alias} ({stype.upper()})"
|
||||
|
||||
self._info_label.configure(text=info_text)
|
||||
self._connect_btn.configure(state="normal")
|
||||
|
||||
def _on_connect(self):
|
||||
if not self._current_alias or not self._server_type:
|
||||
return
|
||||
|
||||
server = self.store.get_server(self._current_alias)
|
||||
if not server:
|
||||
return
|
||||
|
||||
self._connect_btn.configure(state="disabled")
|
||||
self._status_label.configure(
|
||||
text=t("launch_starting"), text_color="#ccaa00",
|
||||
)
|
||||
|
||||
stype = self._server_type
|
||||
|
||||
def _do():
|
||||
try:
|
||||
if stype == "rdp":
|
||||
RemoteDesktopLauncher.launch_rdp(server)
|
||||
elif stype == "vnc":
|
||||
RemoteDesktopLauncher.launch_vnc(server)
|
||||
|
||||
self.after(0, lambda: self._status_label.configure(
|
||||
text=t("launch_started"), text_color="#44cc44",
|
||||
))
|
||||
except Exception as exc:
|
||||
self.after(0, lambda: self._status_label.configure(
|
||||
text=t("launch_error").format(error=str(exc)),
|
||||
text_color="#ff4444",
|
||||
))
|
||||
finally:
|
||||
self.after(0, lambda: self._connect_btn.configure(state="normal"))
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
242
gui/tabs/powershell_tab.py
Normal file
242
gui/tabs/powershell_tab.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
PowerShell/CMD tab — request-response terminal for WinRM servers.
|
||||
|
||||
No pyte needed: WinRM is not an interactive PTY, just command → output.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import customtkinter as ctk
|
||||
from core.winrm_client import WinRMClient
|
||||
from core.i18n import t
|
||||
|
||||
|
||||
class PowershellTab(ctk.CTkFrame):
|
||||
"""Simplified terminal for WinRM command execution (PS or CMD)."""
|
||||
|
||||
_MAX_HISTORY = 200
|
||||
|
||||
def __init__(self, master, store):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.store = store
|
||||
self._current_alias: str | None = None
|
||||
self._client: WinRMClient | None = None
|
||||
self._mode: str = "ps" # "ps" or "cmd"
|
||||
self._history: list[str] = []
|
||||
self._history_index: int = -1
|
||||
self._running = False
|
||||
|
||||
self._build_ui()
|
||||
|
||||
# ── UI construction ──────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
# Top bar: mode toggle
|
||||
top = ctk.CTkFrame(self, fg_color="transparent")
|
||||
top.pack(fill="x", padx=8, pady=(8, 0))
|
||||
|
||||
self._mode_var = ctk.StringVar(value="ps")
|
||||
self._ps_radio = ctk.CTkRadioButton(
|
||||
top, text=t("ps_mode_ps"), variable=self._mode_var,
|
||||
value="ps", command=self._on_mode_changed,
|
||||
)
|
||||
self._ps_radio.pack(side="left", padx=(0, 12))
|
||||
|
||||
self._cmd_radio = ctk.CTkRadioButton(
|
||||
top, text=t("ps_mode_cmd"), variable=self._mode_var,
|
||||
value="cmd", command=self._on_mode_changed,
|
||||
)
|
||||
self._cmd_radio.pack(side="left")
|
||||
|
||||
# Output console
|
||||
self._output = ctk.CTkTextbox(
|
||||
self, font=ctk.CTkFont(family="Consolas", size=13),
|
||||
state="disabled", wrap="word",
|
||||
)
|
||||
self._output.pack(fill="both", expand=True, padx=8, pady=8)
|
||||
|
||||
# Input row: entry + execute button
|
||||
input_row = ctk.CTkFrame(self, fg_color="transparent")
|
||||
input_row.pack(fill="x", padx=8, pady=(0, 4))
|
||||
|
||||
self._entry = ctk.CTkEntry(
|
||||
input_row, placeholder_text="PS> ...",
|
||||
font=ctk.CTkFont(family="Consolas", size=13),
|
||||
)
|
||||
self._entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||||
self._entry.bind("<Return>", lambda e: self._execute())
|
||||
self._entry.bind("<Up>", lambda e: self._history_navigate(-1))
|
||||
self._entry.bind("<Down>", lambda e: self._history_navigate(1))
|
||||
|
||||
self._exec_btn = ctk.CTkButton(
|
||||
input_row, text=t("ps_execute"), width=90,
|
||||
command=self._execute,
|
||||
)
|
||||
self._exec_btn.pack(side="right")
|
||||
|
||||
# Status bar
|
||||
self._status = ctk.CTkLabel(
|
||||
self, text="", anchor="w",
|
||||
font=ctk.CTkFont(size=11), text_color="#888888",
|
||||
)
|
||||
self._status.pack(fill="x", padx=10, pady=(0, 6))
|
||||
|
||||
# ── Public API ───────────────────────────────────────────────────
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
"""Switch to a different server (or None to disconnect)."""
|
||||
if alias == self._current_alias:
|
||||
return
|
||||
|
||||
self._disconnect()
|
||||
self._current_alias = alias
|
||||
self._history.clear()
|
||||
self._history_index = -1
|
||||
|
||||
if alias is None:
|
||||
self._set_status(t("ps_disconnected"), "#888888")
|
||||
return
|
||||
|
||||
self._connect(alias)
|
||||
|
||||
# ── Connection ───────────────────────────────────────────────────
|
||||
|
||||
def _connect(self, alias: str):
|
||||
server = self.store.get_server(alias)
|
||||
if not server:
|
||||
self._set_status(t("server_not_found").format(alias=alias), "#ff4444")
|
||||
return
|
||||
|
||||
self._set_status(t("ps_connecting").format(alias=alias), "#ccaa00")
|
||||
|
||||
def _do():
|
||||
try:
|
||||
client = WinRMClient(server)
|
||||
client.connect()
|
||||
self._client = client
|
||||
self.after(0, lambda: self._set_status(
|
||||
t("ps_connected").format(alias=alias), "#44cc44",
|
||||
))
|
||||
self.after(0, lambda: self._entry.focus())
|
||||
except Exception as exc:
|
||||
self.after(0, lambda: self._set_status(
|
||||
t("ps_connect_failed").format(error=str(exc)), "#ff4444",
|
||||
))
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _disconnect(self):
|
||||
if self._client:
|
||||
try:
|
||||
self._client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._client = None
|
||||
|
||||
# ── Command execution ────────────────────────────────────────────
|
||||
|
||||
def _execute(self):
|
||||
cmd = self._entry.get().strip()
|
||||
if not cmd:
|
||||
return
|
||||
if not self._client:
|
||||
self._set_status(t("ps_not_connected"), "#ff4444")
|
||||
return
|
||||
if self._running:
|
||||
return
|
||||
|
||||
# Save to history
|
||||
if not self._history or self._history[-1] != cmd:
|
||||
self._history.append(cmd)
|
||||
if len(self._history) > self._MAX_HISTORY:
|
||||
self._history.pop(0)
|
||||
self._history_index = -1
|
||||
|
||||
# Show command in output
|
||||
prompt = "PS>" if self._mode == "ps" else "CMD>"
|
||||
self._append_output(f"\n{prompt} {cmd}\n")
|
||||
|
||||
self._entry.delete(0, "end")
|
||||
self._running = True
|
||||
self._exec_btn.configure(state="disabled")
|
||||
self._set_status(t("ps_running"), "#ccaa00")
|
||||
|
||||
mode = self._mode
|
||||
client = self._client
|
||||
|
||||
def _run():
|
||||
try:
|
||||
if mode == "ps":
|
||||
result = client.exec_ps(cmd)
|
||||
else:
|
||||
result = client.exec_cmd(cmd)
|
||||
|
||||
stdout = result.get("stdout", "")
|
||||
stderr = result.get("stderr", "")
|
||||
rc = result.get("return_code", None)
|
||||
|
||||
def _show():
|
||||
if stdout:
|
||||
self._append_output(stdout)
|
||||
if stderr:
|
||||
self._append_output(f"[STDERR] {stderr}")
|
||||
if rc is not None and rc != 0:
|
||||
self._append_output(f"[Exit code: {rc}]")
|
||||
self._set_status(t("ps_done"), "#44cc44")
|
||||
|
||||
self.after(0, _show)
|
||||
except Exception as exc:
|
||||
self.after(0, lambda: self._append_output(
|
||||
f"\n[ERROR] {exc}\n"
|
||||
))
|
||||
self.after(0, lambda: self._set_status(
|
||||
t("ps_exec_error"), "#ff4444",
|
||||
))
|
||||
finally:
|
||||
self._running = False
|
||||
self.after(0, lambda: self._exec_btn.configure(state="normal"))
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
# ── History navigation ───────────────────────────────────────────
|
||||
|
||||
def _history_navigate(self, direction: int):
|
||||
"""Navigate command history. direction: -1 = older, +1 = newer."""
|
||||
if not self._history:
|
||||
self._set_status(t("ps_history_empty"), "#888888")
|
||||
return
|
||||
|
||||
if self._history_index == -1:
|
||||
if direction == -1:
|
||||
self._history_index = len(self._history) - 1
|
||||
else:
|
||||
return
|
||||
else:
|
||||
self._history_index += direction
|
||||
|
||||
if self._history_index < 0:
|
||||
self._history_index = 0
|
||||
elif self._history_index >= len(self._history):
|
||||
self._history_index = -1
|
||||
self._entry.delete(0, "end")
|
||||
return
|
||||
|
||||
self._entry.delete(0, "end")
|
||||
self._entry.insert(0, self._history[self._history_index])
|
||||
|
||||
# ── Mode toggle ──────────────────────────────────────────────────
|
||||
|
||||
def _on_mode_changed(self):
|
||||
self._mode = self._mode_var.get()
|
||||
placeholder = "PS> ..." if self._mode == "ps" else "CMD> ..."
|
||||
self._entry.configure(placeholder_text=placeholder)
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _append_output(self, text: str):
|
||||
self._output.configure(state="normal")
|
||||
self._output.insert("end", text)
|
||||
self._output.see("end")
|
||||
self._output.configure(state="disabled")
|
||||
|
||||
def _set_status(self, text: str, color: str = "#888888"):
|
||||
self._status.configure(text=text, text_color=color)
|
||||
266
gui/tabs/prometheus_tab.py
Normal file
266
gui/tabs/prometheus_tab.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Prometheus tab — PromQL query executor, targets overview, and alerts.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from tkinter import ttk
|
||||
|
||||
import customtkinter as ctk
|
||||
from core.prometheus_client import PrometheusClient
|
||||
from core.i18n import t
|
||||
|
||||
|
||||
class PrometheusTab(ctk.CTkFrame):
|
||||
def __init__(self, master, store):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.store = store
|
||||
self._current_alias: str | None = None
|
||||
self._client: PrometheusClient | None = None
|
||||
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
# ── PromQL query section ──
|
||||
query_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
query_frame.pack(fill="x", padx=15, pady=(15, 5))
|
||||
|
||||
query_label = ctk.CTkLabel(query_frame, text=t("prom_query"),
|
||||
font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
|
||||
query_label.pack(side="left", padx=(0, 10))
|
||||
|
||||
self._query_entry = ctk.CTkEntry(query_frame,
|
||||
placeholder_text=t("prom_query_placeholder"),
|
||||
font=ctk.CTkFont(family="Consolas", size=13))
|
||||
self._query_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||
self._query_entry.bind("<Return>", lambda e: self._execute_query())
|
||||
|
||||
self._exec_btn = ctk.CTkButton(query_frame, text=t("prom_execute"), width=90,
|
||||
command=self._execute_query)
|
||||
self._exec_btn.pack(side="left")
|
||||
|
||||
# ── Query results ──
|
||||
results_label = ctk.CTkLabel(self, text=t("prom_results"),
|
||||
font=ctk.CTkFont(size=12, weight="bold"), anchor="w")
|
||||
results_label.pack(fill="x", padx=15, pady=(10, 3))
|
||||
|
||||
self._results_box = ctk.CTkTextbox(self, height=150,
|
||||
font=ctk.CTkFont(family="Consolas", size=12),
|
||||
state="disabled")
|
||||
self._results_box.pack(fill="x", padx=15, pady=(0, 5))
|
||||
|
||||
# ── Targets section ──
|
||||
targets_header = ctk.CTkFrame(self, fg_color="transparent")
|
||||
targets_header.pack(fill="x", padx=15, pady=(10, 3))
|
||||
|
||||
targets_label = ctk.CTkLabel(targets_header, text=t("prom_targets"),
|
||||
font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
|
||||
targets_label.pack(side="left")
|
||||
|
||||
self._refresh_btn = ctk.CTkButton(targets_header, text=t("prom_refresh"), width=90,
|
||||
command=self._refresh_all)
|
||||
self._refresh_btn.pack(side="right")
|
||||
|
||||
targets_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
targets_frame.pack(fill="both", expand=True, padx=15, pady=(0, 5))
|
||||
|
||||
target_columns = ("job", "instance", "health", "last_scrape")
|
||||
self._targets_tree = ttk.Treeview(targets_frame, columns=target_columns, show="headings",
|
||||
selectmode="browse", height=6)
|
||||
self._targets_tree.heading("job", text=t("prom_target_job"))
|
||||
self._targets_tree.heading("instance", text=t("prom_target_instance"))
|
||||
self._targets_tree.heading("health", text=t("prom_target_health"))
|
||||
self._targets_tree.heading("last_scrape", text=t("prom_target_scrape"))
|
||||
self._targets_tree.column("job", width=120, minwidth=80)
|
||||
self._targets_tree.column("instance", width=200, minwidth=120)
|
||||
self._targets_tree.column("health", width=80, minwidth=60)
|
||||
self._targets_tree.column("last_scrape", width=150, minwidth=80)
|
||||
self._targets_tree.pack(side="left", fill="both", expand=True)
|
||||
|
||||
targets_scroll = ttk.Scrollbar(targets_frame, orient="vertical",
|
||||
command=self._targets_tree.yview)
|
||||
targets_scroll.pack(side="right", fill="y")
|
||||
self._targets_tree.configure(yscrollcommand=targets_scroll.set)
|
||||
|
||||
# ── Alerts section ──
|
||||
alerts_label = ctk.CTkLabel(self, text=t("prom_alerts"),
|
||||
font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
|
||||
alerts_label.pack(fill="x", padx=15, pady=(10, 3))
|
||||
|
||||
self._alerts_box = ctk.CTkTextbox(self, height=100,
|
||||
font=ctk.CTkFont(family="Consolas", size=12),
|
||||
state="disabled")
|
||||
self._alerts_box.pack(fill="x", padx=15, pady=(0, 5))
|
||||
|
||||
# ── Status bar ──
|
||||
self._status_bar = ctk.CTkLabel(self, text=t("prom_no_server"), anchor="w",
|
||||
font=ctk.CTkFont(size=11), text_color="#9ca3af")
|
||||
self._status_bar.pack(fill="x", padx=15, pady=(5, 10))
|
||||
|
||||
# ── Public API ──
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
"""Called when user selects a server in sidebar."""
|
||||
self._current_alias = alias
|
||||
self._client = None
|
||||
self._clear_all()
|
||||
|
||||
if alias:
|
||||
self._set_status(t("prom_connected").format(alias=alias), "#22c55e")
|
||||
self._refresh_all()
|
||||
else:
|
||||
self._set_status(t("prom_no_server"), "#9ca3af")
|
||||
|
||||
# ── PromQL execution ──
|
||||
|
||||
def _execute_query(self):
|
||||
query = self._query_entry.get().strip()
|
||||
if not query:
|
||||
return
|
||||
if not self._current_alias:
|
||||
self._set_results(t("no_server_selected"))
|
||||
return
|
||||
|
||||
self._exec_btn.configure(state="disabled")
|
||||
self._set_results(t("prom_executing"))
|
||||
|
||||
def _do():
|
||||
try:
|
||||
client = self._get_client()
|
||||
result = client.query(query)
|
||||
formatted = self._format_query_result(result)
|
||||
self.after(0, lambda: self._set_results(formatted))
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self._set_results(f"(error) {e}"))
|
||||
finally:
|
||||
self.after(0, lambda: self._exec_btn.configure(state="normal"))
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _format_query_result(self, result: dict) -> str:
|
||||
"""Format Prometheus query API response for display."""
|
||||
status = result.get("status", "unknown")
|
||||
if status != "success":
|
||||
error = result.get("error", "Unknown error")
|
||||
return f"Error: {error}"
|
||||
|
||||
data = result.get("data", {})
|
||||
result_type = data.get("resultType", "")
|
||||
results = data.get("result", [])
|
||||
|
||||
if not results:
|
||||
return "(empty result)"
|
||||
|
||||
lines = [f"# Type: {result_type}", f"# Results: {len(results)}", ""]
|
||||
|
||||
for item in results:
|
||||
metric = item.get("metric", {})
|
||||
metric_str = ", ".join(f'{k}="{v}"' for k, v in metric.items())
|
||||
|
||||
if result_type == "vector":
|
||||
value = item.get("value", [None, ""])[1]
|
||||
lines.append(f"{{{metric_str}}} => {value}")
|
||||
elif result_type == "matrix":
|
||||
values = item.get("values", [])
|
||||
lines.append(f"{{{metric_str}}}")
|
||||
for ts, val in values[-10:]: # Show last 10 points
|
||||
lines.append(f" @{ts} => {val}")
|
||||
if len(values) > 10:
|
||||
lines.append(f" ... ({len(values)} total points)")
|
||||
else:
|
||||
lines.append(f"{{{metric_str}}} => {item}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Refresh targets & alerts ──
|
||||
|
||||
def _refresh_all(self):
|
||||
if not self._current_alias:
|
||||
self._set_status(t("no_server_selected"), "#ef4444")
|
||||
return
|
||||
|
||||
self._refresh_btn.configure(state="disabled", text=t("prom_loading"))
|
||||
self._set_status(t("prom_loading"), "#ccaa00")
|
||||
|
||||
def _do():
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
targets = client.get_targets()
|
||||
alerts = client.get_alerts()
|
||||
|
||||
self.after(0, lambda: self._populate_targets(targets))
|
||||
self.after(0, lambda: self._populate_alerts(alerts))
|
||||
self.after(0, lambda: self._set_status(
|
||||
t("prom_loaded").format(
|
||||
targets=len(targets), alerts=len(alerts)
|
||||
), "#22c55e"))
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self._set_status(f"(error) {e}", "#ef4444"))
|
||||
finally:
|
||||
self.after(0, lambda: self._refresh_btn.configure(
|
||||
state="normal", text=t("prom_refresh")))
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _get_client(self) -> PrometheusClient:
|
||||
if self._client is None:
|
||||
self._client = PrometheusClient(self._current_alias, self.store)
|
||||
return self._client
|
||||
|
||||
# ── Table population ──
|
||||
|
||||
def _populate_targets(self, targets: list[dict]):
|
||||
self._targets_tree.delete(*self._targets_tree.get_children())
|
||||
for target in targets:
|
||||
job = target.get("labels", {}).get("job", "—")
|
||||
instance = target.get("labels", {}).get("instance", "—")
|
||||
health = target.get("health", "unknown")
|
||||
last_scrape = target.get("lastScrape", "—")
|
||||
|
||||
tag = ""
|
||||
if health == "up":
|
||||
tag = "up"
|
||||
elif health == "down":
|
||||
tag = "down"
|
||||
|
||||
self._targets_tree.insert("", "end",
|
||||
values=(job, instance, health, last_scrape),
|
||||
tags=(tag,))
|
||||
|
||||
self._targets_tree.tag_configure("up", foreground="#22c55e")
|
||||
self._targets_tree.tag_configure("down", foreground="#ef4444")
|
||||
|
||||
def _populate_alerts(self, alerts: list[dict]):
|
||||
self._alerts_box.configure(state="normal")
|
||||
self._alerts_box.delete("1.0", "end")
|
||||
|
||||
if not alerts:
|
||||
self._alerts_box.insert("1.0", t("prom_no_alerts"))
|
||||
else:
|
||||
lines = []
|
||||
for a in alerts:
|
||||
name = a.get("labels", {}).get("alertname", a.get("name", "unknown"))
|
||||
state = a.get("state", "unknown")
|
||||
severity = a.get("labels", {}).get("severity", "—")
|
||||
lines.append(f"[{state.upper()}] {name} (severity: {severity})")
|
||||
self._alerts_box.insert("1.0", "\n".join(lines))
|
||||
|
||||
self._alerts_box.configure(state="disabled")
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _set_results(self, text: str):
|
||||
self._results_box.configure(state="normal")
|
||||
self._results_box.delete("1.0", "end")
|
||||
self._results_box.insert("1.0", text)
|
||||
self._results_box.configure(state="disabled")
|
||||
|
||||
def _clear_all(self):
|
||||
self._targets_tree.delete(*self._targets_tree.get_children())
|
||||
self._set_results("")
|
||||
self._alerts_box.configure(state="normal")
|
||||
self._alerts_box.delete("1.0", "end")
|
||||
self._alerts_box.configure(state="disabled")
|
||||
|
||||
def _set_status(self, text: str, color: str = "#9ca3af"):
|
||||
self._status_bar.configure(text=text, text_color=color)
|
||||
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))
|
||||
266
gui/tabs/redis_tab.py
Normal file
266
gui/tabs/redis_tab.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Redis tab — interactive Redis CLI with DB selector, command history, and output console.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import customtkinter as ctk
|
||||
from core.redis_client import RedisClient
|
||||
from core.i18n import t
|
||||
|
||||
|
||||
class RedisTab(ctk.CTkFrame):
|
||||
def __init__(self, master, store):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.store = store
|
||||
self._current_alias: str | None = None
|
||||
self._client: RedisClient | None = None
|
||||
self._command_history: list[str] = []
|
||||
self._history_index: int = -1
|
||||
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
# ── Top bar: DB selector + stats ──
|
||||
top_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
top_frame.pack(fill="x", padx=15, pady=(15, 5))
|
||||
|
||||
# DB selector
|
||||
db_label = ctk.CTkLabel(top_frame, text=t("redis_db"), anchor="w",
|
||||
font=ctk.CTkFont(size=12, weight="bold"))
|
||||
db_label.pack(side="left", padx=(0, 5))
|
||||
|
||||
self._db_var = ctk.StringVar(value="0")
|
||||
self._db_selector = ctk.CTkOptionMenu(
|
||||
top_frame, values=[str(i) for i in range(16)],
|
||||
variable=self._db_var, width=70,
|
||||
command=self._on_db_changed,
|
||||
)
|
||||
self._db_selector.pack(side="left", padx=(0, 15))
|
||||
|
||||
# Keys count
|
||||
self._keys_label = ctk.CTkLabel(top_frame, text=t("redis_keys") + ": —",
|
||||
font=ctk.CTkFont(size=12), text_color="#9ca3af")
|
||||
self._keys_label.pack(side="left", padx=(0, 15))
|
||||
|
||||
# Memory usage
|
||||
self._memory_label = ctk.CTkLabel(top_frame, text=t("redis_memory") + ": —",
|
||||
font=ctk.CTkFont(size=12), text_color="#9ca3af")
|
||||
self._memory_label.pack(side="left")
|
||||
|
||||
# ── Command input row ──
|
||||
cmd_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
cmd_frame.pack(fill="x", padx=15, pady=5)
|
||||
|
||||
prompt_label = ctk.CTkLabel(cmd_frame, text="redis>", font=ctk.CTkFont(family="Consolas", size=13),
|
||||
text_color="#ef4444")
|
||||
prompt_label.pack(side="left", padx=(0, 5))
|
||||
|
||||
self._cmd_entry = ctk.CTkEntry(cmd_frame, placeholder_text=t("redis_command_placeholder"),
|
||||
font=ctk.CTkFont(family="Consolas", size=13))
|
||||
self._cmd_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||
self._cmd_entry.bind("<Return>", lambda e: self._execute_command())
|
||||
self._cmd_entry.bind("<Up>", self._history_up)
|
||||
self._cmd_entry.bind("<Down>", self._history_down)
|
||||
|
||||
# ── Buttons row ──
|
||||
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
btn_frame.pack(fill="x", padx=15, pady=5)
|
||||
|
||||
self._exec_btn = ctk.CTkButton(btn_frame, text=t("redis_execute"), width=90,
|
||||
command=self._execute_command)
|
||||
self._exec_btn.pack(side="left", padx=(0, 5))
|
||||
|
||||
self._info_btn = ctk.CTkButton(btn_frame, text="INFO", width=70,
|
||||
fg_color="#6b7280", hover_color="#4b5563",
|
||||
command=lambda: self._run_quick("INFO"))
|
||||
self._info_btn.pack(side="left", padx=(0, 5))
|
||||
|
||||
self._dbsize_btn = ctk.CTkButton(btn_frame, text="DBSIZE", width=80,
|
||||
fg_color="#6b7280", hover_color="#4b5563",
|
||||
command=lambda: self._run_quick("DBSIZE"))
|
||||
self._dbsize_btn.pack(side="left", padx=(0, 5))
|
||||
|
||||
self._scan_btn = ctk.CTkButton(btn_frame, text="SCAN", width=70,
|
||||
fg_color="#6b7280", hover_color="#4b5563",
|
||||
command=lambda: self._run_quick("SCAN 0 COUNT 100"))
|
||||
self._scan_btn.pack(side="left", padx=(0, 5))
|
||||
|
||||
self._clear_btn = ctk.CTkButton(btn_frame, text=t("redis_clear"), width=70,
|
||||
fg_color="#374151", hover_color="#1f2937",
|
||||
command=self._clear_output)
|
||||
self._clear_btn.pack(side="right")
|
||||
|
||||
# ── Output console ──
|
||||
self._output = ctk.CTkTextbox(self, font=ctk.CTkFont(family="Consolas", size=12),
|
||||
state="disabled")
|
||||
self._output.pack(fill="both", expand=True, padx=15, pady=(5, 5))
|
||||
|
||||
# ── Status bar ──
|
||||
self._status_bar = ctk.CTkLabel(self, text=t("redis_disconnected"), anchor="w",
|
||||
font=ctk.CTkFont(size=11), text_color="#9ca3af")
|
||||
self._status_bar.pack(fill="x", padx=15, pady=(0, 10))
|
||||
|
||||
# ── Public API ──
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
"""Called when user selects a server in sidebar."""
|
||||
self._current_alias = alias
|
||||
self._client = None
|
||||
self._command_history.clear()
|
||||
self._history_index = -1
|
||||
self._clear_output()
|
||||
|
||||
if alias:
|
||||
self._set_status(t("redis_ready").format(alias=alias), "#22c55e")
|
||||
self._refresh_stats()
|
||||
else:
|
||||
self._set_status(t("redis_disconnected"), "#9ca3af")
|
||||
self._keys_label.configure(text=t("redis_keys") + ": —")
|
||||
self._memory_label.configure(text=t("redis_memory") + ": —")
|
||||
|
||||
# ── Command execution ──
|
||||
|
||||
def _execute_command(self):
|
||||
cmd = self._cmd_entry.get().strip()
|
||||
if not cmd:
|
||||
return
|
||||
if not self._current_alias:
|
||||
self._append_output(t("no_server_selected"))
|
||||
return
|
||||
|
||||
# Add to history
|
||||
if not self._command_history or self._command_history[-1] != cmd:
|
||||
self._command_history.append(cmd)
|
||||
self._history_index = -1
|
||||
|
||||
self._cmd_entry.delete(0, "end")
|
||||
self._append_output(f"redis> {cmd}")
|
||||
self._set_buttons_state("disabled")
|
||||
|
||||
db = int(self._db_var.get())
|
||||
|
||||
def _do():
|
||||
try:
|
||||
client = self._get_client()
|
||||
result = client.execute(cmd, db=db)
|
||||
formatted = self._format_result(result)
|
||||
self.after(0, lambda: self._append_output(formatted))
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self._append_output(f"(error) {e}"))
|
||||
finally:
|
||||
self.after(0, lambda: self._set_buttons_state("normal"))
|
||||
self.after(0, self._refresh_stats)
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _run_quick(self, cmd: str):
|
||||
"""Execute a preset command."""
|
||||
self._cmd_entry.delete(0, "end")
|
||||
self._cmd_entry.insert(0, cmd)
|
||||
self._execute_command()
|
||||
|
||||
def _get_client(self) -> RedisClient:
|
||||
if self._client is None:
|
||||
self._client = RedisClient(self._current_alias, self.store)
|
||||
return self._client
|
||||
|
||||
# ── Stats refresh ──
|
||||
|
||||
def _refresh_stats(self):
|
||||
if not self._current_alias:
|
||||
return
|
||||
|
||||
def _do():
|
||||
try:
|
||||
client = self._get_client()
|
||||
db = int(self._db_var.get())
|
||||
keys_count = client.execute("DBSIZE", db=db)
|
||||
info = client.execute("INFO memory", db=db)
|
||||
|
||||
# Parse memory from INFO output
|
||||
memory = "—"
|
||||
if isinstance(info, str):
|
||||
for line in info.split("\r\n"):
|
||||
if line.startswith("used_memory_human:"):
|
||||
memory = line.split(":")[1].strip()
|
||||
break
|
||||
|
||||
keys_text = str(keys_count) if keys_count is not None else "—"
|
||||
self.after(0, lambda: self._keys_label.configure(
|
||||
text=t("redis_keys") + f": {keys_text}"))
|
||||
self.after(0, lambda: self._memory_label.configure(
|
||||
text=t("redis_memory") + f": {memory}"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _on_db_changed(self, _value: str):
|
||||
self._refresh_stats()
|
||||
|
||||
# ── History navigation ──
|
||||
|
||||
def _history_up(self, _event):
|
||||
if not self._command_history:
|
||||
return "break"
|
||||
if self._history_index == -1:
|
||||
self._history_index = len(self._command_history) - 1
|
||||
elif self._history_index > 0:
|
||||
self._history_index -= 1
|
||||
self._cmd_entry.delete(0, "end")
|
||||
self._cmd_entry.insert(0, self._command_history[self._history_index])
|
||||
return "break"
|
||||
|
||||
def _history_down(self, _event):
|
||||
if not self._command_history:
|
||||
return "break"
|
||||
if self._history_index == -1:
|
||||
return "break"
|
||||
if self._history_index < len(self._command_history) - 1:
|
||||
self._history_index += 1
|
||||
self._cmd_entry.delete(0, "end")
|
||||
self._cmd_entry.insert(0, self._command_history[self._history_index])
|
||||
else:
|
||||
self._history_index = -1
|
||||
self._cmd_entry.delete(0, "end")
|
||||
return "break"
|
||||
|
||||
# ── Output helpers ──
|
||||
|
||||
def _format_result(self, result) -> str:
|
||||
"""Format Redis response for display."""
|
||||
if result is None:
|
||||
return "(nil)"
|
||||
if isinstance(result, bytes):
|
||||
return result.decode("utf-8", errors="replace")
|
||||
if isinstance(result, int):
|
||||
return f"(integer) {result}"
|
||||
if isinstance(result, list):
|
||||
if not result:
|
||||
return "(empty list or set)"
|
||||
lines = []
|
||||
for i, item in enumerate(result, 1):
|
||||
val = item.decode("utf-8", errors="replace") if isinstance(item, bytes) else str(item)
|
||||
lines.append(f"{i}) \"{val}\"")
|
||||
return "\n".join(lines)
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
return str(result)
|
||||
|
||||
def _append_output(self, text: str):
|
||||
self._output.configure(state="normal")
|
||||
self._output.insert("end", text + "\n")
|
||||
self._output.configure(state="disabled")
|
||||
self._output.see("end")
|
||||
|
||||
def _clear_output(self):
|
||||
self._output.configure(state="normal")
|
||||
self._output.delete("1.0", "end")
|
||||
self._output.configure(state="disabled")
|
||||
|
||||
def _set_status(self, text: str, color: str = "#9ca3af"):
|
||||
self._status_bar.configure(text=text, text_color=color)
|
||||
|
||||
def _set_buttons_state(self, state: str):
|
||||
for btn in (self._exec_btn, self._info_btn, self._dbsize_btn, self._scan_btn):
|
||||
btn.configure(state=state)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Terminal tab — persistent interactive SSH shell via ShellSession + TerminalWidget.
|
||||
Terminal tab — persistent interactive SSH/Telnet shell via ShellSession/TelnetSession + TerminalWidget.
|
||||
"""
|
||||
|
||||
import queue
|
||||
@@ -8,6 +8,7 @@ import threading
|
||||
import time
|
||||
import customtkinter as ctk
|
||||
from core.ssh_client import ShellSession
|
||||
from core.telnet_client import TelnetSession
|
||||
from core.i18n import t
|
||||
|
||||
# Regex to strip ANSI escape sequences
|
||||
@@ -20,7 +21,7 @@ class TerminalTab(ctk.CTkFrame):
|
||||
self.store = store
|
||||
self.session_pool = session_pool
|
||||
self._current_alias: str | None = None
|
||||
self._session: ShellSession | None = None
|
||||
self._session: ShellSession | TelnetSession | None = None
|
||||
self._reconnect_count = 0
|
||||
self._max_reconnect = 5
|
||||
self._intentional_disconnect = False
|
||||
@@ -76,16 +77,25 @@ class TerminalTab(ctk.CTkFrame):
|
||||
return
|
||||
|
||||
alias = self._current_alias
|
||||
server_type = server.get("type", "ssh")
|
||||
self._terminal.set_status(t("term_connecting").format(alias=alias), "#ccaa00")
|
||||
self._intentional_disconnect = False
|
||||
|
||||
def _do_connect():
|
||||
try:
|
||||
key_path = self.store.get_ssh_key_path()
|
||||
cols, rows = self._terminal.get_size()
|
||||
|
||||
# Use session pool if available
|
||||
if self.session_pool:
|
||||
cols, rows = self._terminal.get_size()
|
||||
if server_type == "telnet":
|
||||
# Telnet — direct session, no pool (pool is SSH-specific)
|
||||
self.after(0, self._terminal.reset)
|
||||
session = TelnetSession(server, cols=cols, rows=rows)
|
||||
session.on_data = self._on_data_received
|
||||
session.on_disconnect = self._on_disconnected
|
||||
session.connect()
|
||||
self._session = session
|
||||
elif self.session_pool:
|
||||
# SSH with session pool
|
||||
session, is_new = self.session_pool.get_or_create_shell_session(alias, server, key_path)
|
||||
if is_new:
|
||||
# New session — reset terminal for clean start
|
||||
@@ -108,9 +118,8 @@ class TerminalTab(ctk.CTkFrame):
|
||||
session.on_disconnect = self._on_disconnected
|
||||
self._session = session
|
||||
else:
|
||||
# Legacy behavior without session pool
|
||||
# SSH without pool (legacy)
|
||||
self.after(0, self._terminal.reset)
|
||||
cols, rows = self._terminal.get_size()
|
||||
session = ShellSession(server, key_path, cols=cols, rows=rows)
|
||||
session.on_data = self._on_data_received
|
||||
session.on_disconnect = self._on_disconnected
|
||||
@@ -136,12 +145,18 @@ class TerminalTab(ctk.CTkFrame):
|
||||
|
||||
def _disconnect(self):
|
||||
self._intentional_disconnect = True
|
||||
# Only disconnect if we don't have a session pool (otherwise session stays alive)
|
||||
if not self.session_pool and self._session:
|
||||
if not self._session:
|
||||
return
|
||||
# Telnet sessions are never pooled — always disconnect directly
|
||||
if isinstance(self._session, TelnetSession):
|
||||
self._session.disconnect()
|
||||
self._session = None
|
||||
# If using session pool, session remains active in the pool
|
||||
elif self.session_pool and self._session:
|
||||
# SSH without session pool — disconnect directly
|
||||
elif not self.session_pool:
|
||||
self._session.disconnect()
|
||||
self._session = None
|
||||
# SSH with session pool — session remains active in the pool
|
||||
else:
|
||||
# Remove callbacks to prevent processing data after switch
|
||||
self._session.on_data = None
|
||||
self._session.on_disconnect = None
|
||||
|
||||
Reference in New Issue
Block a user