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:
9
build.py
9
build.py
@@ -111,13 +111,20 @@ def build():
|
||||
if os.path.exists(icon_path):
|
||||
cmd_parts.extend(["--icon", icon_path])
|
||||
|
||||
# Hidden imports for customtkinter
|
||||
# Hidden imports for customtkinter and connection libraries
|
||||
cmd_parts.extend([
|
||||
"--hidden-import", "customtkinter",
|
||||
"--hidden-import", "PIL",
|
||||
"--hidden-import", "pyotp",
|
||||
"--hidden-import", "pyte",
|
||||
"--hidden-import", "psutil",
|
||||
"--hidden-import", "pymysql",
|
||||
"--hidden-import", "psycopg2",
|
||||
"--hidden-import", "pymssql",
|
||||
"--hidden-import", "redis",
|
||||
"--hidden-import", "requests",
|
||||
"--hidden-import", "winrm",
|
||||
"--hidden-import", "telnetlib3",
|
||||
"--collect-all", "customtkinter",
|
||||
])
|
||||
|
||||
|
||||
@@ -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
170
core/grafana_client.py
Normal 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 {}
|
||||
321
core/i18n.py
321
core/i18n.py
@@ -233,6 +233,12 @@ _EN = {
|
||||
"totp_secret_dialog": "TOTP Secret",
|
||||
"placeholder_totp_secret": "Base32 secret (optional)",
|
||||
"port_out_of_range": "Port must be 1-65535",
|
||||
"database": "Database",
|
||||
"db_index": "DB Index",
|
||||
"api_token": "API Token",
|
||||
"placeholder_api_token": "Bearer token or API key",
|
||||
"use_ssl": "Use SSL / HTTPS",
|
||||
"db_index_must_be_number": "DB index must be a number",
|
||||
|
||||
# Monitoring
|
||||
"monitoring": "Monitoring",
|
||||
@@ -289,6 +295,107 @@ _EN = {
|
||||
"recursive_delete_confirm": "Delete folder '{name}' and all contents?",
|
||||
"drive": "Drive",
|
||||
"active_sessions": "Active: {count}",
|
||||
|
||||
# Tab names (new server types)
|
||||
"query": "Query",
|
||||
"console": "Console",
|
||||
"dashboards": "Dashboards",
|
||||
"alerts": "Alerts",
|
||||
"metrics": "Metrics",
|
||||
"targets": "Targets",
|
||||
"powershell": "PowerShell",
|
||||
"launch": "Connect",
|
||||
|
||||
# Server dialog fields (new types)
|
||||
"database": "Database",
|
||||
"placeholder_database": "mydb",
|
||||
"db_index": "DB Index (0-15)",
|
||||
"placeholder_db_index": "0",
|
||||
"api_token": "API Token",
|
||||
"placeholder_api_token": "Token...",
|
||||
"use_ssl": "Use SSL/TLS",
|
||||
|
||||
# Query tab
|
||||
"query_execute": "Execute (F5)",
|
||||
"query_clear": "Clear",
|
||||
"query_export_csv": "Export CSV",
|
||||
"query_database": "Database:",
|
||||
"query_editor_placeholder": "Enter SQL query...",
|
||||
"query_status_rows": "{rows} rows | {elapsed}s",
|
||||
"query_error": "Error: {error}",
|
||||
"query_no_results": "Query executed, no results",
|
||||
"query_connected": "Connected to {alias} ({db})",
|
||||
"query_connecting": "Connecting...",
|
||||
"query_disconnected": "Not connected",
|
||||
"query_exported": "Exported to {path}",
|
||||
|
||||
# Redis tab
|
||||
"redis_execute": "Execute",
|
||||
"redis_db": "DB:",
|
||||
"redis_keys_count": "Keys: {count}",
|
||||
"redis_memory": "Mem: {mem}",
|
||||
"redis_prompt": "redis>",
|
||||
"redis_connected": "Connected to {alias}",
|
||||
"redis_connecting": "Connecting...",
|
||||
"redis_disconnected": "Not connected",
|
||||
"redis_error": "Error: {error}",
|
||||
|
||||
# Grafana tab
|
||||
"grafana_dashboards": "Dashboards",
|
||||
"grafana_alerts": "Alerts",
|
||||
"grafana_uid": "UID",
|
||||
"grafana_title": "Title",
|
||||
"grafana_folder": "Folder",
|
||||
"grafana_state": "State",
|
||||
"grafana_name": "Name",
|
||||
"grafana_severity": "Severity",
|
||||
"grafana_connected": "Connected to {alias}",
|
||||
"grafana_no_dashboards": "No dashboards found",
|
||||
"grafana_no_alerts": "No alerts",
|
||||
|
||||
# Prometheus tab
|
||||
"prom_query": "PromQL Query",
|
||||
"prom_execute": "Execute",
|
||||
"prom_targets": "Targets",
|
||||
"prom_alerts": "Alerts",
|
||||
"prom_job": "Job",
|
||||
"prom_instance": "Instance",
|
||||
"prom_health": "Health",
|
||||
"prom_last_scrape": "Last Scrape",
|
||||
"prom_connected": "Connected to {alias}",
|
||||
"prom_no_targets": "No targets",
|
||||
"prom_no_alerts": "No alerts",
|
||||
"prom_placeholder": "up",
|
||||
|
||||
# PowerShell tab
|
||||
"ps_execute": "Execute",
|
||||
"ps_mode_ps": "PowerShell",
|
||||
"ps_mode_cmd": "CMD",
|
||||
"ps_placeholder_ps": "Get-Process...",
|
||||
"ps_placeholder_cmd": "dir...",
|
||||
"ps_history_empty": "No command history",
|
||||
"ps_disconnected": "Not connected",
|
||||
"ps_connecting": "Connecting...",
|
||||
"ps_connected": "Connected to {alias}",
|
||||
"ps_connect_failed": "Connection failed: {error}",
|
||||
"ps_not_connected": "Not connected to server",
|
||||
"ps_running": "Running...",
|
||||
"ps_done": "Done",
|
||||
"ps_exec_error": "Error: {error}",
|
||||
|
||||
# Launch tab
|
||||
"launch_connect": "Connect",
|
||||
"launch_rdp_info": "Remote Desktop (RDP) to {alias}",
|
||||
"launch_vnc_info": "VNC connection to {alias}",
|
||||
"launch_started": "Client launched",
|
||||
"launch_starting": "Launching...",
|
||||
"launch_error": "Launch failed: {error}",
|
||||
"launch_no_server": "Select a server to connect",
|
||||
|
||||
# Info tab type-specific
|
||||
"info_database": "Database:",
|
||||
"info_ssl": "SSL:",
|
||||
"info_db_index": "DB Index:",
|
||||
}
|
||||
|
||||
_RU = {
|
||||
@@ -499,6 +606,12 @@ _RU = {
|
||||
"totp_secret_dialog": "TOTP-секрет",
|
||||
"placeholder_totp_secret": "Base32 секрет (необязательно)",
|
||||
"port_out_of_range": "Порт должен быть от 1 до 65535",
|
||||
"database": "База данных",
|
||||
"db_index": "Индекс БД",
|
||||
"api_token": "API-токен",
|
||||
"placeholder_api_token": "Bearer-токен или API-ключ",
|
||||
"use_ssl": "Использовать SSL / HTTPS",
|
||||
"db_index_must_be_number": "Индекс БД должен быть числом",
|
||||
|
||||
# Monitoring
|
||||
"monitoring": "Мониторинг",
|
||||
@@ -555,6 +668,107 @@ _RU = {
|
||||
"recursive_delete_confirm": "Удалить папку '{name}' со всем содержимым?",
|
||||
"drive": "Диск",
|
||||
"active_sessions": "Активных: {count}",
|
||||
|
||||
# Tab names (new server types)
|
||||
"query": "Запросы",
|
||||
"console": "Консоль",
|
||||
"dashboards": "Дашборды",
|
||||
"alerts": "Оповещения",
|
||||
"metrics": "Метрики",
|
||||
"targets": "Цели",
|
||||
"powershell": "PowerShell",
|
||||
"launch": "Подключение",
|
||||
|
||||
# Server dialog fields (new types)
|
||||
"database": "База данных",
|
||||
"placeholder_database": "mydb",
|
||||
"db_index": "Индекс БД (0-15)",
|
||||
"placeholder_db_index": "0",
|
||||
"api_token": "API-токен",
|
||||
"placeholder_api_token": "Токен...",
|
||||
"use_ssl": "Использовать SSL/TLS",
|
||||
|
||||
# Query tab
|
||||
"query_execute": "Выполнить (F5)",
|
||||
"query_clear": "Очистить",
|
||||
"query_export_csv": "Экспорт CSV",
|
||||
"query_database": "База данных:",
|
||||
"query_editor_placeholder": "Введите SQL запрос...",
|
||||
"query_status_rows": "{rows} строк | {elapsed}с",
|
||||
"query_error": "Ошибка: {error}",
|
||||
"query_no_results": "Запрос выполнен, нет результатов",
|
||||
"query_connected": "Подключено к {alias} ({db})",
|
||||
"query_connecting": "Подключение...",
|
||||
"query_disconnected": "Не подключено",
|
||||
"query_exported": "Экспортировано в {path}",
|
||||
|
||||
# Redis tab
|
||||
"redis_execute": "Выполнить",
|
||||
"redis_db": "БД:",
|
||||
"redis_keys_count": "Ключей: {count}",
|
||||
"redis_memory": "Память: {mem}",
|
||||
"redis_prompt": "redis>",
|
||||
"redis_connected": "Подключено к {alias}",
|
||||
"redis_connecting": "Подключение...",
|
||||
"redis_disconnected": "Не подключено",
|
||||
"redis_error": "Ошибка: {error}",
|
||||
|
||||
# Grafana tab
|
||||
"grafana_dashboards": "Дашборды",
|
||||
"grafana_alerts": "Оповещения",
|
||||
"grafana_uid": "UID",
|
||||
"grafana_title": "Название",
|
||||
"grafana_folder": "Папка",
|
||||
"grafana_state": "Состояние",
|
||||
"grafana_name": "Имя",
|
||||
"grafana_severity": "Серьёзность",
|
||||
"grafana_connected": "Подключено к {alias}",
|
||||
"grafana_no_dashboards": "Дашборды не найдены",
|
||||
"grafana_no_alerts": "Нет оповещений",
|
||||
|
||||
# Prometheus tab
|
||||
"prom_query": "PromQL запрос",
|
||||
"prom_execute": "Выполнить",
|
||||
"prom_targets": "Цели",
|
||||
"prom_alerts": "Оповещения",
|
||||
"prom_job": "Job",
|
||||
"prom_instance": "Инстанс",
|
||||
"prom_health": "Здоровье",
|
||||
"prom_last_scrape": "Последний опрос",
|
||||
"prom_connected": "Подключено к {alias}",
|
||||
"prom_no_targets": "Нет целей",
|
||||
"prom_no_alerts": "Нет оповещений",
|
||||
"prom_placeholder": "up",
|
||||
|
||||
# PowerShell tab
|
||||
"ps_execute": "Выполнить",
|
||||
"ps_mode_ps": "PowerShell",
|
||||
"ps_mode_cmd": "CMD",
|
||||
"ps_placeholder_ps": "Get-Process...",
|
||||
"ps_placeholder_cmd": "dir...",
|
||||
"ps_history_empty": "Нет истории команд",
|
||||
"ps_disconnected": "Не подключено",
|
||||
"ps_connecting": "Подключение...",
|
||||
"ps_connected": "Подключено к {alias}",
|
||||
"ps_connect_failed": "Ошибка подключения: {error}",
|
||||
"ps_not_connected": "Нет подключения к серверу",
|
||||
"ps_running": "Выполнение...",
|
||||
"ps_done": "Готово",
|
||||
"ps_exec_error": "Ошибка: {error}",
|
||||
|
||||
# Launch tab
|
||||
"launch_connect": "Подключиться",
|
||||
"launch_rdp_info": "Удалённый рабочий стол (RDP) к {alias}",
|
||||
"launch_vnc_info": "VNC-подключение к {alias}",
|
||||
"launch_started": "Клиент запущен",
|
||||
"launch_starting": "Запуск...",
|
||||
"launch_error": "Ошибка запуска: {error}",
|
||||
"launch_no_server": "Выберите сервер для подключения",
|
||||
|
||||
# Info tab type-specific
|
||||
"info_database": "База данных:",
|
||||
"info_ssl": "SSL:",
|
||||
"info_db_index": "Индекс БД:",
|
||||
}
|
||||
|
||||
_ZH = {
|
||||
@@ -765,6 +979,12 @@ _ZH = {
|
||||
"totp_secret_dialog": "TOTP密钥",
|
||||
"placeholder_totp_secret": "Base32密钥(可选)",
|
||||
"port_out_of_range": "端口必须在1-65535之间",
|
||||
"database": "数据库",
|
||||
"db_index": "数据库索引",
|
||||
"api_token": "API令牌",
|
||||
"placeholder_api_token": "Bearer令牌或API密钥",
|
||||
"use_ssl": "使用 SSL / HTTPS",
|
||||
"db_index_must_be_number": "数据库索引必须是数字",
|
||||
|
||||
# Monitoring
|
||||
"monitoring": "监控",
|
||||
@@ -821,6 +1041,107 @@ _ZH = {
|
||||
"recursive_delete_confirm": "删除文件夹 '{name}' 及所有内容?",
|
||||
"drive": "驱动器",
|
||||
"active_sessions": "活跃: {count}",
|
||||
|
||||
# Tab names (new server types)
|
||||
"query": "查询",
|
||||
"console": "控制台",
|
||||
"dashboards": "仪表盘",
|
||||
"alerts": "告警",
|
||||
"metrics": "指标",
|
||||
"targets": "目标",
|
||||
"powershell": "PowerShell",
|
||||
"launch": "连接",
|
||||
|
||||
# Server dialog fields (new types)
|
||||
"database": "数据库",
|
||||
"placeholder_database": "mydb",
|
||||
"db_index": "数据库索引 (0-15)",
|
||||
"placeholder_db_index": "0",
|
||||
"api_token": "API令牌",
|
||||
"placeholder_api_token": "令牌...",
|
||||
"use_ssl": "使用SSL/TLS",
|
||||
|
||||
# Query tab
|
||||
"query_execute": "执行 (F5)",
|
||||
"query_clear": "清除",
|
||||
"query_export_csv": "导出CSV",
|
||||
"query_database": "数据库:",
|
||||
"query_editor_placeholder": "输入SQL查询...",
|
||||
"query_status_rows": "{rows} 行 | {elapsed}秒",
|
||||
"query_error": "错误: {error}",
|
||||
"query_no_results": "查询已执行,无结果",
|
||||
"query_connected": "已连接到 {alias} ({db})",
|
||||
"query_connecting": "连接中...",
|
||||
"query_disconnected": "未连接",
|
||||
"query_exported": "已导出到 {path}",
|
||||
|
||||
# Redis tab
|
||||
"redis_execute": "执行",
|
||||
"redis_db": "数据库:",
|
||||
"redis_keys_count": "键数: {count}",
|
||||
"redis_memory": "内存: {mem}",
|
||||
"redis_prompt": "redis>",
|
||||
"redis_connected": "已连接到 {alias}",
|
||||
"redis_connecting": "连接中...",
|
||||
"redis_disconnected": "未连接",
|
||||
"redis_error": "错误: {error}",
|
||||
|
||||
# Grafana tab
|
||||
"grafana_dashboards": "仪表盘",
|
||||
"grafana_alerts": "告警",
|
||||
"grafana_uid": "UID",
|
||||
"grafana_title": "标题",
|
||||
"grafana_folder": "文件夹",
|
||||
"grafana_state": "状态",
|
||||
"grafana_name": "名称",
|
||||
"grafana_severity": "严重程度",
|
||||
"grafana_connected": "已连接到 {alias}",
|
||||
"grafana_no_dashboards": "未找到仪表盘",
|
||||
"grafana_no_alerts": "无告警",
|
||||
|
||||
# Prometheus tab
|
||||
"prom_query": "PromQL查询",
|
||||
"prom_execute": "执行",
|
||||
"prom_targets": "目标",
|
||||
"prom_alerts": "告警",
|
||||
"prom_job": "任务",
|
||||
"prom_instance": "实例",
|
||||
"prom_health": "健康",
|
||||
"prom_last_scrape": "最后抓取",
|
||||
"prom_connected": "已连接到 {alias}",
|
||||
"prom_no_targets": "无目标",
|
||||
"prom_no_alerts": "无告警",
|
||||
"prom_placeholder": "up",
|
||||
|
||||
# PowerShell tab
|
||||
"ps_execute": "执行",
|
||||
"ps_mode_ps": "PowerShell",
|
||||
"ps_mode_cmd": "CMD",
|
||||
"ps_placeholder_ps": "Get-Process...",
|
||||
"ps_placeholder_cmd": "dir...",
|
||||
"ps_history_empty": "无命令历史",
|
||||
"ps_disconnected": "未连接",
|
||||
"ps_connecting": "连接中...",
|
||||
"ps_connected": "已连接到 {alias}",
|
||||
"ps_connect_failed": "连接失败: {error}",
|
||||
"ps_not_connected": "未连接到服务器",
|
||||
"ps_running": "执行中...",
|
||||
"ps_done": "完成",
|
||||
"ps_exec_error": "错误: {error}",
|
||||
|
||||
# Launch tab
|
||||
"launch_connect": "连接",
|
||||
"launch_rdp_info": "远程桌面 (RDP) 到 {alias}",
|
||||
"launch_vnc_info": "VNC连接到 {alias}",
|
||||
"launch_started": "客户端已启动",
|
||||
"launch_starting": "启动中...",
|
||||
"launch_error": "启动失败: {error}",
|
||||
"launch_no_server": "选择服务器以连接",
|
||||
|
||||
# Info tab type-specific
|
||||
"info_database": "数据库:",
|
||||
"info_ssl": "SSL:",
|
||||
"info_db_index": "数据库索引:",
|
||||
}
|
||||
|
||||
_TRANSLATIONS = {
|
||||
|
||||
153
core/prometheus_client.py
Normal file
153
core/prometheus_client.py
Normal 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
171
core/redis_client.py
Normal 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
124
core/remote_desktop.py
Normal 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}"
|
||||
@@ -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
197
core/sql_client.py
Normal 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"
|
||||
@@ -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
180
core/telnet_client.py
Normal 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
115
core/winrm_client.py
Normal 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
|
||||
258
gui/app.py
258
gui/app.py
@@ -20,6 +20,43 @@ from gui.tabs.info_tab import InfoTab
|
||||
from gui.tabs.keys_tab import KeysTab
|
||||
from gui.tabs.setup_tab import SetupTab
|
||||
from gui.tabs.totp_tab import TOTPTab
|
||||
from gui.tabs.query_tab import QueryTab
|
||||
from gui.tabs.redis_tab import RedisTab
|
||||
from gui.tabs.grafana_tab import GrafanaTab
|
||||
from gui.tabs.prometheus_tab import PrometheusTab
|
||||
from gui.tabs.powershell_tab import PowershellTab
|
||||
from gui.tabs.launch_tab import LaunchTab
|
||||
|
||||
# Tab sets per server type — determines which tabs are shown
|
||||
TAB_REGISTRY = {
|
||||
"ssh": ["terminal", "files", "info", "keys", "totp", "setup"],
|
||||
"telnet": ["terminal", "info", "setup"],
|
||||
"winrm": ["powershell", "info", "setup"],
|
||||
"mariadb": ["query", "info", "setup"],
|
||||
"mssql": ["query", "info", "setup"],
|
||||
"postgresql": ["query", "info", "setup"],
|
||||
"redis": ["console", "info", "setup"],
|
||||
"grafana": ["dashboards", "info", "setup"],
|
||||
"prometheus": ["metrics", "info", "setup"],
|
||||
"rdp": ["launch", "info", "setup"],
|
||||
"vnc": ["launch", "info", "setup"],
|
||||
}
|
||||
|
||||
# Map tab key → widget class (used as lazy factory)
|
||||
TAB_CLASSES = {
|
||||
"terminal": TerminalTab,
|
||||
"files": FilesTab,
|
||||
"info": InfoTab,
|
||||
"keys": KeysTab,
|
||||
"totp": TOTPTab,
|
||||
"setup": SetupTab,
|
||||
"query": QueryTab,
|
||||
"console": RedisTab,
|
||||
"dashboards": GrafanaTab,
|
||||
"metrics": PrometheusTab,
|
||||
"powershell": PowershellTab,
|
||||
"launch": LaunchTab,
|
||||
}
|
||||
|
||||
|
||||
class App(ctk.CTk):
|
||||
@@ -67,11 +104,11 @@ class App(ctk.CTk):
|
||||
self.sidebar.delete_callback = self._delete_server
|
||||
|
||||
# Main area
|
||||
main = ctk.CTkFrame(self, fg_color="transparent")
|
||||
main.pack(side="right", fill="both", expand=True)
|
||||
self._main_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
self._main_frame.pack(side="right", fill="both", expand=True)
|
||||
|
||||
# Header bar (language + about)
|
||||
header_bar = ctk.CTkFrame(main, fg_color="transparent", height=40)
|
||||
header_bar = ctk.CTkFrame(self._main_frame, fg_color="transparent", height=40)
|
||||
header_bar.pack(fill="x", padx=10, pady=(8, 0))
|
||||
header_bar.pack_propagate(False)
|
||||
|
||||
@@ -93,39 +130,96 @@ class App(ctk.CTk):
|
||||
)
|
||||
self.about_btn.pack(side="right", padx=(5, 5))
|
||||
|
||||
# Tabview
|
||||
self.tabview = ctk.CTkTabview(main, command=self._on_tab_changed)
|
||||
# Initialize tab tracking
|
||||
self.tabview = None
|
||||
self._tab_keys = []
|
||||
self._tab_instances = {}
|
||||
|
||||
# Build default SSH tab set
|
||||
self._rebuild_tabs(TAB_REGISTRY["ssh"])
|
||||
|
||||
def _rebuild_tabs(self, tab_keys: list[str], restore_tab_key: str | None = None):
|
||||
"""Destroy current tabview and rebuild with the given tab keys."""
|
||||
# Remember current active tab
|
||||
if restore_tab_key is None:
|
||||
restore_tab_key = self._get_current_tab_key() if self._tab_keys else None
|
||||
|
||||
# Destroy old tab instances
|
||||
for key, widget in self._tab_instances.items():
|
||||
try:
|
||||
widget.pack_forget()
|
||||
widget.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
self._tab_instances = {}
|
||||
|
||||
# Destroy old tabview
|
||||
if self.tabview is not None:
|
||||
try:
|
||||
self.tabview.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Store new tab key list
|
||||
self._tab_keys = list(tab_keys)
|
||||
|
||||
# Create new tabview
|
||||
self.tabview = ctk.CTkTabview(self._main_frame, command=self._on_tab_changed)
|
||||
self.tabview.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
|
||||
# Tab names stored for language updates
|
||||
self._tab_keys = ["terminal", "files", "info", "keys", "totp", "setup"]
|
||||
for key in self._tab_keys:
|
||||
self.tabview.add(t(key))
|
||||
|
||||
self.terminal_tab = TerminalTab(self.tabview.tab(t("terminal")), self.store, self.session_pool)
|
||||
self.terminal_tab.pack(fill="both", expand=True)
|
||||
# Create tab instances using TAB_CLASSES factory
|
||||
for key in self._tab_keys:
|
||||
cls = TAB_CLASSES.get(key)
|
||||
if cls is None:
|
||||
continue
|
||||
parent = self.tabview.tab(t(key))
|
||||
widget = self._create_tab_instance(cls, key, parent)
|
||||
widget.pack(fill="both", expand=True)
|
||||
self._tab_instances[key] = widget
|
||||
|
||||
self.files_tab = FilesTab(self.tabview.tab(t("files")), self.store, self.session_pool)
|
||||
self.files_tab.pack(fill="both", expand=True)
|
||||
# Restore previously active tab if still available
|
||||
if restore_tab_key and restore_tab_key in self._tab_keys:
|
||||
try:
|
||||
self.tabview.set(t(restore_tab_key))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.info_tab = InfoTab(self.tabview.tab(t("info")), self.store, edit_callback=self._edit_server)
|
||||
self.info_tab.pack(fill="both", expand=True)
|
||||
|
||||
self.keys_tab = KeysTab(self.tabview.tab(t("keys")), self.store)
|
||||
self.keys_tab.pack(fill="both", expand=True)
|
||||
|
||||
self.totp_tab = TOTPTab(self.tabview.tab(t("totp")), self.store)
|
||||
self.totp_tab.pack(fill="both", expand=True)
|
||||
|
||||
self.setup_tab = SetupTab(self.tabview.tab(t("setup")), self.store)
|
||||
self.setup_tab.pack(fill="both", expand=True)
|
||||
def _create_tab_instance(self, cls, key: str, parent):
|
||||
"""Create a tab widget instance with the correct constructor args."""
|
||||
if cls in (TerminalTab, FilesTab):
|
||||
return cls(parent, self.store, self.session_pool)
|
||||
elif cls is InfoTab:
|
||||
return cls(parent, self.store, edit_callback=self._edit_server)
|
||||
elif cls is SetupTab:
|
||||
return cls(parent, self.store)
|
||||
elif cls in (KeysTab, TOTPTab):
|
||||
return cls(parent, self.store)
|
||||
else:
|
||||
# QueryTab, RedisTab, GrafanaTab, PrometheusTab, PowershellTab, LaunchTab
|
||||
return cls(parent, self.store)
|
||||
|
||||
def _on_server_select(self, alias: str):
|
||||
self.terminal_tab.set_server(alias)
|
||||
self.files_tab.set_server(alias)
|
||||
self.info_tab.set_server(alias)
|
||||
self.keys_tab.set_server(alias)
|
||||
self.totp_tab.set_server(alias)
|
||||
# Determine server type and required tabs
|
||||
if alias:
|
||||
server = self.store.get_server(alias)
|
||||
server_type = server.get("type", "ssh") if server else "ssh"
|
||||
else:
|
||||
server_type = "ssh"
|
||||
|
||||
new_tab_keys = TAB_REGISTRY.get(server_type, TAB_REGISTRY["ssh"])
|
||||
|
||||
# Rebuild tabs only if the tab set changed
|
||||
if new_tab_keys != self._tab_keys:
|
||||
self._rebuild_tabs(new_tab_keys)
|
||||
|
||||
# Notify each tab instance about the selected server
|
||||
for key, widget in self._tab_instances.items():
|
||||
if hasattr(widget, "set_server"):
|
||||
widget.set_server(alias)
|
||||
|
||||
# Update session indicators after a short delay (connection is async)
|
||||
self.after(1500, self.sidebar.update_session_indicators)
|
||||
|
||||
@@ -143,7 +237,9 @@ class App(ctk.CTk):
|
||||
self.sidebar._select(new_alias)
|
||||
self.session_pool.rename_server(alias, new_alias)
|
||||
else:
|
||||
self.info_tab.refresh()
|
||||
info = self._tab_instances.get("info")
|
||||
if info and hasattr(info, "refresh"):
|
||||
info.refresh()
|
||||
|
||||
def _delete_server(self, alias: str):
|
||||
if messagebox.askyesno(t("delete_server"), t("delete_confirm").format(alias=alias)):
|
||||
@@ -155,7 +251,9 @@ class App(ctk.CTk):
|
||||
def _on_status_update(self):
|
||||
self.sidebar.update_statuses()
|
||||
self.sidebar.update_session_indicators()
|
||||
self.info_tab.refresh()
|
||||
info = self._tab_instances.get("info")
|
||||
if info and hasattr(info, "refresh"):
|
||||
info.refresh()
|
||||
|
||||
def _show_about(self):
|
||||
AboutDialog(self)
|
||||
@@ -190,76 +288,42 @@ class App(ctk.CTk):
|
||||
# Remember selected server
|
||||
alias = self.sidebar.get_selected()
|
||||
# Use provided key or default to first tab
|
||||
current_key = restore_tab_key or self._tab_keys[0]
|
||||
current_key = restore_tab_key or (self._tab_keys[0] if self._tab_keys else "terminal")
|
||||
|
||||
# Save state before destroying tabs
|
||||
saved_remote_path = self.files_tab._remote_path
|
||||
saved_local_path = self.files_tab._local_path
|
||||
had_sftp = self.files_tab._sftp is not None and self.files_tab._sftp.connected
|
||||
# Save FilesTab state if it exists
|
||||
files_tab = self._tab_instances.get("files")
|
||||
saved_remote_path = None
|
||||
saved_local_path = None
|
||||
had_sftp = False
|
||||
if files_tab:
|
||||
saved_remote_path = files_tab._remote_path
|
||||
saved_local_path = files_tab._local_path
|
||||
had_sftp = files_tab._sftp is not None and files_tab._sftp.connected
|
||||
|
||||
# Disconnect all sessions in the pool
|
||||
self.session_pool.disconnect_all()
|
||||
|
||||
# Detach tab contents
|
||||
self.terminal_tab.pack_forget()
|
||||
self.files_tab.pack_forget()
|
||||
self.info_tab.pack_forget()
|
||||
self.keys_tab.pack_forget()
|
||||
self.totp_tab.pack_forget()
|
||||
self.setup_tab.pack_forget()
|
||||
# Rebuild tabs with translated names (same tab keys, just new language)
|
||||
self._rebuild_tabs(self._tab_keys, restore_tab_key=current_key)
|
||||
|
||||
# Get the main frame and destroy old tabview
|
||||
main = self.tabview.master
|
||||
self.tabview.destroy()
|
||||
# Restore FilesTab state if it exists in new tab set
|
||||
files_tab = self._tab_instances.get("files")
|
||||
if files_tab:
|
||||
files_tab._local_path = saved_local_path
|
||||
files_tab._refresh_local()
|
||||
if alias and had_sftp:
|
||||
files_tab._remote_path = saved_remote_path
|
||||
files_tab.set_server(alias)
|
||||
elif alias:
|
||||
files_tab.set_server(alias)
|
||||
|
||||
# Create new tabview with translated names
|
||||
self.tabview = ctk.CTkTabview(main, command=self._on_tab_changed)
|
||||
self.tabview.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
|
||||
for key in self._tab_keys:
|
||||
self.tabview.add(t(key))
|
||||
|
||||
# Re-parent tab contents
|
||||
self.terminal_tab = TerminalTab(self.tabview.tab(t("terminal")), self.store, self.session_pool)
|
||||
self.terminal_tab.pack(fill="both", expand=True)
|
||||
|
||||
self.files_tab = FilesTab(self.tabview.tab(t("files")), self.store, self.session_pool)
|
||||
self.files_tab.pack(fill="both", expand=True)
|
||||
|
||||
self.info_tab = InfoTab(self.tabview.tab(t("info")), self.store, edit_callback=self._edit_server)
|
||||
self.info_tab.pack(fill="both", expand=True)
|
||||
|
||||
self.keys_tab = KeysTab(self.tabview.tab(t("keys")), self.store)
|
||||
self.keys_tab.pack(fill="both", expand=True)
|
||||
|
||||
self.totp_tab = TOTPTab(self.tabview.tab(t("totp")), self.store)
|
||||
self.totp_tab.pack(fill="both", expand=True)
|
||||
|
||||
self.setup_tab = SetupTab(self.tabview.tab(t("setup")), self.store)
|
||||
self.setup_tab.pack(fill="both", expand=True)
|
||||
|
||||
# Restore active tab by key
|
||||
try:
|
||||
self.tabview.set(t(current_key))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Restore file paths and reconnect properly
|
||||
self.files_tab._local_path = saved_local_path
|
||||
self.files_tab._refresh_local()
|
||||
if alias and had_sftp:
|
||||
# Had active SFTP — reconnect and restore remote path
|
||||
self.files_tab._remote_path = saved_remote_path
|
||||
self.files_tab.set_server(alias)
|
||||
elif alias:
|
||||
self.files_tab.set_server(alias)
|
||||
|
||||
# Restore server selection for other tabs (terminal auto-reconnects)
|
||||
# Restore server selection for all other tabs
|
||||
if alias:
|
||||
self.terminal_tab.set_server(alias)
|
||||
self.info_tab.set_server(alias)
|
||||
self.keys_tab.set_server(alias)
|
||||
self.totp_tab.set_server(alias)
|
||||
for key, widget in self._tab_instances.items():
|
||||
if key == "files":
|
||||
continue # Already handled above
|
||||
if hasattr(widget, "set_server"):
|
||||
widget.set_server(alias)
|
||||
|
||||
# Update sidebar
|
||||
self.sidebar.update_language()
|
||||
@@ -367,14 +431,22 @@ class App(ctk.CTk):
|
||||
"""Handle tab switch — manage terminal focus."""
|
||||
try:
|
||||
current = self.tabview.get()
|
||||
if current == t("terminal"):
|
||||
self.terminal_tab._terminal.focus_terminal()
|
||||
terminal = self._tab_instances.get("terminal")
|
||||
if terminal and current == t("terminal"):
|
||||
terminal._terminal.focus_terminal()
|
||||
else:
|
||||
self.focus_set()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_close(self):
|
||||
# Clean up tab instances
|
||||
for key, widget in self._tab_instances.items():
|
||||
if hasattr(widget, "on_close"):
|
||||
try:
|
||||
widget.on_close()
|
||||
except Exception:
|
||||
pass
|
||||
# Disconnect all sessions before closing
|
||||
self.session_pool.disconnect_all()
|
||||
self.checker.stop()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
202
gui/tabs/grafana_tab.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Grafana tab — dashboards browser and alerts overview.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import webbrowser
|
||||
from tkinter import ttk
|
||||
|
||||
import customtkinter as ctk
|
||||
from core.grafana_client import GrafanaClient
|
||||
from core.i18n import t
|
||||
|
||||
|
||||
class GrafanaTab(ctk.CTkFrame):
|
||||
def __init__(self, master, store):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.store = store
|
||||
self._current_alias: str | None = None
|
||||
self._client: GrafanaClient | None = None
|
||||
self._dashboards: list[dict] = []
|
||||
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
# ── Header + Refresh ──
|
||||
header_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
header_frame.pack(fill="x", padx=15, pady=(15, 5))
|
||||
|
||||
title = ctk.CTkLabel(header_frame, text=t("grafana_title"),
|
||||
font=ctk.CTkFont(size=18, weight="bold"))
|
||||
title.pack(side="left")
|
||||
|
||||
self._refresh_btn = ctk.CTkButton(header_frame, text=t("grafana_refresh"), width=100,
|
||||
command=self._refresh)
|
||||
self._refresh_btn.pack(side="right")
|
||||
|
||||
# ── Dashboards section ──
|
||||
dash_label = ctk.CTkLabel(self, text=t("grafana_dashboards"),
|
||||
font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
|
||||
dash_label.pack(fill="x", padx=15, pady=(10, 3))
|
||||
|
||||
dash_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
dash_frame.pack(fill="both", expand=True, padx=15, pady=(0, 5))
|
||||
|
||||
columns = ("uid", "title", "folder")
|
||||
self._dash_tree = ttk.Treeview(dash_frame, columns=columns, show="headings",
|
||||
selectmode="browse", height=8)
|
||||
self._dash_tree.heading("uid", text="UID")
|
||||
self._dash_tree.heading("title", text=t("grafana_dash_title"))
|
||||
self._dash_tree.heading("folder", text=t("grafana_dash_folder"))
|
||||
self._dash_tree.column("uid", width=120, minwidth=80)
|
||||
self._dash_tree.column("title", width=300, minwidth=150)
|
||||
self._dash_tree.column("folder", width=150, minwidth=80)
|
||||
self._dash_tree.pack(side="left", fill="both", expand=True)
|
||||
|
||||
dash_scroll = ttk.Scrollbar(dash_frame, orient="vertical", command=self._dash_tree.yview)
|
||||
dash_scroll.pack(side="right", fill="y")
|
||||
self._dash_tree.configure(yscrollcommand=dash_scroll.set)
|
||||
|
||||
self._dash_tree.bind("<Double-1>", self._on_dashboard_click)
|
||||
|
||||
# ── Alerts section ──
|
||||
alerts_label = ctk.CTkLabel(self, text=t("grafana_alerts"),
|
||||
font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
|
||||
alerts_label.pack(fill="x", padx=15, pady=(10, 3))
|
||||
|
||||
alerts_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
alerts_frame.pack(fill="both", expand=True, padx=15, pady=(0, 5))
|
||||
|
||||
alert_columns = ("state", "name", "severity")
|
||||
self._alerts_tree = ttk.Treeview(alerts_frame, columns=alert_columns, show="headings",
|
||||
selectmode="browse", height=6)
|
||||
self._alerts_tree.heading("state", text=t("grafana_alert_state"))
|
||||
self._alerts_tree.heading("name", text=t("grafana_alert_name"))
|
||||
self._alerts_tree.heading("severity", text=t("grafana_alert_severity"))
|
||||
self._alerts_tree.column("state", width=100, minwidth=60)
|
||||
self._alerts_tree.column("name", width=300, minwidth=150)
|
||||
self._alerts_tree.column("severity", width=100, minwidth=60)
|
||||
self._alerts_tree.pack(side="left", fill="both", expand=True)
|
||||
|
||||
alerts_scroll = ttk.Scrollbar(alerts_frame, orient="vertical", command=self._alerts_tree.yview)
|
||||
alerts_scroll.pack(side="right", fill="y")
|
||||
self._alerts_tree.configure(yscrollcommand=alerts_scroll.set)
|
||||
|
||||
# ── Status bar ──
|
||||
self._status_bar = ctk.CTkLabel(self, text=t("grafana_no_server"), anchor="w",
|
||||
font=ctk.CTkFont(size=11), text_color="#9ca3af")
|
||||
self._status_bar.pack(fill="x", padx=15, pady=(5, 10))
|
||||
|
||||
# ── Public API ──
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
"""Called when user selects a server in sidebar."""
|
||||
self._current_alias = alias
|
||||
self._client = None
|
||||
self._dashboards.clear()
|
||||
self._clear_tables()
|
||||
|
||||
if alias:
|
||||
self._set_status(t("grafana_connected").format(alias=alias), "#22c55e")
|
||||
self._refresh()
|
||||
else:
|
||||
self._set_status(t("grafana_no_server"), "#9ca3af")
|
||||
|
||||
# ── Refresh ──
|
||||
|
||||
def _refresh(self):
|
||||
if not self._current_alias:
|
||||
self._set_status(t("no_server_selected"), "#ef4444")
|
||||
return
|
||||
|
||||
self._refresh_btn.configure(state="disabled", text=t("grafana_loading"))
|
||||
self._set_status(t("grafana_loading"), "#ccaa00")
|
||||
|
||||
def _do():
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
dashboards = client.list_dashboards()
|
||||
alerts = client.list_alerts()
|
||||
|
||||
self.after(0, lambda: self._populate_dashboards(dashboards))
|
||||
self.after(0, lambda: self._populate_alerts(alerts))
|
||||
self.after(0, lambda: self._set_status(
|
||||
t("grafana_loaded").format(
|
||||
dashboards=len(dashboards), alerts=len(alerts)
|
||||
), "#22c55e"))
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self._set_status(f"(error) {e}", "#ef4444"))
|
||||
finally:
|
||||
self.after(0, lambda: self._refresh_btn.configure(
|
||||
state="normal", text=t("grafana_refresh")))
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _get_client(self) -> GrafanaClient:
|
||||
if self._client is None:
|
||||
self._client = GrafanaClient(self._current_alias, self.store)
|
||||
return self._client
|
||||
|
||||
# ── Table population ──
|
||||
|
||||
def _populate_dashboards(self, dashboards: list[dict]):
|
||||
self._dash_tree.delete(*self._dash_tree.get_children())
|
||||
self._dashboards = dashboards
|
||||
for d in dashboards:
|
||||
uid = d.get("uid", "")
|
||||
title = d.get("title", "")
|
||||
folder = d.get("folderTitle", d.get("folder", "General"))
|
||||
self._dash_tree.insert("", "end", values=(uid, title, folder))
|
||||
|
||||
def _populate_alerts(self, alerts: list[dict]):
|
||||
self._alerts_tree.delete(*self._alerts_tree.get_children())
|
||||
for a in alerts:
|
||||
state = a.get("state", a.get("status", "unknown"))
|
||||
name = a.get("name", a.get("title", ""))
|
||||
severity = a.get("severity", a.get("labels", {}).get("severity", "—"))
|
||||
tag = ""
|
||||
if state in ("alerting", "firing"):
|
||||
tag = "alerting"
|
||||
elif state in ("ok", "normal", "inactive"):
|
||||
tag = "ok"
|
||||
self._alerts_tree.insert("", "end", values=(state, name, severity), tags=(tag,))
|
||||
|
||||
# Color-code alert states
|
||||
self._alerts_tree.tag_configure("alerting", foreground="#ef4444")
|
||||
self._alerts_tree.tag_configure("ok", foreground="#22c55e")
|
||||
|
||||
def _clear_tables(self):
|
||||
self._dash_tree.delete(*self._dash_tree.get_children())
|
||||
self._alerts_tree.delete(*self._alerts_tree.get_children())
|
||||
|
||||
# ── Events ──
|
||||
|
||||
def _on_dashboard_click(self, _event):
|
||||
"""Open dashboard URL in browser on double-click."""
|
||||
selection = self._dash_tree.selection()
|
||||
if not selection:
|
||||
return
|
||||
item = self._dash_tree.item(selection[0])
|
||||
uid = item["values"][0] if item["values"] else None
|
||||
if not uid:
|
||||
return
|
||||
|
||||
# Find the dashboard data to get the URL
|
||||
for d in self._dashboards:
|
||||
if d.get("uid") == uid:
|
||||
url = d.get("url", "")
|
||||
if url:
|
||||
try:
|
||||
client = self._get_client()
|
||||
full_url = client.get_dashboard_url(url)
|
||||
webbrowser.open(full_url)
|
||||
except Exception:
|
||||
# Fallback: just open relative URL
|
||||
webbrowser.open(url)
|
||||
break
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _set_status(self, text: str, color: str = "#9ca3af"):
|
||||
self._status_bar.configure(text=text, text_color=color)
|
||||
@@ -8,17 +8,25 @@ from core.i18n import t
|
||||
|
||||
class InfoTab(ctk.CTkFrame):
|
||||
# Map field keys to i18n keys
|
||||
_FIELD_KEYS = ["alias", "ip", "port", "user", "type", "notes", "status"]
|
||||
_FIELD_KEYS = ["alias", "ip", "port", "user", "type", "database", "db_index", "ssl", "notes", "status"]
|
||||
_FIELD_I18N = {
|
||||
"alias": "info_alias",
|
||||
"ip": "info_ip",
|
||||
"port": "info_port",
|
||||
"user": "info_user",
|
||||
"type": "info_type",
|
||||
"database": "info_database",
|
||||
"db_index": "info_db_index",
|
||||
"ssl": "info_ssl",
|
||||
"notes": "info_notes",
|
||||
"status": "info_status",
|
||||
}
|
||||
|
||||
# Which fields are relevant per server type
|
||||
_SQL_TYPES = {"mariadb", "mssql", "postgresql"}
|
||||
_SSL_TYPES = {"grafana", "prometheus", "winrm"}
|
||||
_NO_USER_TYPES = {"redis", "grafana", "prometheus"}
|
||||
|
||||
def __init__(self, master, store, edit_callback=None):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.store = store
|
||||
@@ -65,12 +73,39 @@ class InfoTab(ctk.CTkFrame):
|
||||
if not server:
|
||||
return
|
||||
|
||||
stype = server.get("type", "ssh").lower()
|
||||
|
||||
self.header.configure(text=server["alias"])
|
||||
self._fields["alias"].configure(text=server.get("alias", "-"))
|
||||
self._fields["ip"].configure(text=server.get("ip", "-"))
|
||||
self._fields["port"].configure(text=str(server.get("port", 22)))
|
||||
self._fields["user"].configure(text=server.get("user", "root"))
|
||||
self._fields["type"].configure(text=server.get("type", "ssh").upper())
|
||||
|
||||
# Hide user for types that don't use it
|
||||
if stype in self._NO_USER_TYPES:
|
||||
self._fields["user"].configure(text="-")
|
||||
else:
|
||||
self._fields["user"].configure(text=server.get("user", "root"))
|
||||
|
||||
self._fields["type"].configure(text=stype.upper())
|
||||
|
||||
# Database field — relevant for SQL types
|
||||
if stype in self._SQL_TYPES:
|
||||
self._fields["database"].configure(text=server.get("database", "-"))
|
||||
else:
|
||||
self._fields["database"].configure(text="-")
|
||||
|
||||
# DB index — relevant for redis
|
||||
if stype == "redis":
|
||||
self._fields["db_index"].configure(text=str(server.get("db_index", 0)))
|
||||
else:
|
||||
self._fields["db_index"].configure(text="-")
|
||||
|
||||
# SSL — relevant for grafana, prometheus, winrm
|
||||
if stype in self._SSL_TYPES:
|
||||
self._fields["ssl"].configure(text="Yes" if server.get("use_ssl") else "No")
|
||||
else:
|
||||
self._fields["ssl"].configure(text="-")
|
||||
|
||||
self._fields["notes"].configure(text=server.get("notes", "-") or "-")
|
||||
|
||||
status = self.store.get_status(self._current_alias)
|
||||
|
||||
110
gui/tabs/launch_tab.py
Normal file
110
gui/tabs/launch_tab.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Launch tab — connect button for RDP/VNC remote desktop sessions.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import customtkinter as ctk
|
||||
from core.remote_desktop import RemoteDesktopLauncher
|
||||
from core.i18n import t
|
||||
|
||||
|
||||
class LaunchTab(ctk.CTkFrame):
|
||||
"""Minimal tab: server info + big Connect button for RDP/VNC."""
|
||||
|
||||
def __init__(self, master, store):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.store = store
|
||||
self._current_alias: str | None = None
|
||||
self._server_type: str | None = None # "rdp" or "vnc"
|
||||
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
# Server info label
|
||||
self._info_label = ctk.CTkLabel(
|
||||
self, text=t("no_server_selected_info"),
|
||||
font=ctk.CTkFont(size=16), wraplength=400,
|
||||
)
|
||||
self._info_label.pack(padx=20, pady=(40, 20))
|
||||
|
||||
# Big connect button
|
||||
self._connect_btn = ctk.CTkButton(
|
||||
self, text=t("launch_connect"),
|
||||
font=ctk.CTkFont(size=18, weight="bold"),
|
||||
width=220, height=50,
|
||||
command=self._on_connect,
|
||||
)
|
||||
self._connect_btn.pack(pady=20)
|
||||
self._connect_btn.configure(state="disabled")
|
||||
|
||||
# Status / result label
|
||||
self._status_label = ctk.CTkLabel(
|
||||
self, text="", font=ctk.CTkFont(size=13),
|
||||
text_color="#888888", wraplength=400,
|
||||
)
|
||||
self._status_label.pack(padx=20, pady=(10, 0))
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
self._current_alias = alias
|
||||
self._status_label.configure(text="", text_color="#888888")
|
||||
|
||||
if alias is None:
|
||||
self._info_label.configure(text=t("no_server_selected_info"))
|
||||
self._connect_btn.configure(state="disabled")
|
||||
self._server_type = None
|
||||
return
|
||||
|
||||
server = self.store.get_server(alias)
|
||||
if not server:
|
||||
self._info_label.configure(text=t("server_not_found").format(alias=alias))
|
||||
self._connect_btn.configure(state="disabled")
|
||||
self._server_type = None
|
||||
return
|
||||
|
||||
stype = server.get("type", "").lower()
|
||||
self._server_type = stype
|
||||
|
||||
if stype == "rdp":
|
||||
info_text = t("launch_rdp_info").format(alias=alias)
|
||||
elif stype == "vnc":
|
||||
info_text = t("launch_vnc_info").format(alias=alias)
|
||||
else:
|
||||
info_text = f"{alias} ({stype.upper()})"
|
||||
|
||||
self._info_label.configure(text=info_text)
|
||||
self._connect_btn.configure(state="normal")
|
||||
|
||||
def _on_connect(self):
|
||||
if not self._current_alias or not self._server_type:
|
||||
return
|
||||
|
||||
server = self.store.get_server(self._current_alias)
|
||||
if not server:
|
||||
return
|
||||
|
||||
self._connect_btn.configure(state="disabled")
|
||||
self._status_label.configure(
|
||||
text=t("launch_starting"), text_color="#ccaa00",
|
||||
)
|
||||
|
||||
stype = self._server_type
|
||||
|
||||
def _do():
|
||||
try:
|
||||
if stype == "rdp":
|
||||
RemoteDesktopLauncher.launch_rdp(server)
|
||||
elif stype == "vnc":
|
||||
RemoteDesktopLauncher.launch_vnc(server)
|
||||
|
||||
self.after(0, lambda: self._status_label.configure(
|
||||
text=t("launch_started"), text_color="#44cc44",
|
||||
))
|
||||
except Exception as exc:
|
||||
self.after(0, lambda: self._status_label.configure(
|
||||
text=t("launch_error").format(error=str(exc)),
|
||||
text_color="#ff4444",
|
||||
))
|
||||
finally:
|
||||
self.after(0, lambda: self._connect_btn.configure(state="normal"))
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
242
gui/tabs/powershell_tab.py
Normal file
242
gui/tabs/powershell_tab.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
PowerShell/CMD tab — request-response terminal for WinRM servers.
|
||||
|
||||
No pyte needed: WinRM is not an interactive PTY, just command → output.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import customtkinter as ctk
|
||||
from core.winrm_client import WinRMClient
|
||||
from core.i18n import t
|
||||
|
||||
|
||||
class PowershellTab(ctk.CTkFrame):
|
||||
"""Simplified terminal for WinRM command execution (PS or CMD)."""
|
||||
|
||||
_MAX_HISTORY = 200
|
||||
|
||||
def __init__(self, master, store):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.store = store
|
||||
self._current_alias: str | None = None
|
||||
self._client: WinRMClient | None = None
|
||||
self._mode: str = "ps" # "ps" or "cmd"
|
||||
self._history: list[str] = []
|
||||
self._history_index: int = -1
|
||||
self._running = False
|
||||
|
||||
self._build_ui()
|
||||
|
||||
# ── UI construction ──────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
# Top bar: mode toggle
|
||||
top = ctk.CTkFrame(self, fg_color="transparent")
|
||||
top.pack(fill="x", padx=8, pady=(8, 0))
|
||||
|
||||
self._mode_var = ctk.StringVar(value="ps")
|
||||
self._ps_radio = ctk.CTkRadioButton(
|
||||
top, text=t("ps_mode_ps"), variable=self._mode_var,
|
||||
value="ps", command=self._on_mode_changed,
|
||||
)
|
||||
self._ps_radio.pack(side="left", padx=(0, 12))
|
||||
|
||||
self._cmd_radio = ctk.CTkRadioButton(
|
||||
top, text=t("ps_mode_cmd"), variable=self._mode_var,
|
||||
value="cmd", command=self._on_mode_changed,
|
||||
)
|
||||
self._cmd_radio.pack(side="left")
|
||||
|
||||
# Output console
|
||||
self._output = ctk.CTkTextbox(
|
||||
self, font=ctk.CTkFont(family="Consolas", size=13),
|
||||
state="disabled", wrap="word",
|
||||
)
|
||||
self._output.pack(fill="both", expand=True, padx=8, pady=8)
|
||||
|
||||
# Input row: entry + execute button
|
||||
input_row = ctk.CTkFrame(self, fg_color="transparent")
|
||||
input_row.pack(fill="x", padx=8, pady=(0, 4))
|
||||
|
||||
self._entry = ctk.CTkEntry(
|
||||
input_row, placeholder_text="PS> ...",
|
||||
font=ctk.CTkFont(family="Consolas", size=13),
|
||||
)
|
||||
self._entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||||
self._entry.bind("<Return>", lambda e: self._execute())
|
||||
self._entry.bind("<Up>", lambda e: self._history_navigate(-1))
|
||||
self._entry.bind("<Down>", lambda e: self._history_navigate(1))
|
||||
|
||||
self._exec_btn = ctk.CTkButton(
|
||||
input_row, text=t("ps_execute"), width=90,
|
||||
command=self._execute,
|
||||
)
|
||||
self._exec_btn.pack(side="right")
|
||||
|
||||
# Status bar
|
||||
self._status = ctk.CTkLabel(
|
||||
self, text="", anchor="w",
|
||||
font=ctk.CTkFont(size=11), text_color="#888888",
|
||||
)
|
||||
self._status.pack(fill="x", padx=10, pady=(0, 6))
|
||||
|
||||
# ── Public API ───────────────────────────────────────────────────
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
"""Switch to a different server (or None to disconnect)."""
|
||||
if alias == self._current_alias:
|
||||
return
|
||||
|
||||
self._disconnect()
|
||||
self._current_alias = alias
|
||||
self._history.clear()
|
||||
self._history_index = -1
|
||||
|
||||
if alias is None:
|
||||
self._set_status(t("ps_disconnected"), "#888888")
|
||||
return
|
||||
|
||||
self._connect(alias)
|
||||
|
||||
# ── Connection ───────────────────────────────────────────────────
|
||||
|
||||
def _connect(self, alias: str):
|
||||
server = self.store.get_server(alias)
|
||||
if not server:
|
||||
self._set_status(t("server_not_found").format(alias=alias), "#ff4444")
|
||||
return
|
||||
|
||||
self._set_status(t("ps_connecting").format(alias=alias), "#ccaa00")
|
||||
|
||||
def _do():
|
||||
try:
|
||||
client = WinRMClient(server)
|
||||
client.connect()
|
||||
self._client = client
|
||||
self.after(0, lambda: self._set_status(
|
||||
t("ps_connected").format(alias=alias), "#44cc44",
|
||||
))
|
||||
self.after(0, lambda: self._entry.focus())
|
||||
except Exception as exc:
|
||||
self.after(0, lambda: self._set_status(
|
||||
t("ps_connect_failed").format(error=str(exc)), "#ff4444",
|
||||
))
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _disconnect(self):
|
||||
if self._client:
|
||||
try:
|
||||
self._client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._client = None
|
||||
|
||||
# ── Command execution ────────────────────────────────────────────
|
||||
|
||||
def _execute(self):
|
||||
cmd = self._entry.get().strip()
|
||||
if not cmd:
|
||||
return
|
||||
if not self._client:
|
||||
self._set_status(t("ps_not_connected"), "#ff4444")
|
||||
return
|
||||
if self._running:
|
||||
return
|
||||
|
||||
# Save to history
|
||||
if not self._history or self._history[-1] != cmd:
|
||||
self._history.append(cmd)
|
||||
if len(self._history) > self._MAX_HISTORY:
|
||||
self._history.pop(0)
|
||||
self._history_index = -1
|
||||
|
||||
# Show command in output
|
||||
prompt = "PS>" if self._mode == "ps" else "CMD>"
|
||||
self._append_output(f"\n{prompt} {cmd}\n")
|
||||
|
||||
self._entry.delete(0, "end")
|
||||
self._running = True
|
||||
self._exec_btn.configure(state="disabled")
|
||||
self._set_status(t("ps_running"), "#ccaa00")
|
||||
|
||||
mode = self._mode
|
||||
client = self._client
|
||||
|
||||
def _run():
|
||||
try:
|
||||
if mode == "ps":
|
||||
result = client.exec_ps(cmd)
|
||||
else:
|
||||
result = client.exec_cmd(cmd)
|
||||
|
||||
stdout = result.get("stdout", "")
|
||||
stderr = result.get("stderr", "")
|
||||
rc = result.get("return_code", None)
|
||||
|
||||
def _show():
|
||||
if stdout:
|
||||
self._append_output(stdout)
|
||||
if stderr:
|
||||
self._append_output(f"[STDERR] {stderr}")
|
||||
if rc is not None and rc != 0:
|
||||
self._append_output(f"[Exit code: {rc}]")
|
||||
self._set_status(t("ps_done"), "#44cc44")
|
||||
|
||||
self.after(0, _show)
|
||||
except Exception as exc:
|
||||
self.after(0, lambda: self._append_output(
|
||||
f"\n[ERROR] {exc}\n"
|
||||
))
|
||||
self.after(0, lambda: self._set_status(
|
||||
t("ps_exec_error"), "#ff4444",
|
||||
))
|
||||
finally:
|
||||
self._running = False
|
||||
self.after(0, lambda: self._exec_btn.configure(state="normal"))
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
# ── History navigation ───────────────────────────────────────────
|
||||
|
||||
def _history_navigate(self, direction: int):
|
||||
"""Navigate command history. direction: -1 = older, +1 = newer."""
|
||||
if not self._history:
|
||||
self._set_status(t("ps_history_empty"), "#888888")
|
||||
return
|
||||
|
||||
if self._history_index == -1:
|
||||
if direction == -1:
|
||||
self._history_index = len(self._history) - 1
|
||||
else:
|
||||
return
|
||||
else:
|
||||
self._history_index += direction
|
||||
|
||||
if self._history_index < 0:
|
||||
self._history_index = 0
|
||||
elif self._history_index >= len(self._history):
|
||||
self._history_index = -1
|
||||
self._entry.delete(0, "end")
|
||||
return
|
||||
|
||||
self._entry.delete(0, "end")
|
||||
self._entry.insert(0, self._history[self._history_index])
|
||||
|
||||
# ── Mode toggle ──────────────────────────────────────────────────
|
||||
|
||||
def _on_mode_changed(self):
|
||||
self._mode = self._mode_var.get()
|
||||
placeholder = "PS> ..." if self._mode == "ps" else "CMD> ..."
|
||||
self._entry.configure(placeholder_text=placeholder)
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _append_output(self, text: str):
|
||||
self._output.configure(state="normal")
|
||||
self._output.insert("end", text)
|
||||
self._output.see("end")
|
||||
self._output.configure(state="disabled")
|
||||
|
||||
def _set_status(self, text: str, color: str = "#888888"):
|
||||
self._status.configure(text=text, text_color=color)
|
||||
266
gui/tabs/prometheus_tab.py
Normal file
266
gui/tabs/prometheus_tab.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Prometheus tab — PromQL query executor, targets overview, and alerts.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from tkinter import ttk
|
||||
|
||||
import customtkinter as ctk
|
||||
from core.prometheus_client import PrometheusClient
|
||||
from core.i18n import t
|
||||
|
||||
|
||||
class PrometheusTab(ctk.CTkFrame):
|
||||
def __init__(self, master, store):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.store = store
|
||||
self._current_alias: str | None = None
|
||||
self._client: PrometheusClient | None = None
|
||||
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
# ── PromQL query section ──
|
||||
query_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
query_frame.pack(fill="x", padx=15, pady=(15, 5))
|
||||
|
||||
query_label = ctk.CTkLabel(query_frame, text=t("prom_query"),
|
||||
font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
|
||||
query_label.pack(side="left", padx=(0, 10))
|
||||
|
||||
self._query_entry = ctk.CTkEntry(query_frame,
|
||||
placeholder_text=t("prom_query_placeholder"),
|
||||
font=ctk.CTkFont(family="Consolas", size=13))
|
||||
self._query_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||
self._query_entry.bind("<Return>", lambda e: self._execute_query())
|
||||
|
||||
self._exec_btn = ctk.CTkButton(query_frame, text=t("prom_execute"), width=90,
|
||||
command=self._execute_query)
|
||||
self._exec_btn.pack(side="left")
|
||||
|
||||
# ── Query results ──
|
||||
results_label = ctk.CTkLabel(self, text=t("prom_results"),
|
||||
font=ctk.CTkFont(size=12, weight="bold"), anchor="w")
|
||||
results_label.pack(fill="x", padx=15, pady=(10, 3))
|
||||
|
||||
self._results_box = ctk.CTkTextbox(self, height=150,
|
||||
font=ctk.CTkFont(family="Consolas", size=12),
|
||||
state="disabled")
|
||||
self._results_box.pack(fill="x", padx=15, pady=(0, 5))
|
||||
|
||||
# ── Targets section ──
|
||||
targets_header = ctk.CTkFrame(self, fg_color="transparent")
|
||||
targets_header.pack(fill="x", padx=15, pady=(10, 3))
|
||||
|
||||
targets_label = ctk.CTkLabel(targets_header, text=t("prom_targets"),
|
||||
font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
|
||||
targets_label.pack(side="left")
|
||||
|
||||
self._refresh_btn = ctk.CTkButton(targets_header, text=t("prom_refresh"), width=90,
|
||||
command=self._refresh_all)
|
||||
self._refresh_btn.pack(side="right")
|
||||
|
||||
targets_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
targets_frame.pack(fill="both", expand=True, padx=15, pady=(0, 5))
|
||||
|
||||
target_columns = ("job", "instance", "health", "last_scrape")
|
||||
self._targets_tree = ttk.Treeview(targets_frame, columns=target_columns, show="headings",
|
||||
selectmode="browse", height=6)
|
||||
self._targets_tree.heading("job", text=t("prom_target_job"))
|
||||
self._targets_tree.heading("instance", text=t("prom_target_instance"))
|
||||
self._targets_tree.heading("health", text=t("prom_target_health"))
|
||||
self._targets_tree.heading("last_scrape", text=t("prom_target_scrape"))
|
||||
self._targets_tree.column("job", width=120, minwidth=80)
|
||||
self._targets_tree.column("instance", width=200, minwidth=120)
|
||||
self._targets_tree.column("health", width=80, minwidth=60)
|
||||
self._targets_tree.column("last_scrape", width=150, minwidth=80)
|
||||
self._targets_tree.pack(side="left", fill="both", expand=True)
|
||||
|
||||
targets_scroll = ttk.Scrollbar(targets_frame, orient="vertical",
|
||||
command=self._targets_tree.yview)
|
||||
targets_scroll.pack(side="right", fill="y")
|
||||
self._targets_tree.configure(yscrollcommand=targets_scroll.set)
|
||||
|
||||
# ── Alerts section ──
|
||||
alerts_label = ctk.CTkLabel(self, text=t("prom_alerts"),
|
||||
font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
|
||||
alerts_label.pack(fill="x", padx=15, pady=(10, 3))
|
||||
|
||||
self._alerts_box = ctk.CTkTextbox(self, height=100,
|
||||
font=ctk.CTkFont(family="Consolas", size=12),
|
||||
state="disabled")
|
||||
self._alerts_box.pack(fill="x", padx=15, pady=(0, 5))
|
||||
|
||||
# ── Status bar ──
|
||||
self._status_bar = ctk.CTkLabel(self, text=t("prom_no_server"), anchor="w",
|
||||
font=ctk.CTkFont(size=11), text_color="#9ca3af")
|
||||
self._status_bar.pack(fill="x", padx=15, pady=(5, 10))
|
||||
|
||||
# ── Public API ──
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
"""Called when user selects a server in sidebar."""
|
||||
self._current_alias = alias
|
||||
self._client = None
|
||||
self._clear_all()
|
||||
|
||||
if alias:
|
||||
self._set_status(t("prom_connected").format(alias=alias), "#22c55e")
|
||||
self._refresh_all()
|
||||
else:
|
||||
self._set_status(t("prom_no_server"), "#9ca3af")
|
||||
|
||||
# ── PromQL execution ──
|
||||
|
||||
def _execute_query(self):
|
||||
query = self._query_entry.get().strip()
|
||||
if not query:
|
||||
return
|
||||
if not self._current_alias:
|
||||
self._set_results(t("no_server_selected"))
|
||||
return
|
||||
|
||||
self._exec_btn.configure(state="disabled")
|
||||
self._set_results(t("prom_executing"))
|
||||
|
||||
def _do():
|
||||
try:
|
||||
client = self._get_client()
|
||||
result = client.query(query)
|
||||
formatted = self._format_query_result(result)
|
||||
self.after(0, lambda: self._set_results(formatted))
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self._set_results(f"(error) {e}"))
|
||||
finally:
|
||||
self.after(0, lambda: self._exec_btn.configure(state="normal"))
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _format_query_result(self, result: dict) -> str:
|
||||
"""Format Prometheus query API response for display."""
|
||||
status = result.get("status", "unknown")
|
||||
if status != "success":
|
||||
error = result.get("error", "Unknown error")
|
||||
return f"Error: {error}"
|
||||
|
||||
data = result.get("data", {})
|
||||
result_type = data.get("resultType", "")
|
||||
results = data.get("result", [])
|
||||
|
||||
if not results:
|
||||
return "(empty result)"
|
||||
|
||||
lines = [f"# Type: {result_type}", f"# Results: {len(results)}", ""]
|
||||
|
||||
for item in results:
|
||||
metric = item.get("metric", {})
|
||||
metric_str = ", ".join(f'{k}="{v}"' for k, v in metric.items())
|
||||
|
||||
if result_type == "vector":
|
||||
value = item.get("value", [None, ""])[1]
|
||||
lines.append(f"{{{metric_str}}} => {value}")
|
||||
elif result_type == "matrix":
|
||||
values = item.get("values", [])
|
||||
lines.append(f"{{{metric_str}}}")
|
||||
for ts, val in values[-10:]: # Show last 10 points
|
||||
lines.append(f" @{ts} => {val}")
|
||||
if len(values) > 10:
|
||||
lines.append(f" ... ({len(values)} total points)")
|
||||
else:
|
||||
lines.append(f"{{{metric_str}}} => {item}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Refresh targets & alerts ──
|
||||
|
||||
def _refresh_all(self):
|
||||
if not self._current_alias:
|
||||
self._set_status(t("no_server_selected"), "#ef4444")
|
||||
return
|
||||
|
||||
self._refresh_btn.configure(state="disabled", text=t("prom_loading"))
|
||||
self._set_status(t("prom_loading"), "#ccaa00")
|
||||
|
||||
def _do():
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
targets = client.get_targets()
|
||||
alerts = client.get_alerts()
|
||||
|
||||
self.after(0, lambda: self._populate_targets(targets))
|
||||
self.after(0, lambda: self._populate_alerts(alerts))
|
||||
self.after(0, lambda: self._set_status(
|
||||
t("prom_loaded").format(
|
||||
targets=len(targets), alerts=len(alerts)
|
||||
), "#22c55e"))
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self._set_status(f"(error) {e}", "#ef4444"))
|
||||
finally:
|
||||
self.after(0, lambda: self._refresh_btn.configure(
|
||||
state="normal", text=t("prom_refresh")))
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _get_client(self) -> PrometheusClient:
|
||||
if self._client is None:
|
||||
self._client = PrometheusClient(self._current_alias, self.store)
|
||||
return self._client
|
||||
|
||||
# ── Table population ──
|
||||
|
||||
def _populate_targets(self, targets: list[dict]):
|
||||
self._targets_tree.delete(*self._targets_tree.get_children())
|
||||
for target in targets:
|
||||
job = target.get("labels", {}).get("job", "—")
|
||||
instance = target.get("labels", {}).get("instance", "—")
|
||||
health = target.get("health", "unknown")
|
||||
last_scrape = target.get("lastScrape", "—")
|
||||
|
||||
tag = ""
|
||||
if health == "up":
|
||||
tag = "up"
|
||||
elif health == "down":
|
||||
tag = "down"
|
||||
|
||||
self._targets_tree.insert("", "end",
|
||||
values=(job, instance, health, last_scrape),
|
||||
tags=(tag,))
|
||||
|
||||
self._targets_tree.tag_configure("up", foreground="#22c55e")
|
||||
self._targets_tree.tag_configure("down", foreground="#ef4444")
|
||||
|
||||
def _populate_alerts(self, alerts: list[dict]):
|
||||
self._alerts_box.configure(state="normal")
|
||||
self._alerts_box.delete("1.0", "end")
|
||||
|
||||
if not alerts:
|
||||
self._alerts_box.insert("1.0", t("prom_no_alerts"))
|
||||
else:
|
||||
lines = []
|
||||
for a in alerts:
|
||||
name = a.get("labels", {}).get("alertname", a.get("name", "unknown"))
|
||||
state = a.get("state", "unknown")
|
||||
severity = a.get("labels", {}).get("severity", "—")
|
||||
lines.append(f"[{state.upper()}] {name} (severity: {severity})")
|
||||
self._alerts_box.insert("1.0", "\n".join(lines))
|
||||
|
||||
self._alerts_box.configure(state="disabled")
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _set_results(self, text: str):
|
||||
self._results_box.configure(state="normal")
|
||||
self._results_box.delete("1.0", "end")
|
||||
self._results_box.insert("1.0", text)
|
||||
self._results_box.configure(state="disabled")
|
||||
|
||||
def _clear_all(self):
|
||||
self._targets_tree.delete(*self._targets_tree.get_children())
|
||||
self._set_results("")
|
||||
self._alerts_box.configure(state="normal")
|
||||
self._alerts_box.delete("1.0", "end")
|
||||
self._alerts_box.configure(state="disabled")
|
||||
|
||||
def _set_status(self, text: str, color: str = "#9ca3af"):
|
||||
self._status_bar.configure(text=text, text_color=color)
|
||||
336
gui/tabs/query_tab.py
Normal file
336
gui/tabs/query_tab.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
Query tab — SQL database interaction with editor, results grid, and export.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import time
|
||||
import threading
|
||||
from tkinter import ttk, filedialog
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from core.i18n import t
|
||||
from core.sql_client import SQLClient
|
||||
|
||||
|
||||
class QueryTab(ctk.CTkFrame):
|
||||
def __init__(self, master, store):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self._current_alias: str | None = None
|
||||
self.store = store
|
||||
self._client: SQLClient | None = None
|
||||
self._results: list[list] = []
|
||||
self._columns: list[str] = []
|
||||
self._executing = False
|
||||
|
||||
self._build_ui()
|
||||
|
||||
# ── UI construction ────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
# === Database selector row ===
|
||||
db_row = ctk.CTkFrame(self, fg_color="transparent")
|
||||
db_row.pack(fill="x", padx=10, pady=(10, 5))
|
||||
|
||||
ctk.CTkLabel(db_row, text=t("query_database"), anchor="w").pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
|
||||
self._db_var = ctk.StringVar(value="")
|
||||
self._db_combo = ctk.CTkComboBox(
|
||||
db_row,
|
||||
variable=self._db_var,
|
||||
values=[],
|
||||
width=220,
|
||||
command=self._on_db_selected,
|
||||
)
|
||||
self._db_combo.pack(side="left")
|
||||
|
||||
# === SQL Editor ===
|
||||
editor_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
editor_frame.pack(fill="both", expand=True, padx=10, pady=5, side="top")
|
||||
# Give editor roughly 1/3 of space
|
||||
editor_frame.pack_configure(expand=False)
|
||||
|
||||
self._editor = ctk.CTkTextbox(
|
||||
editor_frame,
|
||||
font=ctk.CTkFont(family="Consolas", size=13),
|
||||
height=160,
|
||||
wrap="none",
|
||||
)
|
||||
self._editor.pack(fill="both", expand=True)
|
||||
self._editor.insert("0.0", t("query_editor_placeholder"))
|
||||
self._editor.bind("<FocusIn>", self._on_editor_focus)
|
||||
|
||||
# Bind keyboard shortcuts
|
||||
self._editor.bind("<F5>", lambda e: self._execute_query())
|
||||
self._editor.bind("<Control-Return>", lambda e: self._execute_query())
|
||||
|
||||
# === Button row ===
|
||||
btn_row = ctk.CTkFrame(self, fg_color="transparent")
|
||||
btn_row.pack(fill="x", padx=10, pady=5)
|
||||
|
||||
self._exec_btn = ctk.CTkButton(
|
||||
btn_row,
|
||||
text=f"{t('query_execute')} (F5)",
|
||||
command=self._execute_query,
|
||||
width=130,
|
||||
fg_color="#2563eb",
|
||||
hover_color="#1d4ed8",
|
||||
)
|
||||
self._exec_btn.pack(side="left", padx=(0, 6))
|
||||
|
||||
self._clear_btn = ctk.CTkButton(
|
||||
btn_row,
|
||||
text=t("query_clear"),
|
||||
command=self._clear_all,
|
||||
width=80,
|
||||
fg_color="#6b7280",
|
||||
hover_color="#4b5563",
|
||||
)
|
||||
self._clear_btn.pack(side="left", padx=(0, 6))
|
||||
|
||||
self._export_btn = ctk.CTkButton(
|
||||
btn_row,
|
||||
text=t("query_export_csv"),
|
||||
command=self._export_csv,
|
||||
width=110,
|
||||
fg_color="#059669",
|
||||
hover_color="#047857",
|
||||
)
|
||||
self._export_btn.pack(side="left")
|
||||
|
||||
# === Results area (Treeview) ===
|
||||
results_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
results_frame.pack(fill="both", expand=True, padx=10, pady=(5, 5))
|
||||
|
||||
# Horizontal scrollbar
|
||||
self._tree_xscroll = ttk.Scrollbar(results_frame, orient="horizontal")
|
||||
self._tree_xscroll.pack(side="bottom", fill="x")
|
||||
|
||||
# Vertical scrollbar
|
||||
self._tree_yscroll = ttk.Scrollbar(results_frame, orient="vertical")
|
||||
self._tree_yscroll.pack(side="right", fill="y")
|
||||
|
||||
self._tree = ttk.Treeview(
|
||||
results_frame,
|
||||
show="headings",
|
||||
xscrollcommand=self._tree_xscroll.set,
|
||||
yscrollcommand=self._tree_yscroll.set,
|
||||
)
|
||||
self._tree.pack(fill="both", expand=True)
|
||||
|
||||
self._tree_xscroll.config(command=self._tree.xview)
|
||||
self._tree_yscroll.config(command=self._tree.yview)
|
||||
|
||||
# === Status bar ===
|
||||
self._status_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="",
|
||||
anchor="w",
|
||||
font=ctk.CTkFont(size=12),
|
||||
text_color="#9ca3af",
|
||||
)
|
||||
self._status_label.pack(fill="x", padx=12, pady=(0, 8))
|
||||
|
||||
# ── Editor placeholder logic ───────────────────────────────────
|
||||
|
||||
def _on_editor_focus(self, event=None):
|
||||
content = self._editor.get("0.0", "end").strip()
|
||||
placeholder = t("query_editor_placeholder")
|
||||
if content == placeholder:
|
||||
self._editor.delete("0.0", "end")
|
||||
|
||||
# ── Server / database connection ───────────────────────────────
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
"""Called when user selects a server in the sidebar."""
|
||||
self._current_alias = alias
|
||||
self._disconnect()
|
||||
self._clear_results()
|
||||
self._set_status("")
|
||||
|
||||
if not alias:
|
||||
self._db_combo.configure(values=[])
|
||||
self._db_var.set("")
|
||||
return
|
||||
|
||||
self._set_status(f"Connecting to {alias}...")
|
||||
threading.Thread(
|
||||
target=self._connect_and_list_dbs,
|
||||
args=(alias,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _connect_and_list_dbs(self, alias: str):
|
||||
"""Background: create SQLClient, fetch database list."""
|
||||
try:
|
||||
server = self.store.get_server(alias)
|
||||
if not server:
|
||||
self._schedule(self._set_status, t("query_error"), error=True)
|
||||
return
|
||||
|
||||
client = SQLClient(server)
|
||||
databases = client.list_databases()
|
||||
|
||||
def _update():
|
||||
if self._current_alias != alias:
|
||||
return # switched away
|
||||
self._client = client
|
||||
self._db_combo.configure(values=databases)
|
||||
if databases:
|
||||
self._db_var.set(databases[0])
|
||||
self._switch_database(databases[0])
|
||||
self._set_status("OK")
|
||||
|
||||
self._schedule(_update)
|
||||
except Exception as exc:
|
||||
self._schedule(self._set_status, str(exc), error=True)
|
||||
|
||||
def _on_db_selected(self, value: str):
|
||||
if value:
|
||||
self._switch_database(value)
|
||||
|
||||
def _switch_database(self, db_name: str):
|
||||
"""Switch active database on the current client."""
|
||||
if not self._client:
|
||||
return
|
||||
try:
|
||||
self._client.use_database(db_name)
|
||||
self._set_status(f"Database: {db_name}")
|
||||
except Exception as exc:
|
||||
self._set_status(str(exc), error=True)
|
||||
|
||||
def _disconnect(self):
|
||||
if self._client:
|
||||
try:
|
||||
self._client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._client = None
|
||||
|
||||
# ── Query execution ────────────────────────────────────────────
|
||||
|
||||
def _execute_query(self):
|
||||
"""Run the SQL query in a background thread."""
|
||||
if self._executing or not self._client:
|
||||
return
|
||||
|
||||
sql = self._editor.get("0.0", "end").strip()
|
||||
if not sql or sql == t("query_editor_placeholder"):
|
||||
return
|
||||
|
||||
self._executing = True
|
||||
self._exec_btn.configure(state="disabled")
|
||||
self._set_status("Executing...")
|
||||
|
||||
threading.Thread(
|
||||
target=self._run_query,
|
||||
args=(sql,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _run_query(self, sql: str):
|
||||
"""Background thread: execute SQL, measure time, post results."""
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
columns, rows = self._client.execute(sql)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
def _update():
|
||||
self._columns = columns
|
||||
self._results = rows
|
||||
self._populate_tree(columns, rows)
|
||||
row_count = len(rows)
|
||||
self._set_status(
|
||||
t("query_status_rows").format(
|
||||
rows=row_count, time=f"{elapsed:.3f}"
|
||||
)
|
||||
)
|
||||
self._executing = False
|
||||
self._exec_btn.configure(state="normal")
|
||||
|
||||
self._schedule(_update)
|
||||
except Exception as exc:
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
def _update_err():
|
||||
self._set_status(
|
||||
f"{t('query_error')}: {exc}", error=True
|
||||
)
|
||||
self._executing = False
|
||||
self._exec_btn.configure(state="normal")
|
||||
|
||||
self._schedule(_update_err)
|
||||
|
||||
# ── Treeview population ────────────────────────────────────────
|
||||
|
||||
def _populate_tree(self, columns: list[str], rows: list[list]):
|
||||
"""Clear and populate the Treeview with query results."""
|
||||
self._tree.delete(*self._tree.get_children())
|
||||
|
||||
if not columns:
|
||||
self._tree["columns"] = ()
|
||||
return
|
||||
|
||||
self._tree["columns"] = columns
|
||||
for col in columns:
|
||||
self._tree.heading(col, text=col, anchor="w")
|
||||
self._tree.column(col, width=120, minwidth=60, anchor="w")
|
||||
|
||||
for row in rows:
|
||||
display = [str(v) if v is not None else "NULL" for v in row]
|
||||
self._tree.insert("", "end", values=display)
|
||||
|
||||
def _clear_results(self):
|
||||
"""Remove all rows and columns from the Treeview."""
|
||||
self._tree.delete(*self._tree.get_children())
|
||||
self._tree["columns"] = ()
|
||||
self._columns = []
|
||||
self._results = []
|
||||
|
||||
# ── Button actions ─────────────────────────────────────────────
|
||||
|
||||
def _clear_all(self):
|
||||
"""Clear editor content and results."""
|
||||
self._editor.delete("0.0", "end")
|
||||
self._clear_results()
|
||||
self._set_status("")
|
||||
|
||||
def _export_csv(self):
|
||||
"""Export current results to a CSV file via save dialog."""
|
||||
if not self._columns or not self._results:
|
||||
return
|
||||
|
||||
path = filedialog.asksaveasfilename(
|
||||
defaultextension=".csv",
|
||||
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
|
||||
title=t("query_export_csv"),
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(path, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(self._columns)
|
||||
for row in self._results:
|
||||
writer.writerow(
|
||||
[str(v) if v is not None else "" for v in row]
|
||||
)
|
||||
self._set_status(f"Exported {len(self._results)} rows to {path}")
|
||||
except Exception as exc:
|
||||
self._set_status(f"{t('query_error')}: {exc}", error=True)
|
||||
|
||||
# ── Status bar ─────────────────────────────────────────────────
|
||||
|
||||
def _set_status(self, text: str, error: bool = False):
|
||||
color = "#ef4444" if error else "#9ca3af"
|
||||
self._status_label.configure(text=text, text_color=color)
|
||||
|
||||
# ── Thread-safe scheduling ─────────────────────────────────────
|
||||
|
||||
def _schedule(self, func, *args, **kwargs):
|
||||
"""Schedule a function to run on the main (tkinter) thread."""
|
||||
self.after(0, lambda: func(*args, **kwargs))
|
||||
266
gui/tabs/redis_tab.py
Normal file
266
gui/tabs/redis_tab.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Redis tab — interactive Redis CLI with DB selector, command history, and output console.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import customtkinter as ctk
|
||||
from core.redis_client import RedisClient
|
||||
from core.i18n import t
|
||||
|
||||
|
||||
class RedisTab(ctk.CTkFrame):
|
||||
def __init__(self, master, store):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.store = store
|
||||
self._current_alias: str | None = None
|
||||
self._client: RedisClient | None = None
|
||||
self._command_history: list[str] = []
|
||||
self._history_index: int = -1
|
||||
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
# ── Top bar: DB selector + stats ──
|
||||
top_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
top_frame.pack(fill="x", padx=15, pady=(15, 5))
|
||||
|
||||
# DB selector
|
||||
db_label = ctk.CTkLabel(top_frame, text=t("redis_db"), anchor="w",
|
||||
font=ctk.CTkFont(size=12, weight="bold"))
|
||||
db_label.pack(side="left", padx=(0, 5))
|
||||
|
||||
self._db_var = ctk.StringVar(value="0")
|
||||
self._db_selector = ctk.CTkOptionMenu(
|
||||
top_frame, values=[str(i) for i in range(16)],
|
||||
variable=self._db_var, width=70,
|
||||
command=self._on_db_changed,
|
||||
)
|
||||
self._db_selector.pack(side="left", padx=(0, 15))
|
||||
|
||||
# Keys count
|
||||
self._keys_label = ctk.CTkLabel(top_frame, text=t("redis_keys") + ": —",
|
||||
font=ctk.CTkFont(size=12), text_color="#9ca3af")
|
||||
self._keys_label.pack(side="left", padx=(0, 15))
|
||||
|
||||
# Memory usage
|
||||
self._memory_label = ctk.CTkLabel(top_frame, text=t("redis_memory") + ": —",
|
||||
font=ctk.CTkFont(size=12), text_color="#9ca3af")
|
||||
self._memory_label.pack(side="left")
|
||||
|
||||
# ── Command input row ──
|
||||
cmd_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
cmd_frame.pack(fill="x", padx=15, pady=5)
|
||||
|
||||
prompt_label = ctk.CTkLabel(cmd_frame, text="redis>", font=ctk.CTkFont(family="Consolas", size=13),
|
||||
text_color="#ef4444")
|
||||
prompt_label.pack(side="left", padx=(0, 5))
|
||||
|
||||
self._cmd_entry = ctk.CTkEntry(cmd_frame, placeholder_text=t("redis_command_placeholder"),
|
||||
font=ctk.CTkFont(family="Consolas", size=13))
|
||||
self._cmd_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||
self._cmd_entry.bind("<Return>", lambda e: self._execute_command())
|
||||
self._cmd_entry.bind("<Up>", self._history_up)
|
||||
self._cmd_entry.bind("<Down>", self._history_down)
|
||||
|
||||
# ── Buttons row ──
|
||||
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
btn_frame.pack(fill="x", padx=15, pady=5)
|
||||
|
||||
self._exec_btn = ctk.CTkButton(btn_frame, text=t("redis_execute"), width=90,
|
||||
command=self._execute_command)
|
||||
self._exec_btn.pack(side="left", padx=(0, 5))
|
||||
|
||||
self._info_btn = ctk.CTkButton(btn_frame, text="INFO", width=70,
|
||||
fg_color="#6b7280", hover_color="#4b5563",
|
||||
command=lambda: self._run_quick("INFO"))
|
||||
self._info_btn.pack(side="left", padx=(0, 5))
|
||||
|
||||
self._dbsize_btn = ctk.CTkButton(btn_frame, text="DBSIZE", width=80,
|
||||
fg_color="#6b7280", hover_color="#4b5563",
|
||||
command=lambda: self._run_quick("DBSIZE"))
|
||||
self._dbsize_btn.pack(side="left", padx=(0, 5))
|
||||
|
||||
self._scan_btn = ctk.CTkButton(btn_frame, text="SCAN", width=70,
|
||||
fg_color="#6b7280", hover_color="#4b5563",
|
||||
command=lambda: self._run_quick("SCAN 0 COUNT 100"))
|
||||
self._scan_btn.pack(side="left", padx=(0, 5))
|
||||
|
||||
self._clear_btn = ctk.CTkButton(btn_frame, text=t("redis_clear"), width=70,
|
||||
fg_color="#374151", hover_color="#1f2937",
|
||||
command=self._clear_output)
|
||||
self._clear_btn.pack(side="right")
|
||||
|
||||
# ── Output console ──
|
||||
self._output = ctk.CTkTextbox(self, font=ctk.CTkFont(family="Consolas", size=12),
|
||||
state="disabled")
|
||||
self._output.pack(fill="both", expand=True, padx=15, pady=(5, 5))
|
||||
|
||||
# ── Status bar ──
|
||||
self._status_bar = ctk.CTkLabel(self, text=t("redis_disconnected"), anchor="w",
|
||||
font=ctk.CTkFont(size=11), text_color="#9ca3af")
|
||||
self._status_bar.pack(fill="x", padx=15, pady=(0, 10))
|
||||
|
||||
# ── Public API ──
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
"""Called when user selects a server in sidebar."""
|
||||
self._current_alias = alias
|
||||
self._client = None
|
||||
self._command_history.clear()
|
||||
self._history_index = -1
|
||||
self._clear_output()
|
||||
|
||||
if alias:
|
||||
self._set_status(t("redis_ready").format(alias=alias), "#22c55e")
|
||||
self._refresh_stats()
|
||||
else:
|
||||
self._set_status(t("redis_disconnected"), "#9ca3af")
|
||||
self._keys_label.configure(text=t("redis_keys") + ": —")
|
||||
self._memory_label.configure(text=t("redis_memory") + ": —")
|
||||
|
||||
# ── Command execution ──
|
||||
|
||||
def _execute_command(self):
|
||||
cmd = self._cmd_entry.get().strip()
|
||||
if not cmd:
|
||||
return
|
||||
if not self._current_alias:
|
||||
self._append_output(t("no_server_selected"))
|
||||
return
|
||||
|
||||
# Add to history
|
||||
if not self._command_history or self._command_history[-1] != cmd:
|
||||
self._command_history.append(cmd)
|
||||
self._history_index = -1
|
||||
|
||||
self._cmd_entry.delete(0, "end")
|
||||
self._append_output(f"redis> {cmd}")
|
||||
self._set_buttons_state("disabled")
|
||||
|
||||
db = int(self._db_var.get())
|
||||
|
||||
def _do():
|
||||
try:
|
||||
client = self._get_client()
|
||||
result = client.execute(cmd, db=db)
|
||||
formatted = self._format_result(result)
|
||||
self.after(0, lambda: self._append_output(formatted))
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self._append_output(f"(error) {e}"))
|
||||
finally:
|
||||
self.after(0, lambda: self._set_buttons_state("normal"))
|
||||
self.after(0, self._refresh_stats)
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _run_quick(self, cmd: str):
|
||||
"""Execute a preset command."""
|
||||
self._cmd_entry.delete(0, "end")
|
||||
self._cmd_entry.insert(0, cmd)
|
||||
self._execute_command()
|
||||
|
||||
def _get_client(self) -> RedisClient:
|
||||
if self._client is None:
|
||||
self._client = RedisClient(self._current_alias, self.store)
|
||||
return self._client
|
||||
|
||||
# ── Stats refresh ──
|
||||
|
||||
def _refresh_stats(self):
|
||||
if not self._current_alias:
|
||||
return
|
||||
|
||||
def _do():
|
||||
try:
|
||||
client = self._get_client()
|
||||
db = int(self._db_var.get())
|
||||
keys_count = client.execute("DBSIZE", db=db)
|
||||
info = client.execute("INFO memory", db=db)
|
||||
|
||||
# Parse memory from INFO output
|
||||
memory = "—"
|
||||
if isinstance(info, str):
|
||||
for line in info.split("\r\n"):
|
||||
if line.startswith("used_memory_human:"):
|
||||
memory = line.split(":")[1].strip()
|
||||
break
|
||||
|
||||
keys_text = str(keys_count) if keys_count is not None else "—"
|
||||
self.after(0, lambda: self._keys_label.configure(
|
||||
text=t("redis_keys") + f": {keys_text}"))
|
||||
self.after(0, lambda: self._memory_label.configure(
|
||||
text=t("redis_memory") + f": {memory}"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _on_db_changed(self, _value: str):
|
||||
self._refresh_stats()
|
||||
|
||||
# ── History navigation ──
|
||||
|
||||
def _history_up(self, _event):
|
||||
if not self._command_history:
|
||||
return "break"
|
||||
if self._history_index == -1:
|
||||
self._history_index = len(self._command_history) - 1
|
||||
elif self._history_index > 0:
|
||||
self._history_index -= 1
|
||||
self._cmd_entry.delete(0, "end")
|
||||
self._cmd_entry.insert(0, self._command_history[self._history_index])
|
||||
return "break"
|
||||
|
||||
def _history_down(self, _event):
|
||||
if not self._command_history:
|
||||
return "break"
|
||||
if self._history_index == -1:
|
||||
return "break"
|
||||
if self._history_index < len(self._command_history) - 1:
|
||||
self._history_index += 1
|
||||
self._cmd_entry.delete(0, "end")
|
||||
self._cmd_entry.insert(0, self._command_history[self._history_index])
|
||||
else:
|
||||
self._history_index = -1
|
||||
self._cmd_entry.delete(0, "end")
|
||||
return "break"
|
||||
|
||||
# ── Output helpers ──
|
||||
|
||||
def _format_result(self, result) -> str:
|
||||
"""Format Redis response for display."""
|
||||
if result is None:
|
||||
return "(nil)"
|
||||
if isinstance(result, bytes):
|
||||
return result.decode("utf-8", errors="replace")
|
||||
if isinstance(result, int):
|
||||
return f"(integer) {result}"
|
||||
if isinstance(result, list):
|
||||
if not result:
|
||||
return "(empty list or set)"
|
||||
lines = []
|
||||
for i, item in enumerate(result, 1):
|
||||
val = item.decode("utf-8", errors="replace") if isinstance(item, bytes) else str(item)
|
||||
lines.append(f"{i}) \"{val}\"")
|
||||
return "\n".join(lines)
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
return str(result)
|
||||
|
||||
def _append_output(self, text: str):
|
||||
self._output.configure(state="normal")
|
||||
self._output.insert("end", text + "\n")
|
||||
self._output.configure(state="disabled")
|
||||
self._output.see("end")
|
||||
|
||||
def _clear_output(self):
|
||||
self._output.configure(state="normal")
|
||||
self._output.delete("1.0", "end")
|
||||
self._output.configure(state="disabled")
|
||||
|
||||
def _set_status(self, text: str, color: str = "#9ca3af"):
|
||||
self._status_bar.configure(text=text, text_color=color)
|
||||
|
||||
def _set_buttons_state(self, state: str):
|
||||
for btn in (self._exec_btn, self._info_btn, self._dbsize_btn, self._scan_btn):
|
||||
btn.configure(state=state)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Terminal tab — persistent interactive SSH shell via ShellSession + TerminalWidget.
|
||||
Terminal tab — persistent interactive SSH/Telnet shell via ShellSession/TelnetSession + TerminalWidget.
|
||||
"""
|
||||
|
||||
import queue
|
||||
@@ -8,6 +8,7 @@ import threading
|
||||
import time
|
||||
import customtkinter as ctk
|
||||
from core.ssh_client import ShellSession
|
||||
from core.telnet_client import TelnetSession
|
||||
from core.i18n import t
|
||||
|
||||
# Regex to strip ANSI escape sequences
|
||||
@@ -20,7 +21,7 @@ class TerminalTab(ctk.CTkFrame):
|
||||
self.store = store
|
||||
self.session_pool = session_pool
|
||||
self._current_alias: str | None = None
|
||||
self._session: ShellSession | None = None
|
||||
self._session: ShellSession | TelnetSession | None = None
|
||||
self._reconnect_count = 0
|
||||
self._max_reconnect = 5
|
||||
self._intentional_disconnect = False
|
||||
@@ -76,16 +77,25 @@ class TerminalTab(ctk.CTkFrame):
|
||||
return
|
||||
|
||||
alias = self._current_alias
|
||||
server_type = server.get("type", "ssh")
|
||||
self._terminal.set_status(t("term_connecting").format(alias=alias), "#ccaa00")
|
||||
self._intentional_disconnect = False
|
||||
|
||||
def _do_connect():
|
||||
try:
|
||||
key_path = self.store.get_ssh_key_path()
|
||||
cols, rows = self._terminal.get_size()
|
||||
|
||||
# Use session pool if available
|
||||
if self.session_pool:
|
||||
cols, rows = self._terminal.get_size()
|
||||
if server_type == "telnet":
|
||||
# Telnet — direct session, no pool (pool is SSH-specific)
|
||||
self.after(0, self._terminal.reset)
|
||||
session = TelnetSession(server, cols=cols, rows=rows)
|
||||
session.on_data = self._on_data_received
|
||||
session.on_disconnect = self._on_disconnected
|
||||
session.connect()
|
||||
self._session = session
|
||||
elif self.session_pool:
|
||||
# SSH with session pool
|
||||
session, is_new = self.session_pool.get_or_create_shell_session(alias, server, key_path)
|
||||
if is_new:
|
||||
# New session — reset terminal for clean start
|
||||
@@ -108,9 +118,8 @@ class TerminalTab(ctk.CTkFrame):
|
||||
session.on_disconnect = self._on_disconnected
|
||||
self._session = session
|
||||
else:
|
||||
# Legacy behavior without session pool
|
||||
# SSH without pool (legacy)
|
||||
self.after(0, self._terminal.reset)
|
||||
cols, rows = self._terminal.get_size()
|
||||
session = ShellSession(server, key_path, cols=cols, rows=rows)
|
||||
session.on_data = self._on_data_received
|
||||
session.on_disconnect = self._on_disconnected
|
||||
@@ -136,12 +145,18 @@ class TerminalTab(ctk.CTkFrame):
|
||||
|
||||
def _disconnect(self):
|
||||
self._intentional_disconnect = True
|
||||
# Only disconnect if we don't have a session pool (otherwise session stays alive)
|
||||
if not self.session_pool and self._session:
|
||||
if not self._session:
|
||||
return
|
||||
# Telnet sessions are never pooled — always disconnect directly
|
||||
if isinstance(self._session, TelnetSession):
|
||||
self._session.disconnect()
|
||||
self._session = None
|
||||
# If using session pool, session remains active in the pool
|
||||
elif self.session_pool and self._session:
|
||||
# SSH without session pool — disconnect directly
|
||||
elif not self.session_pool:
|
||||
self._session.disconnect()
|
||||
self._session = None
|
||||
# SSH with session pool — session remains active in the pool
|
||||
else:
|
||||
# Remove callbacks to prevent processing data after switch
|
||||
self._session.on_data = None
|
||||
self._session.on_disconnect = None
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 для больших баз
|
||||
|
||||
508
tools/ssh.py
508
tools/ssh.py
@@ -3,7 +3,7 @@
|
||||
SSH utility for Claude Code — connects to servers by alias.
|
||||
Credentials stored locally in servers.json (encrypted), NEVER exposed to AI API.
|
||||
|
||||
Usage:
|
||||
Usage (SSH):
|
||||
python ssh.py ALIAS "command" # run as configured user (auto-sudo if needed)
|
||||
python ssh.py ALIAS --no-sudo "command" # run without sudo elevation
|
||||
python ssh.py ALIAS --upload LOCAL REMOTE
|
||||
@@ -16,6 +16,29 @@ Usage:
|
||||
python ssh.py --set-note ALIAS "desc" # update server notes
|
||||
python ssh.py --add ALIAS IP PORT USER PASSWORD [--note "desc"]
|
||||
python ssh.py --remove ALIAS
|
||||
|
||||
SQL (type: mariadb / mssql / postgresql):
|
||||
python ssh.py --sql ALIAS "SELECT * FROM users" # execute SQL query
|
||||
python ssh.py --sql-databases ALIAS # list databases
|
||||
python ssh.py --sql-tables ALIAS [database] # list tables
|
||||
|
||||
Redis (type: redis):
|
||||
python ssh.py --redis ALIAS "GET mykey" # execute Redis command
|
||||
python ssh.py --redis-info ALIAS # Redis INFO
|
||||
python ssh.py --redis-keys ALIAS "user:*" # SCAN keys by pattern
|
||||
|
||||
Grafana (type: grafana):
|
||||
python ssh.py --grafana-dashboards ALIAS # list dashboards
|
||||
python ssh.py --grafana-alerts ALIAS # list alerts
|
||||
|
||||
Prometheus (type: prometheus):
|
||||
python ssh.py --prom-query ALIAS "up" # execute PromQL query
|
||||
python ssh.py --prom-targets ALIAS # list targets
|
||||
python ssh.py --prom-alerts ALIAS # list alerts
|
||||
|
||||
WinRM (type: winrm):
|
||||
python ssh.py --ps ALIAS "Get-Process" # PowerShell via WinRM
|
||||
python ssh.py --cmd ALIAS "dir" # CMD via WinRM
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -464,6 +487,415 @@ def remove_from_ssh_config(alias):
|
||||
f.writelines(new_lines)
|
||||
|
||||
|
||||
# ── SQL commands ──────────────────────────────────────
|
||||
|
||||
def _print_table(headers: list, rows: list):
|
||||
"""Print a formatted ASCII table."""
|
||||
if not rows:
|
||||
print("(no rows)")
|
||||
return
|
||||
widths = [len(str(h)) for h in headers]
|
||||
for row in rows:
|
||||
for i, val in enumerate(row):
|
||||
widths[i] = max(widths[i], len(str(val)))
|
||||
fmt = " ".join(f"{{:<{w}}}" for w in widths)
|
||||
print(fmt.format(*headers))
|
||||
print(" ".join("-" * w for w in widths))
|
||||
for row in rows:
|
||||
print(fmt.format(*[str(v) for v in row]))
|
||||
|
||||
|
||||
def run_sql(server: dict, query: str):
|
||||
"""Execute SQL query against mariadb/mssql/postgresql server."""
|
||||
stype = server.get("type", "mariadb")
|
||||
host = server["ip"]
|
||||
port = server.get("port", 3306)
|
||||
user = server.get("user", "root")
|
||||
password = server.get("password", "")
|
||||
database = server.get("database", "")
|
||||
|
||||
if stype in ("mariadb", "mysql"):
|
||||
import pymysql
|
||||
conn = pymysql.connect(host=host, port=port, user=user, password=password,
|
||||
database=database or None, connect_timeout=15,
|
||||
charset="utf8mb4", cursorclass=pymysql.cursors.Cursor)
|
||||
elif stype == "mssql":
|
||||
import pymssql
|
||||
conn = pymssql.connect(server=host, port=port, user=user, password=password,
|
||||
database=database or None, login_timeout=15)
|
||||
elif stype == "postgresql":
|
||||
import psycopg2
|
||||
port = server.get("port", 5432)
|
||||
conn = psycopg2.connect(host=host, port=port, user=user, password=password,
|
||||
dbname=database or None, connect_timeout=15)
|
||||
else:
|
||||
print(f"ERROR: Unsupported SQL type '{stype}'. Use mariadb, mssql, or postgresql.")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(query)
|
||||
if cur.description:
|
||||
headers = [desc[0] for desc in cur.description]
|
||||
rows = cur.fetchall()
|
||||
_print_table(headers, rows)
|
||||
print(f"\n({len(rows)} row{'s' if len(rows) != 1 else ''})")
|
||||
else:
|
||||
conn.commit()
|
||||
affected = cur.rowcount
|
||||
print(f"OK: {affected} row{'s' if affected != 1 else ''} affected")
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def sql_databases(server: dict):
|
||||
"""List databases on SQL server."""
|
||||
stype = server.get("type", "mariadb")
|
||||
if stype in ("mariadb", "mysql"):
|
||||
run_sql(server, "SHOW DATABASES")
|
||||
elif stype == "mssql":
|
||||
run_sql(server, "SELECT name FROM sys.databases ORDER BY name")
|
||||
elif stype == "postgresql":
|
||||
run_sql(server, "SELECT datname AS database FROM pg_database WHERE datistemplate = false ORDER BY datname")
|
||||
else:
|
||||
print(f"ERROR: Unsupported SQL type '{stype}'.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def sql_tables(server: dict, database: str = None):
|
||||
"""List tables on SQL server, optionally for a specific database."""
|
||||
stype = server.get("type", "mariadb")
|
||||
if database:
|
||||
server = dict(server)
|
||||
server["database"] = database
|
||||
if stype in ("mariadb", "mysql"):
|
||||
if database:
|
||||
run_sql(server, f"SHOW TABLES FROM `{database}`")
|
||||
else:
|
||||
run_sql(server, "SHOW TABLES")
|
||||
elif stype == "mssql":
|
||||
run_sql(server, "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA, TABLE_NAME")
|
||||
elif stype == "postgresql":
|
||||
run_sql(server, "SELECT schemaname, tablename FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema') ORDER BY schemaname, tablename")
|
||||
else:
|
||||
print(f"ERROR: Unsupported SQL type '{stype}'.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ── Redis commands ────────────────────────────────────
|
||||
|
||||
def run_redis_cmd(server: dict, command: str):
|
||||
"""Execute a Redis command."""
|
||||
import redis as redis_lib
|
||||
host = server["ip"]
|
||||
port = server.get("port", 6379)
|
||||
password = server.get("password", "") or None
|
||||
db_index = server.get("db_index", 0)
|
||||
ssl_enabled = server.get("ssl", False)
|
||||
|
||||
r = redis_lib.Redis(host=host, port=port, password=password, db=db_index,
|
||||
decode_responses=True, socket_timeout=10, ssl=ssl_enabled)
|
||||
try:
|
||||
parts = command.split()
|
||||
if not parts:
|
||||
print("ERROR: Empty Redis command")
|
||||
sys.exit(1)
|
||||
result = r.execute_command(*parts)
|
||||
if isinstance(result, list):
|
||||
for i, item in enumerate(result):
|
||||
print(f"{i + 1}) {item}")
|
||||
print(f"\n({len(result)} items)")
|
||||
elif isinstance(result, dict):
|
||||
for k, v in result.items():
|
||||
print(f"{k}: {v}")
|
||||
elif isinstance(result, bytes):
|
||||
print(result.decode("utf-8", errors="replace"))
|
||||
else:
|
||||
print(result)
|
||||
finally:
|
||||
r.close()
|
||||
|
||||
|
||||
def redis_info(server: dict):
|
||||
"""Show Redis INFO."""
|
||||
import redis as redis_lib
|
||||
host = server["ip"]
|
||||
port = server.get("port", 6379)
|
||||
password = server.get("password", "") or None
|
||||
db_index = server.get("db_index", 0)
|
||||
ssl_enabled = server.get("ssl", False)
|
||||
|
||||
r = redis_lib.Redis(host=host, port=port, password=password, db=db_index,
|
||||
decode_responses=True, socket_timeout=10, ssl=ssl_enabled)
|
||||
try:
|
||||
info = r.info()
|
||||
# Print key sections
|
||||
sections = ["redis_version", "redis_mode", "os", "uptime_in_seconds",
|
||||
"connected_clients", "used_memory_human", "used_memory_peak_human",
|
||||
"total_connections_received", "total_commands_processed",
|
||||
"keyspace_hits", "keyspace_misses", "role"]
|
||||
print(f"{'Key':<35} {'Value'}")
|
||||
print("-" * 60)
|
||||
for key in sections:
|
||||
if key in info:
|
||||
print(f"{key:<35} {info[key]}")
|
||||
# Print keyspace info (db0, db1, etc.)
|
||||
for key in sorted(info.keys()):
|
||||
if key.startswith("db"):
|
||||
print(f"{key:<35} {info[key]}")
|
||||
finally:
|
||||
r.close()
|
||||
|
||||
|
||||
def redis_keys(server: dict, pattern: str):
|
||||
"""SCAN keys matching a pattern."""
|
||||
import redis as redis_lib
|
||||
host = server["ip"]
|
||||
port = server.get("port", 6379)
|
||||
password = server.get("password", "") or None
|
||||
db_index = server.get("db_index", 0)
|
||||
ssl_enabled = server.get("ssl", False)
|
||||
|
||||
r = redis_lib.Redis(host=host, port=port, password=password, db=db_index,
|
||||
decode_responses=True, socket_timeout=10, ssl=ssl_enabled)
|
||||
try:
|
||||
keys = []
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, batch = r.scan(cursor=cursor, match=pattern, count=200)
|
||||
keys.extend(batch)
|
||||
if cursor == 0:
|
||||
break
|
||||
if len(keys) >= 1000:
|
||||
print("(truncated at 1000 keys)")
|
||||
break
|
||||
keys.sort()
|
||||
for k in keys:
|
||||
print(k)
|
||||
print(f"\n({len(keys)} key{'s' if len(keys) != 1 else ''})")
|
||||
finally:
|
||||
r.close()
|
||||
|
||||
|
||||
# ── Grafana commands ──────────────────────────────────
|
||||
|
||||
def _grafana_request(server: dict, endpoint: str) -> dict:
|
||||
"""Make an authenticated GET request to Grafana API."""
|
||||
import requests
|
||||
host = server["ip"]
|
||||
port = server.get("port", 3000)
|
||||
protocol = "https" if server.get("ssl", False) else "http"
|
||||
base_url = server.get("base_url", f"{protocol}://{host}:{port}")
|
||||
api_key = server.get("api_key", server.get("password", ""))
|
||||
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
url = f"{base_url.rstrip('/')}/api/{endpoint.lstrip('/')}"
|
||||
resp = requests.get(url, headers=headers, timeout=15, verify=server.get("ssl_verify", True))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def grafana_dashboards(server: dict):
|
||||
"""List Grafana dashboards."""
|
||||
data = _grafana_request(server, "search?type=dash-db")
|
||||
if not data:
|
||||
print("(no dashboards found)")
|
||||
return
|
||||
headers = ["UID", "Title", "Folder", "URL"]
|
||||
rows = []
|
||||
for d in data:
|
||||
rows.append([
|
||||
d.get("uid", ""),
|
||||
d.get("title", ""),
|
||||
d.get("folderTitle", "(root)"),
|
||||
d.get("url", ""),
|
||||
])
|
||||
_print_table(headers, rows)
|
||||
print(f"\n({len(rows)} dashboard{'s' if len(rows) != 1 else ''})")
|
||||
|
||||
|
||||
def grafana_alerts(server: dict):
|
||||
"""List Grafana alert rules."""
|
||||
data = _grafana_request(server, "alertmanager/grafana/api/v2/alerts")
|
||||
if not data:
|
||||
print("(no alerts)")
|
||||
return
|
||||
headers = ["Status", "Name", "Severity", "Summary"]
|
||||
rows = []
|
||||
for alert in data:
|
||||
status = alert.get("status", {}).get("state", "unknown")
|
||||
labels = alert.get("labels", {})
|
||||
annotations = alert.get("annotations", {})
|
||||
rows.append([
|
||||
status,
|
||||
labels.get("alertname", ""),
|
||||
labels.get("severity", ""),
|
||||
annotations.get("summary", "")[:80],
|
||||
])
|
||||
_print_table(headers, rows)
|
||||
print(f"\n({len(rows)} alert{'s' if len(rows) != 1 else ''})")
|
||||
|
||||
|
||||
# ── Prometheus commands ───────────────────────────────
|
||||
|
||||
def _prom_request(server: dict, endpoint: str, params: dict = None) -> dict:
|
||||
"""Make a GET request to Prometheus API."""
|
||||
import requests
|
||||
host = server["ip"]
|
||||
port = server.get("port", 9090)
|
||||
protocol = "https" if server.get("ssl", False) else "http"
|
||||
base_url = server.get("base_url", f"{protocol}://{host}:{port}")
|
||||
auth = None
|
||||
user = server.get("user", "")
|
||||
password = server.get("password", "")
|
||||
if user and password:
|
||||
auth = (user, password)
|
||||
|
||||
url = f"{base_url.rstrip('/')}/api/v1/{endpoint.lstrip('/')}"
|
||||
resp = requests.get(url, params=params, auth=auth, timeout=15,
|
||||
verify=server.get("ssl_verify", True))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def prom_query(server: dict, query: str):
|
||||
"""Execute a PromQL instant query."""
|
||||
data = _prom_request(server, "query", {"query": query})
|
||||
status = data.get("status", "")
|
||||
if status != "success":
|
||||
print(f"ERROR: Prometheus returned status '{status}'")
|
||||
if "error" in data:
|
||||
print(f" {data['error']}")
|
||||
sys.exit(1)
|
||||
|
||||
result = data.get("data", {})
|
||||
result_type = result.get("resultType", "")
|
||||
results = result.get("result", [])
|
||||
|
||||
if not results:
|
||||
print("(no results)")
|
||||
return
|
||||
|
||||
if result_type == "vector":
|
||||
headers = ["Metric", "Value", "Timestamp"]
|
||||
rows = []
|
||||
for r in results:
|
||||
metric = r.get("metric", {})
|
||||
label_str = ", ".join(f'{k}="{v}"' for k, v in metric.items())
|
||||
ts, val = r.get("value", [0, ""])
|
||||
rows.append([label_str or "{}", val, ts])
|
||||
_print_table(headers, rows)
|
||||
elif result_type == "scalar":
|
||||
ts, val = results
|
||||
print(f"Scalar: {val} (at {ts})")
|
||||
elif result_type == "string":
|
||||
ts, val = results
|
||||
print(f"String: {val} (at {ts})")
|
||||
elif result_type == "matrix":
|
||||
for series in results:
|
||||
metric = series.get("metric", {})
|
||||
label_str = ", ".join(f'{k}="{v}"' for k, v in metric.items())
|
||||
print(f"\n--- {label_str or '{}'} ---")
|
||||
values = series.get("values", [])
|
||||
for ts, val in values[-20:]: # last 20 samples
|
||||
print(f" [{ts}] {val}")
|
||||
if len(values) > 20:
|
||||
print(f" ... ({len(values)} total samples, showing last 20)")
|
||||
|
||||
print(f"\n({len(results)} result{'s' if len(results) != 1 else ''}, type: {result_type})")
|
||||
|
||||
|
||||
def prom_targets(server: dict):
|
||||
"""List Prometheus scrape targets."""
|
||||
data = _prom_request(server, "targets")
|
||||
active = data.get("data", {}).get("activeTargets", [])
|
||||
if not active:
|
||||
print("(no active targets)")
|
||||
return
|
||||
headers = ["Job", "Instance", "State", "Health", "Last Scrape"]
|
||||
rows = []
|
||||
for t in active:
|
||||
labels = t.get("labels", {})
|
||||
rows.append([
|
||||
labels.get("job", ""),
|
||||
labels.get("instance", ""),
|
||||
t.get("scrapePool", ""),
|
||||
t.get("health", ""),
|
||||
t.get("lastScrape", "")[:19],
|
||||
])
|
||||
_print_table(headers, rows)
|
||||
print(f"\n({len(rows)} target{'s' if len(rows) != 1 else ''})")
|
||||
|
||||
|
||||
def prom_alerts(server: dict):
|
||||
"""List Prometheus alerts."""
|
||||
data = _prom_request(server, "alerts")
|
||||
alerts = data.get("data", {}).get("alerts", [])
|
||||
if not alerts:
|
||||
print("(no alerts)")
|
||||
return
|
||||
headers = ["State", "Name", "Severity", "Active Since"]
|
||||
rows = []
|
||||
for a in alerts:
|
||||
labels = a.get("labels", {})
|
||||
rows.append([
|
||||
a.get("state", ""),
|
||||
labels.get("alertname", ""),
|
||||
labels.get("severity", ""),
|
||||
a.get("activeAt", "")[:19],
|
||||
])
|
||||
_print_table(headers, rows)
|
||||
print(f"\n({len(rows)} alert{'s' if len(rows) != 1 else ''})")
|
||||
|
||||
|
||||
# ── WinRM commands ────────────────────────────────────
|
||||
|
||||
def _get_winrm_session(server: dict):
|
||||
"""Create a WinRM session."""
|
||||
import winrm
|
||||
host = server["ip"]
|
||||
port = server.get("port", 5985)
|
||||
user = server.get("user", "Administrator")
|
||||
password = server.get("password", "")
|
||||
protocol = "https" if server.get("ssl", False) or port == 5986 else "http"
|
||||
transport = server.get("transport", "ntlm")
|
||||
|
||||
endpoint = f"{protocol}://{host}:{port}/wsman"
|
||||
session = winrm.Session(endpoint, auth=(user, password), transport=transport,
|
||||
server_cert_validation="ignore" if protocol == "https" else "validate")
|
||||
return session
|
||||
|
||||
|
||||
def run_winrm_ps(server: dict, command: str):
|
||||
"""Execute PowerShell command via WinRM."""
|
||||
session = _get_winrm_session(server)
|
||||
result = session.run_ps(command)
|
||||
out = result.std_out.decode("utf-8", errors="replace").strip()
|
||||
err = result.std_err.decode("utf-8", errors="replace").strip()
|
||||
if out:
|
||||
print(out)
|
||||
if err:
|
||||
print(err, file=sys.stderr)
|
||||
sys.exit(result.status_code)
|
||||
|
||||
|
||||
def run_winrm_cmd(server: dict, command: str):
|
||||
"""Execute CMD command via WinRM."""
|
||||
session = _get_winrm_session(server)
|
||||
result = session.run_cmd(command)
|
||||
out = result.std_out.decode("utf-8", errors="replace").strip()
|
||||
err = result.std_err.decode("utf-8", errors="replace").strip()
|
||||
if out:
|
||||
print(out)
|
||||
if err:
|
||||
print(err, file=sys.stderr)
|
||||
sys.exit(result.status_code)
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
@@ -488,6 +920,80 @@ def main():
|
||||
if cmd == "--remove" and len(sys.argv) >= 3:
|
||||
remove_server(sys.argv[2]); sys.exit(0)
|
||||
|
||||
# ── SQL commands (global-style: --sql ALIAS ...) ──
|
||||
if cmd == "--sql" and len(sys.argv) >= 4:
|
||||
_, servers = load_servers()
|
||||
alias = _resolve_alias(sys.argv[2], servers)
|
||||
run_sql(servers[alias], sys.argv[3])
|
||||
sys.exit(0)
|
||||
if cmd == "--sql-databases" and len(sys.argv) >= 3:
|
||||
_, servers = load_servers()
|
||||
alias = _resolve_alias(sys.argv[2], servers)
|
||||
sql_databases(servers[alias])
|
||||
sys.exit(0)
|
||||
if cmd == "--sql-tables" and len(sys.argv) >= 3:
|
||||
_, servers = load_servers()
|
||||
alias = _resolve_alias(sys.argv[2], servers)
|
||||
db = sys.argv[3] if len(sys.argv) >= 4 else None
|
||||
sql_tables(servers[alias], db)
|
||||
sys.exit(0)
|
||||
|
||||
# ── Redis commands ──
|
||||
if cmd == "--redis" and len(sys.argv) >= 4:
|
||||
_, servers = load_servers()
|
||||
alias = _resolve_alias(sys.argv[2], servers)
|
||||
run_redis_cmd(servers[alias], sys.argv[3])
|
||||
sys.exit(0)
|
||||
if cmd == "--redis-info" and len(sys.argv) >= 3:
|
||||
_, servers = load_servers()
|
||||
alias = _resolve_alias(sys.argv[2], servers)
|
||||
redis_info(servers[alias])
|
||||
sys.exit(0)
|
||||
if cmd == "--redis-keys" and len(sys.argv) >= 4:
|
||||
_, servers = load_servers()
|
||||
alias = _resolve_alias(sys.argv[2], servers)
|
||||
redis_keys(servers[alias], sys.argv[3])
|
||||
sys.exit(0)
|
||||
|
||||
# ── Grafana commands ──
|
||||
if cmd == "--grafana-dashboards" and len(sys.argv) >= 3:
|
||||
_, servers = load_servers()
|
||||
alias = _resolve_alias(sys.argv[2], servers)
|
||||
grafana_dashboards(servers[alias])
|
||||
sys.exit(0)
|
||||
if cmd == "--grafana-alerts" and len(sys.argv) >= 3:
|
||||
_, servers = load_servers()
|
||||
alias = _resolve_alias(sys.argv[2], servers)
|
||||
grafana_alerts(servers[alias])
|
||||
sys.exit(0)
|
||||
|
||||
# ── Prometheus commands ──
|
||||
if cmd == "--prom-query" and len(sys.argv) >= 4:
|
||||
_, servers = load_servers()
|
||||
alias = _resolve_alias(sys.argv[2], servers)
|
||||
prom_query(servers[alias], sys.argv[3])
|
||||
sys.exit(0)
|
||||
if cmd == "--prom-targets" and len(sys.argv) >= 3:
|
||||
_, servers = load_servers()
|
||||
alias = _resolve_alias(sys.argv[2], servers)
|
||||
prom_targets(servers[alias])
|
||||
sys.exit(0)
|
||||
if cmd == "--prom-alerts" and len(sys.argv) >= 3:
|
||||
_, servers = load_servers()
|
||||
alias = _resolve_alias(sys.argv[2], servers)
|
||||
prom_alerts(servers[alias])
|
||||
sys.exit(0)
|
||||
|
||||
# ── WinRM commands ──
|
||||
if cmd == "--ps" and len(sys.argv) >= 4:
|
||||
_, servers = load_servers()
|
||||
alias = _resolve_alias(sys.argv[2], servers)
|
||||
run_winrm_ps(servers[alias], sys.argv[3])
|
||||
if cmd == "--cmd" and len(sys.argv) >= 4:
|
||||
_, servers = load_servers()
|
||||
alias = _resolve_alias(sys.argv[2], servers)
|
||||
run_winrm_cmd(servers[alias], sys.argv[3])
|
||||
|
||||
# Server commands — exact match first, then fuzzy search by keyword
|
||||
alias = cmd
|
||||
_, servers = load_servers()
|
||||
|
||||
Reference in New Issue
Block a user