From eede67e6a9e44738e4db8fa4f87da1bb3a97cb2f Mon Sep 17 00:00:00 2001 From: chrome-storm-c442 Date: Tue, 24 Feb 2026 09:35:24 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20multi-type=20server=20support=20?= =?UTF-8?q?=E2=80=94=20SQL,=20Redis,=20Grafana,=20Prometheus,=20Telnet,=20?= =?UTF-8?q?WinRM,=20RDP/VNC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- build.py | 9 +- core/connection_factory.py | 35 ++- core/grafana_client.py | 170 +++++++++++++ core/i18n.py | 321 +++++++++++++++++++++++ core/prometheus_client.py | 153 +++++++++++ core/redis_client.py | 171 +++++++++++++ core/remote_desktop.py | 124 +++++++++ core/server_store.py | 7 +- core/sql_client.py | 197 ++++++++++++++ core/status_checker.py | 99 +++++++- core/telnet_client.py | 180 +++++++++++++ core/winrm_client.py | 115 +++++++++ gui/app.py | 258 ++++++++++++------- gui/server_dialog.py | 148 +++++++++-- gui/sidebar.py | 44 +++- gui/tabs/grafana_tab.py | 202 +++++++++++++++ gui/tabs/info_tab.py | 41 ++- gui/tabs/launch_tab.py | 110 ++++++++ gui/tabs/powershell_tab.py | 242 ++++++++++++++++++ gui/tabs/prometheus_tab.py | 266 +++++++++++++++++++ gui/tabs/query_tab.py | 336 ++++++++++++++++++++++++ gui/tabs/redis_tab.py | 266 +++++++++++++++++++ gui/tabs/terminal_tab.py | 37 ++- requirements.txt | 7 + tools/skill-ssh.md | 112 +++++++- tools/ssh.py | 508 ++++++++++++++++++++++++++++++++++++- 26 files changed, 3990 insertions(+), 168 deletions(-) create mode 100644 core/grafana_client.py create mode 100644 core/prometheus_client.py create mode 100644 core/redis_client.py create mode 100644 core/remote_desktop.py create mode 100644 core/sql_client.py create mode 100644 core/telnet_client.py create mode 100644 core/winrm_client.py create mode 100644 gui/tabs/grafana_tab.py create mode 100644 gui/tabs/launch_tab.py create mode 100644 gui/tabs/powershell_tab.py create mode 100644 gui/tabs/prometheus_tab.py create mode 100644 gui/tabs/query_tab.py create mode 100644 gui/tabs/redis_tab.py diff --git a/build.py b/build.py index 254bb9b..88f2a10 100644 --- a/build.py +++ b/build.py @@ -111,13 +111,20 @@ def build(): if os.path.exists(icon_path): cmd_parts.extend(["--icon", icon_path]) - # Hidden imports for customtkinter + # Hidden imports for customtkinter and connection libraries cmd_parts.extend([ "--hidden-import", "customtkinter", "--hidden-import", "PIL", "--hidden-import", "pyotp", "--hidden-import", "pyte", "--hidden-import", "psutil", + "--hidden-import", "pymysql", + "--hidden-import", "psycopg2", + "--hidden-import", "pymssql", + "--hidden-import", "redis", + "--hidden-import", "requests", + "--hidden-import", "winrm", + "--hidden-import", "telnetlib3", "--collect-all", "customtkinter", ]) diff --git a/core/connection_factory.py b/core/connection_factory.py index 615f9a9..e9362dd 100644 --- a/core/connection_factory.py +++ b/core/connection_factory.py @@ -1,7 +1,6 @@ """ -Connection factory — stubs for non-SSH connection types. -SSH is fully implemented via SSHClientWrapper. -Other types are placeholders for future implementation. +Connection factory — creates connection wrappers based on server type. +Uses lazy imports so missing optional dependencies don't crash the app. """ from core.ssh_client import SSHClientWrapper @@ -14,12 +13,32 @@ def create_connection(server: dict, key_path: str = ""): if server_type == "ssh": return SSHClientWrapper(server, key_path) - # Stubs for future types - if server_type == "rdp": - raise NotImplementedError("RDP connections — use mstsc.exe or rdesktop") if server_type == "telnet": - raise NotImplementedError("Telnet connections — planned") + from core.telnet_client import TelnetSession + return TelnetSession(server) + if server_type in ("mariadb", "mssql", "postgresql"): - raise NotImplementedError(f"{server_type.upper()} connections — planned") + from core.sql_client import SQLClient + return SQLClient(server) + + if server_type == "redis": + from core.redis_client import RedisClient + return RedisClient(server) + + if server_type == "grafana": + from core.grafana_client import GrafanaClient + return GrafanaClient(server) + + if server_type == "prometheus": + from core.prometheus_client import PrometheusClient + return PrometheusClient(server) + + if server_type == "winrm": + from core.winrm_client import WinRMClient + return WinRMClient(server) + + if server_type in ("rdp", "vnc"): + from core.remote_desktop import RemoteDesktopLauncher + return RemoteDesktopLauncher() raise ValueError(f"Unknown server type: {server_type}") diff --git a/core/grafana_client.py b/core/grafana_client.py new file mode 100644 index 0000000..6ddb1e9 --- /dev/null +++ b/core/grafana_client.py @@ -0,0 +1,170 @@ +""" +Grafana API client for ServerManager. + +Provides dashboard listing, alert management, datasource queries, +and annotation creation via Grafana HTTP API. +""" + +from __future__ import annotations + +from typing import Any + +from core.logger import log + + +class GrafanaClient: + """Client for interacting with a Grafana instance via its HTTP API.""" + + def __init__(self, server: dict) -> None: + """ + Initialize the Grafana client. + + Args: + server: dict with keys: ip, port, api_token, use_ssl + """ + self.ip: str = server["ip"] + self.port: int = int(server["port"]) + self.api_token: str = server["api_token"] + self.use_ssl: bool = bool(server.get("use_ssl", False)) + + scheme = "https" if self.use_ssl else "http" + self.base_url: str = f"{scheme}://{self.ip}:{self.port}" + self.headers: dict[str, str] = { + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json", + } + self.timeout: int = 10 + + def _get(self, path: str, params: dict | None = None) -> Any: + """Send a GET request to the Grafana API.""" + import requests + + url = f"{self.base_url}{path}" + log.debug("Grafana GET %s", url) + resp = requests.get( + url, headers=self.headers, params=params, timeout=self.timeout + ) + resp.raise_for_status() + return resp.json() + + def _post(self, path: str, json_data: dict | None = None) -> Any: + """Send a POST request to the Grafana API.""" + import requests + + url = f"{self.base_url}{path}" + log.debug("Grafana POST %s", url) + resp = requests.post( + url, headers=self.headers, json=json_data, timeout=self.timeout + ) + resp.raise_for_status() + return resp.json() + + def check_connection(self) -> bool: + """ + Check connectivity to Grafana via GET /api/health. + + Returns: + True if Grafana responds successfully, False otherwise. + """ + try: + result = self._get("/api/health") + healthy = result.get("database", "") == "ok" + log.info("Grafana health check: %s", "OK" if healthy else "FAIL") + return healthy + except Exception as exc: + log.error("Grafana health check failed: %s", exc) + return False + + def list_dashboards(self) -> list[dict]: + """ + List all dashboards via GET /api/search. + + Returns: + List of dicts with keys: uid, title, folder, url. + """ + try: + results = self._get("/api/search", params={"type": "dash-db"}) + dashboards = [ + { + "uid": d.get("uid", ""), + "title": d.get("title", ""), + "folder": d.get("folderTitle", ""), + "url": d.get("url", ""), + } + for d in results + ] + log.info("Grafana: found %d dashboards", len(dashboards)) + return dashboards + except Exception as exc: + log.error("Grafana list_dashboards failed: %s", exc) + return [] + + def get_dashboard(self, uid: str) -> dict: + """ + Get a single dashboard by UID via GET /api/dashboards/uid/{uid}. + + Args: + uid: Dashboard UID string. + + Returns: + Full dashboard JSON dict, or empty dict on error. + """ + try: + result = self._get(f"/api/dashboards/uid/{uid}") + log.info("Grafana: loaded dashboard '%s'", uid) + return result + except Exception as exc: + log.error("Grafana get_dashboard(%s) failed: %s", uid, exc) + return {} + + def list_alerts(self) -> list[dict]: + """ + List provisioned alert rules via GET /api/v1/provisioning/alert-rules. + + Returns: + List of alert rule dicts, or empty list on error. + """ + try: + results = self._get("/api/v1/provisioning/alert-rules") + log.info("Grafana: found %d alert rules", len(results)) + return results + except Exception as exc: + log.error("Grafana list_alerts failed: %s", exc) + return [] + + def list_datasources(self) -> list[dict]: + """ + List all datasources via GET /api/datasources. + + Returns: + List of datasource dicts, or empty list on error. + """ + try: + results = self._get("/api/datasources") + log.info("Grafana: found %d datasources", len(results)) + return results + except Exception as exc: + log.error("Grafana list_datasources failed: %s", exc) + return [] + + def create_annotation(self, text: str, tags: list[str] | None = None) -> dict: + """ + Create a global annotation via POST /api/annotations. + + Args: + text: Annotation text/description. + tags: Optional list of tag strings. + + Returns: + API response dict, or empty dict on error. + """ + payload: dict[str, Any] = {"text": text} + if tags: + payload["tags"] = tags + try: + result = self._post("/api/annotations", json_data=payload) + log.info("Grafana: created annotation id=%s", result.get("id")) + return result + except Exception as exc: + log.error("Grafana create_annotation failed: %s", exc) + return {} diff --git a/core/i18n.py b/core/i18n.py index 56e021d..8958ed9 100644 --- a/core/i18n.py +++ b/core/i18n.py @@ -233,6 +233,12 @@ _EN = { "totp_secret_dialog": "TOTP Secret", "placeholder_totp_secret": "Base32 secret (optional)", "port_out_of_range": "Port must be 1-65535", + "database": "Database", + "db_index": "DB Index", + "api_token": "API Token", + "placeholder_api_token": "Bearer token or API key", + "use_ssl": "Use SSL / HTTPS", + "db_index_must_be_number": "DB index must be a number", # Monitoring "monitoring": "Monitoring", @@ -289,6 +295,107 @@ _EN = { "recursive_delete_confirm": "Delete folder '{name}' and all contents?", "drive": "Drive", "active_sessions": "Active: {count}", + + # Tab names (new server types) + "query": "Query", + "console": "Console", + "dashboards": "Dashboards", + "alerts": "Alerts", + "metrics": "Metrics", + "targets": "Targets", + "powershell": "PowerShell", + "launch": "Connect", + + # Server dialog fields (new types) + "database": "Database", + "placeholder_database": "mydb", + "db_index": "DB Index (0-15)", + "placeholder_db_index": "0", + "api_token": "API Token", + "placeholder_api_token": "Token...", + "use_ssl": "Use SSL/TLS", + + # Query tab + "query_execute": "Execute (F5)", + "query_clear": "Clear", + "query_export_csv": "Export CSV", + "query_database": "Database:", + "query_editor_placeholder": "Enter SQL query...", + "query_status_rows": "{rows} rows | {elapsed}s", + "query_error": "Error: {error}", + "query_no_results": "Query executed, no results", + "query_connected": "Connected to {alias} ({db})", + "query_connecting": "Connecting...", + "query_disconnected": "Not connected", + "query_exported": "Exported to {path}", + + # Redis tab + "redis_execute": "Execute", + "redis_db": "DB:", + "redis_keys_count": "Keys: {count}", + "redis_memory": "Mem: {mem}", + "redis_prompt": "redis>", + "redis_connected": "Connected to {alias}", + "redis_connecting": "Connecting...", + "redis_disconnected": "Not connected", + "redis_error": "Error: {error}", + + # Grafana tab + "grafana_dashboards": "Dashboards", + "grafana_alerts": "Alerts", + "grafana_uid": "UID", + "grafana_title": "Title", + "grafana_folder": "Folder", + "grafana_state": "State", + "grafana_name": "Name", + "grafana_severity": "Severity", + "grafana_connected": "Connected to {alias}", + "grafana_no_dashboards": "No dashboards found", + "grafana_no_alerts": "No alerts", + + # Prometheus tab + "prom_query": "PromQL Query", + "prom_execute": "Execute", + "prom_targets": "Targets", + "prom_alerts": "Alerts", + "prom_job": "Job", + "prom_instance": "Instance", + "prom_health": "Health", + "prom_last_scrape": "Last Scrape", + "prom_connected": "Connected to {alias}", + "prom_no_targets": "No targets", + "prom_no_alerts": "No alerts", + "prom_placeholder": "up", + + # PowerShell tab + "ps_execute": "Execute", + "ps_mode_ps": "PowerShell", + "ps_mode_cmd": "CMD", + "ps_placeholder_ps": "Get-Process...", + "ps_placeholder_cmd": "dir...", + "ps_history_empty": "No command history", + "ps_disconnected": "Not connected", + "ps_connecting": "Connecting...", + "ps_connected": "Connected to {alias}", + "ps_connect_failed": "Connection failed: {error}", + "ps_not_connected": "Not connected to server", + "ps_running": "Running...", + "ps_done": "Done", + "ps_exec_error": "Error: {error}", + + # Launch tab + "launch_connect": "Connect", + "launch_rdp_info": "Remote Desktop (RDP) to {alias}", + "launch_vnc_info": "VNC connection to {alias}", + "launch_started": "Client launched", + "launch_starting": "Launching...", + "launch_error": "Launch failed: {error}", + "launch_no_server": "Select a server to connect", + + # Info tab type-specific + "info_database": "Database:", + "info_ssl": "SSL:", + "info_db_index": "DB Index:", } _RU = { @@ -499,6 +606,12 @@ _RU = { "totp_secret_dialog": "TOTP-секрет", "placeholder_totp_secret": "Base32 секрет (необязательно)", "port_out_of_range": "Порт должен быть от 1 до 65535", + "database": "База данных", + "db_index": "Индекс БД", + "api_token": "API-токен", + "placeholder_api_token": "Bearer-токен или API-ключ", + "use_ssl": "Использовать SSL / HTTPS", + "db_index_must_be_number": "Индекс БД должен быть числом", # Monitoring "monitoring": "Мониторинг", @@ -555,6 +668,107 @@ _RU = { "recursive_delete_confirm": "Удалить папку '{name}' со всем содержимым?", "drive": "Диск", "active_sessions": "Активных: {count}", + + # Tab names (new server types) + "query": "Запросы", + "console": "Консоль", + "dashboards": "Дашборды", + "alerts": "Оповещения", + "metrics": "Метрики", + "targets": "Цели", + "powershell": "PowerShell", + "launch": "Подключение", + + # Server dialog fields (new types) + "database": "База данных", + "placeholder_database": "mydb", + "db_index": "Индекс БД (0-15)", + "placeholder_db_index": "0", + "api_token": "API-токен", + "placeholder_api_token": "Токен...", + "use_ssl": "Использовать SSL/TLS", + + # Query tab + "query_execute": "Выполнить (F5)", + "query_clear": "Очистить", + "query_export_csv": "Экспорт CSV", + "query_database": "База данных:", + "query_editor_placeholder": "Введите SQL запрос...", + "query_status_rows": "{rows} строк | {elapsed}с", + "query_error": "Ошибка: {error}", + "query_no_results": "Запрос выполнен, нет результатов", + "query_connected": "Подключено к {alias} ({db})", + "query_connecting": "Подключение...", + "query_disconnected": "Не подключено", + "query_exported": "Экспортировано в {path}", + + # Redis tab + "redis_execute": "Выполнить", + "redis_db": "БД:", + "redis_keys_count": "Ключей: {count}", + "redis_memory": "Память: {mem}", + "redis_prompt": "redis>", + "redis_connected": "Подключено к {alias}", + "redis_connecting": "Подключение...", + "redis_disconnected": "Не подключено", + "redis_error": "Ошибка: {error}", + + # Grafana tab + "grafana_dashboards": "Дашборды", + "grafana_alerts": "Оповещения", + "grafana_uid": "UID", + "grafana_title": "Название", + "grafana_folder": "Папка", + "grafana_state": "Состояние", + "grafana_name": "Имя", + "grafana_severity": "Серьёзность", + "grafana_connected": "Подключено к {alias}", + "grafana_no_dashboards": "Дашборды не найдены", + "grafana_no_alerts": "Нет оповещений", + + # Prometheus tab + "prom_query": "PromQL запрос", + "prom_execute": "Выполнить", + "prom_targets": "Цели", + "prom_alerts": "Оповещения", + "prom_job": "Job", + "prom_instance": "Инстанс", + "prom_health": "Здоровье", + "prom_last_scrape": "Последний опрос", + "prom_connected": "Подключено к {alias}", + "prom_no_targets": "Нет целей", + "prom_no_alerts": "Нет оповещений", + "prom_placeholder": "up", + + # PowerShell tab + "ps_execute": "Выполнить", + "ps_mode_ps": "PowerShell", + "ps_mode_cmd": "CMD", + "ps_placeholder_ps": "Get-Process...", + "ps_placeholder_cmd": "dir...", + "ps_history_empty": "Нет истории команд", + "ps_disconnected": "Не подключено", + "ps_connecting": "Подключение...", + "ps_connected": "Подключено к {alias}", + "ps_connect_failed": "Ошибка подключения: {error}", + "ps_not_connected": "Нет подключения к серверу", + "ps_running": "Выполнение...", + "ps_done": "Готово", + "ps_exec_error": "Ошибка: {error}", + + # Launch tab + "launch_connect": "Подключиться", + "launch_rdp_info": "Удалённый рабочий стол (RDP) к {alias}", + "launch_vnc_info": "VNC-подключение к {alias}", + "launch_started": "Клиент запущен", + "launch_starting": "Запуск...", + "launch_error": "Ошибка запуска: {error}", + "launch_no_server": "Выберите сервер для подключения", + + # Info tab type-specific + "info_database": "База данных:", + "info_ssl": "SSL:", + "info_db_index": "Индекс БД:", } _ZH = { @@ -765,6 +979,12 @@ _ZH = { "totp_secret_dialog": "TOTP密钥", "placeholder_totp_secret": "Base32密钥(可选)", "port_out_of_range": "端口必须在1-65535之间", + "database": "数据库", + "db_index": "数据库索引", + "api_token": "API令牌", + "placeholder_api_token": "Bearer令牌或API密钥", + "use_ssl": "使用 SSL / HTTPS", + "db_index_must_be_number": "数据库索引必须是数字", # Monitoring "monitoring": "监控", @@ -821,6 +1041,107 @@ _ZH = { "recursive_delete_confirm": "删除文件夹 '{name}' 及所有内容?", "drive": "驱动器", "active_sessions": "活跃: {count}", + + # Tab names (new server types) + "query": "查询", + "console": "控制台", + "dashboards": "仪表盘", + "alerts": "告警", + "metrics": "指标", + "targets": "目标", + "powershell": "PowerShell", + "launch": "连接", + + # Server dialog fields (new types) + "database": "数据库", + "placeholder_database": "mydb", + "db_index": "数据库索引 (0-15)", + "placeholder_db_index": "0", + "api_token": "API令牌", + "placeholder_api_token": "令牌...", + "use_ssl": "使用SSL/TLS", + + # Query tab + "query_execute": "执行 (F5)", + "query_clear": "清除", + "query_export_csv": "导出CSV", + "query_database": "数据库:", + "query_editor_placeholder": "输入SQL查询...", + "query_status_rows": "{rows} 行 | {elapsed}秒", + "query_error": "错误: {error}", + "query_no_results": "查询已执行,无结果", + "query_connected": "已连接到 {alias} ({db})", + "query_connecting": "连接中...", + "query_disconnected": "未连接", + "query_exported": "已导出到 {path}", + + # Redis tab + "redis_execute": "执行", + "redis_db": "数据库:", + "redis_keys_count": "键数: {count}", + "redis_memory": "内存: {mem}", + "redis_prompt": "redis>", + "redis_connected": "已连接到 {alias}", + "redis_connecting": "连接中...", + "redis_disconnected": "未连接", + "redis_error": "错误: {error}", + + # Grafana tab + "grafana_dashboards": "仪表盘", + "grafana_alerts": "告警", + "grafana_uid": "UID", + "grafana_title": "标题", + "grafana_folder": "文件夹", + "grafana_state": "状态", + "grafana_name": "名称", + "grafana_severity": "严重程度", + "grafana_connected": "已连接到 {alias}", + "grafana_no_dashboards": "未找到仪表盘", + "grafana_no_alerts": "无告警", + + # Prometheus tab + "prom_query": "PromQL查询", + "prom_execute": "执行", + "prom_targets": "目标", + "prom_alerts": "告警", + "prom_job": "任务", + "prom_instance": "实例", + "prom_health": "健康", + "prom_last_scrape": "最后抓取", + "prom_connected": "已连接到 {alias}", + "prom_no_targets": "无目标", + "prom_no_alerts": "无告警", + "prom_placeholder": "up", + + # PowerShell tab + "ps_execute": "执行", + "ps_mode_ps": "PowerShell", + "ps_mode_cmd": "CMD", + "ps_placeholder_ps": "Get-Process...", + "ps_placeholder_cmd": "dir...", + "ps_history_empty": "无命令历史", + "ps_disconnected": "未连接", + "ps_connecting": "连接中...", + "ps_connected": "已连接到 {alias}", + "ps_connect_failed": "连接失败: {error}", + "ps_not_connected": "未连接到服务器", + "ps_running": "执行中...", + "ps_done": "完成", + "ps_exec_error": "错误: {error}", + + # Launch tab + "launch_connect": "连接", + "launch_rdp_info": "远程桌面 (RDP) 到 {alias}", + "launch_vnc_info": "VNC连接到 {alias}", + "launch_started": "客户端已启动", + "launch_starting": "启动中...", + "launch_error": "启动失败: {error}", + "launch_no_server": "选择服务器以连接", + + # Info tab type-specific + "info_database": "数据库:", + "info_ssl": "SSL:", + "info_db_index": "数据库索引:", } _TRANSLATIONS = { diff --git a/core/prometheus_client.py b/core/prometheus_client.py new file mode 100644 index 0000000..20c7288 --- /dev/null +++ b/core/prometheus_client.py @@ -0,0 +1,153 @@ +""" +Prometheus API client for ServerManager. + +Provides instant queries, range queries, target discovery, +alert listing, and rule inspection via the Prometheus HTTP API. +""" + +from __future__ import annotations + +from typing import Any + +from core.logger import log + + +class PrometheusClient: + """Client for interacting with a Prometheus instance via its HTTP API.""" + + def __init__(self, server: dict) -> None: + """ + Initialize the Prometheus client. + + Args: + server: dict with keys: ip, port, use_ssl + """ + self.ip: str = server["ip"] + self.port: int = int(server["port"]) + self.use_ssl: bool = bool(server.get("use_ssl", False)) + + scheme = "https" if self.use_ssl else "http" + self.base_url: str = f"{scheme}://{self.ip}:{self.port}" + self.timeout: int = 10 + + def _get(self, path: str, params: dict | None = None) -> Any: + """Send a GET request to the Prometheus API.""" + import requests + + url = f"{self.base_url}{path}" + log.debug("Prometheus GET %s", url) + resp = requests.get(url, params=params, timeout=self.timeout) + resp.raise_for_status() + return resp.json() + + def check_connection(self) -> bool: + """ + Check connectivity to Prometheus via GET /-/healthy. + + Returns: + True if Prometheus responds successfully, False otherwise. + """ + import requests + + try: + url = f"{self.base_url}/-/healthy" + log.debug("Prometheus health check: %s", url) + resp = requests.get(url, timeout=self.timeout) + healthy = resp.status_code == 200 + log.info("Prometheus health check: %s", "OK" if healthy else "FAIL") + return healthy + except Exception as exc: + log.error("Prometheus health check failed: %s", exc) + return False + + def query(self, promql: str) -> dict: + """ + Execute an instant query via GET /api/v1/query. + + Args: + promql: PromQL expression string. + + Returns: + API response dict with 'status', 'data', etc., or empty dict on error. + """ + try: + result = self._get("/api/v1/query", params={"query": promql}) + log.info("Prometheus query: %s -> status=%s", promql, result.get("status")) + return result + except Exception as exc: + log.error("Prometheus query(%s) failed: %s", promql, exc) + return {} + + def query_range( + self, promql: str, start: str, end: str, step: str + ) -> dict: + """ + Execute a range query via GET /api/v1/query_range. + + Args: + promql: PromQL expression string. + start: Start timestamp (RFC3339 or unix timestamp). + end: End timestamp (RFC3339 or unix timestamp). + step: Query resolution step (e.g. '15s', '1m'). + + Returns: + API response dict, or empty dict on error. + """ + try: + result = self._get( + "/api/v1/query_range", + params={"query": promql, "start": start, "end": end, "step": step}, + ) + log.info("Prometheus query_range: %s -> status=%s", promql, result.get("status")) + return result + except Exception as exc: + log.error("Prometheus query_range(%s) failed: %s", promql, exc) + return {} + + def targets(self) -> dict: + """ + List all scrape targets via GET /api/v1/targets. + + Returns: + API response dict with active/dropped targets, or empty dict on error. + """ + try: + result = self._get("/api/v1/targets") + active = len(result.get("data", {}).get("activeTargets", [])) + log.info("Prometheus: %d active targets", active) + return result + except Exception as exc: + log.error("Prometheus targets failed: %s", exc) + return {} + + def alerts(self) -> dict: + """ + List active alerts via GET /api/v1/alerts. + + Returns: + API response dict with alerts, or empty dict on error. + """ + try: + result = self._get("/api/v1/alerts") + count = len(result.get("data", {}).get("alerts", [])) + log.info("Prometheus: %d active alerts", count) + return result + except Exception as exc: + log.error("Prometheus alerts failed: %s", exc) + return {} + + def rules(self) -> dict: + """ + List all rules (recording + alerting) via GET /api/v1/rules. + + Returns: + API response dict with rule groups, or empty dict on error. + """ + try: + result = self._get("/api/v1/rules") + groups = len(result.get("data", {}).get("groups", [])) + log.info("Prometheus: %d rule groups", groups) + return result + except Exception as exc: + log.error("Prometheus rules failed: %s", exc) + return {} diff --git a/core/redis_client.py b/core/redis_client.py new file mode 100644 index 0000000..81c85c8 --- /dev/null +++ b/core/redis_client.py @@ -0,0 +1,171 @@ +""" +Redis client wrapper — duck-typed, lazy-imports redis module. +""" + +from core.logger import log + +_redis = None + + +def _get_redis(): + global _redis + if _redis is None: + import redis as _r + _redis = _r + return _redis + + +class RedisClient: + """Manage a single Redis connection. No ABC — duck typing.""" + + def __init__(self, server: dict): + self._host = server["ip"] + self._port = int(server.get("port", 6379)) + self._password = server.get("password") or None + self._db = int(server.get("db_index", 0)) + self._conn = None + + # -- lifecycle -------------------------------------------------------- + + def connect(self) -> bool: + try: + r = _get_redis() + self._conn = r.Redis( + host=self._host, + port=self._port, + password=self._password, + db=self._db, + decode_responses=True, + socket_timeout=5, + socket_connect_timeout=5, + ) + self._conn.ping() + log.info("Redis connected %s:%s db=%s", self._host, self._port, self._db) + return True + except Exception as exc: + log.error("Redis connect failed: %s", exc) + self._conn = None + return False + + def disconnect(self): + if self._conn is not None: + try: + self._conn.close() + except Exception: + pass + self._conn = None + log.info("Redis disconnected") + + def check_connection(self) -> bool: + try: + return self._conn is not None and self._conn.ping() + except Exception: + return False + + # -- commands --------------------------------------------------------- + + def execute(self, command: str) -> str: + """Parse a raw command string, execute via redis-py, return formatted.""" + if not self._conn: + return "[not connected]" + parts = command.split() + if not parts: + return "" + try: + result = self._conn.execute_command(*parts) + return self._format(result) + except Exception as exc: + return f"(error) {exc}" + + def info(self, section=None) -> dict: + if not self._conn: + return {} + try: + return self._conn.info(section) if section else self._conn.info() + except Exception as exc: + log.error("Redis INFO failed: %s", exc) + return {} + + def dbsize(self) -> int: + if not self._conn: + return 0 + try: + return self._conn.dbsize() + except Exception as exc: + log.error("Redis DBSIZE failed: %s", exc) + return 0 + + def keys(self, pattern: str = "*", count: int = 100) -> list[str]: + """Return up to *count* keys matching *pattern* via SCAN.""" + if not self._conn: + return [] + result = [] + try: + cursor = 0 + while len(result) < count: + cursor, batch = self._conn.scan(cursor, match=pattern, count=count) + result.extend(batch) + if cursor == 0: + break + return result[:count] + except Exception as exc: + log.error("Redis SCAN failed: %s", exc) + return [] + + def get_type(self, key: str) -> str: + if not self._conn: + return "none" + try: + return self._conn.type(key) + except Exception: + return "none" + + def get_ttl(self, key: str) -> int: + """Return TTL in seconds (-1 no expiry, -2 key missing).""" + if not self._conn: + return -2 + try: + return self._conn.ttl(key) + except Exception: + return -2 + + def get_value(self, key: str) -> str: + """Auto-detect type and return a human-readable string.""" + if not self._conn: + return "(not connected)" + try: + t = self.get_type(key) + if t == "string": + return self._conn.get(key) or "" + if t == "list": + items = self._conn.lrange(key, 0, 99) + return "\n".join(f"{i}) {v}" for i, v in enumerate(items)) + if t == "set": + items = list(self._conn.sscan_iter(key, count=100))[:100] + return "\n".join(items) + if t == "hash": + data = self._conn.hgetall(key) + return "\n".join(f"{k} -> {v}" for k, v in data.items()) + if t == "zset": + items = self._conn.zrange(key, 0, 99, withscores=True) + return "\n".join(f"{v} (score={s})" for v, s in items) + return f"(unknown type: {t})" + except Exception as exc: + return f"(error) {exc}" + + # -- helpers ---------------------------------------------------------- + + @staticmethod + def _format(value) -> str: + if value is None: + return "(nil)" + if isinstance(value, bool): + return "OK" if value else "(error)" + if isinstance(value, int): + return f"(integer) {value}" + if isinstance(value, (list, tuple)): + if not value: + return "(empty list)" + lines = [f"{i + 1}) {RedisClient._format(v)}" for i, v in enumerate(value)] + return "\n".join(lines) + return str(value) diff --git a/core/remote_desktop.py b/core/remote_desktop.py new file mode 100644 index 0000000..c0f14aa --- /dev/null +++ b/core/remote_desktop.py @@ -0,0 +1,124 @@ +""" +Remote desktop launchers — RDP and VNC via external clients. +""" + +import os +import platform +import subprocess +import tempfile +from core.logger import log + + +class RemoteDesktopLauncher: + """Launch external RDP/VNC clients for remote desktop connections.""" + + @staticmethod + def launch_rdp(server: dict) -> str: + """Generate a .rdp temp file and launch the system RDP client. + + Returns: + Status message string. + """ + hostname = server["ip"] + port = server.get("port", 3389) + user = server.get("user", "Administrator") + + rdp_content = ( + f"full address:s:{hostname}:{port}\r\n" + f"username:s:{user}\r\n" + "prompt for credentials:i:1\r\n" + "screen mode id:i:2\r\n" + "desktopwidth:i:1920\r\n" + "desktopheight:i:1080\r\n" + "session bpp:i:32\r\n" + "compression:i:1\r\n" + "disable wallpaper:i:0\r\n" + "allow font smoothing:i:1\r\n" + "networkautodetect:i:1\r\n" + "bandwidthautodetect:i:1\r\n" + ) + + alias = server.get("alias", "remote") + rdp_file = os.path.join(tempfile.gettempdir(), f"sm_{alias}.rdp") + + with open(rdp_file, "w", encoding="utf-8") as f: + f.write(rdp_content) + + log.info(f"RDP file created: {rdp_file}") + + system = platform.system() + if system == "Windows": + os.startfile(rdp_file) + return f"RDP launched via mstsc for {alias}" + elif system == "Linux": + try: + subprocess.Popen( + ["xfreerdp", f"/v:{hostname}:{port}", f"/u:{user}", "/dynamic-resolution"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return f"RDP launched via xfreerdp for {alias}" + except FileNotFoundError: + log.warning("xfreerdp not found, trying rdesktop") + subprocess.Popen( + ["rdesktop", f"{hostname}:{port}", "-u", user], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return f"RDP launched via rdesktop for {alias}" + elif system == "Darwin": + subprocess.Popen(["open", rdp_file]) + return f"RDP launched via macOS for {alias}" + else: + return f"Unsupported platform: {system}. RDP file saved to {rdp_file}" + + @staticmethod + def launch_vnc(server: dict) -> str: + """Launch a VNC viewer for the given server. + + Returns: + Status message string. + """ + hostname = server["ip"] + port = server.get("port", 5900) + alias = server.get("alias", "remote") + target = f"{hostname}:{port}" + + log.info(f"VNC launching for {alias} at {target}") + + system = platform.system() + if system == "Windows": + # Try common VNC viewer paths + viewers = [ + r"C:\Program Files\TightVNC\tvnviewer.exe", + r"C:\Program Files (x86)\TightVNC\tvnviewer.exe", + r"C:\Program Files\RealVNC\VNC Viewer\vncviewer.exe", + r"C:\Program Files (x86)\RealVNC\VNC Viewer\vncviewer.exe", + ] + for viewer in viewers: + if os.path.exists(viewer): + subprocess.Popen([viewer, target]) + return f"VNC launched via {os.path.basename(viewer)} for {alias}" + # Fallback: try vncviewer in PATH + try: + subprocess.Popen(["vncviewer", target]) + return f"VNC launched via vncviewer for {alias}" + except FileNotFoundError: + return "No VNC viewer found. Install TightVNC or RealVNC Viewer." + + elif system == "Linux": + for cmd in ["vncviewer", "xtigervncviewer", "remmina"]: + try: + args = [cmd, target] if cmd != "remmina" else [cmd, f"vnc://{target}"] + subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return f"VNC launched via {cmd} for {alias}" + except FileNotFoundError: + continue + return "No VNC viewer found. Install tigervnc-viewer or remmina." + + elif system == "Darwin": + subprocess.Popen(["open", f"vnc://{target}"]) + return f"VNC launched via macOS Screen Sharing for {alias}" + + else: + return f"Unsupported platform: {system}" diff --git a/core/server_store.py b/core/server_store.py index 7a14bee..8202bd0 100644 --- a/core/server_store.py +++ b/core/server_store.py @@ -26,15 +26,20 @@ BACKUP_DIR = os.path.join(SHARED_DIR, "backups") LOCAL_CONFIG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config") EXAMPLE_FILE = os.path.join(LOCAL_CONFIG_DIR, "servers.example.json") -SERVER_TYPES = ["ssh", "telnet", "rdp", "mariadb", "mssql", "postgresql"] +SERVER_TYPES = ["ssh", "telnet", "rdp", "vnc", "winrm", "mariadb", "mssql", "postgresql", "redis", "grafana", "prometheus"] DEFAULT_PORTS = { "ssh": 22, "telnet": 23, "rdp": 3389, + "vnc": 5900, + "winrm": 5985, "mariadb": 3306, "mssql": 1433, "postgresql": 5432, + "redis": 6379, + "grafana": 3000, + "prometheus": 9090, } # Auto-backup interval: 10 minutes diff --git a/core/sql_client.py b/core/sql_client.py new file mode 100644 index 0000000..bc321b0 --- /dev/null +++ b/core/sql_client.py @@ -0,0 +1,197 @@ +""" +SQL client — connect and query MariaDB/MySQL, PostgreSQL, MSSQL. + +Drivers are imported lazily so the module loads even if a driver is missing. +""" + +import time +from core.logger import log + + +class SQLClient: + """Unified SQL client for MariaDB/MySQL, PostgreSQL, and MSSQL.""" + + DRIVERS = {"mariadb": "pymysql", "mysql": "pymysql", "postgresql": "psycopg2", "mssql": "pymssql"} + + def __init__(self, server: dict): + self._type = server["type"].lower() + self._ip = server["ip"] + self._port = int(server.get("port", self._default_port())) + self._user = server["user"] + self._password = server["password"] + self._database = server.get("database", "") + self._conn = None + + def _default_port(self) -> int: + return {"mariadb": 3306, "mysql": 3306, "postgresql": 5432, "mssql": 1433}.get(self._type, 3306) + + # ── connection ────────────────────────────────────────────── + + def connect(self) -> bool: + try: + if self._type in ("mariadb", "mysql"): + import pymysql + self._conn = pymysql.connect( + host=self._ip, port=self._port, user=self._user, + password=self._password, database=self._database or None, + charset="utf8mb4", connect_timeout=10, autocommit=True, + ) + elif self._type == "postgresql": + import psycopg2 + self._conn = psycopg2.connect( + host=self._ip, port=self._port, user=self._user, + password=self._password, dbname=self._database or "postgres", + connect_timeout=10, + ) + self._conn.autocommit = True + elif self._type == "mssql": + import pymssql + self._conn = pymssql.connect( + server=self._ip, port=self._port, user=self._user, + password=self._password, database=self._database or "master", + login_timeout=10, charset="UTF-8", + ) + else: + log.error("sql_client: unsupported type %s", self._type) + return False + log.info("sql_client: connected to %s (%s)", self._type, self._ip) + return True + except Exception as exc: + log.error("sql_client: connect failed — %s", exc) + return False + + def disconnect(self): + if self._conn: + try: + self._conn.close() + except Exception: + pass + self._conn = None + log.info("sql_client: disconnected") + + def check_connection(self) -> bool: + try: + cur = self._conn.cursor() + cur.execute("SELECT 1") + cur.fetchone() + cur.close() + return True + except Exception: + return False + + # ── query execution ───────────────────────────────────────── + + def execute_query(self, sql: str, params=None) -> dict: + """Execute SQL and return {columns, rows, rowcount, elapsed}.""" + t0 = time.perf_counter() + try: + cur = self._conn.cursor() + cur.execute(sql, params) + elapsed = time.perf_counter() - t0 + + if cur.description: + columns = [col[0] for col in cur.description] + rows = cur.fetchall() + else: + columns, rows = [], [] + + result = { + "columns": columns, + "rows": list(rows), + "rowcount": cur.rowcount, + "elapsed": round(elapsed, 4), + } + cur.close() + return result + except Exception as exc: + elapsed = time.perf_counter() - t0 + log.error("sql_client: query failed (%.3fs) — %s", elapsed, exc) + raise + + # ── introspection ─────────────────────────────────────────── + + def list_databases(self) -> list: + sql = { + "mariadb": "SHOW DATABASES", + "mysql": "SHOW DATABASES", + "postgresql": "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname", + "mssql": "SELECT name FROM sys.databases ORDER BY name", + }[self._type] + rows = self.execute_query(sql)["rows"] + return [r[0] for r in rows] + + def list_tables(self, database: str = None) -> list: + if database: + self.switch_database(database) + if self._type in ("mariadb", "mysql"): + sql = "SHOW TABLES" + elif self._type == "postgresql": + sql = ("SELECT tablename FROM pg_tables " + "WHERE schemaname = 'public' ORDER BY tablename") + else: + sql = ("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES " + "WHERE TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME") + rows = self.execute_query(sql)["rows"] + return [r[0] for r in rows] + + def describe_table(self, table: str) -> list: + if self._type in ("mariadb", "mysql"): + rows = self.execute_query("SHOW COLUMNS FROM `%s`" % table)["rows"] + return [{"name": r[0], "type": r[1], "nullable": r[2] == "YES", + "key": r[3] or "", "default": r[4]} for r in rows] + elif self._type == "postgresql": + sql = ( + "SELECT c.column_name, c.data_type, c.is_nullable, " + "COALESCE(tc.constraint_type, ''), c.column_default " + "FROM information_schema.columns c " + "LEFT JOIN information_schema.key_column_usage kcu " + " ON c.table_name = kcu.table_name AND c.column_name = kcu.column_name " + "LEFT JOIN information_schema.table_constraints tc " + " ON kcu.constraint_name = tc.constraint_name " + "WHERE c.table_name = %s AND c.table_schema = 'public' " + "ORDER BY c.ordinal_position" + ) + rows = self.execute_query(sql, (table,))["rows"] + return [{"name": r[0], "type": r[1], "nullable": r[2] == "YES", + "key": r[3], "default": r[4]} for r in rows] + else: # mssql + sql = ( + "SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, " + "ISNULL(tc.CONSTRAINT_TYPE, ''), c.COLUMN_DEFAULT " + "FROM INFORMATION_SCHEMA.COLUMNS c " + "LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu " + " ON c.TABLE_NAME = kcu.TABLE_NAME AND c.COLUMN_NAME = kcu.COLUMN_NAME " + "LEFT JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc " + " ON kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME " + "WHERE c.TABLE_NAME = %s ORDER BY c.ORDINAL_POSITION" + ) + rows = self.execute_query(sql, (table,))["rows"] + return [{"name": r[0], "type": r[1], "nullable": r[2] == "YES", + "key": r[3], "default": r[4]} for r in rows] + + def current_database(self) -> str: + sql = { + "mariadb": "SELECT DATABASE()", + "mysql": "SELECT DATABASE()", + "postgresql": "SELECT current_database()", + "mssql": "SELECT DB_NAME()", + }[self._type] + rows = self.execute_query(sql)["rows"] + return rows[0][0] if rows else "" + + def switch_database(self, db: str): + if self._type in ("mariadb", "mysql"): + self._conn.select_db(db) + elif self._type == "postgresql": + self.disconnect() + self._database = db + self.connect() + elif self._type == "mssql": + self.execute_query("USE %s" % db) + self._database = db + log.info("sql_client: switched to database %s", db) + + def server_version(self) -> str: + sql = "SELECT VERSION()" if self._type != "mssql" else "SELECT @@VERSION" + rows = self.execute_query(sql)["rows"] + return rows[0][0] if rows else "unknown" diff --git a/core/status_checker.py b/core/status_checker.py index d9a85d4..d13f19b 100644 --- a/core/status_checker.py +++ b/core/status_checker.py @@ -1,7 +1,8 @@ """ -Background status checker — parallel server pings. +Background status checker — parallel server pings for all connection types. """ +import socket import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed @@ -13,6 +14,13 @@ if TYPE_CHECKING: from core.ssh_client import SSHClientWrapper from core.logger import log +# Types that support native check_connection() +_SSH_TYPE = {"ssh"} +_SQL_TYPES = {"mariadb", "mssql", "postgresql"} +_REDIS_TYPE = {"redis"} +_HTTP_TYPES = {"grafana", "prometheus", "winrm"} +_TCP_TYPES = {"telnet", "rdp", "vnc"} + class StatusChecker: def __init__(self, store: "ServerStore"): @@ -37,10 +45,86 @@ class StatusChecker: self._gui_callback = callback def check_one(self, server: dict) -> bool: + """Check a single server based on its type.""" + server_type = server.get("type", "ssh") + + if server_type in _SSH_TYPE: + return self._check_ssh(server) + if server_type in _SQL_TYPES: + return self._check_sql(server) + if server_type in _REDIS_TYPE: + return self._check_redis(server) + if server_type == "grafana": + return self._check_http(server, "/api/health") + if server_type == "prometheus": + return self._check_http(server, "/-/healthy") + if server_type == "winrm": + return self._check_http(server, "/wsman") + if server_type in _TCP_TYPES: + return self._check_tcp(server) + + return False + + def _check_ssh(self, server: dict) -> bool: key_path = self.store.get_ssh_key_path() wrapper = SSHClientWrapper(server, key_path) return wrapper.check_connection() + def _check_tcp(self, server: dict) -> bool: + """Check TCP connectivity (telnet, RDP, VNC).""" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + sock.connect((server["ip"], server.get("port", 23))) + sock.close() + return True + except Exception: + return False + + def _check_sql(self, server: dict) -> bool: + """Check SQL connectivity via SELECT 1.""" + try: + from core.sql_client import SQLClient + client = SQLClient(server) + result = client.connect() + if result: + ok = client.check_connection() + client.disconnect() + return ok + return False + except Exception: + return False + + def _check_redis(self, server: dict) -> bool: + """Check Redis via PING.""" + try: + from core.redis_client import RedisClient + client = RedisClient(server) + result = client.connect() + if result: + ok = client.check_connection() + client.disconnect() + return ok + return False + except Exception: + return False + + def _check_http(self, server: dict, path: str) -> bool: + """Check HTTP(S) endpoint.""" + try: + import requests + use_ssl = server.get("use_ssl", False) + scheme = "https" if use_ssl else "http" + url = f"{scheme}://{server['ip']}:{server.get('port', 80)}{path}" + headers = {} + api_token = server.get("api_token", "") + if api_token: + headers["Authorization"] = f"Bearer {api_token}" + resp = requests.get(url, headers=headers, timeout=5, verify=False) + return resp.status_code < 500 + except Exception: + return False + def check_all_now(self): threading.Thread(target=self._check_cycle, daemon=True).start() @@ -61,23 +145,18 @@ class StatusChecker: if s.get("skip_check", False): self.store.set_status(s["alias"], "disabled") - ssh_servers = [s for s in servers if s.get("type", "ssh") == "ssh" and not s.get("skip_check", False)] + checkable = [s for s in servers if not s.get("skip_check", False)] - # Mark non-SSH (non-skipped) as unknown - for s in servers: - if s.get("type", "ssh") != "ssh" and not s.get("skip_check", False): - self.store.set_status(s["alias"], "unknown") - - if not ssh_servers: + if not checkable: return # Parallel checks — up to 10 concurrent - max_workers = min(10, len(ssh_servers)) + max_workers = min(10, len(checkable)) try: with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = { executor.submit(self.check_one, s): s["alias"] - for s in ssh_servers + for s in checkable } for future in as_completed(futures, timeout=30): if not self._running: diff --git a/core/telnet_client.py b/core/telnet_client.py new file mode 100644 index 0000000..83691cf --- /dev/null +++ b/core/telnet_client.py @@ -0,0 +1,180 @@ +""" +Telnet client — interactive telnet session with the same interface as ShellSession. +""" + +import asyncio +import threading +from core.logger import log + + +class TelnetSession: + """Interactive telnet session — same interface as ShellSession from ssh_client.py.""" + + def __init__(self, server: dict, cols: int = 80, rows: int = 24): + self.server = server + self.cols = cols + self.rows = rows + self._loop: asyncio.AbstractEventLoop | None = None + self._thread: threading.Thread | None = None + self._reader = None + self._writer = None + self._running = False + + # Callbacks — set by the owner + self.on_data = None # on_data(data: bytes) + self.on_disconnect = None # on_disconnect() + + @property + def connected(self) -> bool: + return self._running and self._writer is not None + + def connect(self): + """Start telnet connection in a background thread running an asyncio event loop.""" + self._running = True + self._thread = threading.Thread(target=self._run_loop, daemon=True) + self._thread.start() + + def _run_loop(self): + """Entry point for the background thread — creates event loop and runs connection.""" + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + try: + self._loop.run_until_complete(self._async_connect()) + except Exception as e: + log.debug(f"TelnetSession loop error: {e}") + finally: + self._running = False + try: + self._loop.close() + except Exception: + pass + self._loop = None + if self.on_disconnect: + self.on_disconnect() + + async def _async_connect(self): + """Async telnet connection: open, login, then read loop.""" + try: + import telnetlib3 + except ImportError: + log.error("telnetlib3 not installed. Run: pip install telnetlib3") + raise ImportError("telnetlib3 is required for telnet connections") + + hostname = self.server["ip"] + port = self.server.get("port", 23) + user = self.server.get("user", "") + password = self.server.get("password", "") + + log.info(f"TelnetSession connecting to {self.server.get('alias', '?')} port {port}") + + reader, writer = await telnetlib3.open_connection( + host=hostname, + port=port, + cols=self.cols, + rows=self.rows, + connect_minwait=0.5, + ) + self._reader = reader + self._writer = writer + + # Login sequence — wait for prompts and send credentials + await self._login_sequence(reader, writer, user, password) + + # Main read loop + await self._read_loop(reader) + + async def _login_sequence(self, reader, writer, user: str, password: str): + """Wait for login/password prompts and send credentials.""" + buf = "" + timeout = 10.0 # seconds to wait for login prompt + + while self._running: + try: + data = await asyncio.wait_for(reader.read(4096), timeout=timeout) + except asyncio.TimeoutError: + log.debug("TelnetSession login sequence timed out waiting for prompt") + break + except Exception: + break + + if not data: + break + + if self.on_data: + self.on_data(data.encode("utf-8", errors="replace") if isinstance(data, str) else data) + + buf += data if isinstance(data, str) else data.decode("utf-8", errors="replace") + buf_lower = buf.lower() + + if "login:" in buf_lower or "username:" in buf_lower: + writer.write(user + "\r\n") + buf = "" + continue + + if "password:" in buf_lower: + writer.write(password + "\r\n") + buf = "" + break # Login done, proceed to read loop + + # If we see a shell prompt, login may not be required + if buf_lower.rstrip().endswith(("$", "#", ">")): + break + + log.debug("TelnetSession login sequence complete") + + async def _read_loop(self, reader): + """Read data from telnet and forward to on_data callback.""" + try: + while self._running: + try: + data = await asyncio.wait_for(reader.read(65536), timeout=0.5) + except asyncio.TimeoutError: + continue + except Exception: + break + + if not data: + break + + raw = data.encode("utf-8", errors="replace") if isinstance(data, str) else data + if self.on_data: + self.on_data(raw) + except Exception as e: + log.debug(f"TelnetSession read loop error: {e}") + + def send(self, data: bytes): + """Send data to the telnet session.""" + if not self._running or self._writer is None or self._loop is None: + return + text = data.decode("utf-8", errors="replace") + try: + self._loop.call_soon_threadsafe(self._writer.write, text) + except RuntimeError: + self._running = False + if self.on_disconnect: + self.on_disconnect() + + def resize(self, cols: int, rows: int): + """Resize terminal — NAWS negotiation if supported, otherwise no-op.""" + self.cols = cols + self.rows = rows + # telnetlib3 handles NAWS during initial negotiation; + # runtime resize requires protocol-level support which + # is not reliably available, so this is a best-effort no-op. + log.debug(f"TelnetSession resize requested: {cols}x{rows} (no-op)") + + def disconnect(self): + """Close telnet session and stop background thread.""" + self._running = False + if self._writer is not None: + try: + self._writer.close() + except Exception as e: + log.debug(f"TelnetSession writer close: {e}") + self._writer = None + self._reader = None + if self._loop is not None: + try: + self._loop.call_soon_threadsafe(self._loop.stop) + except RuntimeError: + pass diff --git a/core/winrm_client.py b/core/winrm_client.py new file mode 100644 index 0000000..f50bef6 --- /dev/null +++ b/core/winrm_client.py @@ -0,0 +1,115 @@ +""" +WinRM client — execute PowerShell and CMD commands on remote Windows machines. +""" + +from core.logger import log + + +class WinRMClient: + """Remote Windows management via WinRM (pywinrm).""" + + def __init__(self, server: dict): + self.server = server + self._session = None + + def connect(self) -> bool: + """Create WinRM session and verify connectivity.""" + try: + import winrm + except ImportError: + log.error("pywinrm not installed. Run: pip install pywinrm") + raise ImportError("pywinrm is required for WinRM connections") + + hostname = self.server["ip"] + port = self.server.get("port", 5986 if self.server.get("use_ssl", True) else 5985) + user = self.server.get("user", "Administrator") + password = self.server.get("password", "") + use_ssl = self.server.get("use_ssl", True) + + transport = "ssl" if use_ssl else "ntlm" + scheme = "https" if use_ssl else "http" + endpoint = f"{scheme}://{hostname}:{port}/wsman" + + log.info(f"WinRM connecting to {self.server.get('alias', '?')} via {transport}") + + self._session = winrm.Session( + target=endpoint, + auth=(user, password), + transport=transport, + server_cert_validation="ignore", + ) + + # Verify connection with a simple command + try: + result = self._session.run_cmd("hostname") + if result.status_code == 0: + host = result.std_out.decode("utf-8", errors="replace").strip() + log.info(f"WinRM connected to {host}") + return True + else: + log.warning(f"WinRM connection test returned exit code {result.status_code}") + return False + except Exception as e: + log.error(f"WinRM connection test failed: {e}") + self._session = None + raise + + def disconnect(self): + """Close WinRM session.""" + self._session = None + log.debug("WinRM session cleared") + + def check_connection(self) -> bool: + """Check if WinRM session is alive.""" + if self._session is None: + return False + try: + result = self._session.run_cmd("echo ok") + return result.status_code == 0 + except Exception: + return False + + def _ensure_session(self): + """Raise if not connected.""" + if self._session is None: + raise ConnectionError("WinRM session not established. Call connect() first.") + + def exec_ps(self, script: str) -> tuple[str, str, int]: + """Execute a PowerShell script on the remote host. + + Returns: + (stdout, stderr, exit_code) + """ + self._ensure_session() + log.debug(f"WinRM exec_ps: {script[:120]}...") + + try: + result = self._session.run_ps(script) + stdout = result.std_out.decode("utf-8", errors="replace") + stderr = result.std_err.decode("utf-8", errors="replace") + exit_code = result.status_code + log.debug(f"WinRM exec_ps exit_code={exit_code}") + return stdout, stderr, exit_code + except Exception as e: + log.error(f"WinRM exec_ps failed: {e}") + raise + + def exec_cmd(self, command: str) -> tuple[str, str, int]: + """Execute a CMD command on the remote host. + + Returns: + (stdout, stderr, exit_code) + """ + self._ensure_session() + log.debug(f"WinRM exec_cmd: {command[:120]}...") + + try: + result = self._session.run_cmd(command) + stdout = result.std_out.decode("utf-8", errors="replace") + stderr = result.std_err.decode("utf-8", errors="replace") + exit_code = result.status_code + log.debug(f"WinRM exec_cmd exit_code={exit_code}") + return stdout, stderr, exit_code + except Exception as e: + log.error(f"WinRM exec_cmd failed: {e}") + raise diff --git a/gui/app.py b/gui/app.py index fc96751..fb00b8c 100644 --- a/gui/app.py +++ b/gui/app.py @@ -20,6 +20,43 @@ from gui.tabs.info_tab import InfoTab from gui.tabs.keys_tab import KeysTab from gui.tabs.setup_tab import SetupTab from gui.tabs.totp_tab import TOTPTab +from gui.tabs.query_tab import QueryTab +from gui.tabs.redis_tab import RedisTab +from gui.tabs.grafana_tab import GrafanaTab +from gui.tabs.prometheus_tab import PrometheusTab +from gui.tabs.powershell_tab import PowershellTab +from gui.tabs.launch_tab import LaunchTab + +# Tab sets per server type — determines which tabs are shown +TAB_REGISTRY = { + "ssh": ["terminal", "files", "info", "keys", "totp", "setup"], + "telnet": ["terminal", "info", "setup"], + "winrm": ["powershell", "info", "setup"], + "mariadb": ["query", "info", "setup"], + "mssql": ["query", "info", "setup"], + "postgresql": ["query", "info", "setup"], + "redis": ["console", "info", "setup"], + "grafana": ["dashboards", "info", "setup"], + "prometheus": ["metrics", "info", "setup"], + "rdp": ["launch", "info", "setup"], + "vnc": ["launch", "info", "setup"], +} + +# Map tab key → widget class (used as lazy factory) +TAB_CLASSES = { + "terminal": TerminalTab, + "files": FilesTab, + "info": InfoTab, + "keys": KeysTab, + "totp": TOTPTab, + "setup": SetupTab, + "query": QueryTab, + "console": RedisTab, + "dashboards": GrafanaTab, + "metrics": PrometheusTab, + "powershell": PowershellTab, + "launch": LaunchTab, +} class App(ctk.CTk): @@ -67,11 +104,11 @@ class App(ctk.CTk): self.sidebar.delete_callback = self._delete_server # Main area - main = ctk.CTkFrame(self, fg_color="transparent") - main.pack(side="right", fill="both", expand=True) + self._main_frame = ctk.CTkFrame(self, fg_color="transparent") + self._main_frame.pack(side="right", fill="both", expand=True) # Header bar (language + about) - header_bar = ctk.CTkFrame(main, fg_color="transparent", height=40) + header_bar = ctk.CTkFrame(self._main_frame, fg_color="transparent", height=40) header_bar.pack(fill="x", padx=10, pady=(8, 0)) header_bar.pack_propagate(False) @@ -93,39 +130,96 @@ class App(ctk.CTk): ) self.about_btn.pack(side="right", padx=(5, 5)) - # Tabview - self.tabview = ctk.CTkTabview(main, command=self._on_tab_changed) + # Initialize tab tracking + self.tabview = None + self._tab_keys = [] + self._tab_instances = {} + + # Build default SSH tab set + self._rebuild_tabs(TAB_REGISTRY["ssh"]) + + def _rebuild_tabs(self, tab_keys: list[str], restore_tab_key: str | None = None): + """Destroy current tabview and rebuild with the given tab keys.""" + # Remember current active tab + if restore_tab_key is None: + restore_tab_key = self._get_current_tab_key() if self._tab_keys else None + + # Destroy old tab instances + for key, widget in self._tab_instances.items(): + try: + widget.pack_forget() + widget.destroy() + except Exception: + pass + self._tab_instances = {} + + # Destroy old tabview + if self.tabview is not None: + try: + self.tabview.destroy() + except Exception: + pass + + # Store new tab key list + self._tab_keys = list(tab_keys) + + # Create new tabview + self.tabview = ctk.CTkTabview(self._main_frame, command=self._on_tab_changed) self.tabview.pack(fill="both", expand=True, padx=10, pady=10) - # Tab names stored for language updates - self._tab_keys = ["terminal", "files", "info", "keys", "totp", "setup"] for key in self._tab_keys: self.tabview.add(t(key)) - self.terminal_tab = TerminalTab(self.tabview.tab(t("terminal")), self.store, self.session_pool) - self.terminal_tab.pack(fill="both", expand=True) + # Create tab instances using TAB_CLASSES factory + for key in self._tab_keys: + cls = TAB_CLASSES.get(key) + if cls is None: + continue + parent = self.tabview.tab(t(key)) + widget = self._create_tab_instance(cls, key, parent) + widget.pack(fill="both", expand=True) + self._tab_instances[key] = widget - self.files_tab = FilesTab(self.tabview.tab(t("files")), self.store, self.session_pool) - self.files_tab.pack(fill="both", expand=True) + # Restore previously active tab if still available + if restore_tab_key and restore_tab_key in self._tab_keys: + try: + self.tabview.set(t(restore_tab_key)) + except Exception: + pass - self.info_tab = InfoTab(self.tabview.tab(t("info")), self.store, edit_callback=self._edit_server) - self.info_tab.pack(fill="both", expand=True) - - self.keys_tab = KeysTab(self.tabview.tab(t("keys")), self.store) - self.keys_tab.pack(fill="both", expand=True) - - self.totp_tab = TOTPTab(self.tabview.tab(t("totp")), self.store) - self.totp_tab.pack(fill="both", expand=True) - - self.setup_tab = SetupTab(self.tabview.tab(t("setup")), self.store) - self.setup_tab.pack(fill="both", expand=True) + def _create_tab_instance(self, cls, key: str, parent): + """Create a tab widget instance with the correct constructor args.""" + if cls in (TerminalTab, FilesTab): + return cls(parent, self.store, self.session_pool) + elif cls is InfoTab: + return cls(parent, self.store, edit_callback=self._edit_server) + elif cls is SetupTab: + return cls(parent, self.store) + elif cls in (KeysTab, TOTPTab): + return cls(parent, self.store) + else: + # QueryTab, RedisTab, GrafanaTab, PrometheusTab, PowershellTab, LaunchTab + return cls(parent, self.store) def _on_server_select(self, alias: str): - self.terminal_tab.set_server(alias) - self.files_tab.set_server(alias) - self.info_tab.set_server(alias) - self.keys_tab.set_server(alias) - self.totp_tab.set_server(alias) + # Determine server type and required tabs + if alias: + server = self.store.get_server(alias) + server_type = server.get("type", "ssh") if server else "ssh" + else: + server_type = "ssh" + + new_tab_keys = TAB_REGISTRY.get(server_type, TAB_REGISTRY["ssh"]) + + # Rebuild tabs only if the tab set changed + if new_tab_keys != self._tab_keys: + self._rebuild_tabs(new_tab_keys) + + # Notify each tab instance about the selected server + for key, widget in self._tab_instances.items(): + if hasattr(widget, "set_server"): + widget.set_server(alias) + # Update session indicators after a short delay (connection is async) self.after(1500, self.sidebar.update_session_indicators) @@ -143,7 +237,9 @@ class App(ctk.CTk): self.sidebar._select(new_alias) self.session_pool.rename_server(alias, new_alias) else: - self.info_tab.refresh() + info = self._tab_instances.get("info") + if info and hasattr(info, "refresh"): + info.refresh() def _delete_server(self, alias: str): if messagebox.askyesno(t("delete_server"), t("delete_confirm").format(alias=alias)): @@ -155,7 +251,9 @@ class App(ctk.CTk): def _on_status_update(self): self.sidebar.update_statuses() self.sidebar.update_session_indicators() - self.info_tab.refresh() + info = self._tab_instances.get("info") + if info and hasattr(info, "refresh"): + info.refresh() def _show_about(self): AboutDialog(self) @@ -190,76 +288,42 @@ class App(ctk.CTk): # Remember selected server alias = self.sidebar.get_selected() # Use provided key or default to first tab - current_key = restore_tab_key or self._tab_keys[0] + current_key = restore_tab_key or (self._tab_keys[0] if self._tab_keys else "terminal") - # Save state before destroying tabs - saved_remote_path = self.files_tab._remote_path - saved_local_path = self.files_tab._local_path - had_sftp = self.files_tab._sftp is not None and self.files_tab._sftp.connected + # Save FilesTab state if it exists + files_tab = self._tab_instances.get("files") + saved_remote_path = None + saved_local_path = None + had_sftp = False + if files_tab: + saved_remote_path = files_tab._remote_path + saved_local_path = files_tab._local_path + had_sftp = files_tab._sftp is not None and files_tab._sftp.connected # Disconnect all sessions in the pool self.session_pool.disconnect_all() - # Detach tab contents - self.terminal_tab.pack_forget() - self.files_tab.pack_forget() - self.info_tab.pack_forget() - self.keys_tab.pack_forget() - self.totp_tab.pack_forget() - self.setup_tab.pack_forget() + # Rebuild tabs with translated names (same tab keys, just new language) + self._rebuild_tabs(self._tab_keys, restore_tab_key=current_key) - # Get the main frame and destroy old tabview - main = self.tabview.master - self.tabview.destroy() + # Restore FilesTab state if it exists in new tab set + files_tab = self._tab_instances.get("files") + if files_tab: + files_tab._local_path = saved_local_path + files_tab._refresh_local() + if alias and had_sftp: + files_tab._remote_path = saved_remote_path + files_tab.set_server(alias) + elif alias: + files_tab.set_server(alias) - # Create new tabview with translated names - self.tabview = ctk.CTkTabview(main, command=self._on_tab_changed) - self.tabview.pack(fill="both", expand=True, padx=10, pady=10) - - for key in self._tab_keys: - self.tabview.add(t(key)) - - # Re-parent tab contents - self.terminal_tab = TerminalTab(self.tabview.tab(t("terminal")), self.store, self.session_pool) - self.terminal_tab.pack(fill="both", expand=True) - - self.files_tab = FilesTab(self.tabview.tab(t("files")), self.store, self.session_pool) - self.files_tab.pack(fill="both", expand=True) - - self.info_tab = InfoTab(self.tabview.tab(t("info")), self.store, edit_callback=self._edit_server) - self.info_tab.pack(fill="both", expand=True) - - self.keys_tab = KeysTab(self.tabview.tab(t("keys")), self.store) - self.keys_tab.pack(fill="both", expand=True) - - self.totp_tab = TOTPTab(self.tabview.tab(t("totp")), self.store) - self.totp_tab.pack(fill="both", expand=True) - - self.setup_tab = SetupTab(self.tabview.tab(t("setup")), self.store) - self.setup_tab.pack(fill="both", expand=True) - - # Restore active tab by key - try: - self.tabview.set(t(current_key)) - except Exception: - pass - - # Restore file paths and reconnect properly - self.files_tab._local_path = saved_local_path - self.files_tab._refresh_local() - if alias and had_sftp: - # Had active SFTP — reconnect and restore remote path - self.files_tab._remote_path = saved_remote_path - self.files_tab.set_server(alias) - elif alias: - self.files_tab.set_server(alias) - - # Restore server selection for other tabs (terminal auto-reconnects) + # Restore server selection for all other tabs if alias: - self.terminal_tab.set_server(alias) - self.info_tab.set_server(alias) - self.keys_tab.set_server(alias) - self.totp_tab.set_server(alias) + for key, widget in self._tab_instances.items(): + if key == "files": + continue # Already handled above + if hasattr(widget, "set_server"): + widget.set_server(alias) # Update sidebar self.sidebar.update_language() @@ -367,14 +431,22 @@ class App(ctk.CTk): """Handle tab switch — manage terminal focus.""" try: current = self.tabview.get() - if current == t("terminal"): - self.terminal_tab._terminal.focus_terminal() + terminal = self._tab_instances.get("terminal") + if terminal and current == t("terminal"): + terminal._terminal.focus_terminal() else: self.focus_set() except Exception: pass def _on_close(self): + # Clean up tab instances + for key, widget in self._tab_instances.items(): + if hasattr(widget, "on_close"): + try: + widget.on_close() + except Exception: + pass # Disconnect all sessions before closing self.session_pool.disconnect_all() self.checker.stop() diff --git a/gui/server_dialog.py b/gui/server_dialog.py index a1d5744..4494932 100644 --- a/gui/server_dialog.py +++ b/gui/server_dialog.py @@ -1,5 +1,6 @@ """ Server add/edit dialog — modal window with all server fields. +Form adapts visible fields based on selected server type. """ import customtkinter as ctk @@ -7,6 +8,24 @@ from core.server_store import SERVER_TYPES, DEFAULT_PORTS from core.i18n import t +# Which conditional fields to show for each server type. +# Fields NOT listed here (alias, ip, type+port, skip_check, notes, buttons) +# are always visible. +FIELD_MAP = { + "ssh": ["user", "password", "totp", "bind_interface"], + "telnet": ["user", "password"], + "winrm": ["user", "password", "use_ssl"], + "mariadb": ["user", "password", "database"], + "mssql": ["user", "password", "database"], + "postgresql": ["user", "password", "database"], + "redis": ["password", "db_index"], + "grafana": ["api_token", "use_ssl"], + "prometheus": ["use_ssl"], + "rdp": ["user", "password"], + "vnc": ["password"], +} + + def _get_network_interfaces() -> list[tuple[str, str]]: """Return list of (name, ipv4_address) for available network interfaces.""" try: @@ -30,30 +49,31 @@ class ServerDialog(ctk.CTkToplevel): self.result = None self.title(t("edit_server") if server else t("add_server")) - self.geometry("450x680") + self.geometry("450x720") self.resizable(False, False) self.grab_set() # Center on parent self.transient(master) + self._field_frames: dict[str, ctk.CTkFrame] = {} self._build_ui(server) def _build_ui(self, server: dict | None): pad = {"padx": 20, "pady": (5, 0)} entry_pad = {"padx": 20, "pady": (2, 5)} - # Alias + # ── Always visible: Alias ── ctk.CTkLabel(self, text=t("alias"), anchor="w").pack(fill="x", **pad) self.alias_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_alias")) self.alias_entry.pack(fill="x", **entry_pad) - # IP + # ── Always visible: IP ── ctk.CTkLabel(self, text=t("ip"), anchor="w").pack(fill="x", **pad) self.ip_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_ip")) self.ip_entry.pack(fill="x", **entry_pad) - # Type + Port row + # ── Always visible: Type + Port row ── row = ctk.CTkFrame(self, fg_color="transparent") row.pack(fill="x", padx=20, pady=(5, 5)) @@ -73,9 +93,13 @@ class ServerDialog(ctk.CTkToplevel): self.port_entry = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port")) self.port_entry.pack(fill="x") - # Network interface - ctk.CTkLabel(self, text=t("network_interface"), anchor="w").pack(fill="x", **pad) - self._iface_map: dict[str, str] = {} # display_name -> ip + # ── Conditional fields container — all packed here, shown/hidden dynamically ── + # We use self as parent but wrap each field group in a frame for easy show/hide. + + # --- bind_interface --- + f = ctk.CTkFrame(self, fg_color="transparent") + ctk.CTkLabel(f, text=t("network_interface"), anchor="w").pack(fill="x", **pad) + self._iface_map: dict[str, str] = {} ifaces = _get_network_interfaces() auto_label = t("auto_default") iface_values = [auto_label] @@ -84,43 +108,78 @@ class ServerDialog(ctk.CTkToplevel): iface_values.append(label) self._iface_map[label] = ip self._iface_var = ctk.StringVar(value=auto_label) - self._iface_menu = ctk.CTkOptionMenu(self, values=iface_values, variable=self._iface_var) + self._iface_menu = ctk.CTkOptionMenu(f, values=iface_values, variable=self._iface_var) self._iface_menu.pack(fill="x", **entry_pad) + self._field_frames["bind_interface"] = f - # User - ctk.CTkLabel(self, text=t("username"), anchor="w").pack(fill="x", **pad) - self.user_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_user")) + # --- user --- + f = ctk.CTkFrame(self, fg_color="transparent") + ctk.CTkLabel(f, text=t("username"), anchor="w").pack(fill="x", **pad) + self.user_entry = ctk.CTkEntry(f, placeholder_text=t("placeholder_user")) self.user_entry.pack(fill="x", **entry_pad) + self._field_frames["user"] = f - # Password - ctk.CTkLabel(self, text=t("password"), anchor="w").pack(fill="x", **pad) - pass_frame = ctk.CTkFrame(self, fg_color="transparent") - pass_frame.pack(fill="x", padx=20, pady=(2, 5)) - self.password_entry = ctk.CTkEntry(pass_frame, show="*", placeholder_text=t("placeholder_password")) + # --- password --- + f = ctk.CTkFrame(self, fg_color="transparent") + ctk.CTkLabel(f, text=t("password"), anchor="w").pack(fill="x", **pad) + pass_inner = ctk.CTkFrame(f, fg_color="transparent") + pass_inner.pack(fill="x", padx=20, pady=(2, 5)) + self.password_entry = ctk.CTkEntry(pass_inner, show="*", placeholder_text=t("placeholder_password")) self.password_entry.pack(side="left", fill="x", expand=True, padx=(0, 5)) - self.show_pass = ctk.CTkButton(pass_frame, text=t("show"), width=60, command=self._toggle_password) + self.show_pass = ctk.CTkButton(pass_inner, text=t("show"), width=60, command=self._toggle_password) self.show_pass.pack(side="right") self._pass_visible = False + self._field_frames["password"] = f - # TOTP Secret - ctk.CTkLabel(self, text=t("totp_secret_dialog"), anchor="w").pack(fill="x", **pad) - self.totp_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_totp_secret"), + # --- totp --- + f = ctk.CTkFrame(self, fg_color="transparent") + ctk.CTkLabel(f, text=t("totp_secret_dialog"), anchor="w").pack(fill="x", **pad) + self.totp_entry = ctk.CTkEntry(f, placeholder_text=t("placeholder_totp_secret"), font=ctk.CTkFont(family="Consolas", size=12)) self.totp_entry.pack(fill="x", **entry_pad) + self._field_frames["totp"] = f - # Skip status checks + # --- database --- + f = ctk.CTkFrame(self, fg_color="transparent") + ctk.CTkLabel(f, text=t("database"), anchor="w").pack(fill="x", **pad) + self.database_entry = ctk.CTkEntry(f, placeholder_text="mydb") + self.database_entry.pack(fill="x", **entry_pad) + self._field_frames["database"] = f + + # --- db_index --- + f = ctk.CTkFrame(self, fg_color="transparent") + ctk.CTkLabel(f, text=t("db_index"), anchor="w").pack(fill="x", **pad) + self.db_index_entry = ctk.CTkEntry(f, placeholder_text="0") + self.db_index_entry.pack(fill="x", **entry_pad) + self._field_frames["db_index"] = f + + # --- api_token --- + f = ctk.CTkFrame(self, fg_color="transparent") + ctk.CTkLabel(f, text=t("api_token"), anchor="w").pack(fill="x", **pad) + self.api_token_entry = ctk.CTkEntry(f, show="*", placeholder_text=t("placeholder_api_token")) + self.api_token_entry.pack(fill="x", **entry_pad) + self._field_frames["api_token"] = f + + # --- use_ssl --- + f = ctk.CTkFrame(self, fg_color="transparent") + self.use_ssl_var = ctk.BooleanVar(value=False) + self.use_ssl_cb = ctk.CTkCheckBox(f, text=t("use_ssl"), variable=self.use_ssl_var) + self.use_ssl_cb.pack(fill="x", padx=20, pady=(8, 2)) + self._field_frames["use_ssl"] = f + + # ── Always visible: Skip status checks ── self.skip_check_var = ctk.BooleanVar(value=False) self.skip_check_cb = ctk.CTkCheckBox( self, text=t("skip_check"), variable=self.skip_check_var ) self.skip_check_cb.pack(fill="x", padx=20, pady=(8, 2)) - # Notes + # ── Always visible: Notes ── ctk.CTkLabel(self, text=t("notes"), anchor="w").pack(fill="x", **pad) self.notes_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_notes")) self.notes_entry.pack(fill="x", **entry_pad) - # Buttons + # ── Always visible: Buttons ── btn_frame = ctk.CTkFrame(self, fg_color="transparent") btn_frame.pack(fill="x", padx=20, pady=(15, 20)) ctk.CTkButton(btn_frame, text=t("cancel"), fg_color="#6b7280", command=self.destroy).pack(side="left", expand=True, padx=(0, 5)) @@ -137,6 +196,10 @@ class ServerDialog(ctk.CTkToplevel): self.totp_entry.insert(0, server.get("totp_secret", "")) self.skip_check_var.set(server.get("skip_check", False)) self.notes_entry.insert(0, server.get("notes", "")) + self.database_entry.insert(0, server.get("database", "")) + self.db_index_entry.insert(0, str(server.get("db_index", ""))) + self.api_token_entry.insert(0, server.get("api_token", "")) + self.use_ssl_var.set(server.get("use_ssl", False)) # Restore network interface selection saved_ip = server.get("bind_interface") @@ -155,10 +218,23 @@ class ServerDialog(ctk.CTkToplevel): self._iface_menu.configure(values=current_values) self._iface_var.set(unavail_label) + # Apply field visibility for initial type + self._apply_field_visibility(self.type_var.get()) + + def _apply_field_visibility(self, server_type: str): + """Hide all conditional fields, then show only those for the given type.""" + visible = set(FIELD_MAP.get(server_type, [])) + for name, frame in self._field_frames.items(): + if name in visible: + frame.pack(fill="x", before=self.skip_check_cb) + else: + frame.pack_forget() + def _on_type_change(self, value): default_port = DEFAULT_PORTS.get(value, 22) self.port_entry.delete(0, "end") self.port_entry.insert(0, str(default_port)) + self._apply_field_visibility(value) def _toggle_password(self): self._pass_visible = not self._pass_visible @@ -211,6 +287,32 @@ class ServerDialog(ctk.CTkToplevel): if bind_ip: server_data["bind_interface"] = bind_ip + # New conditional fields + visible = set(FIELD_MAP.get(server_type, [])) + + if "database" in visible: + db = self.database_entry.get().strip() + if db: + server_data["database"] = db + + if "db_index" in visible: + db_idx = self.db_index_entry.get().strip() + if db_idx: + try: + server_data["db_index"] = int(db_idx) + except ValueError: + self._show_error(t("db_index_must_be_number")) + return + + if "api_token" in visible: + token = self.api_token_entry.get().strip() + if token: + server_data["api_token"] = token + + if "use_ssl" in visible: + if self.use_ssl_var.get(): + server_data["use_ssl"] = True + try: if self.editing: if alias != self._original_alias and self.store.get_server(alias): diff --git a/gui/sidebar.py b/gui/sidebar.py index f95f44a..2d28fed 100644 --- a/gui/sidebar.py +++ b/gui/sidebar.py @@ -6,6 +6,34 @@ import customtkinter as ctk from core.i18n import t from gui.widgets.status_badge import StatusBadge +TYPE_COLORS = { + "ssh": "#22c55e", + "telnet": "#a855f7", + "rdp": "#3b82f6", + "vnc": "#6366f1", + "winrm": "#0ea5e9", + "mariadb": "#f59e0b", + "mssql": "#ef4444", + "postgresql": "#3b82f6", + "redis": "#dc2626", + "grafana": "#f97316", + "prometheus": "#e11d48", +} + +TYPE_LABELS = { + "ssh": "SSH", + "telnet": "TEL", + "rdp": "RDP", + "vnc": "VNC", + "winrm": "PS", + "mariadb": "MDB", + "mssql": "SQL", + "postgresql": "PG", + "redis": "RDS", + "grafana": "GRF", + "prometheus": "PRM", +} + class Sidebar(ctk.CTkFrame): def __init__(self, master, store, on_select=None, session_pool=None): @@ -101,6 +129,17 @@ class Sidebar(ctk.CTkFrame): badge.pack(side="left", padx=(10, 5), pady=10) self._badges[alias] = badge + # Type badge (colored short label) + type_color = TYPE_COLORS.get(stype, "#6b7280") + type_label_text = TYPE_LABELS.get(stype, stype.upper()[:3]) + type_badge = ctk.CTkLabel( + frame, text=type_label_text, + font=ctk.CTkFont(size=9, weight="bold"), + text_color=type_color, + width=30 + ) + type_badge.pack(side="left", padx=(0, 2), pady=10) + # Active session indicator (right side) session_ind = ctk.CTkLabel( frame, text="", width=12, height=12, @@ -117,12 +156,11 @@ class Sidebar(ctk.CTkFrame): name_label = ctk.CTkLabel(info, text=alias, font=ctk.CTkFont(size=13, weight="bold"), anchor="w") name_label.pack(fill="x") - detail = f"{ip} [{stype}]" - detail_label = ctk.CTkLabel(info, text=detail, font=ctk.CTkFont(size=10), text_color="#9ca3af", anchor="w") + detail_label = ctk.CTkLabel(info, text=ip, font=ctk.CTkFont(size=10), text_color="#9ca3af", anchor="w") detail_label.pack(fill="x") # Click handlers - for widget in [frame, info, name_label, detail_label, badge, session_ind]: + for widget in [frame, info, name_label, detail_label, badge, type_badge, session_ind]: widget.bind("", lambda e, a=alias: self._select(a)) self._server_frames[alias] = frame diff --git a/gui/tabs/grafana_tab.py b/gui/tabs/grafana_tab.py new file mode 100644 index 0000000..20ff8de --- /dev/null +++ b/gui/tabs/grafana_tab.py @@ -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("", 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) diff --git a/gui/tabs/info_tab.py b/gui/tabs/info_tab.py index 5c39c92..7630f62 100644 --- a/gui/tabs/info_tab.py +++ b/gui/tabs/info_tab.py @@ -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) diff --git a/gui/tabs/launch_tab.py b/gui/tabs/launch_tab.py new file mode 100644 index 0000000..ef01bf0 --- /dev/null +++ b/gui/tabs/launch_tab.py @@ -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() diff --git a/gui/tabs/powershell_tab.py b/gui/tabs/powershell_tab.py new file mode 100644 index 0000000..b9b4d63 --- /dev/null +++ b/gui/tabs/powershell_tab.py @@ -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("", lambda e: self._execute()) + self._entry.bind("", lambda e: self._history_navigate(-1)) + self._entry.bind("", 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) diff --git a/gui/tabs/prometheus_tab.py b/gui/tabs/prometheus_tab.py new file mode 100644 index 0000000..acf2b2b --- /dev/null +++ b/gui/tabs/prometheus_tab.py @@ -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("", 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) diff --git a/gui/tabs/query_tab.py b/gui/tabs/query_tab.py new file mode 100644 index 0000000..0a7bef3 --- /dev/null +++ b/gui/tabs/query_tab.py @@ -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("", self._on_editor_focus) + + # Bind keyboard shortcuts + self._editor.bind("", lambda e: self._execute_query()) + self._editor.bind("", 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)) diff --git a/gui/tabs/redis_tab.py b/gui/tabs/redis_tab.py new file mode 100644 index 0000000..8401648 --- /dev/null +++ b/gui/tabs/redis_tab.py @@ -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("", lambda e: self._execute_command()) + self._cmd_entry.bind("", self._history_up) + self._cmd_entry.bind("", 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) diff --git a/gui/tabs/terminal_tab.py b/gui/tabs/terminal_tab.py index 3b3ff4b..6259054 100644 --- a/gui/tabs/terminal_tab.py +++ b/gui/tabs/terminal_tab.py @@ -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 diff --git a/requirements.txt b/requirements.txt index df0ded7..28860ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,10 @@ cryptography>=41.0.0 pyotp>=2.9.0 pyte>=0.8.1 psutil>=5.9.0 +pymysql>=1.1.0 +psycopg2-binary>=2.9.9 +pymssql>=2.2.8 +redis>=5.0.0 +requests>=2.31.0 +pywinrm>=0.4.3 +telnetlib3>=2.0.0 diff --git a/tools/skill-ssh.md b/tools/skill-ssh.md index 7e67639..f431a6f 100644 --- a/tools/skill-ssh.md +++ b/tools/skill-ssh.md @@ -1,6 +1,7 @@ # Скилл /ssh — управление удалёнными серверами -Ты управляешь удалёнными серверами через SSH-утилиту. +Ты управляешь удалёнными серверами через универсальную CLI-утилиту. +Поддерживаются: SSH, SQL (MariaDB/MSSQL/PostgreSQL), Redis, Grafana, Prometheus, WinRM (PowerShell/CMD). ## ВАЖНО — Безопасность @@ -18,7 +19,7 @@ Пользователь передаёт через `$ARGUMENTS`. Разбери и выполни. -## Команды +## Общие команды ### Список серверов (безопасный — alias, тип, ключ, заметки) ```bash @@ -35,6 +36,19 @@ python ~/.server-connections/ssh.py --info ALIAS python ~/.server-connections/ssh.py --status ``` +### Обновить заметки сервера +```bash +python ~/.server-connections/ssh.py --set-note ALIAS "описание сервера" +``` + +### Удалить сервер +```bash +python ~/.server-connections/ssh.py --remove ALIAS +``` +**Спроси подтверждение у пользователя перед удалением!** + +## SSH-команды (тип: ssh) + ### Выполнить команду на сервере ```bash python ~/.server-connections/ssh.py ALIAS "command" @@ -68,30 +82,98 @@ python ~/.server-connections/ssh.py ALIAS --install-key python ~/.server-connections/ssh.py ALIAS --ping ``` -### Обновить заметки сервера -```bash -python ~/.server-connections/ssh.py --set-note ALIAS "описание сервера" -``` -Используй чтобы сохранить контекст: что на сервере работает, для чего он нужен. +## SQL-команды (типы: mariadb, mssql, postgresql) -### Удалить сервер +### Выполнить SQL-запрос ```bash -python ~/.server-connections/ssh.py --remove ALIAS +python ~/.server-connections/ssh.py --sql ALIAS "SELECT * FROM users LIMIT 10" ``` -**Спроси подтверждение у пользователя перед удалением!** -## Альтернативный способ (только если SSH-ключ установлен) +### Список баз данных +```bash +python ~/.server-connections/ssh.py --sql-databases ALIAS +``` + +### Список таблиц +```bash +python ~/.server-connections/ssh.py --sql-tables ALIAS +python ~/.server-connections/ssh.py --sql-tables ALIAS mydb +``` + +## Redis-команды (тип: redis) + +### Выполнить Redis-команду +```bash +python ~/.server-connections/ssh.py --redis ALIAS "GET mykey" +python ~/.server-connections/ssh.py --redis ALIAS "SET mykey myvalue" +``` + +### Информация о Redis +```bash +python ~/.server-connections/ssh.py --redis-info ALIAS +``` + +### Поиск ключей (SCAN) +```bash +python ~/.server-connections/ssh.py --redis-keys ALIAS "user:*" +``` + +## Grafana-команды (тип: grafana) + +### Список дашбордов +```bash +python ~/.server-connections/ssh.py --grafana-dashboards ALIAS +``` + +### Список оповещений +```bash +python ~/.server-connections/ssh.py --grafana-alerts ALIAS +``` + +## Prometheus-команды (тип: prometheus) + +### Выполнить PromQL-запрос +```bash +python ~/.server-connections/ssh.py --prom-query ALIAS "up" +python ~/.server-connections/ssh.py --prom-query ALIAS "rate(http_requests_total[5m])" +``` + +### Список целей (targets) +```bash +python ~/.server-connections/ssh.py --prom-targets ALIAS +``` + +### Список оповещений +```bash +python ~/.server-connections/ssh.py --prom-alerts ALIAS +``` + +## WinRM-команды (тип: winrm) + +### PowerShell +```bash +python ~/.server-connections/ssh.py --ps ALIAS "Get-Process" +python ~/.server-connections/ssh.py --ps ALIAS "Get-Service | Where-Object {$_.Status -eq 'Running'}" +``` + +### CMD +```bash +python ~/.server-connections/ssh.py --cmd ALIAS "dir C:\\" +``` + +## Альтернативный способ (только SSH с установленным ключом) ```bash unset SSH_ASKPASS && unset DISPLAY && ssh ALIAS "command" ``` ## Поведение -- **Auto-sudo**: если user на сервере не root — команды автоматически оборачиваются в `sudo -S`, пароль подаётся через stdin. Тебе НЕ нужно добавлять `sudo` в команду -- **--no-sudo**: если команда не требует root (например `ls`, `cat`), используй `--no-sudo` для скорости -- **Timeout**: 120 секунд на команду, 15 секунд на подключение +- **Auto-sudo** (SSH): если user на сервере не root — команды автоматически оборачиваются в `sudo -S`, пароль подаётся через stdin. Тебе НЕ нужно добавлять `sudo` в команду +- **--no-sudo** (SSH): если команда не требует root (например `ls`, `cat`), используй `--no-sudo` для скорости +- **Timeout**: 120 секунд на SSH-команду, 10 секунд на SQL/Redis/HTTP-запросы, 15 секунд на подключение - **SSH-ключ**: пробуется первым, fallback на пароль если ключ не подходит - **Прогресс**: upload/download файлов >=1MB показывают 25/50/75% milestone, итог с размером/временем/скоростью +- **Тип сервера**: определяется автоматически из конфигурации. `--list` показывает тип каждого сервера ## Правила @@ -101,3 +183,5 @@ unset SSH_ASKPASS && unset DISPLAY && ssh ALIAS "command" - Если timeout — предложи проверить VPN/firewall/панель хостера - Файлы создаваемые на сервере должны иметь права 664 (owner+group rw) - При вопросе о серверах — СНАЧАЛА `--list`, потом `--info ALIAS` если нужны детали +- SQL-запросы: используй `LIMIT` для больших таблиц, чтобы не перегружать вывод +- Redis: используй SCAN, а не KEYS для больших баз diff --git a/tools/ssh.py b/tools/ssh.py index ec33676..9e88197 100644 --- a/tools/ssh.py +++ b/tools/ssh.py @@ -3,7 +3,7 @@ SSH utility for Claude Code — connects to servers by alias. Credentials stored locally in servers.json (encrypted), NEVER exposed to AI API. -Usage: +Usage (SSH): python ssh.py ALIAS "command" # run as configured user (auto-sudo if needed) python ssh.py ALIAS --no-sudo "command" # run without sudo elevation python ssh.py ALIAS --upload LOCAL REMOTE @@ -16,6 +16,29 @@ Usage: python ssh.py --set-note ALIAS "desc" # update server notes python ssh.py --add ALIAS IP PORT USER PASSWORD [--note "desc"] python ssh.py --remove ALIAS + +SQL (type: mariadb / mssql / postgresql): + python ssh.py --sql ALIAS "SELECT * FROM users" # execute SQL query + python ssh.py --sql-databases ALIAS # list databases + python ssh.py --sql-tables ALIAS [database] # list tables + +Redis (type: redis): + python ssh.py --redis ALIAS "GET mykey" # execute Redis command + python ssh.py --redis-info ALIAS # Redis INFO + python ssh.py --redis-keys ALIAS "user:*" # SCAN keys by pattern + +Grafana (type: grafana): + python ssh.py --grafana-dashboards ALIAS # list dashboards + python ssh.py --grafana-alerts ALIAS # list alerts + +Prometheus (type: prometheus): + python ssh.py --prom-query ALIAS "up" # execute PromQL query + python ssh.py --prom-targets ALIAS # list targets + python ssh.py --prom-alerts ALIAS # list alerts + +WinRM (type: winrm): + python ssh.py --ps ALIAS "Get-Process" # PowerShell via WinRM + python ssh.py --cmd ALIAS "dir" # CMD via WinRM """ import sys @@ -464,6 +487,415 @@ def remove_from_ssh_config(alias): f.writelines(new_lines) +# ── SQL commands ────────────────────────────────────── + +def _print_table(headers: list, rows: list): + """Print a formatted ASCII table.""" + if not rows: + print("(no rows)") + return + widths = [len(str(h)) for h in headers] + for row in rows: + for i, val in enumerate(row): + widths[i] = max(widths[i], len(str(val))) + fmt = " ".join(f"{{:<{w}}}" for w in widths) + print(fmt.format(*headers)) + print(" ".join("-" * w for w in widths)) + for row in rows: + print(fmt.format(*[str(v) for v in row])) + + +def run_sql(server: dict, query: str): + """Execute SQL query against mariadb/mssql/postgresql server.""" + stype = server.get("type", "mariadb") + host = server["ip"] + port = server.get("port", 3306) + user = server.get("user", "root") + password = server.get("password", "") + database = server.get("database", "") + + if stype in ("mariadb", "mysql"): + import pymysql + conn = pymysql.connect(host=host, port=port, user=user, password=password, + database=database or None, connect_timeout=15, + charset="utf8mb4", cursorclass=pymysql.cursors.Cursor) + elif stype == "mssql": + import pymssql + conn = pymssql.connect(server=host, port=port, user=user, password=password, + database=database or None, login_timeout=15) + elif stype == "postgresql": + import psycopg2 + port = server.get("port", 5432) + conn = psycopg2.connect(host=host, port=port, user=user, password=password, + dbname=database or None, connect_timeout=15) + else: + print(f"ERROR: Unsupported SQL type '{stype}'. Use mariadb, mssql, or postgresql.") + sys.exit(1) + + try: + cur = conn.cursor() + cur.execute(query) + if cur.description: + headers = [desc[0] for desc in cur.description] + rows = cur.fetchall() + _print_table(headers, rows) + print(f"\n({len(rows)} row{'s' if len(rows) != 1 else ''})") + else: + conn.commit() + affected = cur.rowcount + print(f"OK: {affected} row{'s' if affected != 1 else ''} affected") + cur.close() + finally: + conn.close() + + +def sql_databases(server: dict): + """List databases on SQL server.""" + stype = server.get("type", "mariadb") + if stype in ("mariadb", "mysql"): + run_sql(server, "SHOW DATABASES") + elif stype == "mssql": + run_sql(server, "SELECT name FROM sys.databases ORDER BY name") + elif stype == "postgresql": + run_sql(server, "SELECT datname AS database FROM pg_database WHERE datistemplate = false ORDER BY datname") + else: + print(f"ERROR: Unsupported SQL type '{stype}'.") + sys.exit(1) + + +def sql_tables(server: dict, database: str = None): + """List tables on SQL server, optionally for a specific database.""" + stype = server.get("type", "mariadb") + if database: + server = dict(server) + server["database"] = database + if stype in ("mariadb", "mysql"): + if database: + run_sql(server, f"SHOW TABLES FROM `{database}`") + else: + run_sql(server, "SHOW TABLES") + elif stype == "mssql": + run_sql(server, "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA, TABLE_NAME") + elif stype == "postgresql": + run_sql(server, "SELECT schemaname, tablename FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema') ORDER BY schemaname, tablename") + else: + print(f"ERROR: Unsupported SQL type '{stype}'.") + sys.exit(1) + + +# ── Redis commands ──────────────────────────────────── + +def run_redis_cmd(server: dict, command: str): + """Execute a Redis command.""" + import redis as redis_lib + host = server["ip"] + port = server.get("port", 6379) + password = server.get("password", "") or None + db_index = server.get("db_index", 0) + ssl_enabled = server.get("ssl", False) + + r = redis_lib.Redis(host=host, port=port, password=password, db=db_index, + decode_responses=True, socket_timeout=10, ssl=ssl_enabled) + try: + parts = command.split() + if not parts: + print("ERROR: Empty Redis command") + sys.exit(1) + result = r.execute_command(*parts) + if isinstance(result, list): + for i, item in enumerate(result): + print(f"{i + 1}) {item}") + print(f"\n({len(result)} items)") + elif isinstance(result, dict): + for k, v in result.items(): + print(f"{k}: {v}") + elif isinstance(result, bytes): + print(result.decode("utf-8", errors="replace")) + else: + print(result) + finally: + r.close() + + +def redis_info(server: dict): + """Show Redis INFO.""" + import redis as redis_lib + host = server["ip"] + port = server.get("port", 6379) + password = server.get("password", "") or None + db_index = server.get("db_index", 0) + ssl_enabled = server.get("ssl", False) + + r = redis_lib.Redis(host=host, port=port, password=password, db=db_index, + decode_responses=True, socket_timeout=10, ssl=ssl_enabled) + try: + info = r.info() + # Print key sections + sections = ["redis_version", "redis_mode", "os", "uptime_in_seconds", + "connected_clients", "used_memory_human", "used_memory_peak_human", + "total_connections_received", "total_commands_processed", + "keyspace_hits", "keyspace_misses", "role"] + print(f"{'Key':<35} {'Value'}") + print("-" * 60) + for key in sections: + if key in info: + print(f"{key:<35} {info[key]}") + # Print keyspace info (db0, db1, etc.) + for key in sorted(info.keys()): + if key.startswith("db"): + print(f"{key:<35} {info[key]}") + finally: + r.close() + + +def redis_keys(server: dict, pattern: str): + """SCAN keys matching a pattern.""" + import redis as redis_lib + host = server["ip"] + port = server.get("port", 6379) + password = server.get("password", "") or None + db_index = server.get("db_index", 0) + ssl_enabled = server.get("ssl", False) + + r = redis_lib.Redis(host=host, port=port, password=password, db=db_index, + decode_responses=True, socket_timeout=10, ssl=ssl_enabled) + try: + keys = [] + cursor = 0 + while True: + cursor, batch = r.scan(cursor=cursor, match=pattern, count=200) + keys.extend(batch) + if cursor == 0: + break + if len(keys) >= 1000: + print("(truncated at 1000 keys)") + break + keys.sort() + for k in keys: + print(k) + print(f"\n({len(keys)} key{'s' if len(keys) != 1 else ''})") + finally: + r.close() + + +# ── Grafana commands ────────────────────────────────── + +def _grafana_request(server: dict, endpoint: str) -> dict: + """Make an authenticated GET request to Grafana API.""" + import requests + host = server["ip"] + port = server.get("port", 3000) + protocol = "https" if server.get("ssl", False) else "http" + base_url = server.get("base_url", f"{protocol}://{host}:{port}") + api_key = server.get("api_key", server.get("password", "")) + + headers = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + url = f"{base_url.rstrip('/')}/api/{endpoint.lstrip('/')}" + resp = requests.get(url, headers=headers, timeout=15, verify=server.get("ssl_verify", True)) + resp.raise_for_status() + return resp.json() + + +def grafana_dashboards(server: dict): + """List Grafana dashboards.""" + data = _grafana_request(server, "search?type=dash-db") + if not data: + print("(no dashboards found)") + return + headers = ["UID", "Title", "Folder", "URL"] + rows = [] + for d in data: + rows.append([ + d.get("uid", ""), + d.get("title", ""), + d.get("folderTitle", "(root)"), + d.get("url", ""), + ]) + _print_table(headers, rows) + print(f"\n({len(rows)} dashboard{'s' if len(rows) != 1 else ''})") + + +def grafana_alerts(server: dict): + """List Grafana alert rules.""" + data = _grafana_request(server, "alertmanager/grafana/api/v2/alerts") + if not data: + print("(no alerts)") + return + headers = ["Status", "Name", "Severity", "Summary"] + rows = [] + for alert in data: + status = alert.get("status", {}).get("state", "unknown") + labels = alert.get("labels", {}) + annotations = alert.get("annotations", {}) + rows.append([ + status, + labels.get("alertname", ""), + labels.get("severity", ""), + annotations.get("summary", "")[:80], + ]) + _print_table(headers, rows) + print(f"\n({len(rows)} alert{'s' if len(rows) != 1 else ''})") + + +# ── Prometheus commands ─────────────────────────────── + +def _prom_request(server: dict, endpoint: str, params: dict = None) -> dict: + """Make a GET request to Prometheus API.""" + import requests + host = server["ip"] + port = server.get("port", 9090) + protocol = "https" if server.get("ssl", False) else "http" + base_url = server.get("base_url", f"{protocol}://{host}:{port}") + auth = None + user = server.get("user", "") + password = server.get("password", "") + if user and password: + auth = (user, password) + + url = f"{base_url.rstrip('/')}/api/v1/{endpoint.lstrip('/')}" + resp = requests.get(url, params=params, auth=auth, timeout=15, + verify=server.get("ssl_verify", True)) + resp.raise_for_status() + return resp.json() + + +def prom_query(server: dict, query: str): + """Execute a PromQL instant query.""" + data = _prom_request(server, "query", {"query": query}) + status = data.get("status", "") + if status != "success": + print(f"ERROR: Prometheus returned status '{status}'") + if "error" in data: + print(f" {data['error']}") + sys.exit(1) + + result = data.get("data", {}) + result_type = result.get("resultType", "") + results = result.get("result", []) + + if not results: + print("(no results)") + return + + if result_type == "vector": + headers = ["Metric", "Value", "Timestamp"] + rows = [] + for r in results: + metric = r.get("metric", {}) + label_str = ", ".join(f'{k}="{v}"' for k, v in metric.items()) + ts, val = r.get("value", [0, ""]) + rows.append([label_str or "{}", val, ts]) + _print_table(headers, rows) + elif result_type == "scalar": + ts, val = results + print(f"Scalar: {val} (at {ts})") + elif result_type == "string": + ts, val = results + print(f"String: {val} (at {ts})") + elif result_type == "matrix": + for series in results: + metric = series.get("metric", {}) + label_str = ", ".join(f'{k}="{v}"' for k, v in metric.items()) + print(f"\n--- {label_str or '{}'} ---") + values = series.get("values", []) + for ts, val in values[-20:]: # last 20 samples + print(f" [{ts}] {val}") + if len(values) > 20: + print(f" ... ({len(values)} total samples, showing last 20)") + + print(f"\n({len(results)} result{'s' if len(results) != 1 else ''}, type: {result_type})") + + +def prom_targets(server: dict): + """List Prometheus scrape targets.""" + data = _prom_request(server, "targets") + active = data.get("data", {}).get("activeTargets", []) + if not active: + print("(no active targets)") + return + headers = ["Job", "Instance", "State", "Health", "Last Scrape"] + rows = [] + for t in active: + labels = t.get("labels", {}) + rows.append([ + labels.get("job", ""), + labels.get("instance", ""), + t.get("scrapePool", ""), + t.get("health", ""), + t.get("lastScrape", "")[:19], + ]) + _print_table(headers, rows) + print(f"\n({len(rows)} target{'s' if len(rows) != 1 else ''})") + + +def prom_alerts(server: dict): + """List Prometheus alerts.""" + data = _prom_request(server, "alerts") + alerts = data.get("data", {}).get("alerts", []) + if not alerts: + print("(no alerts)") + return + headers = ["State", "Name", "Severity", "Active Since"] + rows = [] + for a in alerts: + labels = a.get("labels", {}) + rows.append([ + a.get("state", ""), + labels.get("alertname", ""), + labels.get("severity", ""), + a.get("activeAt", "")[:19], + ]) + _print_table(headers, rows) + print(f"\n({len(rows)} alert{'s' if len(rows) != 1 else ''})") + + +# ── WinRM commands ──────────────────────────────────── + +def _get_winrm_session(server: dict): + """Create a WinRM session.""" + import winrm + host = server["ip"] + port = server.get("port", 5985) + user = server.get("user", "Administrator") + password = server.get("password", "") + protocol = "https" if server.get("ssl", False) or port == 5986 else "http" + transport = server.get("transport", "ntlm") + + endpoint = f"{protocol}://{host}:{port}/wsman" + session = winrm.Session(endpoint, auth=(user, password), transport=transport, + server_cert_validation="ignore" if protocol == "https" else "validate") + return session + + +def run_winrm_ps(server: dict, command: str): + """Execute PowerShell command via WinRM.""" + session = _get_winrm_session(server) + result = session.run_ps(command) + out = result.std_out.decode("utf-8", errors="replace").strip() + err = result.std_err.decode("utf-8", errors="replace").strip() + if out: + print(out) + if err: + print(err, file=sys.stderr) + sys.exit(result.status_code) + + +def run_winrm_cmd(server: dict, command: str): + """Execute CMD command via WinRM.""" + session = _get_winrm_session(server) + result = session.run_cmd(command) + out = result.std_out.decode("utf-8", errors="replace").strip() + err = result.std_err.decode("utf-8", errors="replace").strip() + if out: + print(out) + if err: + print(err, file=sys.stderr) + sys.exit(result.status_code) + + # ── Main ────────────────────────────────────────────── def main(): @@ -488,6 +920,80 @@ def main(): if cmd == "--remove" and len(sys.argv) >= 3: remove_server(sys.argv[2]); sys.exit(0) + # ── SQL commands (global-style: --sql ALIAS ...) ── + if cmd == "--sql" and len(sys.argv) >= 4: + _, servers = load_servers() + alias = _resolve_alias(sys.argv[2], servers) + run_sql(servers[alias], sys.argv[3]) + sys.exit(0) + if cmd == "--sql-databases" and len(sys.argv) >= 3: + _, servers = load_servers() + alias = _resolve_alias(sys.argv[2], servers) + sql_databases(servers[alias]) + sys.exit(0) + if cmd == "--sql-tables" and len(sys.argv) >= 3: + _, servers = load_servers() + alias = _resolve_alias(sys.argv[2], servers) + db = sys.argv[3] if len(sys.argv) >= 4 else None + sql_tables(servers[alias], db) + sys.exit(0) + + # ── Redis commands ── + if cmd == "--redis" and len(sys.argv) >= 4: + _, servers = load_servers() + alias = _resolve_alias(sys.argv[2], servers) + run_redis_cmd(servers[alias], sys.argv[3]) + sys.exit(0) + if cmd == "--redis-info" and len(sys.argv) >= 3: + _, servers = load_servers() + alias = _resolve_alias(sys.argv[2], servers) + redis_info(servers[alias]) + sys.exit(0) + if cmd == "--redis-keys" and len(sys.argv) >= 4: + _, servers = load_servers() + alias = _resolve_alias(sys.argv[2], servers) + redis_keys(servers[alias], sys.argv[3]) + sys.exit(0) + + # ── Grafana commands ── + if cmd == "--grafana-dashboards" and len(sys.argv) >= 3: + _, servers = load_servers() + alias = _resolve_alias(sys.argv[2], servers) + grafana_dashboards(servers[alias]) + sys.exit(0) + if cmd == "--grafana-alerts" and len(sys.argv) >= 3: + _, servers = load_servers() + alias = _resolve_alias(sys.argv[2], servers) + grafana_alerts(servers[alias]) + sys.exit(0) + + # ── Prometheus commands ── + if cmd == "--prom-query" and len(sys.argv) >= 4: + _, servers = load_servers() + alias = _resolve_alias(sys.argv[2], servers) + prom_query(servers[alias], sys.argv[3]) + sys.exit(0) + if cmd == "--prom-targets" and len(sys.argv) >= 3: + _, servers = load_servers() + alias = _resolve_alias(sys.argv[2], servers) + prom_targets(servers[alias]) + sys.exit(0) + if cmd == "--prom-alerts" and len(sys.argv) >= 3: + _, servers = load_servers() + alias = _resolve_alias(sys.argv[2], servers) + prom_alerts(servers[alias]) + sys.exit(0) + + # ── WinRM commands ── + if cmd == "--ps" and len(sys.argv) >= 4: + _, servers = load_servers() + alias = _resolve_alias(sys.argv[2], servers) + run_winrm_ps(servers[alias], sys.argv[3]) + if cmd == "--cmd" and len(sys.argv) >= 4: + _, servers = load_servers() + alias = _resolve_alias(sys.argv[2], servers) + run_winrm_cmd(servers[alias], sys.argv[3]) + # Server commands — exact match first, then fuzzy search by keyword alias = cmd _, servers = load_servers()