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):
cmd_parts.extend(["--icon", icon_path])
# Hidden imports for customtkinter
# Hidden imports for customtkinter and connection libraries
cmd_parts.extend([
"--hidden-import", "customtkinter",
"--hidden-import", "PIL",
"--hidden-import", "pyotp",
"--hidden-import", "pyte",
"--hidden-import", "psutil",
"--hidden-import", "pymysql",
"--hidden-import", "psycopg2",
"--hidden-import", "pymssql",
"--hidden-import", "redis",
"--hidden-import", "requests",
"--hidden-import", "winrm",
"--hidden-import", "telnetlib3",
"--collect-all", "customtkinter",
])

View File

@@ -1,7 +1,6 @@
"""
Connection factory — stubs for non-SSH connection types.
SSH is fully implemented via SSHClientWrapper.
Other types are placeholders for future implementation.
Connection factory — creates connection wrappers based on server type.
Uses lazy imports so missing optional dependencies don't crash the app.
"""
from core.ssh_client import SSHClientWrapper
@@ -14,12 +13,32 @@ def create_connection(server: dict, key_path: str = ""):
if server_type == "ssh":
return SSHClientWrapper(server, key_path)
# Stubs for future types
if server_type == "rdp":
raise NotImplementedError("RDP connections — use mstsc.exe or rdesktop")
if server_type == "telnet":
raise NotImplementedError("Telnet connections — planned")
from core.telnet_client import TelnetSession
return TelnetSession(server)
if server_type in ("mariadb", "mssql", "postgresql"):
raise NotImplementedError(f"{server_type.upper()} connections — planned")
from core.sql_client import SQLClient
return SQLClient(server)
if server_type == "redis":
from core.redis_client import RedisClient
return RedisClient(server)
if server_type == "grafana":
from core.grafana_client import GrafanaClient
return GrafanaClient(server)
if server_type == "prometheus":
from core.prometheus_client import PrometheusClient
return PrometheusClient(server)
if server_type == "winrm":
from core.winrm_client import WinRMClient
return WinRMClient(server)
if server_type in ("rdp", "vnc"):
from core.remote_desktop import RemoteDesktopLauncher
return RemoteDesktopLauncher()
raise ValueError(f"Unknown server type: {server_type}")

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",
"placeholder_totp_secret": "Base32 secret (optional)",
"port_out_of_range": "Port must be 1-65535",
"database": "Database",
"db_index": "DB Index",
"api_token": "API Token",
"placeholder_api_token": "Bearer token or API key",
"use_ssl": "Use SSL / HTTPS",
"db_index_must_be_number": "DB index must be a number",
# Monitoring
"monitoring": "Monitoring",
@@ -289,6 +295,107 @@ _EN = {
"recursive_delete_confirm": "Delete folder '{name}' and all contents?",
"drive": "Drive",
"active_sessions": "Active: {count}",
# Tab names (new server types)
"query": "Query",
"console": "Console",
"dashboards": "Dashboards",
"alerts": "Alerts",
"metrics": "Metrics",
"targets": "Targets",
"powershell": "PowerShell",
"launch": "Connect",
# Server dialog fields (new types)
"database": "Database",
"placeholder_database": "mydb",
"db_index": "DB Index (0-15)",
"placeholder_db_index": "0",
"api_token": "API Token",
"placeholder_api_token": "Token...",
"use_ssl": "Use SSL/TLS",
# Query tab
"query_execute": "Execute (F5)",
"query_clear": "Clear",
"query_export_csv": "Export CSV",
"query_database": "Database:",
"query_editor_placeholder": "Enter SQL query...",
"query_status_rows": "{rows} rows | {elapsed}s",
"query_error": "Error: {error}",
"query_no_results": "Query executed, no results",
"query_connected": "Connected to {alias} ({db})",
"query_connecting": "Connecting...",
"query_disconnected": "Not connected",
"query_exported": "Exported to {path}",
# Redis tab
"redis_execute": "Execute",
"redis_db": "DB:",
"redis_keys_count": "Keys: {count}",
"redis_memory": "Mem: {mem}",
"redis_prompt": "redis>",
"redis_connected": "Connected to {alias}",
"redis_connecting": "Connecting...",
"redis_disconnected": "Not connected",
"redis_error": "Error: {error}",
# Grafana tab
"grafana_dashboards": "Dashboards",
"grafana_alerts": "Alerts",
"grafana_uid": "UID",
"grafana_title": "Title",
"grafana_folder": "Folder",
"grafana_state": "State",
"grafana_name": "Name",
"grafana_severity": "Severity",
"grafana_connected": "Connected to {alias}",
"grafana_no_dashboards": "No dashboards found",
"grafana_no_alerts": "No alerts",
# Prometheus tab
"prom_query": "PromQL Query",
"prom_execute": "Execute",
"prom_targets": "Targets",
"prom_alerts": "Alerts",
"prom_job": "Job",
"prom_instance": "Instance",
"prom_health": "Health",
"prom_last_scrape": "Last Scrape",
"prom_connected": "Connected to {alias}",
"prom_no_targets": "No targets",
"prom_no_alerts": "No alerts",
"prom_placeholder": "up",
# PowerShell tab
"ps_execute": "Execute",
"ps_mode_ps": "PowerShell",
"ps_mode_cmd": "CMD",
"ps_placeholder_ps": "Get-Process...",
"ps_placeholder_cmd": "dir...",
"ps_history_empty": "No command history",
"ps_disconnected": "Not connected",
"ps_connecting": "Connecting...",
"ps_connected": "Connected to {alias}",
"ps_connect_failed": "Connection failed: {error}",
"ps_not_connected": "Not connected to server",
"ps_running": "Running...",
"ps_done": "Done",
"ps_exec_error": "Error: {error}",
# Launch tab
"launch_connect": "Connect",
"launch_rdp_info": "Remote Desktop (RDP) to {alias}",
"launch_vnc_info": "VNC connection to {alias}",
"launch_started": "Client launched",
"launch_starting": "Launching...",
"launch_error": "Launch failed: {error}",
"launch_no_server": "Select a server to connect",
# Info tab type-specific
"info_database": "Database:",
"info_ssl": "SSL:",
"info_db_index": "DB Index:",
}
_RU = {
@@ -499,6 +606,12 @@ _RU = {
"totp_secret_dialog": "TOTP-секрет",
"placeholder_totp_secret": "Base32 секрет (необязательно)",
"port_out_of_range": "Порт должен быть от 1 до 65535",
"database": "База данных",
"db_index": "Индекс БД",
"api_token": "API-токен",
"placeholder_api_token": "Bearer-токен или API-ключ",
"use_ssl": "Использовать SSL / HTTPS",
"db_index_must_be_number": "Индекс БД должен быть числом",
# Monitoring
"monitoring": "Мониторинг",
@@ -555,6 +668,107 @@ _RU = {
"recursive_delete_confirm": "Удалить папку '{name}' со всем содержимым?",
"drive": "Диск",
"active_sessions": "Активных: {count}",
# Tab names (new server types)
"query": "Запросы",
"console": "Консоль",
"dashboards": "Дашборды",
"alerts": "Оповещения",
"metrics": "Метрики",
"targets": "Цели",
"powershell": "PowerShell",
"launch": "Подключение",
# Server dialog fields (new types)
"database": "База данных",
"placeholder_database": "mydb",
"db_index": "Индекс БД (0-15)",
"placeholder_db_index": "0",
"api_token": "API-токен",
"placeholder_api_token": "Токен...",
"use_ssl": "Использовать SSL/TLS",
# Query tab
"query_execute": "Выполнить (F5)",
"query_clear": "Очистить",
"query_export_csv": "Экспорт CSV",
"query_database": "База данных:",
"query_editor_placeholder": "Введите SQL запрос...",
"query_status_rows": "{rows} строк | {elapsed}с",
"query_error": "Ошибка: {error}",
"query_no_results": "Запрос выполнен, нет результатов",
"query_connected": "Подключено к {alias} ({db})",
"query_connecting": "Подключение...",
"query_disconnected": "Не подключено",
"query_exported": "Экспортировано в {path}",
# Redis tab
"redis_execute": "Выполнить",
"redis_db": "БД:",
"redis_keys_count": "Ключей: {count}",
"redis_memory": "Память: {mem}",
"redis_prompt": "redis>",
"redis_connected": "Подключено к {alias}",
"redis_connecting": "Подключение...",
"redis_disconnected": "Не подключено",
"redis_error": "Ошибка: {error}",
# Grafana tab
"grafana_dashboards": "Дашборды",
"grafana_alerts": "Оповещения",
"grafana_uid": "UID",
"grafana_title": "Название",
"grafana_folder": "Папка",
"grafana_state": "Состояние",
"grafana_name": "Имя",
"grafana_severity": "Серьёзность",
"grafana_connected": "Подключено к {alias}",
"grafana_no_dashboards": "Дашборды не найдены",
"grafana_no_alerts": "Нет оповещений",
# Prometheus tab
"prom_query": "PromQL запрос",
"prom_execute": "Выполнить",
"prom_targets": "Цели",
"prom_alerts": "Оповещения",
"prom_job": "Job",
"prom_instance": "Инстанс",
"prom_health": "Здоровье",
"prom_last_scrape": "Последний опрос",
"prom_connected": "Подключено к {alias}",
"prom_no_targets": "Нет целей",
"prom_no_alerts": "Нет оповещений",
"prom_placeholder": "up",
# PowerShell tab
"ps_execute": "Выполнить",
"ps_mode_ps": "PowerShell",
"ps_mode_cmd": "CMD",
"ps_placeholder_ps": "Get-Process...",
"ps_placeholder_cmd": "dir...",
"ps_history_empty": "Нет истории команд",
"ps_disconnected": "Не подключено",
"ps_connecting": "Подключение...",
"ps_connected": "Подключено к {alias}",
"ps_connect_failed": "Ошибка подключения: {error}",
"ps_not_connected": "Нет подключения к серверу",
"ps_running": "Выполнение...",
"ps_done": "Готово",
"ps_exec_error": "Ошибка: {error}",
# Launch tab
"launch_connect": "Подключиться",
"launch_rdp_info": "Удалённый рабочий стол (RDP) к {alias}",
"launch_vnc_info": "VNC-подключение к {alias}",
"launch_started": "Клиент запущен",
"launch_starting": "Запуск...",
"launch_error": "Ошибка запуска: {error}",
"launch_no_server": "Выберите сервер для подключения",
# Info tab type-specific
"info_database": "База данных:",
"info_ssl": "SSL:",
"info_db_index": "Индекс БД:",
}
_ZH = {
@@ -765,6 +979,12 @@ _ZH = {
"totp_secret_dialog": "TOTP密钥",
"placeholder_totp_secret": "Base32密钥可选",
"port_out_of_range": "端口必须在1-65535之间",
"database": "数据库",
"db_index": "数据库索引",
"api_token": "API令牌",
"placeholder_api_token": "Bearer令牌或API密钥",
"use_ssl": "使用 SSL / HTTPS",
"db_index_must_be_number": "数据库索引必须是数字",
# Monitoring
"monitoring": "监控",
@@ -821,6 +1041,107 @@ _ZH = {
"recursive_delete_confirm": "删除文件夹 '{name}' 及所有内容?",
"drive": "驱动器",
"active_sessions": "活跃: {count}",
# Tab names (new server types)
"query": "查询",
"console": "控制台",
"dashboards": "仪表盘",
"alerts": "告警",
"metrics": "指标",
"targets": "目标",
"powershell": "PowerShell",
"launch": "连接",
# Server dialog fields (new types)
"database": "数据库",
"placeholder_database": "mydb",
"db_index": "数据库索引 (0-15)",
"placeholder_db_index": "0",
"api_token": "API令牌",
"placeholder_api_token": "令牌...",
"use_ssl": "使用SSL/TLS",
# Query tab
"query_execute": "执行 (F5)",
"query_clear": "清除",
"query_export_csv": "导出CSV",
"query_database": "数据库:",
"query_editor_placeholder": "输入SQL查询...",
"query_status_rows": "{rows} 行 | {elapsed}",
"query_error": "错误: {error}",
"query_no_results": "查询已执行,无结果",
"query_connected": "已连接到 {alias} ({db})",
"query_connecting": "连接中...",
"query_disconnected": "未连接",
"query_exported": "已导出到 {path}",
# Redis tab
"redis_execute": "执行",
"redis_db": "数据库:",
"redis_keys_count": "键数: {count}",
"redis_memory": "内存: {mem}",
"redis_prompt": "redis>",
"redis_connected": "已连接到 {alias}",
"redis_connecting": "连接中...",
"redis_disconnected": "未连接",
"redis_error": "错误: {error}",
# Grafana tab
"grafana_dashboards": "仪表盘",
"grafana_alerts": "告警",
"grafana_uid": "UID",
"grafana_title": "标题",
"grafana_folder": "文件夹",
"grafana_state": "状态",
"grafana_name": "名称",
"grafana_severity": "严重程度",
"grafana_connected": "已连接到 {alias}",
"grafana_no_dashboards": "未找到仪表盘",
"grafana_no_alerts": "无告警",
# Prometheus tab
"prom_query": "PromQL查询",
"prom_execute": "执行",
"prom_targets": "目标",
"prom_alerts": "告警",
"prom_job": "任务",
"prom_instance": "实例",
"prom_health": "健康",
"prom_last_scrape": "最后抓取",
"prom_connected": "已连接到 {alias}",
"prom_no_targets": "无目标",
"prom_no_alerts": "无告警",
"prom_placeholder": "up",
# PowerShell tab
"ps_execute": "执行",
"ps_mode_ps": "PowerShell",
"ps_mode_cmd": "CMD",
"ps_placeholder_ps": "Get-Process...",
"ps_placeholder_cmd": "dir...",
"ps_history_empty": "无命令历史",
"ps_disconnected": "未连接",
"ps_connecting": "连接中...",
"ps_connected": "已连接到 {alias}",
"ps_connect_failed": "连接失败: {error}",
"ps_not_connected": "未连接到服务器",
"ps_running": "执行中...",
"ps_done": "完成",
"ps_exec_error": "错误: {error}",
# Launch tab
"launch_connect": "连接",
"launch_rdp_info": "远程桌面 (RDP) 到 {alias}",
"launch_vnc_info": "VNC连接到 {alias}",
"launch_started": "客户端已启动",
"launch_starting": "启动中...",
"launch_error": "启动失败: {error}",
"launch_no_server": "选择服务器以连接",
# Info tab type-specific
"info_database": "数据库:",
"info_ssl": "SSL:",
"info_db_index": "数据库索引:",
}
_TRANSLATIONS = {

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")
EXAMPLE_FILE = os.path.join(LOCAL_CONFIG_DIR, "servers.example.json")
SERVER_TYPES = ["ssh", "telnet", "rdp", "mariadb", "mssql", "postgresql"]
SERVER_TYPES = ["ssh", "telnet", "rdp", "vnc", "winrm", "mariadb", "mssql", "postgresql", "redis", "grafana", "prometheus"]
DEFAULT_PORTS = {
"ssh": 22,
"telnet": 23,
"rdp": 3389,
"vnc": 5900,
"winrm": 5985,
"mariadb": 3306,
"mssql": 1433,
"postgresql": 5432,
"redis": 6379,
"grafana": 3000,
"prometheus": 9090,
}
# Auto-backup interval: 10 minutes

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 time
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -13,6 +14,13 @@ if TYPE_CHECKING:
from core.ssh_client import SSHClientWrapper
from core.logger import log
# Types that support native check_connection()
_SSH_TYPE = {"ssh"}
_SQL_TYPES = {"mariadb", "mssql", "postgresql"}
_REDIS_TYPE = {"redis"}
_HTTP_TYPES = {"grafana", "prometheus", "winrm"}
_TCP_TYPES = {"telnet", "rdp", "vnc"}
class StatusChecker:
def __init__(self, store: "ServerStore"):
@@ -37,10 +45,86 @@ class StatusChecker:
self._gui_callback = callback
def check_one(self, server: dict) -> bool:
"""Check a single server based on its type."""
server_type = server.get("type", "ssh")
if server_type in _SSH_TYPE:
return self._check_ssh(server)
if server_type in _SQL_TYPES:
return self._check_sql(server)
if server_type in _REDIS_TYPE:
return self._check_redis(server)
if server_type == "grafana":
return self._check_http(server, "/api/health")
if server_type == "prometheus":
return self._check_http(server, "/-/healthy")
if server_type == "winrm":
return self._check_http(server, "/wsman")
if server_type in _TCP_TYPES:
return self._check_tcp(server)
return False
def _check_ssh(self, server: dict) -> bool:
key_path = self.store.get_ssh_key_path()
wrapper = SSHClientWrapper(server, key_path)
return wrapper.check_connection()
def _check_tcp(self, server: dict) -> bool:
"""Check TCP connectivity (telnet, RDP, VNC)."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
sock.connect((server["ip"], server.get("port", 23)))
sock.close()
return True
except Exception:
return False
def _check_sql(self, server: dict) -> bool:
"""Check SQL connectivity via SELECT 1."""
try:
from core.sql_client import SQLClient
client = SQLClient(server)
result = client.connect()
if result:
ok = client.check_connection()
client.disconnect()
return ok
return False
except Exception:
return False
def _check_redis(self, server: dict) -> bool:
"""Check Redis via PING."""
try:
from core.redis_client import RedisClient
client = RedisClient(server)
result = client.connect()
if result:
ok = client.check_connection()
client.disconnect()
return ok
return False
except Exception:
return False
def _check_http(self, server: dict, path: str) -> bool:
"""Check HTTP(S) endpoint."""
try:
import requests
use_ssl = server.get("use_ssl", False)
scheme = "https" if use_ssl else "http"
url = f"{scheme}://{server['ip']}:{server.get('port', 80)}{path}"
headers = {}
api_token = server.get("api_token", "")
if api_token:
headers["Authorization"] = f"Bearer {api_token}"
resp = requests.get(url, headers=headers, timeout=5, verify=False)
return resp.status_code < 500
except Exception:
return False
def check_all_now(self):
threading.Thread(target=self._check_cycle, daemon=True).start()
@@ -61,23 +145,18 @@ class StatusChecker:
if s.get("skip_check", False):
self.store.set_status(s["alias"], "disabled")
ssh_servers = [s for s in servers if s.get("type", "ssh") == "ssh" and not s.get("skip_check", False)]
checkable = [s for s in servers if not s.get("skip_check", False)]
# Mark non-SSH (non-skipped) as unknown
for s in servers:
if s.get("type", "ssh") != "ssh" and not s.get("skip_check", False):
self.store.set_status(s["alias"], "unknown")
if not ssh_servers:
if not checkable:
return
# Parallel checks — up to 10 concurrent
max_workers = min(10, len(ssh_servers))
max_workers = min(10, len(checkable))
try:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(self.check_one, s): s["alias"]
for s in ssh_servers
for s in checkable
}
for future in as_completed(futures, timeout=30):
if not self._running:

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.setup_tab import SetupTab
from gui.tabs.totp_tab import TOTPTab
from gui.tabs.query_tab import QueryTab
from gui.tabs.redis_tab import RedisTab
from gui.tabs.grafana_tab import GrafanaTab
from gui.tabs.prometheus_tab import PrometheusTab
from gui.tabs.powershell_tab import PowershellTab
from gui.tabs.launch_tab import LaunchTab
# Tab sets per server type — determines which tabs are shown
TAB_REGISTRY = {
"ssh": ["terminal", "files", "info", "keys", "totp", "setup"],
"telnet": ["terminal", "info", "setup"],
"winrm": ["powershell", "info", "setup"],
"mariadb": ["query", "info", "setup"],
"mssql": ["query", "info", "setup"],
"postgresql": ["query", "info", "setup"],
"redis": ["console", "info", "setup"],
"grafana": ["dashboards", "info", "setup"],
"prometheus": ["metrics", "info", "setup"],
"rdp": ["launch", "info", "setup"],
"vnc": ["launch", "info", "setup"],
}
# Map tab key → widget class (used as lazy factory)
TAB_CLASSES = {
"terminal": TerminalTab,
"files": FilesTab,
"info": InfoTab,
"keys": KeysTab,
"totp": TOTPTab,
"setup": SetupTab,
"query": QueryTab,
"console": RedisTab,
"dashboards": GrafanaTab,
"metrics": PrometheusTab,
"powershell": PowershellTab,
"launch": LaunchTab,
}
class App(ctk.CTk):
@@ -67,11 +104,11 @@ class App(ctk.CTk):
self.sidebar.delete_callback = self._delete_server
# Main area
main = ctk.CTkFrame(self, fg_color="transparent")
main.pack(side="right", fill="both", expand=True)
self._main_frame = ctk.CTkFrame(self, fg_color="transparent")
self._main_frame.pack(side="right", fill="both", expand=True)
# Header bar (language + about)
header_bar = ctk.CTkFrame(main, fg_color="transparent", height=40)
header_bar = ctk.CTkFrame(self._main_frame, fg_color="transparent", height=40)
header_bar.pack(fill="x", padx=10, pady=(8, 0))
header_bar.pack_propagate(False)
@@ -93,39 +130,96 @@ class App(ctk.CTk):
)
self.about_btn.pack(side="right", padx=(5, 5))
# Tabview
self.tabview = ctk.CTkTabview(main, command=self._on_tab_changed)
# Initialize tab tracking
self.tabview = None
self._tab_keys = []
self._tab_instances = {}
# Build default SSH tab set
self._rebuild_tabs(TAB_REGISTRY["ssh"])
def _rebuild_tabs(self, tab_keys: list[str], restore_tab_key: str | None = None):
"""Destroy current tabview and rebuild with the given tab keys."""
# Remember current active tab
if restore_tab_key is None:
restore_tab_key = self._get_current_tab_key() if self._tab_keys else None
# Destroy old tab instances
for key, widget in self._tab_instances.items():
try:
widget.pack_forget()
widget.destroy()
except Exception:
pass
self._tab_instances = {}
# Destroy old tabview
if self.tabview is not None:
try:
self.tabview.destroy()
except Exception:
pass
# Store new tab key list
self._tab_keys = list(tab_keys)
# Create new tabview
self.tabview = ctk.CTkTabview(self._main_frame, command=self._on_tab_changed)
self.tabview.pack(fill="both", expand=True, padx=10, pady=10)
# Tab names stored for language updates
self._tab_keys = ["terminal", "files", "info", "keys", "totp", "setup"]
for key in self._tab_keys:
self.tabview.add(t(key))
self.terminal_tab = TerminalTab(self.tabview.tab(t("terminal")), self.store, self.session_pool)
self.terminal_tab.pack(fill="both", expand=True)
# Create tab instances using TAB_CLASSES factory
for key in self._tab_keys:
cls = TAB_CLASSES.get(key)
if cls is None:
continue
parent = self.tabview.tab(t(key))
widget = self._create_tab_instance(cls, key, parent)
widget.pack(fill="both", expand=True)
self._tab_instances[key] = widget
self.files_tab = FilesTab(self.tabview.tab(t("files")), self.store, self.session_pool)
self.files_tab.pack(fill="both", expand=True)
# Restore previously active tab if still available
if restore_tab_key and restore_tab_key in self._tab_keys:
try:
self.tabview.set(t(restore_tab_key))
except Exception:
pass
self.info_tab = InfoTab(self.tabview.tab(t("info")), self.store, edit_callback=self._edit_server)
self.info_tab.pack(fill="both", expand=True)
self.keys_tab = KeysTab(self.tabview.tab(t("keys")), self.store)
self.keys_tab.pack(fill="both", expand=True)
self.totp_tab = TOTPTab(self.tabview.tab(t("totp")), self.store)
self.totp_tab.pack(fill="both", expand=True)
self.setup_tab = SetupTab(self.tabview.tab(t("setup")), self.store)
self.setup_tab.pack(fill="both", expand=True)
def _create_tab_instance(self, cls, key: str, parent):
"""Create a tab widget instance with the correct constructor args."""
if cls in (TerminalTab, FilesTab):
return cls(parent, self.store, self.session_pool)
elif cls is InfoTab:
return cls(parent, self.store, edit_callback=self._edit_server)
elif cls is SetupTab:
return cls(parent, self.store)
elif cls in (KeysTab, TOTPTab):
return cls(parent, self.store)
else:
# QueryTab, RedisTab, GrafanaTab, PrometheusTab, PowershellTab, LaunchTab
return cls(parent, self.store)
def _on_server_select(self, alias: str):
self.terminal_tab.set_server(alias)
self.files_tab.set_server(alias)
self.info_tab.set_server(alias)
self.keys_tab.set_server(alias)
self.totp_tab.set_server(alias)
# Determine server type and required tabs
if alias:
server = self.store.get_server(alias)
server_type = server.get("type", "ssh") if server else "ssh"
else:
server_type = "ssh"
new_tab_keys = TAB_REGISTRY.get(server_type, TAB_REGISTRY["ssh"])
# Rebuild tabs only if the tab set changed
if new_tab_keys != self._tab_keys:
self._rebuild_tabs(new_tab_keys)
# Notify each tab instance about the selected server
for key, widget in self._tab_instances.items():
if hasattr(widget, "set_server"):
widget.set_server(alias)
# Update session indicators after a short delay (connection is async)
self.after(1500, self.sidebar.update_session_indicators)
@@ -143,7 +237,9 @@ class App(ctk.CTk):
self.sidebar._select(new_alias)
self.session_pool.rename_server(alias, new_alias)
else:
self.info_tab.refresh()
info = self._tab_instances.get("info")
if info and hasattr(info, "refresh"):
info.refresh()
def _delete_server(self, alias: str):
if messagebox.askyesno(t("delete_server"), t("delete_confirm").format(alias=alias)):
@@ -155,7 +251,9 @@ class App(ctk.CTk):
def _on_status_update(self):
self.sidebar.update_statuses()
self.sidebar.update_session_indicators()
self.info_tab.refresh()
info = self._tab_instances.get("info")
if info and hasattr(info, "refresh"):
info.refresh()
def _show_about(self):
AboutDialog(self)
@@ -190,76 +288,42 @@ class App(ctk.CTk):
# Remember selected server
alias = self.sidebar.get_selected()
# Use provided key or default to first tab
current_key = restore_tab_key or self._tab_keys[0]
current_key = restore_tab_key or (self._tab_keys[0] if self._tab_keys else "terminal")
# Save state before destroying tabs
saved_remote_path = self.files_tab._remote_path
saved_local_path = self.files_tab._local_path
had_sftp = self.files_tab._sftp is not None and self.files_tab._sftp.connected
# Save FilesTab state if it exists
files_tab = self._tab_instances.get("files")
saved_remote_path = None
saved_local_path = None
had_sftp = False
if files_tab:
saved_remote_path = files_tab._remote_path
saved_local_path = files_tab._local_path
had_sftp = files_tab._sftp is not None and files_tab._sftp.connected
# Disconnect all sessions in the pool
self.session_pool.disconnect_all()
# Detach tab contents
self.terminal_tab.pack_forget()
self.files_tab.pack_forget()
self.info_tab.pack_forget()
self.keys_tab.pack_forget()
self.totp_tab.pack_forget()
self.setup_tab.pack_forget()
# Rebuild tabs with translated names (same tab keys, just new language)
self._rebuild_tabs(self._tab_keys, restore_tab_key=current_key)
# Get the main frame and destroy old tabview
main = self.tabview.master
self.tabview.destroy()
# Create new tabview with translated names
self.tabview = ctk.CTkTabview(main, command=self._on_tab_changed)
self.tabview.pack(fill="both", expand=True, padx=10, pady=10)
for key in self._tab_keys:
self.tabview.add(t(key))
# Re-parent tab contents
self.terminal_tab = TerminalTab(self.tabview.tab(t("terminal")), self.store, self.session_pool)
self.terminal_tab.pack(fill="both", expand=True)
self.files_tab = FilesTab(self.tabview.tab(t("files")), self.store, self.session_pool)
self.files_tab.pack(fill="both", expand=True)
self.info_tab = InfoTab(self.tabview.tab(t("info")), self.store, edit_callback=self._edit_server)
self.info_tab.pack(fill="both", expand=True)
self.keys_tab = KeysTab(self.tabview.tab(t("keys")), self.store)
self.keys_tab.pack(fill="both", expand=True)
self.totp_tab = TOTPTab(self.tabview.tab(t("totp")), self.store)
self.totp_tab.pack(fill="both", expand=True)
self.setup_tab = SetupTab(self.tabview.tab(t("setup")), self.store)
self.setup_tab.pack(fill="both", expand=True)
# Restore active tab by key
try:
self.tabview.set(t(current_key))
except Exception:
pass
# Restore file paths and reconnect properly
self.files_tab._local_path = saved_local_path
self.files_tab._refresh_local()
# Restore FilesTab state if it exists in new tab set
files_tab = self._tab_instances.get("files")
if files_tab:
files_tab._local_path = saved_local_path
files_tab._refresh_local()
if alias and had_sftp:
# Had active SFTP — reconnect and restore remote path
self.files_tab._remote_path = saved_remote_path
self.files_tab.set_server(alias)
files_tab._remote_path = saved_remote_path
files_tab.set_server(alias)
elif alias:
self.files_tab.set_server(alias)
files_tab.set_server(alias)
# Restore server selection for other tabs (terminal auto-reconnects)
# Restore server selection for all other tabs
if alias:
self.terminal_tab.set_server(alias)
self.info_tab.set_server(alias)
self.keys_tab.set_server(alias)
self.totp_tab.set_server(alias)
for key, widget in self._tab_instances.items():
if key == "files":
continue # Already handled above
if hasattr(widget, "set_server"):
widget.set_server(alias)
# Update sidebar
self.sidebar.update_language()
@@ -367,14 +431,22 @@ class App(ctk.CTk):
"""Handle tab switch — manage terminal focus."""
try:
current = self.tabview.get()
if current == t("terminal"):
self.terminal_tab._terminal.focus_terminal()
terminal = self._tab_instances.get("terminal")
if terminal and current == t("terminal"):
terminal._terminal.focus_terminal()
else:
self.focus_set()
except Exception:
pass
def _on_close(self):
# Clean up tab instances
for key, widget in self._tab_instances.items():
if hasattr(widget, "on_close"):
try:
widget.on_close()
except Exception:
pass
# Disconnect all sessions before closing
self.session_pool.disconnect_all()
self.checker.stop()

View File

@@ -1,5 +1,6 @@
"""
Server add/edit dialog — modal window with all server fields.
Form adapts visible fields based on selected server type.
"""
import customtkinter as ctk
@@ -7,6 +8,24 @@ from core.server_store import SERVER_TYPES, DEFAULT_PORTS
from core.i18n import t
# Which conditional fields to show for each server type.
# Fields NOT listed here (alias, ip, type+port, skip_check, notes, buttons)
# are always visible.
FIELD_MAP = {
"ssh": ["user", "password", "totp", "bind_interface"],
"telnet": ["user", "password"],
"winrm": ["user", "password", "use_ssl"],
"mariadb": ["user", "password", "database"],
"mssql": ["user", "password", "database"],
"postgresql": ["user", "password", "database"],
"redis": ["password", "db_index"],
"grafana": ["api_token", "use_ssl"],
"prometheus": ["use_ssl"],
"rdp": ["user", "password"],
"vnc": ["password"],
}
def _get_network_interfaces() -> list[tuple[str, str]]:
"""Return list of (name, ipv4_address) for available network interfaces."""
try:
@@ -30,30 +49,31 @@ class ServerDialog(ctk.CTkToplevel):
self.result = None
self.title(t("edit_server") if server else t("add_server"))
self.geometry("450x680")
self.geometry("450x720")
self.resizable(False, False)
self.grab_set()
# Center on parent
self.transient(master)
self._field_frames: dict[str, ctk.CTkFrame] = {}
self._build_ui(server)
def _build_ui(self, server: dict | None):
pad = {"padx": 20, "pady": (5, 0)}
entry_pad = {"padx": 20, "pady": (2, 5)}
# Alias
# ── Always visible: Alias ──
ctk.CTkLabel(self, text=t("alias"), anchor="w").pack(fill="x", **pad)
self.alias_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_alias"))
self.alias_entry.pack(fill="x", **entry_pad)
# IP
# ── Always visible: IP ──
ctk.CTkLabel(self, text=t("ip"), anchor="w").pack(fill="x", **pad)
self.ip_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_ip"))
self.ip_entry.pack(fill="x", **entry_pad)
# Type + Port row
# ── Always visible: Type + Port row ──
row = ctk.CTkFrame(self, fg_color="transparent")
row.pack(fill="x", padx=20, pady=(5, 5))
@@ -73,9 +93,13 @@ class ServerDialog(ctk.CTkToplevel):
self.port_entry = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port"))
self.port_entry.pack(fill="x")
# Network interface
ctk.CTkLabel(self, text=t("network_interface"), anchor="w").pack(fill="x", **pad)
self._iface_map: dict[str, str] = {} # display_name -> ip
# ── Conditional fields container — all packed here, shown/hidden dynamically ──
# We use self as parent but wrap each field group in a frame for easy show/hide.
# --- bind_interface ---
f = ctk.CTkFrame(self, fg_color="transparent")
ctk.CTkLabel(f, text=t("network_interface"), anchor="w").pack(fill="x", **pad)
self._iface_map: dict[str, str] = {}
ifaces = _get_network_interfaces()
auto_label = t("auto_default")
iface_values = [auto_label]
@@ -84,43 +108,78 @@ class ServerDialog(ctk.CTkToplevel):
iface_values.append(label)
self._iface_map[label] = ip
self._iface_var = ctk.StringVar(value=auto_label)
self._iface_menu = ctk.CTkOptionMenu(self, values=iface_values, variable=self._iface_var)
self._iface_menu = ctk.CTkOptionMenu(f, values=iface_values, variable=self._iface_var)
self._iface_menu.pack(fill="x", **entry_pad)
self._field_frames["bind_interface"] = f
# User
ctk.CTkLabel(self, text=t("username"), anchor="w").pack(fill="x", **pad)
self.user_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_user"))
# --- user ---
f = ctk.CTkFrame(self, fg_color="transparent")
ctk.CTkLabel(f, text=t("username"), anchor="w").pack(fill="x", **pad)
self.user_entry = ctk.CTkEntry(f, placeholder_text=t("placeholder_user"))
self.user_entry.pack(fill="x", **entry_pad)
self._field_frames["user"] = f
# Password
ctk.CTkLabel(self, text=t("password"), anchor="w").pack(fill="x", **pad)
pass_frame = ctk.CTkFrame(self, fg_color="transparent")
pass_frame.pack(fill="x", padx=20, pady=(2, 5))
self.password_entry = ctk.CTkEntry(pass_frame, show="*", placeholder_text=t("placeholder_password"))
# --- password ---
f = ctk.CTkFrame(self, fg_color="transparent")
ctk.CTkLabel(f, text=t("password"), anchor="w").pack(fill="x", **pad)
pass_inner = ctk.CTkFrame(f, fg_color="transparent")
pass_inner.pack(fill="x", padx=20, pady=(2, 5))
self.password_entry = ctk.CTkEntry(pass_inner, show="*", placeholder_text=t("placeholder_password"))
self.password_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
self.show_pass = ctk.CTkButton(pass_frame, text=t("show"), width=60, command=self._toggle_password)
self.show_pass = ctk.CTkButton(pass_inner, text=t("show"), width=60, command=self._toggle_password)
self.show_pass.pack(side="right")
self._pass_visible = False
self._field_frames["password"] = f
# TOTP Secret
ctk.CTkLabel(self, text=t("totp_secret_dialog"), anchor="w").pack(fill="x", **pad)
self.totp_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_totp_secret"),
# --- totp ---
f = ctk.CTkFrame(self, fg_color="transparent")
ctk.CTkLabel(f, text=t("totp_secret_dialog"), anchor="w").pack(fill="x", **pad)
self.totp_entry = ctk.CTkEntry(f, placeholder_text=t("placeholder_totp_secret"),
font=ctk.CTkFont(family="Consolas", size=12))
self.totp_entry.pack(fill="x", **entry_pad)
self._field_frames["totp"] = f
# Skip status checks
# --- database ---
f = ctk.CTkFrame(self, fg_color="transparent")
ctk.CTkLabel(f, text=t("database"), anchor="w").pack(fill="x", **pad)
self.database_entry = ctk.CTkEntry(f, placeholder_text="mydb")
self.database_entry.pack(fill="x", **entry_pad)
self._field_frames["database"] = f
# --- db_index ---
f = ctk.CTkFrame(self, fg_color="transparent")
ctk.CTkLabel(f, text=t("db_index"), anchor="w").pack(fill="x", **pad)
self.db_index_entry = ctk.CTkEntry(f, placeholder_text="0")
self.db_index_entry.pack(fill="x", **entry_pad)
self._field_frames["db_index"] = f
# --- api_token ---
f = ctk.CTkFrame(self, fg_color="transparent")
ctk.CTkLabel(f, text=t("api_token"), anchor="w").pack(fill="x", **pad)
self.api_token_entry = ctk.CTkEntry(f, show="*", placeholder_text=t("placeholder_api_token"))
self.api_token_entry.pack(fill="x", **entry_pad)
self._field_frames["api_token"] = f
# --- use_ssl ---
f = ctk.CTkFrame(self, fg_color="transparent")
self.use_ssl_var = ctk.BooleanVar(value=False)
self.use_ssl_cb = ctk.CTkCheckBox(f, text=t("use_ssl"), variable=self.use_ssl_var)
self.use_ssl_cb.pack(fill="x", padx=20, pady=(8, 2))
self._field_frames["use_ssl"] = f
# ── Always visible: Skip status checks ──
self.skip_check_var = ctk.BooleanVar(value=False)
self.skip_check_cb = ctk.CTkCheckBox(
self, text=t("skip_check"), variable=self.skip_check_var
)
self.skip_check_cb.pack(fill="x", padx=20, pady=(8, 2))
# Notes
# ── Always visible: Notes ──
ctk.CTkLabel(self, text=t("notes"), anchor="w").pack(fill="x", **pad)
self.notes_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_notes"))
self.notes_entry.pack(fill="x", **entry_pad)
# Buttons
# ── Always visible: Buttons ──
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=20, pady=(15, 20))
ctk.CTkButton(btn_frame, text=t("cancel"), fg_color="#6b7280", command=self.destroy).pack(side="left", expand=True, padx=(0, 5))
@@ -137,6 +196,10 @@ class ServerDialog(ctk.CTkToplevel):
self.totp_entry.insert(0, server.get("totp_secret", ""))
self.skip_check_var.set(server.get("skip_check", False))
self.notes_entry.insert(0, server.get("notes", ""))
self.database_entry.insert(0, server.get("database", ""))
self.db_index_entry.insert(0, str(server.get("db_index", "")))
self.api_token_entry.insert(0, server.get("api_token", ""))
self.use_ssl_var.set(server.get("use_ssl", False))
# Restore network interface selection
saved_ip = server.get("bind_interface")
@@ -155,10 +218,23 @@ class ServerDialog(ctk.CTkToplevel):
self._iface_menu.configure(values=current_values)
self._iface_var.set(unavail_label)
# Apply field visibility for initial type
self._apply_field_visibility(self.type_var.get())
def _apply_field_visibility(self, server_type: str):
"""Hide all conditional fields, then show only those for the given type."""
visible = set(FIELD_MAP.get(server_type, []))
for name, frame in self._field_frames.items():
if name in visible:
frame.pack(fill="x", before=self.skip_check_cb)
else:
frame.pack_forget()
def _on_type_change(self, value):
default_port = DEFAULT_PORTS.get(value, 22)
self.port_entry.delete(0, "end")
self.port_entry.insert(0, str(default_port))
self._apply_field_visibility(value)
def _toggle_password(self):
self._pass_visible = not self._pass_visible
@@ -211,6 +287,32 @@ class ServerDialog(ctk.CTkToplevel):
if bind_ip:
server_data["bind_interface"] = bind_ip
# New conditional fields
visible = set(FIELD_MAP.get(server_type, []))
if "database" in visible:
db = self.database_entry.get().strip()
if db:
server_data["database"] = db
if "db_index" in visible:
db_idx = self.db_index_entry.get().strip()
if db_idx:
try:
server_data["db_index"] = int(db_idx)
except ValueError:
self._show_error(t("db_index_must_be_number"))
return
if "api_token" in visible:
token = self.api_token_entry.get().strip()
if token:
server_data["api_token"] = token
if "use_ssl" in visible:
if self.use_ssl_var.get():
server_data["use_ssl"] = True
try:
if self.editing:
if alias != self._original_alias and self.store.get_server(alias):

View File

@@ -6,6 +6,34 @@ import customtkinter as ctk
from core.i18n import t
from gui.widgets.status_badge import StatusBadge
TYPE_COLORS = {
"ssh": "#22c55e",
"telnet": "#a855f7",
"rdp": "#3b82f6",
"vnc": "#6366f1",
"winrm": "#0ea5e9",
"mariadb": "#f59e0b",
"mssql": "#ef4444",
"postgresql": "#3b82f6",
"redis": "#dc2626",
"grafana": "#f97316",
"prometheus": "#e11d48",
}
TYPE_LABELS = {
"ssh": "SSH",
"telnet": "TEL",
"rdp": "RDP",
"vnc": "VNC",
"winrm": "PS",
"mariadb": "MDB",
"mssql": "SQL",
"postgresql": "PG",
"redis": "RDS",
"grafana": "GRF",
"prometheus": "PRM",
}
class Sidebar(ctk.CTkFrame):
def __init__(self, master, store, on_select=None, session_pool=None):
@@ -101,6 +129,17 @@ class Sidebar(ctk.CTkFrame):
badge.pack(side="left", padx=(10, 5), pady=10)
self._badges[alias] = badge
# Type badge (colored short label)
type_color = TYPE_COLORS.get(stype, "#6b7280")
type_label_text = TYPE_LABELS.get(stype, stype.upper()[:3])
type_badge = ctk.CTkLabel(
frame, text=type_label_text,
font=ctk.CTkFont(size=9, weight="bold"),
text_color=type_color,
width=30
)
type_badge.pack(side="left", padx=(0, 2), pady=10)
# Active session indicator (right side)
session_ind = ctk.CTkLabel(
frame, text="", width=12, height=12,
@@ -117,12 +156,11 @@ class Sidebar(ctk.CTkFrame):
name_label = ctk.CTkLabel(info, text=alias, font=ctk.CTkFont(size=13, weight="bold"), anchor="w")
name_label.pack(fill="x")
detail = f"{ip} [{stype}]"
detail_label = ctk.CTkLabel(info, text=detail, font=ctk.CTkFont(size=10), text_color="#9ca3af", anchor="w")
detail_label = ctk.CTkLabel(info, text=ip, font=ctk.CTkFont(size=10), text_color="#9ca3af", anchor="w")
detail_label.pack(fill="x")
# Click handlers
for widget in [frame, info, name_label, detail_label, badge, session_ind]:
for widget in [frame, info, name_label, detail_label, badge, type_badge, session_ind]:
widget.bind("<Button-1>", lambda e, a=alias: self._select(a))
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):
# Map field keys to i18n keys
_FIELD_KEYS = ["alias", "ip", "port", "user", "type", "notes", "status"]
_FIELD_KEYS = ["alias", "ip", "port", "user", "type", "database", "db_index", "ssl", "notes", "status"]
_FIELD_I18N = {
"alias": "info_alias",
"ip": "info_ip",
"port": "info_port",
"user": "info_user",
"type": "info_type",
"database": "info_database",
"db_index": "info_db_index",
"ssl": "info_ssl",
"notes": "info_notes",
"status": "info_status",
}
# Which fields are relevant per server type
_SQL_TYPES = {"mariadb", "mssql", "postgresql"}
_SSL_TYPES = {"grafana", "prometheus", "winrm"}
_NO_USER_TYPES = {"redis", "grafana", "prometheus"}
def __init__(self, master, store, edit_callback=None):
super().__init__(master, fg_color="transparent")
self.store = store
@@ -65,12 +73,39 @@ class InfoTab(ctk.CTkFrame):
if not server:
return
stype = server.get("type", "ssh").lower()
self.header.configure(text=server["alias"])
self._fields["alias"].configure(text=server.get("alias", "-"))
self._fields["ip"].configure(text=server.get("ip", "-"))
self._fields["port"].configure(text=str(server.get("port", 22)))
# 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=server.get("type", "ssh").upper())
self._fields["type"].configure(text=stype.upper())
# Database field — relevant for SQL types
if stype in self._SQL_TYPES:
self._fields["database"].configure(text=server.get("database", "-"))
else:
self._fields["database"].configure(text="-")
# DB index — relevant for redis
if stype == "redis":
self._fields["db_index"].configure(text=str(server.get("db_index", 0)))
else:
self._fields["db_index"].configure(text="-")
# SSL — relevant for grafana, prometheus, winrm
if stype in self._SSL_TYPES:
self._fields["ssl"].configure(text="Yes" if server.get("use_ssl") else "No")
else:
self._fields["ssl"].configure(text="-")
self._fields["notes"].configure(text=server.get("notes", "-") or "-")
status = self.store.get_status(self._current_alias)

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

View File

@@ -5,3 +5,10 @@ cryptography>=41.0.0
pyotp>=2.9.0
pyte>=0.8.1
psutil>=5.9.0
pymysql>=1.1.0
psycopg2-binary>=2.9.9
pymssql>=2.2.8
redis>=5.0.0
requests>=2.31.0
pywinrm>=0.4.3
telnetlib3>=2.0.0

View File

@@ -1,6 +1,7 @@
# Скилл /ssh — управление удалёнными серверами
Ты управляешь удалёнными серверами через SSH-утилиту.
Ты управляешь удалёнными серверами через универсальную CLI-утилиту.
Поддерживаются: SSH, SQL (MariaDB/MSSQL/PostgreSQL), Redis, Grafana, Prometheus, WinRM (PowerShell/CMD).
## ВАЖНО — Безопасность
@@ -18,7 +19,7 @@
Пользователь передаёт через `$ARGUMENTS`. Разбери и выполни.
## Команды
## Общие команды
### Список серверов (безопасный — alias, тип, ключ, заметки)
```bash
@@ -35,6 +36,19 @@ python ~/.server-connections/ssh.py --info ALIAS
python ~/.server-connections/ssh.py --status
```
### Обновить заметки сервера
```bash
python ~/.server-connections/ssh.py --set-note ALIAS "описание сервера"
```
### Удалить сервер
```bash
python ~/.server-connections/ssh.py --remove ALIAS
```
**Спроси подтверждение у пользователя перед удалением!**
## SSH-команды (тип: ssh)
### Выполнить команду на сервере
```bash
python ~/.server-connections/ssh.py ALIAS "command"
@@ -68,30 +82,98 @@ python ~/.server-connections/ssh.py ALIAS --install-key
python ~/.server-connections/ssh.py ALIAS --ping
```
### Обновить заметки сервера
```bash
python ~/.server-connections/ssh.py --set-note ALIAS "описание сервера"
```
Используй чтобы сохранить контекст: что на сервере работает, для чего он нужен.
## SQL-команды (типы: mariadb, mssql, postgresql)
### Удалить сервер
### Выполнить SQL-запрос
```bash
python ~/.server-connections/ssh.py --remove ALIAS
python ~/.server-connections/ssh.py --sql ALIAS "SELECT * FROM users LIMIT 10"
```
**Спроси подтверждение у пользователя перед удалением!**
## Альтернативный способ (только если SSH-ключ установлен)
### Список баз данных
```bash
python ~/.server-connections/ssh.py --sql-databases ALIAS
```
### Список таблиц
```bash
python ~/.server-connections/ssh.py --sql-tables ALIAS
python ~/.server-connections/ssh.py --sql-tables ALIAS mydb
```
## Redis-команды (тип: redis)
### Выполнить Redis-команду
```bash
python ~/.server-connections/ssh.py --redis ALIAS "GET mykey"
python ~/.server-connections/ssh.py --redis ALIAS "SET mykey myvalue"
```
### Информация о Redis
```bash
python ~/.server-connections/ssh.py --redis-info ALIAS
```
### Поиск ключей (SCAN)
```bash
python ~/.server-connections/ssh.py --redis-keys ALIAS "user:*"
```
## Grafana-команды (тип: grafana)
### Список дашбордов
```bash
python ~/.server-connections/ssh.py --grafana-dashboards ALIAS
```
### Список оповещений
```bash
python ~/.server-connections/ssh.py --grafana-alerts ALIAS
```
## Prometheus-команды (тип: prometheus)
### Выполнить PromQL-запрос
```bash
python ~/.server-connections/ssh.py --prom-query ALIAS "up"
python ~/.server-connections/ssh.py --prom-query ALIAS "rate(http_requests_total[5m])"
```
### Список целей (targets)
```bash
python ~/.server-connections/ssh.py --prom-targets ALIAS
```
### Список оповещений
```bash
python ~/.server-connections/ssh.py --prom-alerts ALIAS
```
## WinRM-команды (тип: winrm)
### PowerShell
```bash
python ~/.server-connections/ssh.py --ps ALIAS "Get-Process"
python ~/.server-connections/ssh.py --ps ALIAS "Get-Service | Where-Object {$_.Status -eq 'Running'}"
```
### CMD
```bash
python ~/.server-connections/ssh.py --cmd ALIAS "dir C:\\"
```
## Альтернативный способ (только SSH с установленным ключом)
```bash
unset SSH_ASKPASS && unset DISPLAY && ssh ALIAS "command"
```
## Поведение
- **Auto-sudo**: если user на сервере не root — команды автоматически оборачиваются в `sudo -S`, пароль подаётся через stdin. Тебе НЕ нужно добавлять `sudo` в команду
- **--no-sudo**: если команда не требует root (например `ls`, `cat`), используй `--no-sudo` для скорости
- **Timeout**: 120 секунд на команду, 15 секунд на подключение
- **Auto-sudo** (SSH): если user на сервере не root — команды автоматически оборачиваются в `sudo -S`, пароль подаётся через stdin. Тебе НЕ нужно добавлять `sudo` в команду
- **--no-sudo** (SSH): если команда не требует root (например `ls`, `cat`), используй `--no-sudo` для скорости
- **Timeout**: 120 секунд на SSH-команду, 10 секунд на SQL/Redis/HTTP-запросы, 15 секунд на подключение
- **SSH-ключ**: пробуется первым, fallback на пароль если ключ не подходит
- **Прогресс**: upload/download файлов >=1MB показывают 25/50/75% milestone, итог с размером/временем/скоростью
- **Тип сервера**: определяется автоматически из конфигурации. `--list` показывает тип каждого сервера
## Правила
@@ -101,3 +183,5 @@ unset SSH_ASKPASS && unset DISPLAY && ssh ALIAS "command"
- Если timeout — предложи проверить VPN/firewall/панель хостера
- Файлы создаваемые на сервере должны иметь права 664 (owner+group rw)
- При вопросе о серверах — СНАЧАЛА `--list`, потом `--info ALIAS` если нужны детали
- SQL-запросы: используй `LIMIT` для больших таблиц, чтобы не перегружать вывод
- Redis: используй SCAN, а не KEYS для больших баз

View File

@@ -3,7 +3,7 @@
SSH utility for Claude Code — connects to servers by alias.
Credentials stored locally in servers.json (encrypted), NEVER exposed to AI API.
Usage:
Usage (SSH):
python ssh.py ALIAS "command" # run as configured user (auto-sudo if needed)
python ssh.py ALIAS --no-sudo "command" # run without sudo elevation
python ssh.py ALIAS --upload LOCAL REMOTE
@@ -16,6 +16,29 @@ Usage:
python ssh.py --set-note ALIAS "desc" # update server notes
python ssh.py --add ALIAS IP PORT USER PASSWORD [--note "desc"]
python ssh.py --remove ALIAS
SQL (type: mariadb / mssql / postgresql):
python ssh.py --sql ALIAS "SELECT * FROM users" # execute SQL query
python ssh.py --sql-databases ALIAS # list databases
python ssh.py --sql-tables ALIAS [database] # list tables
Redis (type: redis):
python ssh.py --redis ALIAS "GET mykey" # execute Redis command
python ssh.py --redis-info ALIAS # Redis INFO
python ssh.py --redis-keys ALIAS "user:*" # SCAN keys by pattern
Grafana (type: grafana):
python ssh.py --grafana-dashboards ALIAS # list dashboards
python ssh.py --grafana-alerts ALIAS # list alerts
Prometheus (type: prometheus):
python ssh.py --prom-query ALIAS "up" # execute PromQL query
python ssh.py --prom-targets ALIAS # list targets
python ssh.py --prom-alerts ALIAS # list alerts
WinRM (type: winrm):
python ssh.py --ps ALIAS "Get-Process" # PowerShell via WinRM
python ssh.py --cmd ALIAS "dir" # CMD via WinRM
"""
import sys
@@ -464,6 +487,415 @@ def remove_from_ssh_config(alias):
f.writelines(new_lines)
# ── SQL commands ──────────────────────────────────────
def _print_table(headers: list, rows: list):
"""Print a formatted ASCII table."""
if not rows:
print("(no rows)")
return
widths = [len(str(h)) for h in headers]
for row in rows:
for i, val in enumerate(row):
widths[i] = max(widths[i], len(str(val)))
fmt = " ".join(f"{{:<{w}}}" for w in widths)
print(fmt.format(*headers))
print(" ".join("-" * w for w in widths))
for row in rows:
print(fmt.format(*[str(v) for v in row]))
def run_sql(server: dict, query: str):
"""Execute SQL query against mariadb/mssql/postgresql server."""
stype = server.get("type", "mariadb")
host = server["ip"]
port = server.get("port", 3306)
user = server.get("user", "root")
password = server.get("password", "")
database = server.get("database", "")
if stype in ("mariadb", "mysql"):
import pymysql
conn = pymysql.connect(host=host, port=port, user=user, password=password,
database=database or None, connect_timeout=15,
charset="utf8mb4", cursorclass=pymysql.cursors.Cursor)
elif stype == "mssql":
import pymssql
conn = pymssql.connect(server=host, port=port, user=user, password=password,
database=database or None, login_timeout=15)
elif stype == "postgresql":
import psycopg2
port = server.get("port", 5432)
conn = psycopg2.connect(host=host, port=port, user=user, password=password,
dbname=database or None, connect_timeout=15)
else:
print(f"ERROR: Unsupported SQL type '{stype}'. Use mariadb, mssql, or postgresql.")
sys.exit(1)
try:
cur = conn.cursor()
cur.execute(query)
if cur.description:
headers = [desc[0] for desc in cur.description]
rows = cur.fetchall()
_print_table(headers, rows)
print(f"\n({len(rows)} row{'s' if len(rows) != 1 else ''})")
else:
conn.commit()
affected = cur.rowcount
print(f"OK: {affected} row{'s' if affected != 1 else ''} affected")
cur.close()
finally:
conn.close()
def sql_databases(server: dict):
"""List databases on SQL server."""
stype = server.get("type", "mariadb")
if stype in ("mariadb", "mysql"):
run_sql(server, "SHOW DATABASES")
elif stype == "mssql":
run_sql(server, "SELECT name FROM sys.databases ORDER BY name")
elif stype == "postgresql":
run_sql(server, "SELECT datname AS database FROM pg_database WHERE datistemplate = false ORDER BY datname")
else:
print(f"ERROR: Unsupported SQL type '{stype}'.")
sys.exit(1)
def sql_tables(server: dict, database: str = None):
"""List tables on SQL server, optionally for a specific database."""
stype = server.get("type", "mariadb")
if database:
server = dict(server)
server["database"] = database
if stype in ("mariadb", "mysql"):
if database:
run_sql(server, f"SHOW TABLES FROM `{database}`")
else:
run_sql(server, "SHOW TABLES")
elif stype == "mssql":
run_sql(server, "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA, TABLE_NAME")
elif stype == "postgresql":
run_sql(server, "SELECT schemaname, tablename FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema') ORDER BY schemaname, tablename")
else:
print(f"ERROR: Unsupported SQL type '{stype}'.")
sys.exit(1)
# ── Redis commands ────────────────────────────────────
def run_redis_cmd(server: dict, command: str):
"""Execute a Redis command."""
import redis as redis_lib
host = server["ip"]
port = server.get("port", 6379)
password = server.get("password", "") or None
db_index = server.get("db_index", 0)
ssl_enabled = server.get("ssl", False)
r = redis_lib.Redis(host=host, port=port, password=password, db=db_index,
decode_responses=True, socket_timeout=10, ssl=ssl_enabled)
try:
parts = command.split()
if not parts:
print("ERROR: Empty Redis command")
sys.exit(1)
result = r.execute_command(*parts)
if isinstance(result, list):
for i, item in enumerate(result):
print(f"{i + 1}) {item}")
print(f"\n({len(result)} items)")
elif isinstance(result, dict):
for k, v in result.items():
print(f"{k}: {v}")
elif isinstance(result, bytes):
print(result.decode("utf-8", errors="replace"))
else:
print(result)
finally:
r.close()
def redis_info(server: dict):
"""Show Redis INFO."""
import redis as redis_lib
host = server["ip"]
port = server.get("port", 6379)
password = server.get("password", "") or None
db_index = server.get("db_index", 0)
ssl_enabled = server.get("ssl", False)
r = redis_lib.Redis(host=host, port=port, password=password, db=db_index,
decode_responses=True, socket_timeout=10, ssl=ssl_enabled)
try:
info = r.info()
# Print key sections
sections = ["redis_version", "redis_mode", "os", "uptime_in_seconds",
"connected_clients", "used_memory_human", "used_memory_peak_human",
"total_connections_received", "total_commands_processed",
"keyspace_hits", "keyspace_misses", "role"]
print(f"{'Key':<35} {'Value'}")
print("-" * 60)
for key in sections:
if key in info:
print(f"{key:<35} {info[key]}")
# Print keyspace info (db0, db1, etc.)
for key in sorted(info.keys()):
if key.startswith("db"):
print(f"{key:<35} {info[key]}")
finally:
r.close()
def redis_keys(server: dict, pattern: str):
"""SCAN keys matching a pattern."""
import redis as redis_lib
host = server["ip"]
port = server.get("port", 6379)
password = server.get("password", "") or None
db_index = server.get("db_index", 0)
ssl_enabled = server.get("ssl", False)
r = redis_lib.Redis(host=host, port=port, password=password, db=db_index,
decode_responses=True, socket_timeout=10, ssl=ssl_enabled)
try:
keys = []
cursor = 0
while True:
cursor, batch = r.scan(cursor=cursor, match=pattern, count=200)
keys.extend(batch)
if cursor == 0:
break
if len(keys) >= 1000:
print("(truncated at 1000 keys)")
break
keys.sort()
for k in keys:
print(k)
print(f"\n({len(keys)} key{'s' if len(keys) != 1 else ''})")
finally:
r.close()
# ── Grafana commands ──────────────────────────────────
def _grafana_request(server: dict, endpoint: str) -> dict:
"""Make an authenticated GET request to Grafana API."""
import requests
host = server["ip"]
port = server.get("port", 3000)
protocol = "https" if server.get("ssl", False) else "http"
base_url = server.get("base_url", f"{protocol}://{host}:{port}")
api_key = server.get("api_key", server.get("password", ""))
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
url = f"{base_url.rstrip('/')}/api/{endpoint.lstrip('/')}"
resp = requests.get(url, headers=headers, timeout=15, verify=server.get("ssl_verify", True))
resp.raise_for_status()
return resp.json()
def grafana_dashboards(server: dict):
"""List Grafana dashboards."""
data = _grafana_request(server, "search?type=dash-db")
if not data:
print("(no dashboards found)")
return
headers = ["UID", "Title", "Folder", "URL"]
rows = []
for d in data:
rows.append([
d.get("uid", ""),
d.get("title", ""),
d.get("folderTitle", "(root)"),
d.get("url", ""),
])
_print_table(headers, rows)
print(f"\n({len(rows)} dashboard{'s' if len(rows) != 1 else ''})")
def grafana_alerts(server: dict):
"""List Grafana alert rules."""
data = _grafana_request(server, "alertmanager/grafana/api/v2/alerts")
if not data:
print("(no alerts)")
return
headers = ["Status", "Name", "Severity", "Summary"]
rows = []
for alert in data:
status = alert.get("status", {}).get("state", "unknown")
labels = alert.get("labels", {})
annotations = alert.get("annotations", {})
rows.append([
status,
labels.get("alertname", ""),
labels.get("severity", ""),
annotations.get("summary", "")[:80],
])
_print_table(headers, rows)
print(f"\n({len(rows)} alert{'s' if len(rows) != 1 else ''})")
# ── Prometheus commands ───────────────────────────────
def _prom_request(server: dict, endpoint: str, params: dict = None) -> dict:
"""Make a GET request to Prometheus API."""
import requests
host = server["ip"]
port = server.get("port", 9090)
protocol = "https" if server.get("ssl", False) else "http"
base_url = server.get("base_url", f"{protocol}://{host}:{port}")
auth = None
user = server.get("user", "")
password = server.get("password", "")
if user and password:
auth = (user, password)
url = f"{base_url.rstrip('/')}/api/v1/{endpoint.lstrip('/')}"
resp = requests.get(url, params=params, auth=auth, timeout=15,
verify=server.get("ssl_verify", True))
resp.raise_for_status()
return resp.json()
def prom_query(server: dict, query: str):
"""Execute a PromQL instant query."""
data = _prom_request(server, "query", {"query": query})
status = data.get("status", "")
if status != "success":
print(f"ERROR: Prometheus returned status '{status}'")
if "error" in data:
print(f" {data['error']}")
sys.exit(1)
result = data.get("data", {})
result_type = result.get("resultType", "")
results = result.get("result", [])
if not results:
print("(no results)")
return
if result_type == "vector":
headers = ["Metric", "Value", "Timestamp"]
rows = []
for r in results:
metric = r.get("metric", {})
label_str = ", ".join(f'{k}="{v}"' for k, v in metric.items())
ts, val = r.get("value", [0, ""])
rows.append([label_str or "{}", val, ts])
_print_table(headers, rows)
elif result_type == "scalar":
ts, val = results
print(f"Scalar: {val} (at {ts})")
elif result_type == "string":
ts, val = results
print(f"String: {val} (at {ts})")
elif result_type == "matrix":
for series in results:
metric = series.get("metric", {})
label_str = ", ".join(f'{k}="{v}"' for k, v in metric.items())
print(f"\n--- {label_str or '{}'} ---")
values = series.get("values", [])
for ts, val in values[-20:]: # last 20 samples
print(f" [{ts}] {val}")
if len(values) > 20:
print(f" ... ({len(values)} total samples, showing last 20)")
print(f"\n({len(results)} result{'s' if len(results) != 1 else ''}, type: {result_type})")
def prom_targets(server: dict):
"""List Prometheus scrape targets."""
data = _prom_request(server, "targets")
active = data.get("data", {}).get("activeTargets", [])
if not active:
print("(no active targets)")
return
headers = ["Job", "Instance", "State", "Health", "Last Scrape"]
rows = []
for t in active:
labels = t.get("labels", {})
rows.append([
labels.get("job", ""),
labels.get("instance", ""),
t.get("scrapePool", ""),
t.get("health", ""),
t.get("lastScrape", "")[:19],
])
_print_table(headers, rows)
print(f"\n({len(rows)} target{'s' if len(rows) != 1 else ''})")
def prom_alerts(server: dict):
"""List Prometheus alerts."""
data = _prom_request(server, "alerts")
alerts = data.get("data", {}).get("alerts", [])
if not alerts:
print("(no alerts)")
return
headers = ["State", "Name", "Severity", "Active Since"]
rows = []
for a in alerts:
labels = a.get("labels", {})
rows.append([
a.get("state", ""),
labels.get("alertname", ""),
labels.get("severity", ""),
a.get("activeAt", "")[:19],
])
_print_table(headers, rows)
print(f"\n({len(rows)} alert{'s' if len(rows) != 1 else ''})")
# ── WinRM commands ────────────────────────────────────
def _get_winrm_session(server: dict):
"""Create a WinRM session."""
import winrm
host = server["ip"]
port = server.get("port", 5985)
user = server.get("user", "Administrator")
password = server.get("password", "")
protocol = "https" if server.get("ssl", False) or port == 5986 else "http"
transport = server.get("transport", "ntlm")
endpoint = f"{protocol}://{host}:{port}/wsman"
session = winrm.Session(endpoint, auth=(user, password), transport=transport,
server_cert_validation="ignore" if protocol == "https" else "validate")
return session
def run_winrm_ps(server: dict, command: str):
"""Execute PowerShell command via WinRM."""
session = _get_winrm_session(server)
result = session.run_ps(command)
out = result.std_out.decode("utf-8", errors="replace").strip()
err = result.std_err.decode("utf-8", errors="replace").strip()
if out:
print(out)
if err:
print(err, file=sys.stderr)
sys.exit(result.status_code)
def run_winrm_cmd(server: dict, command: str):
"""Execute CMD command via WinRM."""
session = _get_winrm_session(server)
result = session.run_cmd(command)
out = result.std_out.decode("utf-8", errors="replace").strip()
err = result.std_err.decode("utf-8", errors="replace").strip()
if out:
print(out)
if err:
print(err, file=sys.stderr)
sys.exit(result.status_code)
# ── Main ──────────────────────────────────────────────
def main():
@@ -488,6 +920,80 @@ def main():
if cmd == "--remove" and len(sys.argv) >= 3:
remove_server(sys.argv[2]); sys.exit(0)
# ── SQL commands (global-style: --sql ALIAS ...) ──
if cmd == "--sql" and len(sys.argv) >= 4:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
run_sql(servers[alias], sys.argv[3])
sys.exit(0)
if cmd == "--sql-databases" and len(sys.argv) >= 3:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
sql_databases(servers[alias])
sys.exit(0)
if cmd == "--sql-tables" and len(sys.argv) >= 3:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
db = sys.argv[3] if len(sys.argv) >= 4 else None
sql_tables(servers[alias], db)
sys.exit(0)
# ── Redis commands ──
if cmd == "--redis" and len(sys.argv) >= 4:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
run_redis_cmd(servers[alias], sys.argv[3])
sys.exit(0)
if cmd == "--redis-info" and len(sys.argv) >= 3:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
redis_info(servers[alias])
sys.exit(0)
if cmd == "--redis-keys" and len(sys.argv) >= 4:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
redis_keys(servers[alias], sys.argv[3])
sys.exit(0)
# ── Grafana commands ──
if cmd == "--grafana-dashboards" and len(sys.argv) >= 3:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
grafana_dashboards(servers[alias])
sys.exit(0)
if cmd == "--grafana-alerts" and len(sys.argv) >= 3:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
grafana_alerts(servers[alias])
sys.exit(0)
# ── Prometheus commands ──
if cmd == "--prom-query" and len(sys.argv) >= 4:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
prom_query(servers[alias], sys.argv[3])
sys.exit(0)
if cmd == "--prom-targets" and len(sys.argv) >= 3:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
prom_targets(servers[alias])
sys.exit(0)
if cmd == "--prom-alerts" and len(sys.argv) >= 3:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
prom_alerts(servers[alias])
sys.exit(0)
# ── WinRM commands ──
if cmd == "--ps" and len(sys.argv) >= 4:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
run_winrm_ps(servers[alias], sys.argv[3])
if cmd == "--cmd" and len(sys.argv) >= 4:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
run_winrm_cmd(servers[alias], sys.argv[3])
# Server commands — exact match first, then fuzzy search by keyword
alias = cmd
_, servers = load_servers()