feat: multi-type server support — SQL, Redis, Grafana, Prometheus, Telnet, WinRM, RDP/VNC

Full implementation of multi-type server management across GUI and CLI:

New clients: SQLClient (MariaDB/MSSQL/PostgreSQL), RedisClient, GrafanaClient,
PrometheusClient, TelnetSession, WinRMClient, RemoteDesktopLauncher.

New GUI tabs: QueryTab (SQL editor + Treeview), RedisTab (console + history),
GrafanaTab (dashboards + alerts), PrometheusTab (PromQL + targets),
PowershellTab (PS/CMD), LaunchTab (RDP/VNC external client).

Infrastructure: TAB_REGISTRY for conditional tabs per server type,
adaptive server_dialog fields, colored type badges in sidebar,
status checker for all types (SSH/TCP/SQL/Redis/HTTP), 100+ i18n keys.

CLI: ssh.py extended with --sql, --redis, --grafana-*, --prom-*, --ps, --cmd.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-24 09:35:24 -05:00
parent 2d1d942ddc
commit eede67e6a9
26 changed files with 3990 additions and 168 deletions

View File

@@ -111,13 +111,20 @@ def build():
if os.path.exists(icon_path): if os.path.exists(icon_path):
cmd_parts.extend(["--icon", icon_path]) cmd_parts.extend(["--icon", icon_path])
# Hidden imports for customtkinter # Hidden imports for customtkinter and connection libraries
cmd_parts.extend([ cmd_parts.extend([
"--hidden-import", "customtkinter", "--hidden-import", "customtkinter",
"--hidden-import", "PIL", "--hidden-import", "PIL",
"--hidden-import", "pyotp", "--hidden-import", "pyotp",
"--hidden-import", "pyte", "--hidden-import", "pyte",
"--hidden-import", "psutil", "--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", "--collect-all", "customtkinter",
]) ])

View File

@@ -1,7 +1,6 @@
""" """
Connection factory — stubs for non-SSH connection types. Connection factory — creates connection wrappers based on server type.
SSH is fully implemented via SSHClientWrapper. Uses lazy imports so missing optional dependencies don't crash the app.
Other types are placeholders for future implementation.
""" """
from core.ssh_client import SSHClientWrapper from core.ssh_client import SSHClientWrapper
@@ -14,12 +13,32 @@ def create_connection(server: dict, key_path: str = ""):
if server_type == "ssh": if server_type == "ssh":
return SSHClientWrapper(server, key_path) 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": 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"): 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}") raise ValueError(f"Unknown server type: {server_type}")

170
core/grafana_client.py Normal file
View File

@@ -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 {}

View File

@@ -233,6 +233,12 @@ _EN = {
"totp_secret_dialog": "TOTP Secret", "totp_secret_dialog": "TOTP Secret",
"placeholder_totp_secret": "Base32 secret (optional)", "placeholder_totp_secret": "Base32 secret (optional)",
"port_out_of_range": "Port must be 1-65535", "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": "Monitoring", "monitoring": "Monitoring",
@@ -289,6 +295,107 @@ _EN = {
"recursive_delete_confirm": "Delete folder '{name}' and all contents?", "recursive_delete_confirm": "Delete folder '{name}' and all contents?",
"drive": "Drive", "drive": "Drive",
"active_sessions": "Active: {count}", "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 = { _RU = {
@@ -499,6 +606,12 @@ _RU = {
"totp_secret_dialog": "TOTP-секрет", "totp_secret_dialog": "TOTP-секрет",
"placeholder_totp_secret": "Base32 секрет (необязательно)", "placeholder_totp_secret": "Base32 секрет (необязательно)",
"port_out_of_range": "Порт должен быть от 1 до 65535", "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
"monitoring": "Мониторинг", "monitoring": "Мониторинг",
@@ -555,6 +668,107 @@ _RU = {
"recursive_delete_confirm": "Удалить папку '{name}' со всем содержимым?", "recursive_delete_confirm": "Удалить папку '{name}' со всем содержимым?",
"drive": "Диск", "drive": "Диск",
"active_sessions": "Активных: {count}", "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 = { _ZH = {
@@ -765,6 +979,12 @@ _ZH = {
"totp_secret_dialog": "TOTP密钥", "totp_secret_dialog": "TOTP密钥",
"placeholder_totp_secret": "Base32密钥可选", "placeholder_totp_secret": "Base32密钥可选",
"port_out_of_range": "端口必须在1-65535之间", "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
"monitoring": "监控", "monitoring": "监控",
@@ -821,6 +1041,107 @@ _ZH = {
"recursive_delete_confirm": "删除文件夹 '{name}' 及所有内容?", "recursive_delete_confirm": "删除文件夹 '{name}' 及所有内容?",
"drive": "驱动器", "drive": "驱动器",
"active_sessions": "活跃: {count}", "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 = { _TRANSLATIONS = {

153
core/prometheus_client.py Normal file
View File

@@ -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 {}

171
core/redis_client.py Normal file
View File

@@ -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)

124
core/remote_desktop.py Normal file
View File

@@ -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}"

View File

@@ -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") 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") 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 = { DEFAULT_PORTS = {
"ssh": 22, "ssh": 22,
"telnet": 23, "telnet": 23,
"rdp": 3389, "rdp": 3389,
"vnc": 5900,
"winrm": 5985,
"mariadb": 3306, "mariadb": 3306,
"mssql": 1433, "mssql": 1433,
"postgresql": 5432, "postgresql": 5432,
"redis": 6379,
"grafana": 3000,
"prometheus": 9090,
} }
# Auto-backup interval: 10 minutes # Auto-backup interval: 10 minutes

197
core/sql_client.py Normal file
View File

@@ -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"

View File

@@ -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 threading
import time import time
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -13,6 +14,13 @@ if TYPE_CHECKING:
from core.ssh_client import SSHClientWrapper from core.ssh_client import SSHClientWrapper
from core.logger import log 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: class StatusChecker:
def __init__(self, store: "ServerStore"): def __init__(self, store: "ServerStore"):
@@ -37,10 +45,86 @@ class StatusChecker:
self._gui_callback = callback self._gui_callback = callback
def check_one(self, server: dict) -> bool: 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() key_path = self.store.get_ssh_key_path()
wrapper = SSHClientWrapper(server, key_path) wrapper = SSHClientWrapper(server, key_path)
return wrapper.check_connection() 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): def check_all_now(self):
threading.Thread(target=self._check_cycle, daemon=True).start() threading.Thread(target=self._check_cycle, daemon=True).start()
@@ -61,23 +145,18 @@ class StatusChecker:
if s.get("skip_check", False): if s.get("skip_check", False):
self.store.set_status(s["alias"], "disabled") 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 if not checkable:
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:
return return
# Parallel checks — up to 10 concurrent # Parallel checks — up to 10 concurrent
max_workers = min(10, len(ssh_servers)) max_workers = min(10, len(checkable))
try: try:
with ThreadPoolExecutor(max_workers=max_workers) as executor: with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = { futures = {
executor.submit(self.check_one, s): s["alias"] 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): for future in as_completed(futures, timeout=30):
if not self._running: if not self._running:

180
core/telnet_client.py Normal file
View File

@@ -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

115
core/winrm_client.py Normal file
View File

@@ -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

View File

@@ -20,6 +20,43 @@ from gui.tabs.info_tab import InfoTab
from gui.tabs.keys_tab import KeysTab from gui.tabs.keys_tab import KeysTab
from gui.tabs.setup_tab import SetupTab from gui.tabs.setup_tab import SetupTab
from gui.tabs.totp_tab import TOTPTab 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): class App(ctk.CTk):
@@ -67,11 +104,11 @@ class App(ctk.CTk):
self.sidebar.delete_callback = self._delete_server self.sidebar.delete_callback = self._delete_server
# Main area # Main area
main = ctk.CTkFrame(self, fg_color="transparent") self._main_frame = ctk.CTkFrame(self, fg_color="transparent")
main.pack(side="right", fill="both", expand=True) self._main_frame.pack(side="right", fill="both", expand=True)
# Header bar (language + about) # 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(fill="x", padx=10, pady=(8, 0))
header_bar.pack_propagate(False) header_bar.pack_propagate(False)
@@ -93,39 +130,96 @@ class App(ctk.CTk):
) )
self.about_btn.pack(side="right", padx=(5, 5)) self.about_btn.pack(side="right", padx=(5, 5))
# Tabview # Initialize tab tracking
self.tabview = ctk.CTkTabview(main, command=self._on_tab_changed) 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) 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: for key in self._tab_keys:
self.tabview.add(t(key)) self.tabview.add(t(key))
self.terminal_tab = TerminalTab(self.tabview.tab(t("terminal")), self.store, self.session_pool) # Create tab instances using TAB_CLASSES factory
self.terminal_tab.pack(fill="both", expand=True) 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) # Restore previously active tab if still available
self.files_tab.pack(fill="both", expand=True) 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) def _create_tab_instance(self, cls, key: str, parent):
self.info_tab.pack(fill="both", expand=True) """Create a tab widget instance with the correct constructor args."""
if cls in (TerminalTab, FilesTab):
self.keys_tab = KeysTab(self.tabview.tab(t("keys")), self.store) return cls(parent, self.store, self.session_pool)
self.keys_tab.pack(fill="both", expand=True) elif cls is InfoTab:
return cls(parent, self.store, edit_callback=self._edit_server)
self.totp_tab = TOTPTab(self.tabview.tab(t("totp")), self.store) elif cls is SetupTab:
self.totp_tab.pack(fill="both", expand=True) return cls(parent, self.store)
elif cls in (KeysTab, TOTPTab):
self.setup_tab = SetupTab(self.tabview.tab(t("setup")), self.store) return cls(parent, self.store)
self.setup_tab.pack(fill="both", expand=True) else:
# QueryTab, RedisTab, GrafanaTab, PrometheusTab, PowershellTab, LaunchTab
return cls(parent, self.store)
def _on_server_select(self, alias: str): def _on_server_select(self, alias: str):
self.terminal_tab.set_server(alias) # Determine server type and required tabs
self.files_tab.set_server(alias) if alias:
self.info_tab.set_server(alias) server = self.store.get_server(alias)
self.keys_tab.set_server(alias) server_type = server.get("type", "ssh") if server else "ssh"
self.totp_tab.set_server(alias) 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) # Update session indicators after a short delay (connection is async)
self.after(1500, self.sidebar.update_session_indicators) self.after(1500, self.sidebar.update_session_indicators)
@@ -143,7 +237,9 @@ class App(ctk.CTk):
self.sidebar._select(new_alias) self.sidebar._select(new_alias)
self.session_pool.rename_server(alias, new_alias) self.session_pool.rename_server(alias, new_alias)
else: 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): def _delete_server(self, alias: str):
if messagebox.askyesno(t("delete_server"), t("delete_confirm").format(alias=alias)): 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): def _on_status_update(self):
self.sidebar.update_statuses() self.sidebar.update_statuses()
self.sidebar.update_session_indicators() 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): def _show_about(self):
AboutDialog(self) AboutDialog(self)
@@ -190,76 +288,42 @@ class App(ctk.CTk):
# Remember selected server # Remember selected server
alias = self.sidebar.get_selected() alias = self.sidebar.get_selected()
# Use provided key or default to first tab # 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 # Save FilesTab state if it exists
saved_remote_path = self.files_tab._remote_path files_tab = self._tab_instances.get("files")
saved_local_path = self.files_tab._local_path saved_remote_path = None
had_sftp = self.files_tab._sftp is not None and self.files_tab._sftp.connected 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 # Disconnect all sessions in the pool
self.session_pool.disconnect_all() self.session_pool.disconnect_all()
# Detach tab contents # Rebuild tabs with translated names (same tab keys, just new language)
self.terminal_tab.pack_forget() self._rebuild_tabs(self._tab_keys, restore_tab_key=current_key)
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()
# Get the main frame and destroy old tabview # Restore FilesTab state if it exists in new tab set
main = self.tabview.master files_tab = self._tab_instances.get("files")
self.tabview.destroy() 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 # Restore server selection for all other tabs
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)
if alias: if alias:
self.terminal_tab.set_server(alias) for key, widget in self._tab_instances.items():
self.info_tab.set_server(alias) if key == "files":
self.keys_tab.set_server(alias) continue # Already handled above
self.totp_tab.set_server(alias) if hasattr(widget, "set_server"):
widget.set_server(alias)
# Update sidebar # Update sidebar
self.sidebar.update_language() self.sidebar.update_language()
@@ -367,14 +431,22 @@ class App(ctk.CTk):
"""Handle tab switch — manage terminal focus.""" """Handle tab switch — manage terminal focus."""
try: try:
current = self.tabview.get() current = self.tabview.get()
if current == t("terminal"): terminal = self._tab_instances.get("terminal")
self.terminal_tab._terminal.focus_terminal() if terminal and current == t("terminal"):
terminal._terminal.focus_terminal()
else: else:
self.focus_set() self.focus_set()
except Exception: except Exception:
pass pass
def _on_close(self): 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 # Disconnect all sessions before closing
self.session_pool.disconnect_all() self.session_pool.disconnect_all()
self.checker.stop() self.checker.stop()

View File

@@ -1,5 +1,6 @@
""" """
Server add/edit dialog — modal window with all server fields. Server add/edit dialog — modal window with all server fields.
Form adapts visible fields based on selected server type.
""" """
import customtkinter as ctk import customtkinter as ctk
@@ -7,6 +8,24 @@ from core.server_store import SERVER_TYPES, DEFAULT_PORTS
from core.i18n import t 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]]: def _get_network_interfaces() -> list[tuple[str, str]]:
"""Return list of (name, ipv4_address) for available network interfaces.""" """Return list of (name, ipv4_address) for available network interfaces."""
try: try:
@@ -30,30 +49,31 @@ class ServerDialog(ctk.CTkToplevel):
self.result = None self.result = None
self.title(t("edit_server") if server else t("add_server")) self.title(t("edit_server") if server else t("add_server"))
self.geometry("450x680") self.geometry("450x720")
self.resizable(False, False) self.resizable(False, False)
self.grab_set() self.grab_set()
# Center on parent # Center on parent
self.transient(master) self.transient(master)
self._field_frames: dict[str, ctk.CTkFrame] = {}
self._build_ui(server) self._build_ui(server)
def _build_ui(self, server: dict | None): def _build_ui(self, server: dict | None):
pad = {"padx": 20, "pady": (5, 0)} pad = {"padx": 20, "pady": (5, 0)}
entry_pad = {"padx": 20, "pady": (2, 5)} entry_pad = {"padx": 20, "pady": (2, 5)}
# Alias # ── Always visible: Alias ──
ctk.CTkLabel(self, text=t("alias"), anchor="w").pack(fill="x", **pad) 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 = ctk.CTkEntry(self, placeholder_text=t("placeholder_alias"))
self.alias_entry.pack(fill="x", **entry_pad) self.alias_entry.pack(fill="x", **entry_pad)
# IP # ── Always visible: IP ──
ctk.CTkLabel(self, text=t("ip"), anchor="w").pack(fill="x", **pad) 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 = ctk.CTkEntry(self, placeholder_text=t("placeholder_ip"))
self.ip_entry.pack(fill="x", **entry_pad) self.ip_entry.pack(fill="x", **entry_pad)
# Type + Port row # ── Always visible: Type + Port row ──
row = ctk.CTkFrame(self, fg_color="transparent") row = ctk.CTkFrame(self, fg_color="transparent")
row.pack(fill="x", padx=20, pady=(5, 5)) 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 = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port"))
self.port_entry.pack(fill="x") self.port_entry.pack(fill="x")
# Network interface # ── Conditional fields container — all packed here, shown/hidden dynamically ──
ctk.CTkLabel(self, text=t("network_interface"), anchor="w").pack(fill="x", **pad) # We use self as parent but wrap each field group in a frame for easy show/hide.
self._iface_map: dict[str, str] = {} # display_name -> ip
# --- 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() ifaces = _get_network_interfaces()
auto_label = t("auto_default") auto_label = t("auto_default")
iface_values = [auto_label] iface_values = [auto_label]
@@ -84,43 +108,78 @@ class ServerDialog(ctk.CTkToplevel):
iface_values.append(label) iface_values.append(label)
self._iface_map[label] = ip self._iface_map[label] = ip
self._iface_var = ctk.StringVar(value=auto_label) 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._iface_menu.pack(fill="x", **entry_pad)
self._field_frames["bind_interface"] = f
# User # --- user ---
ctk.CTkLabel(self, text=t("username"), anchor="w").pack(fill="x", **pad) f = ctk.CTkFrame(self, fg_color="transparent")
self.user_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_user")) 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.user_entry.pack(fill="x", **entry_pad)
self._field_frames["user"] = f
# Password # --- password ---
ctk.CTkLabel(self, text=t("password"), anchor="w").pack(fill="x", **pad) f = ctk.CTkFrame(self, fg_color="transparent")
pass_frame = ctk.CTkFrame(self, fg_color="transparent") ctk.CTkLabel(f, text=t("password"), anchor="w").pack(fill="x", **pad)
pass_frame.pack(fill="x", padx=20, pady=(2, 5)) pass_inner = ctk.CTkFrame(f, fg_color="transparent")
self.password_entry = ctk.CTkEntry(pass_frame, show="*", placeholder_text=t("placeholder_password")) 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.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.show_pass.pack(side="right")
self._pass_visible = False self._pass_visible = False
self._field_frames["password"] = f
# TOTP Secret # --- totp ---
ctk.CTkLabel(self, text=t("totp_secret_dialog"), anchor="w").pack(fill="x", **pad) f = ctk.CTkFrame(self, fg_color="transparent")
self.totp_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_totp_secret"), 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)) font=ctk.CTkFont(family="Consolas", size=12))
self.totp_entry.pack(fill="x", **entry_pad) 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_var = ctk.BooleanVar(value=False)
self.skip_check_cb = ctk.CTkCheckBox( self.skip_check_cb = ctk.CTkCheckBox(
self, text=t("skip_check"), variable=self.skip_check_var self, text=t("skip_check"), variable=self.skip_check_var
) )
self.skip_check_cb.pack(fill="x", padx=20, pady=(8, 2)) 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) 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 = ctk.CTkEntry(self, placeholder_text=t("placeholder_notes"))
self.notes_entry.pack(fill="x", **entry_pad) self.notes_entry.pack(fill="x", **entry_pad)
# Buttons # ── Always visible: Buttons ──
btn_frame = ctk.CTkFrame(self, fg_color="transparent") btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=20, pady=(15, 20)) 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)) 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.totp_entry.insert(0, server.get("totp_secret", ""))
self.skip_check_var.set(server.get("skip_check", False)) self.skip_check_var.set(server.get("skip_check", False))
self.notes_entry.insert(0, server.get("notes", "")) 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 # Restore network interface selection
saved_ip = server.get("bind_interface") saved_ip = server.get("bind_interface")
@@ -155,10 +218,23 @@ class ServerDialog(ctk.CTkToplevel):
self._iface_menu.configure(values=current_values) self._iface_menu.configure(values=current_values)
self._iface_var.set(unavail_label) 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): def _on_type_change(self, value):
default_port = DEFAULT_PORTS.get(value, 22) default_port = DEFAULT_PORTS.get(value, 22)
self.port_entry.delete(0, "end") self.port_entry.delete(0, "end")
self.port_entry.insert(0, str(default_port)) self.port_entry.insert(0, str(default_port))
self._apply_field_visibility(value)
def _toggle_password(self): def _toggle_password(self):
self._pass_visible = not self._pass_visible self._pass_visible = not self._pass_visible
@@ -211,6 +287,32 @@ class ServerDialog(ctk.CTkToplevel):
if bind_ip: if bind_ip:
server_data["bind_interface"] = 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: try:
if self.editing: if self.editing:
if alias != self._original_alias and self.store.get_server(alias): if alias != self._original_alias and self.store.get_server(alias):

View File

@@ -6,6 +6,34 @@ import customtkinter as ctk
from core.i18n import t from core.i18n import t
from gui.widgets.status_badge import StatusBadge 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): class Sidebar(ctk.CTkFrame):
def __init__(self, master, store, on_select=None, session_pool=None): 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) badge.pack(side="left", padx=(10, 5), pady=10)
self._badges[alias] = badge 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) # Active session indicator (right side)
session_ind = ctk.CTkLabel( session_ind = ctk.CTkLabel(
frame, text="", width=12, height=12, 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 = ctk.CTkLabel(info, text=alias, font=ctk.CTkFont(size=13, weight="bold"), anchor="w")
name_label.pack(fill="x") name_label.pack(fill="x")
detail = f"{ip} [{stype}]" detail_label = ctk.CTkLabel(info, text=ip, font=ctk.CTkFont(size=10), text_color="#9ca3af", anchor="w")
detail_label = ctk.CTkLabel(info, text=detail, font=ctk.CTkFont(size=10), text_color="#9ca3af", anchor="w")
detail_label.pack(fill="x") detail_label.pack(fill="x")
# Click handlers # 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("<Button-1>", lambda e, a=alias: self._select(a)) widget.bind("<Button-1>", lambda e, a=alias: self._select(a))
self._server_frames[alias] = frame self._server_frames[alias] = frame

202
gui/tabs/grafana_tab.py Normal file
View File

@@ -0,0 +1,202 @@
"""
Grafana tab — dashboards browser and alerts overview.
"""
import threading
import webbrowser
from tkinter import ttk
import customtkinter as ctk
from core.grafana_client import GrafanaClient
from core.i18n import t
class GrafanaTab(ctk.CTkFrame):
def __init__(self, master, store):
super().__init__(master, fg_color="transparent")
self.store = store
self._current_alias: str | None = None
self._client: GrafanaClient | None = None
self._dashboards: list[dict] = []
self._build_ui()
def _build_ui(self):
# ── Header + Refresh ──
header_frame = ctk.CTkFrame(self, fg_color="transparent")
header_frame.pack(fill="x", padx=15, pady=(15, 5))
title = ctk.CTkLabel(header_frame, text=t("grafana_title"),
font=ctk.CTkFont(size=18, weight="bold"))
title.pack(side="left")
self._refresh_btn = ctk.CTkButton(header_frame, text=t("grafana_refresh"), width=100,
command=self._refresh)
self._refresh_btn.pack(side="right")
# ── Dashboards section ──
dash_label = ctk.CTkLabel(self, text=t("grafana_dashboards"),
font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
dash_label.pack(fill="x", padx=15, pady=(10, 3))
dash_frame = ctk.CTkFrame(self, fg_color="transparent")
dash_frame.pack(fill="both", expand=True, padx=15, pady=(0, 5))
columns = ("uid", "title", "folder")
self._dash_tree = ttk.Treeview(dash_frame, columns=columns, show="headings",
selectmode="browse", height=8)
self._dash_tree.heading("uid", text="UID")
self._dash_tree.heading("title", text=t("grafana_dash_title"))
self._dash_tree.heading("folder", text=t("grafana_dash_folder"))
self._dash_tree.column("uid", width=120, minwidth=80)
self._dash_tree.column("title", width=300, minwidth=150)
self._dash_tree.column("folder", width=150, minwidth=80)
self._dash_tree.pack(side="left", fill="both", expand=True)
dash_scroll = ttk.Scrollbar(dash_frame, orient="vertical", command=self._dash_tree.yview)
dash_scroll.pack(side="right", fill="y")
self._dash_tree.configure(yscrollcommand=dash_scroll.set)
self._dash_tree.bind("<Double-1>", self._on_dashboard_click)
# ── Alerts section ──
alerts_label = ctk.CTkLabel(self, text=t("grafana_alerts"),
font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
alerts_label.pack(fill="x", padx=15, pady=(10, 3))
alerts_frame = ctk.CTkFrame(self, fg_color="transparent")
alerts_frame.pack(fill="both", expand=True, padx=15, pady=(0, 5))
alert_columns = ("state", "name", "severity")
self._alerts_tree = ttk.Treeview(alerts_frame, columns=alert_columns, show="headings",
selectmode="browse", height=6)
self._alerts_tree.heading("state", text=t("grafana_alert_state"))
self._alerts_tree.heading("name", text=t("grafana_alert_name"))
self._alerts_tree.heading("severity", text=t("grafana_alert_severity"))
self._alerts_tree.column("state", width=100, minwidth=60)
self._alerts_tree.column("name", width=300, minwidth=150)
self._alerts_tree.column("severity", width=100, minwidth=60)
self._alerts_tree.pack(side="left", fill="both", expand=True)
alerts_scroll = ttk.Scrollbar(alerts_frame, orient="vertical", command=self._alerts_tree.yview)
alerts_scroll.pack(side="right", fill="y")
self._alerts_tree.configure(yscrollcommand=alerts_scroll.set)
# ── Status bar ──
self._status_bar = ctk.CTkLabel(self, text=t("grafana_no_server"), anchor="w",
font=ctk.CTkFont(size=11), text_color="#9ca3af")
self._status_bar.pack(fill="x", padx=15, pady=(5, 10))
# ── Public API ──
def set_server(self, alias: str | None):
"""Called when user selects a server in sidebar."""
self._current_alias = alias
self._client = None
self._dashboards.clear()
self._clear_tables()
if alias:
self._set_status(t("grafana_connected").format(alias=alias), "#22c55e")
self._refresh()
else:
self._set_status(t("grafana_no_server"), "#9ca3af")
# ── Refresh ──
def _refresh(self):
if not self._current_alias:
self._set_status(t("no_server_selected"), "#ef4444")
return
self._refresh_btn.configure(state="disabled", text=t("grafana_loading"))
self._set_status(t("grafana_loading"), "#ccaa00")
def _do():
try:
client = self._get_client()
dashboards = client.list_dashboards()
alerts = client.list_alerts()
self.after(0, lambda: self._populate_dashboards(dashboards))
self.after(0, lambda: self._populate_alerts(alerts))
self.after(0, lambda: self._set_status(
t("grafana_loaded").format(
dashboards=len(dashboards), alerts=len(alerts)
), "#22c55e"))
except Exception as e:
self.after(0, lambda: self._set_status(f"(error) {e}", "#ef4444"))
finally:
self.after(0, lambda: self._refresh_btn.configure(
state="normal", text=t("grafana_refresh")))
threading.Thread(target=_do, daemon=True).start()
def _get_client(self) -> GrafanaClient:
if self._client is None:
self._client = GrafanaClient(self._current_alias, self.store)
return self._client
# ── Table population ──
def _populate_dashboards(self, dashboards: list[dict]):
self._dash_tree.delete(*self._dash_tree.get_children())
self._dashboards = dashboards
for d in dashboards:
uid = d.get("uid", "")
title = d.get("title", "")
folder = d.get("folderTitle", d.get("folder", "General"))
self._dash_tree.insert("", "end", values=(uid, title, folder))
def _populate_alerts(self, alerts: list[dict]):
self._alerts_tree.delete(*self._alerts_tree.get_children())
for a in alerts:
state = a.get("state", a.get("status", "unknown"))
name = a.get("name", a.get("title", ""))
severity = a.get("severity", a.get("labels", {}).get("severity", ""))
tag = ""
if state in ("alerting", "firing"):
tag = "alerting"
elif state in ("ok", "normal", "inactive"):
tag = "ok"
self._alerts_tree.insert("", "end", values=(state, name, severity), tags=(tag,))
# Color-code alert states
self._alerts_tree.tag_configure("alerting", foreground="#ef4444")
self._alerts_tree.tag_configure("ok", foreground="#22c55e")
def _clear_tables(self):
self._dash_tree.delete(*self._dash_tree.get_children())
self._alerts_tree.delete(*self._alerts_tree.get_children())
# ── Events ──
def _on_dashboard_click(self, _event):
"""Open dashboard URL in browser on double-click."""
selection = self._dash_tree.selection()
if not selection:
return
item = self._dash_tree.item(selection[0])
uid = item["values"][0] if item["values"] else None
if not uid:
return
# Find the dashboard data to get the URL
for d in self._dashboards:
if d.get("uid") == uid:
url = d.get("url", "")
if url:
try:
client = self._get_client()
full_url = client.get_dashboard_url(url)
webbrowser.open(full_url)
except Exception:
# Fallback: just open relative URL
webbrowser.open(url)
break
# ── Helpers ──
def _set_status(self, text: str, color: str = "#9ca3af"):
self._status_bar.configure(text=text, text_color=color)

View File

@@ -8,17 +8,25 @@ from core.i18n import t
class InfoTab(ctk.CTkFrame): class InfoTab(ctk.CTkFrame):
# Map field keys to i18n keys # 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 = { _FIELD_I18N = {
"alias": "info_alias", "alias": "info_alias",
"ip": "info_ip", "ip": "info_ip",
"port": "info_port", "port": "info_port",
"user": "info_user", "user": "info_user",
"type": "info_type", "type": "info_type",
"database": "info_database",
"db_index": "info_db_index",
"ssl": "info_ssl",
"notes": "info_notes", "notes": "info_notes",
"status": "info_status", "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): def __init__(self, master, store, edit_callback=None):
super().__init__(master, fg_color="transparent") super().__init__(master, fg_color="transparent")
self.store = store self.store = store
@@ -65,12 +73,39 @@ class InfoTab(ctk.CTkFrame):
if not server: if not server:
return return
stype = server.get("type", "ssh").lower()
self.header.configure(text=server["alias"]) self.header.configure(text=server["alias"])
self._fields["alias"].configure(text=server.get("alias", "-")) self._fields["alias"].configure(text=server.get("alias", "-"))
self._fields["ip"].configure(text=server.get("ip", "-")) self._fields["ip"].configure(text=server.get("ip", "-"))
self._fields["port"].configure(text=str(server.get("port", 22))) 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 "-") self._fields["notes"].configure(text=server.get("notes", "-") or "-")
status = self.store.get_status(self._current_alias) status = self.store.get_status(self._current_alias)

110
gui/tabs/launch_tab.py Normal file
View File

@@ -0,0 +1,110 @@
"""
Launch tab — connect button for RDP/VNC remote desktop sessions.
"""
import threading
import customtkinter as ctk
from core.remote_desktop import RemoteDesktopLauncher
from core.i18n import t
class LaunchTab(ctk.CTkFrame):
"""Minimal tab: server info + big Connect button for RDP/VNC."""
def __init__(self, master, store):
super().__init__(master, fg_color="transparent")
self.store = store
self._current_alias: str | None = None
self._server_type: str | None = None # "rdp" or "vnc"
self._build_ui()
def _build_ui(self):
# Server info label
self._info_label = ctk.CTkLabel(
self, text=t("no_server_selected_info"),
font=ctk.CTkFont(size=16), wraplength=400,
)
self._info_label.pack(padx=20, pady=(40, 20))
# Big connect button
self._connect_btn = ctk.CTkButton(
self, text=t("launch_connect"),
font=ctk.CTkFont(size=18, weight="bold"),
width=220, height=50,
command=self._on_connect,
)
self._connect_btn.pack(pady=20)
self._connect_btn.configure(state="disabled")
# Status / result label
self._status_label = ctk.CTkLabel(
self, text="", font=ctk.CTkFont(size=13),
text_color="#888888", wraplength=400,
)
self._status_label.pack(padx=20, pady=(10, 0))
def set_server(self, alias: str | None):
self._current_alias = alias
self._status_label.configure(text="", text_color="#888888")
if alias is None:
self._info_label.configure(text=t("no_server_selected_info"))
self._connect_btn.configure(state="disabled")
self._server_type = None
return
server = self.store.get_server(alias)
if not server:
self._info_label.configure(text=t("server_not_found").format(alias=alias))
self._connect_btn.configure(state="disabled")
self._server_type = None
return
stype = server.get("type", "").lower()
self._server_type = stype
if stype == "rdp":
info_text = t("launch_rdp_info").format(alias=alias)
elif stype == "vnc":
info_text = t("launch_vnc_info").format(alias=alias)
else:
info_text = f"{alias} ({stype.upper()})"
self._info_label.configure(text=info_text)
self._connect_btn.configure(state="normal")
def _on_connect(self):
if not self._current_alias or not self._server_type:
return
server = self.store.get_server(self._current_alias)
if not server:
return
self._connect_btn.configure(state="disabled")
self._status_label.configure(
text=t("launch_starting"), text_color="#ccaa00",
)
stype = self._server_type
def _do():
try:
if stype == "rdp":
RemoteDesktopLauncher.launch_rdp(server)
elif stype == "vnc":
RemoteDesktopLauncher.launch_vnc(server)
self.after(0, lambda: self._status_label.configure(
text=t("launch_started"), text_color="#44cc44",
))
except Exception as exc:
self.after(0, lambda: self._status_label.configure(
text=t("launch_error").format(error=str(exc)),
text_color="#ff4444",
))
finally:
self.after(0, lambda: self._connect_btn.configure(state="normal"))
threading.Thread(target=_do, daemon=True).start()

242
gui/tabs/powershell_tab.py Normal file
View File

@@ -0,0 +1,242 @@
"""
PowerShell/CMD tab — request-response terminal for WinRM servers.
No pyte needed: WinRM is not an interactive PTY, just command → output.
"""
import threading
import customtkinter as ctk
from core.winrm_client import WinRMClient
from core.i18n import t
class PowershellTab(ctk.CTkFrame):
"""Simplified terminal for WinRM command execution (PS or CMD)."""
_MAX_HISTORY = 200
def __init__(self, master, store):
super().__init__(master, fg_color="transparent")
self.store = store
self._current_alias: str | None = None
self._client: WinRMClient | None = None
self._mode: str = "ps" # "ps" or "cmd"
self._history: list[str] = []
self._history_index: int = -1
self._running = False
self._build_ui()
# ── UI construction ──────────────────────────────────────────────
def _build_ui(self):
# Top bar: mode toggle
top = ctk.CTkFrame(self, fg_color="transparent")
top.pack(fill="x", padx=8, pady=(8, 0))
self._mode_var = ctk.StringVar(value="ps")
self._ps_radio = ctk.CTkRadioButton(
top, text=t("ps_mode_ps"), variable=self._mode_var,
value="ps", command=self._on_mode_changed,
)
self._ps_radio.pack(side="left", padx=(0, 12))
self._cmd_radio = ctk.CTkRadioButton(
top, text=t("ps_mode_cmd"), variable=self._mode_var,
value="cmd", command=self._on_mode_changed,
)
self._cmd_radio.pack(side="left")
# Output console
self._output = ctk.CTkTextbox(
self, font=ctk.CTkFont(family="Consolas", size=13),
state="disabled", wrap="word",
)
self._output.pack(fill="both", expand=True, padx=8, pady=8)
# Input row: entry + execute button
input_row = ctk.CTkFrame(self, fg_color="transparent")
input_row.pack(fill="x", padx=8, pady=(0, 4))
self._entry = ctk.CTkEntry(
input_row, placeholder_text="PS> ...",
font=ctk.CTkFont(family="Consolas", size=13),
)
self._entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
self._entry.bind("<Return>", lambda e: self._execute())
self._entry.bind("<Up>", lambda e: self._history_navigate(-1))
self._entry.bind("<Down>", lambda e: self._history_navigate(1))
self._exec_btn = ctk.CTkButton(
input_row, text=t("ps_execute"), width=90,
command=self._execute,
)
self._exec_btn.pack(side="right")
# Status bar
self._status = ctk.CTkLabel(
self, text="", anchor="w",
font=ctk.CTkFont(size=11), text_color="#888888",
)
self._status.pack(fill="x", padx=10, pady=(0, 6))
# ── Public API ───────────────────────────────────────────────────
def set_server(self, alias: str | None):
"""Switch to a different server (or None to disconnect)."""
if alias == self._current_alias:
return
self._disconnect()
self._current_alias = alias
self._history.clear()
self._history_index = -1
if alias is None:
self._set_status(t("ps_disconnected"), "#888888")
return
self._connect(alias)
# ── Connection ───────────────────────────────────────────────────
def _connect(self, alias: str):
server = self.store.get_server(alias)
if not server:
self._set_status(t("server_not_found").format(alias=alias), "#ff4444")
return
self._set_status(t("ps_connecting").format(alias=alias), "#ccaa00")
def _do():
try:
client = WinRMClient(server)
client.connect()
self._client = client
self.after(0, lambda: self._set_status(
t("ps_connected").format(alias=alias), "#44cc44",
))
self.after(0, lambda: self._entry.focus())
except Exception as exc:
self.after(0, lambda: self._set_status(
t("ps_connect_failed").format(error=str(exc)), "#ff4444",
))
threading.Thread(target=_do, daemon=True).start()
def _disconnect(self):
if self._client:
try:
self._client.close()
except Exception:
pass
self._client = None
# ── Command execution ────────────────────────────────────────────
def _execute(self):
cmd = self._entry.get().strip()
if not cmd:
return
if not self._client:
self._set_status(t("ps_not_connected"), "#ff4444")
return
if self._running:
return
# Save to history
if not self._history or self._history[-1] != cmd:
self._history.append(cmd)
if len(self._history) > self._MAX_HISTORY:
self._history.pop(0)
self._history_index = -1
# Show command in output
prompt = "PS>" if self._mode == "ps" else "CMD>"
self._append_output(f"\n{prompt} {cmd}\n")
self._entry.delete(0, "end")
self._running = True
self._exec_btn.configure(state="disabled")
self._set_status(t("ps_running"), "#ccaa00")
mode = self._mode
client = self._client
def _run():
try:
if mode == "ps":
result = client.exec_ps(cmd)
else:
result = client.exec_cmd(cmd)
stdout = result.get("stdout", "")
stderr = result.get("stderr", "")
rc = result.get("return_code", None)
def _show():
if stdout:
self._append_output(stdout)
if stderr:
self._append_output(f"[STDERR] {stderr}")
if rc is not None and rc != 0:
self._append_output(f"[Exit code: {rc}]")
self._set_status(t("ps_done"), "#44cc44")
self.after(0, _show)
except Exception as exc:
self.after(0, lambda: self._append_output(
f"\n[ERROR] {exc}\n"
))
self.after(0, lambda: self._set_status(
t("ps_exec_error"), "#ff4444",
))
finally:
self._running = False
self.after(0, lambda: self._exec_btn.configure(state="normal"))
threading.Thread(target=_run, daemon=True).start()
# ── History navigation ───────────────────────────────────────────
def _history_navigate(self, direction: int):
"""Navigate command history. direction: -1 = older, +1 = newer."""
if not self._history:
self._set_status(t("ps_history_empty"), "#888888")
return
if self._history_index == -1:
if direction == -1:
self._history_index = len(self._history) - 1
else:
return
else:
self._history_index += direction
if self._history_index < 0:
self._history_index = 0
elif self._history_index >= len(self._history):
self._history_index = -1
self._entry.delete(0, "end")
return
self._entry.delete(0, "end")
self._entry.insert(0, self._history[self._history_index])
# ── Mode toggle ──────────────────────────────────────────────────
def _on_mode_changed(self):
self._mode = self._mode_var.get()
placeholder = "PS> ..." if self._mode == "ps" else "CMD> ..."
self._entry.configure(placeholder_text=placeholder)
# ── Helpers ──────────────────────────────────────────────────────
def _append_output(self, text: str):
self._output.configure(state="normal")
self._output.insert("end", text)
self._output.see("end")
self._output.configure(state="disabled")
def _set_status(self, text: str, color: str = "#888888"):
self._status.configure(text=text, text_color=color)

266
gui/tabs/prometheus_tab.py Normal file
View File

@@ -0,0 +1,266 @@
"""
Prometheus tab — PromQL query executor, targets overview, and alerts.
"""
import threading
from tkinter import ttk
import customtkinter as ctk
from core.prometheus_client import PrometheusClient
from core.i18n import t
class PrometheusTab(ctk.CTkFrame):
def __init__(self, master, store):
super().__init__(master, fg_color="transparent")
self.store = store
self._current_alias: str | None = None
self._client: PrometheusClient | None = None
self._build_ui()
def _build_ui(self):
# ── PromQL query section ──
query_frame = ctk.CTkFrame(self, fg_color="transparent")
query_frame.pack(fill="x", padx=15, pady=(15, 5))
query_label = ctk.CTkLabel(query_frame, text=t("prom_query"),
font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
query_label.pack(side="left", padx=(0, 10))
self._query_entry = ctk.CTkEntry(query_frame,
placeholder_text=t("prom_query_placeholder"),
font=ctk.CTkFont(family="Consolas", size=13))
self._query_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
self._query_entry.bind("<Return>", lambda e: self._execute_query())
self._exec_btn = ctk.CTkButton(query_frame, text=t("prom_execute"), width=90,
command=self._execute_query)
self._exec_btn.pack(side="left")
# ── Query results ──
results_label = ctk.CTkLabel(self, text=t("prom_results"),
font=ctk.CTkFont(size=12, weight="bold"), anchor="w")
results_label.pack(fill="x", padx=15, pady=(10, 3))
self._results_box = ctk.CTkTextbox(self, height=150,
font=ctk.CTkFont(family="Consolas", size=12),
state="disabled")
self._results_box.pack(fill="x", padx=15, pady=(0, 5))
# ── Targets section ──
targets_header = ctk.CTkFrame(self, fg_color="transparent")
targets_header.pack(fill="x", padx=15, pady=(10, 3))
targets_label = ctk.CTkLabel(targets_header, text=t("prom_targets"),
font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
targets_label.pack(side="left")
self._refresh_btn = ctk.CTkButton(targets_header, text=t("prom_refresh"), width=90,
command=self._refresh_all)
self._refresh_btn.pack(side="right")
targets_frame = ctk.CTkFrame(self, fg_color="transparent")
targets_frame.pack(fill="both", expand=True, padx=15, pady=(0, 5))
target_columns = ("job", "instance", "health", "last_scrape")
self._targets_tree = ttk.Treeview(targets_frame, columns=target_columns, show="headings",
selectmode="browse", height=6)
self._targets_tree.heading("job", text=t("prom_target_job"))
self._targets_tree.heading("instance", text=t("prom_target_instance"))
self._targets_tree.heading("health", text=t("prom_target_health"))
self._targets_tree.heading("last_scrape", text=t("prom_target_scrape"))
self._targets_tree.column("job", width=120, minwidth=80)
self._targets_tree.column("instance", width=200, minwidth=120)
self._targets_tree.column("health", width=80, minwidth=60)
self._targets_tree.column("last_scrape", width=150, minwidth=80)
self._targets_tree.pack(side="left", fill="both", expand=True)
targets_scroll = ttk.Scrollbar(targets_frame, orient="vertical",
command=self._targets_tree.yview)
targets_scroll.pack(side="right", fill="y")
self._targets_tree.configure(yscrollcommand=targets_scroll.set)
# ── Alerts section ──
alerts_label = ctk.CTkLabel(self, text=t("prom_alerts"),
font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
alerts_label.pack(fill="x", padx=15, pady=(10, 3))
self._alerts_box = ctk.CTkTextbox(self, height=100,
font=ctk.CTkFont(family="Consolas", size=12),
state="disabled")
self._alerts_box.pack(fill="x", padx=15, pady=(0, 5))
# ── Status bar ──
self._status_bar = ctk.CTkLabel(self, text=t("prom_no_server"), anchor="w",
font=ctk.CTkFont(size=11), text_color="#9ca3af")
self._status_bar.pack(fill="x", padx=15, pady=(5, 10))
# ── Public API ──
def set_server(self, alias: str | None):
"""Called when user selects a server in sidebar."""
self._current_alias = alias
self._client = None
self._clear_all()
if alias:
self._set_status(t("prom_connected").format(alias=alias), "#22c55e")
self._refresh_all()
else:
self._set_status(t("prom_no_server"), "#9ca3af")
# ── PromQL execution ──
def _execute_query(self):
query = self._query_entry.get().strip()
if not query:
return
if not self._current_alias:
self._set_results(t("no_server_selected"))
return
self._exec_btn.configure(state="disabled")
self._set_results(t("prom_executing"))
def _do():
try:
client = self._get_client()
result = client.query(query)
formatted = self._format_query_result(result)
self.after(0, lambda: self._set_results(formatted))
except Exception as e:
self.after(0, lambda: self._set_results(f"(error) {e}"))
finally:
self.after(0, lambda: self._exec_btn.configure(state="normal"))
threading.Thread(target=_do, daemon=True).start()
def _format_query_result(self, result: dict) -> str:
"""Format Prometheus query API response for display."""
status = result.get("status", "unknown")
if status != "success":
error = result.get("error", "Unknown error")
return f"Error: {error}"
data = result.get("data", {})
result_type = data.get("resultType", "")
results = data.get("result", [])
if not results:
return "(empty result)"
lines = [f"# Type: {result_type}", f"# Results: {len(results)}", ""]
for item in results:
metric = item.get("metric", {})
metric_str = ", ".join(f'{k}="{v}"' for k, v in metric.items())
if result_type == "vector":
value = item.get("value", [None, ""])[1]
lines.append(f"{{{metric_str}}} => {value}")
elif result_type == "matrix":
values = item.get("values", [])
lines.append(f"{{{metric_str}}}")
for ts, val in values[-10:]: # Show last 10 points
lines.append(f" @{ts} => {val}")
if len(values) > 10:
lines.append(f" ... ({len(values)} total points)")
else:
lines.append(f"{{{metric_str}}} => {item}")
return "\n".join(lines)
# ── Refresh targets & alerts ──
def _refresh_all(self):
if not self._current_alias:
self._set_status(t("no_server_selected"), "#ef4444")
return
self._refresh_btn.configure(state="disabled", text=t("prom_loading"))
self._set_status(t("prom_loading"), "#ccaa00")
def _do():
try:
client = self._get_client()
targets = client.get_targets()
alerts = client.get_alerts()
self.after(0, lambda: self._populate_targets(targets))
self.after(0, lambda: self._populate_alerts(alerts))
self.after(0, lambda: self._set_status(
t("prom_loaded").format(
targets=len(targets), alerts=len(alerts)
), "#22c55e"))
except Exception as e:
self.after(0, lambda: self._set_status(f"(error) {e}", "#ef4444"))
finally:
self.after(0, lambda: self._refresh_btn.configure(
state="normal", text=t("prom_refresh")))
threading.Thread(target=_do, daemon=True).start()
def _get_client(self) -> PrometheusClient:
if self._client is None:
self._client = PrometheusClient(self._current_alias, self.store)
return self._client
# ── Table population ──
def _populate_targets(self, targets: list[dict]):
self._targets_tree.delete(*self._targets_tree.get_children())
for target in targets:
job = target.get("labels", {}).get("job", "")
instance = target.get("labels", {}).get("instance", "")
health = target.get("health", "unknown")
last_scrape = target.get("lastScrape", "")
tag = ""
if health == "up":
tag = "up"
elif health == "down":
tag = "down"
self._targets_tree.insert("", "end",
values=(job, instance, health, last_scrape),
tags=(tag,))
self._targets_tree.tag_configure("up", foreground="#22c55e")
self._targets_tree.tag_configure("down", foreground="#ef4444")
def _populate_alerts(self, alerts: list[dict]):
self._alerts_box.configure(state="normal")
self._alerts_box.delete("1.0", "end")
if not alerts:
self._alerts_box.insert("1.0", t("prom_no_alerts"))
else:
lines = []
for a in alerts:
name = a.get("labels", {}).get("alertname", a.get("name", "unknown"))
state = a.get("state", "unknown")
severity = a.get("labels", {}).get("severity", "")
lines.append(f"[{state.upper()}] {name} (severity: {severity})")
self._alerts_box.insert("1.0", "\n".join(lines))
self._alerts_box.configure(state="disabled")
# ── Helpers ──
def _set_results(self, text: str):
self._results_box.configure(state="normal")
self._results_box.delete("1.0", "end")
self._results_box.insert("1.0", text)
self._results_box.configure(state="disabled")
def _clear_all(self):
self._targets_tree.delete(*self._targets_tree.get_children())
self._set_results("")
self._alerts_box.configure(state="normal")
self._alerts_box.delete("1.0", "end")
self._alerts_box.configure(state="disabled")
def _set_status(self, text: str, color: str = "#9ca3af"):
self._status_bar.configure(text=text, text_color=color)

336
gui/tabs/query_tab.py Normal file
View File

@@ -0,0 +1,336 @@
"""
Query tab — SQL database interaction with editor, results grid, and export.
"""
import csv
import io
import time
import threading
from tkinter import ttk, filedialog
import customtkinter as ctk
from core.i18n import t
from core.sql_client import SQLClient
class QueryTab(ctk.CTkFrame):
def __init__(self, master, store):
super().__init__(master, fg_color="transparent")
self._current_alias: str | None = None
self.store = store
self._client: SQLClient | None = None
self._results: list[list] = []
self._columns: list[str] = []
self._executing = False
self._build_ui()
# ── UI construction ────────────────────────────────────────────
def _build_ui(self):
# === Database selector row ===
db_row = ctk.CTkFrame(self, fg_color="transparent")
db_row.pack(fill="x", padx=10, pady=(10, 5))
ctk.CTkLabel(db_row, text=t("query_database"), anchor="w").pack(
side="left", padx=(0, 8)
)
self._db_var = ctk.StringVar(value="")
self._db_combo = ctk.CTkComboBox(
db_row,
variable=self._db_var,
values=[],
width=220,
command=self._on_db_selected,
)
self._db_combo.pack(side="left")
# === SQL Editor ===
editor_frame = ctk.CTkFrame(self, fg_color="transparent")
editor_frame.pack(fill="both", expand=True, padx=10, pady=5, side="top")
# Give editor roughly 1/3 of space
editor_frame.pack_configure(expand=False)
self._editor = ctk.CTkTextbox(
editor_frame,
font=ctk.CTkFont(family="Consolas", size=13),
height=160,
wrap="none",
)
self._editor.pack(fill="both", expand=True)
self._editor.insert("0.0", t("query_editor_placeholder"))
self._editor.bind("<FocusIn>", self._on_editor_focus)
# Bind keyboard shortcuts
self._editor.bind("<F5>", lambda e: self._execute_query())
self._editor.bind("<Control-Return>", lambda e: self._execute_query())
# === Button row ===
btn_row = ctk.CTkFrame(self, fg_color="transparent")
btn_row.pack(fill="x", padx=10, pady=5)
self._exec_btn = ctk.CTkButton(
btn_row,
text=f"{t('query_execute')} (F5)",
command=self._execute_query,
width=130,
fg_color="#2563eb",
hover_color="#1d4ed8",
)
self._exec_btn.pack(side="left", padx=(0, 6))
self._clear_btn = ctk.CTkButton(
btn_row,
text=t("query_clear"),
command=self._clear_all,
width=80,
fg_color="#6b7280",
hover_color="#4b5563",
)
self._clear_btn.pack(side="left", padx=(0, 6))
self._export_btn = ctk.CTkButton(
btn_row,
text=t("query_export_csv"),
command=self._export_csv,
width=110,
fg_color="#059669",
hover_color="#047857",
)
self._export_btn.pack(side="left")
# === Results area (Treeview) ===
results_frame = ctk.CTkFrame(self, fg_color="transparent")
results_frame.pack(fill="both", expand=True, padx=10, pady=(5, 5))
# Horizontal scrollbar
self._tree_xscroll = ttk.Scrollbar(results_frame, orient="horizontal")
self._tree_xscroll.pack(side="bottom", fill="x")
# Vertical scrollbar
self._tree_yscroll = ttk.Scrollbar(results_frame, orient="vertical")
self._tree_yscroll.pack(side="right", fill="y")
self._tree = ttk.Treeview(
results_frame,
show="headings",
xscrollcommand=self._tree_xscroll.set,
yscrollcommand=self._tree_yscroll.set,
)
self._tree.pack(fill="both", expand=True)
self._tree_xscroll.config(command=self._tree.xview)
self._tree_yscroll.config(command=self._tree.yview)
# === Status bar ===
self._status_label = ctk.CTkLabel(
self,
text="",
anchor="w",
font=ctk.CTkFont(size=12),
text_color="#9ca3af",
)
self._status_label.pack(fill="x", padx=12, pady=(0, 8))
# ── Editor placeholder logic ───────────────────────────────────
def _on_editor_focus(self, event=None):
content = self._editor.get("0.0", "end").strip()
placeholder = t("query_editor_placeholder")
if content == placeholder:
self._editor.delete("0.0", "end")
# ── Server / database connection ───────────────────────────────
def set_server(self, alias: str | None):
"""Called when user selects a server in the sidebar."""
self._current_alias = alias
self._disconnect()
self._clear_results()
self._set_status("")
if not alias:
self._db_combo.configure(values=[])
self._db_var.set("")
return
self._set_status(f"Connecting to {alias}...")
threading.Thread(
target=self._connect_and_list_dbs,
args=(alias,),
daemon=True,
).start()
def _connect_and_list_dbs(self, alias: str):
"""Background: create SQLClient, fetch database list."""
try:
server = self.store.get_server(alias)
if not server:
self._schedule(self._set_status, t("query_error"), error=True)
return
client = SQLClient(server)
databases = client.list_databases()
def _update():
if self._current_alias != alias:
return # switched away
self._client = client
self._db_combo.configure(values=databases)
if databases:
self._db_var.set(databases[0])
self._switch_database(databases[0])
self._set_status("OK")
self._schedule(_update)
except Exception as exc:
self._schedule(self._set_status, str(exc), error=True)
def _on_db_selected(self, value: str):
if value:
self._switch_database(value)
def _switch_database(self, db_name: str):
"""Switch active database on the current client."""
if not self._client:
return
try:
self._client.use_database(db_name)
self._set_status(f"Database: {db_name}")
except Exception as exc:
self._set_status(str(exc), error=True)
def _disconnect(self):
if self._client:
try:
self._client.close()
except Exception:
pass
self._client = None
# ── Query execution ────────────────────────────────────────────
def _execute_query(self):
"""Run the SQL query in a background thread."""
if self._executing or not self._client:
return
sql = self._editor.get("0.0", "end").strip()
if not sql or sql == t("query_editor_placeholder"):
return
self._executing = True
self._exec_btn.configure(state="disabled")
self._set_status("Executing...")
threading.Thread(
target=self._run_query,
args=(sql,),
daemon=True,
).start()
def _run_query(self, sql: str):
"""Background thread: execute SQL, measure time, post results."""
start = time.perf_counter()
try:
columns, rows = self._client.execute(sql)
elapsed = time.perf_counter() - start
def _update():
self._columns = columns
self._results = rows
self._populate_tree(columns, rows)
row_count = len(rows)
self._set_status(
t("query_status_rows").format(
rows=row_count, time=f"{elapsed:.3f}"
)
)
self._executing = False
self._exec_btn.configure(state="normal")
self._schedule(_update)
except Exception as exc:
elapsed = time.perf_counter() - start
def _update_err():
self._set_status(
f"{t('query_error')}: {exc}", error=True
)
self._executing = False
self._exec_btn.configure(state="normal")
self._schedule(_update_err)
# ── Treeview population ────────────────────────────────────────
def _populate_tree(self, columns: list[str], rows: list[list]):
"""Clear and populate the Treeview with query results."""
self._tree.delete(*self._tree.get_children())
if not columns:
self._tree["columns"] = ()
return
self._tree["columns"] = columns
for col in columns:
self._tree.heading(col, text=col, anchor="w")
self._tree.column(col, width=120, minwidth=60, anchor="w")
for row in rows:
display = [str(v) if v is not None else "NULL" for v in row]
self._tree.insert("", "end", values=display)
def _clear_results(self):
"""Remove all rows and columns from the Treeview."""
self._tree.delete(*self._tree.get_children())
self._tree["columns"] = ()
self._columns = []
self._results = []
# ── Button actions ─────────────────────────────────────────────
def _clear_all(self):
"""Clear editor content and results."""
self._editor.delete("0.0", "end")
self._clear_results()
self._set_status("")
def _export_csv(self):
"""Export current results to a CSV file via save dialog."""
if not self._columns or not self._results:
return
path = filedialog.asksaveasfilename(
defaultextension=".csv",
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
title=t("query_export_csv"),
)
if not path:
return
try:
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(self._columns)
for row in self._results:
writer.writerow(
[str(v) if v is not None else "" for v in row]
)
self._set_status(f"Exported {len(self._results)} rows to {path}")
except Exception as exc:
self._set_status(f"{t('query_error')}: {exc}", error=True)
# ── Status bar ─────────────────────────────────────────────────
def _set_status(self, text: str, error: bool = False):
color = "#ef4444" if error else "#9ca3af"
self._status_label.configure(text=text, text_color=color)
# ── Thread-safe scheduling ─────────────────────────────────────
def _schedule(self, func, *args, **kwargs):
"""Schedule a function to run on the main (tkinter) thread."""
self.after(0, lambda: func(*args, **kwargs))

266
gui/tabs/redis_tab.py Normal file
View File

@@ -0,0 +1,266 @@
"""
Redis tab — interactive Redis CLI with DB selector, command history, and output console.
"""
import threading
import customtkinter as ctk
from core.redis_client import RedisClient
from core.i18n import t
class RedisTab(ctk.CTkFrame):
def __init__(self, master, store):
super().__init__(master, fg_color="transparent")
self.store = store
self._current_alias: str | None = None
self._client: RedisClient | None = None
self._command_history: list[str] = []
self._history_index: int = -1
self._build_ui()
def _build_ui(self):
# ── Top bar: DB selector + stats ──
top_frame = ctk.CTkFrame(self, fg_color="transparent")
top_frame.pack(fill="x", padx=15, pady=(15, 5))
# DB selector
db_label = ctk.CTkLabel(top_frame, text=t("redis_db"), anchor="w",
font=ctk.CTkFont(size=12, weight="bold"))
db_label.pack(side="left", padx=(0, 5))
self._db_var = ctk.StringVar(value="0")
self._db_selector = ctk.CTkOptionMenu(
top_frame, values=[str(i) for i in range(16)],
variable=self._db_var, width=70,
command=self._on_db_changed,
)
self._db_selector.pack(side="left", padx=(0, 15))
# Keys count
self._keys_label = ctk.CTkLabel(top_frame, text=t("redis_keys") + ": —",
font=ctk.CTkFont(size=12), text_color="#9ca3af")
self._keys_label.pack(side="left", padx=(0, 15))
# Memory usage
self._memory_label = ctk.CTkLabel(top_frame, text=t("redis_memory") + ": —",
font=ctk.CTkFont(size=12), text_color="#9ca3af")
self._memory_label.pack(side="left")
# ── Command input row ──
cmd_frame = ctk.CTkFrame(self, fg_color="transparent")
cmd_frame.pack(fill="x", padx=15, pady=5)
prompt_label = ctk.CTkLabel(cmd_frame, text="redis>", font=ctk.CTkFont(family="Consolas", size=13),
text_color="#ef4444")
prompt_label.pack(side="left", padx=(0, 5))
self._cmd_entry = ctk.CTkEntry(cmd_frame, placeholder_text=t("redis_command_placeholder"),
font=ctk.CTkFont(family="Consolas", size=13))
self._cmd_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
self._cmd_entry.bind("<Return>", lambda e: self._execute_command())
self._cmd_entry.bind("<Up>", self._history_up)
self._cmd_entry.bind("<Down>", self._history_down)
# ── Buttons row ──
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=15, pady=5)
self._exec_btn = ctk.CTkButton(btn_frame, text=t("redis_execute"), width=90,
command=self._execute_command)
self._exec_btn.pack(side="left", padx=(0, 5))
self._info_btn = ctk.CTkButton(btn_frame, text="INFO", width=70,
fg_color="#6b7280", hover_color="#4b5563",
command=lambda: self._run_quick("INFO"))
self._info_btn.pack(side="left", padx=(0, 5))
self._dbsize_btn = ctk.CTkButton(btn_frame, text="DBSIZE", width=80,
fg_color="#6b7280", hover_color="#4b5563",
command=lambda: self._run_quick("DBSIZE"))
self._dbsize_btn.pack(side="left", padx=(0, 5))
self._scan_btn = ctk.CTkButton(btn_frame, text="SCAN", width=70,
fg_color="#6b7280", hover_color="#4b5563",
command=lambda: self._run_quick("SCAN 0 COUNT 100"))
self._scan_btn.pack(side="left", padx=(0, 5))
self._clear_btn = ctk.CTkButton(btn_frame, text=t("redis_clear"), width=70,
fg_color="#374151", hover_color="#1f2937",
command=self._clear_output)
self._clear_btn.pack(side="right")
# ── Output console ──
self._output = ctk.CTkTextbox(self, font=ctk.CTkFont(family="Consolas", size=12),
state="disabled")
self._output.pack(fill="both", expand=True, padx=15, pady=(5, 5))
# ── Status bar ──
self._status_bar = ctk.CTkLabel(self, text=t("redis_disconnected"), anchor="w",
font=ctk.CTkFont(size=11), text_color="#9ca3af")
self._status_bar.pack(fill="x", padx=15, pady=(0, 10))
# ── Public API ──
def set_server(self, alias: str | None):
"""Called when user selects a server in sidebar."""
self._current_alias = alias
self._client = None
self._command_history.clear()
self._history_index = -1
self._clear_output()
if alias:
self._set_status(t("redis_ready").format(alias=alias), "#22c55e")
self._refresh_stats()
else:
self._set_status(t("redis_disconnected"), "#9ca3af")
self._keys_label.configure(text=t("redis_keys") + ": —")
self._memory_label.configure(text=t("redis_memory") + ": —")
# ── Command execution ──
def _execute_command(self):
cmd = self._cmd_entry.get().strip()
if not cmd:
return
if not self._current_alias:
self._append_output(t("no_server_selected"))
return
# Add to history
if not self._command_history or self._command_history[-1] != cmd:
self._command_history.append(cmd)
self._history_index = -1
self._cmd_entry.delete(0, "end")
self._append_output(f"redis> {cmd}")
self._set_buttons_state("disabled")
db = int(self._db_var.get())
def _do():
try:
client = self._get_client()
result = client.execute(cmd, db=db)
formatted = self._format_result(result)
self.after(0, lambda: self._append_output(formatted))
except Exception as e:
self.after(0, lambda: self._append_output(f"(error) {e}"))
finally:
self.after(0, lambda: self._set_buttons_state("normal"))
self.after(0, self._refresh_stats)
threading.Thread(target=_do, daemon=True).start()
def _run_quick(self, cmd: str):
"""Execute a preset command."""
self._cmd_entry.delete(0, "end")
self._cmd_entry.insert(0, cmd)
self._execute_command()
def _get_client(self) -> RedisClient:
if self._client is None:
self._client = RedisClient(self._current_alias, self.store)
return self._client
# ── Stats refresh ──
def _refresh_stats(self):
if not self._current_alias:
return
def _do():
try:
client = self._get_client()
db = int(self._db_var.get())
keys_count = client.execute("DBSIZE", db=db)
info = client.execute("INFO memory", db=db)
# Parse memory from INFO output
memory = ""
if isinstance(info, str):
for line in info.split("\r\n"):
if line.startswith("used_memory_human:"):
memory = line.split(":")[1].strip()
break
keys_text = str(keys_count) if keys_count is not None else ""
self.after(0, lambda: self._keys_label.configure(
text=t("redis_keys") + f": {keys_text}"))
self.after(0, lambda: self._memory_label.configure(
text=t("redis_memory") + f": {memory}"))
except Exception:
pass
threading.Thread(target=_do, daemon=True).start()
def _on_db_changed(self, _value: str):
self._refresh_stats()
# ── History navigation ──
def _history_up(self, _event):
if not self._command_history:
return "break"
if self._history_index == -1:
self._history_index = len(self._command_history) - 1
elif self._history_index > 0:
self._history_index -= 1
self._cmd_entry.delete(0, "end")
self._cmd_entry.insert(0, self._command_history[self._history_index])
return "break"
def _history_down(self, _event):
if not self._command_history:
return "break"
if self._history_index == -1:
return "break"
if self._history_index < len(self._command_history) - 1:
self._history_index += 1
self._cmd_entry.delete(0, "end")
self._cmd_entry.insert(0, self._command_history[self._history_index])
else:
self._history_index = -1
self._cmd_entry.delete(0, "end")
return "break"
# ── Output helpers ──
def _format_result(self, result) -> str:
"""Format Redis response for display."""
if result is None:
return "(nil)"
if isinstance(result, bytes):
return result.decode("utf-8", errors="replace")
if isinstance(result, int):
return f"(integer) {result}"
if isinstance(result, list):
if not result:
return "(empty list or set)"
lines = []
for i, item in enumerate(result, 1):
val = item.decode("utf-8", errors="replace") if isinstance(item, bytes) else str(item)
lines.append(f"{i}) \"{val}\"")
return "\n".join(lines)
if isinstance(result, str):
return result
return str(result)
def _append_output(self, text: str):
self._output.configure(state="normal")
self._output.insert("end", text + "\n")
self._output.configure(state="disabled")
self._output.see("end")
def _clear_output(self):
self._output.configure(state="normal")
self._output.delete("1.0", "end")
self._output.configure(state="disabled")
def _set_status(self, text: str, color: str = "#9ca3af"):
self._status_bar.configure(text=text, text_color=color)
def _set_buttons_state(self, state: str):
for btn in (self._exec_btn, self._info_btn, self._dbsize_btn, self._scan_btn):
btn.configure(state=state)

View File

@@ -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 import queue
@@ -8,6 +8,7 @@ import threading
import time import time
import customtkinter as ctk import customtkinter as ctk
from core.ssh_client import ShellSession from core.ssh_client import ShellSession
from core.telnet_client import TelnetSession
from core.i18n import t from core.i18n import t
# Regex to strip ANSI escape sequences # Regex to strip ANSI escape sequences
@@ -20,7 +21,7 @@ class TerminalTab(ctk.CTkFrame):
self.store = store self.store = store
self.session_pool = session_pool self.session_pool = session_pool
self._current_alias: str | None = None self._current_alias: str | None = None
self._session: ShellSession | None = None self._session: ShellSession | TelnetSession | None = None
self._reconnect_count = 0 self._reconnect_count = 0
self._max_reconnect = 5 self._max_reconnect = 5
self._intentional_disconnect = False self._intentional_disconnect = False
@@ -76,16 +77,25 @@ class TerminalTab(ctk.CTkFrame):
return return
alias = self._current_alias alias = self._current_alias
server_type = server.get("type", "ssh")
self._terminal.set_status(t("term_connecting").format(alias=alias), "#ccaa00") self._terminal.set_status(t("term_connecting").format(alias=alias), "#ccaa00")
self._intentional_disconnect = False self._intentional_disconnect = False
def _do_connect(): def _do_connect():
try: try:
key_path = self.store.get_ssh_key_path() key_path = self.store.get_ssh_key_path()
cols, rows = self._terminal.get_size()
# Use session pool if available if server_type == "telnet":
if self.session_pool: # Telnet — direct session, no pool (pool is SSH-specific)
cols, rows = self._terminal.get_size() 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) session, is_new = self.session_pool.get_or_create_shell_session(alias, server, key_path)
if is_new: if is_new:
# New session — reset terminal for clean start # New session — reset terminal for clean start
@@ -108,9 +118,8 @@ class TerminalTab(ctk.CTkFrame):
session.on_disconnect = self._on_disconnected session.on_disconnect = self._on_disconnected
self._session = session self._session = session
else: else:
# Legacy behavior without session pool # SSH without pool (legacy)
self.after(0, self._terminal.reset) self.after(0, self._terminal.reset)
cols, rows = self._terminal.get_size()
session = ShellSession(server, key_path, cols=cols, rows=rows) session = ShellSession(server, key_path, cols=cols, rows=rows)
session.on_data = self._on_data_received session.on_data = self._on_data_received
session.on_disconnect = self._on_disconnected session.on_disconnect = self._on_disconnected
@@ -136,12 +145,18 @@ class TerminalTab(ctk.CTkFrame):
def _disconnect(self): def _disconnect(self):
self._intentional_disconnect = True self._intentional_disconnect = True
# Only disconnect if we don't have a session pool (otherwise session stays alive) if not self._session:
if not self.session_pool and self._session: return
# Telnet sessions are never pooled — always disconnect directly
if isinstance(self._session, TelnetSession):
self._session.disconnect() self._session.disconnect()
self._session = None self._session = None
# If using session pool, session remains active in the pool # SSH without session pool — disconnect directly
elif self.session_pool and self._session: 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 # Remove callbacks to prevent processing data after switch
self._session.on_data = None self._session.on_data = None
self._session.on_disconnect = None self._session.on_disconnect = None

View File

@@ -5,3 +5,10 @@ cryptography>=41.0.0
pyotp>=2.9.0 pyotp>=2.9.0
pyte>=0.8.1 pyte>=0.8.1
psutil>=5.9.0 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

View File

@@ -1,6 +1,7 @@
# Скилл /ssh — управление удалёнными серверами # Скилл /ssh — управление удалёнными серверами
Ты управляешь удалёнными серверами через SSH-утилиту. Ты управляешь удалёнными серверами через универсальную CLI-утилиту.
Поддерживаются: SSH, SQL (MariaDB/MSSQL/PostgreSQL), Redis, Grafana, Prometheus, WinRM (PowerShell/CMD).
## ВАЖНО — Безопасность ## ВАЖНО — Безопасность
@@ -18,7 +19,7 @@
Пользователь передаёт через `$ARGUMENTS`. Разбери и выполни. Пользователь передаёт через `$ARGUMENTS`. Разбери и выполни.
## Команды ## Общие команды
### Список серверов (безопасный — alias, тип, ключ, заметки) ### Список серверов (безопасный — alias, тип, ключ, заметки)
```bash ```bash
@@ -35,6 +36,19 @@ python ~/.server-connections/ssh.py --info ALIAS
python ~/.server-connections/ssh.py --status 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 ```bash
python ~/.server-connections/ssh.py ALIAS "command" 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 python ~/.server-connections/ssh.py ALIAS --ping
``` ```
### Обновить заметки сервера ## SQL-команды (типы: mariadb, mssql, postgresql)
```bash
python ~/.server-connections/ssh.py --set-note ALIAS "описание сервера"
```
Используй чтобы сохранить контекст: что на сервере работает, для чего он нужен.
### Удалить сервер ### Выполнить SQL-запрос
```bash ```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 ```bash
unset SSH_ASKPASS && unset DISPLAY && ssh ALIAS "command" unset SSH_ASKPASS && unset DISPLAY && ssh ALIAS "command"
``` ```
## Поведение ## Поведение
- **Auto-sudo**: если user на сервере не root — команды автоматически оборачиваются в `sudo -S`, пароль подаётся через stdin. Тебе НЕ нужно добавлять `sudo` в команду - **Auto-sudo** (SSH): если user на сервере не root — команды автоматически оборачиваются в `sudo -S`, пароль подаётся через stdin. Тебе НЕ нужно добавлять `sudo` в команду
- **--no-sudo**: если команда не требует root (например `ls`, `cat`), используй `--no-sudo` для скорости - **--no-sudo** (SSH): если команда не требует root (например `ls`, `cat`), используй `--no-sudo` для скорости
- **Timeout**: 120 секунд на команду, 15 секунд на подключение - **Timeout**: 120 секунд на SSH-команду, 10 секунд на SQL/Redis/HTTP-запросы, 15 секунд на подключение
- **SSH-ключ**: пробуется первым, fallback на пароль если ключ не подходит - **SSH-ключ**: пробуется первым, fallback на пароль если ключ не подходит
- **Прогресс**: upload/download файлов >=1MB показывают 25/50/75% milestone, итог с размером/временем/скоростью - **Прогресс**: upload/download файлов >=1MB показывают 25/50/75% milestone, итог с размером/временем/скоростью
- **Тип сервера**: определяется автоматически из конфигурации. `--list` показывает тип каждого сервера
## Правила ## Правила
@@ -101,3 +183,5 @@ unset SSH_ASKPASS && unset DISPLAY && ssh ALIAS "command"
- Если timeout — предложи проверить VPN/firewall/панель хостера - Если timeout — предложи проверить VPN/firewall/панель хостера
- Файлы создаваемые на сервере должны иметь права 664 (owner+group rw) - Файлы создаваемые на сервере должны иметь права 664 (owner+group rw)
- При вопросе о серверах — СНАЧАЛА `--list`, потом `--info ALIAS` если нужны детали - При вопросе о серверах — СНАЧАЛА `--list`, потом `--info ALIAS` если нужны детали
- SQL-запросы: используй `LIMIT` для больших таблиц, чтобы не перегружать вывод
- Redis: используй SCAN, а не KEYS для больших баз

View File

@@ -3,7 +3,7 @@
SSH utility for Claude Code — connects to servers by alias. SSH utility for Claude Code — connects to servers by alias.
Credentials stored locally in servers.json (encrypted), NEVER exposed to AI API. 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 "command" # run as configured user (auto-sudo if needed)
python ssh.py ALIAS --no-sudo "command" # run without sudo elevation python ssh.py ALIAS --no-sudo "command" # run without sudo elevation
python ssh.py ALIAS --upload LOCAL REMOTE 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 --set-note ALIAS "desc" # update server notes
python ssh.py --add ALIAS IP PORT USER PASSWORD [--note "desc"] python ssh.py --add ALIAS IP PORT USER PASSWORD [--note "desc"]
python ssh.py --remove ALIAS 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 import sys
@@ -464,6 +487,415 @@ def remove_from_ssh_config(alias):
f.writelines(new_lines) 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 ────────────────────────────────────────────── # ── Main ──────────────────────────────────────────────
def main(): def main():
@@ -488,6 +920,80 @@ def main():
if cmd == "--remove" and len(sys.argv) >= 3: if cmd == "--remove" and len(sys.argv) >= 3:
remove_server(sys.argv[2]); sys.exit(0) 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 # Server commands — exact match first, then fuzzy search by keyword
alias = cmd alias = cmd
_, servers = load_servers() _, servers = load_servers()