v1.8.52: icons module, Windows SSH sanitization, embedded RDP improvements, UI polish

- Add core/icons.py — centralized icon text helper with emoji/symbol support
- Add Windows SSH command sanitization in ssh.py (Linux→Windows auto-translation)
- Improve embedded RDP: launch tab connect/disconnect, fullscreen toggle
- Refactor sidebar: cleaner server type badges
- Update server_dialog: adaptive fields per server type
- Add setup_openssh.bat tool
- Update skill-ssh.md and CLAUDE.md docs for Windows SSH support
- Cleanup old releases, add v1.8.48-v1.8.52

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-24 14:37:37 -05:00
parent 142b68515c
commit 4959004a3f
30 changed files with 596 additions and 134 deletions

View File

@@ -107,6 +107,7 @@ tools/
- **Observer** — `ServerStore` → UI обновляется автоматически
- **Session pool** — SSH-сессии живут при переключении серверов
- **Auto-sudo** — детекция `[sudo] password for`, автоотправка пароля
- **Windows sanitize** — `ssh.py` автоматически транслирует Linux-команды (ls, cat, grep, ps, etc.) в Windows-эквиваленты (cmd.exe/PowerShell) при подключении к Windows SSH серверам. Pipe-цепочки и && корректно обрабатываются. Кодировка принудительно UTF-8 через `chcp 65001`
- **i18n** — все строки через `t(key)`, 3 языка
## Как пользоваться /ssh

View File

@@ -402,6 +402,8 @@ _EN = {
"rdp_quality_lan": "LAN (Best)",
"rdp_quality_broadband": "Broadband",
"rdp_quality_modem": "Low Bandwidth",
"rdp_resolution": "Resolution",
"rdp_resolution_auto": "Auto (Fit Window)",
"rdp_clipboard": "Share Clipboard",
"rdp_drives": "Share Drives (Files)",
"rdp_printers": "Share Printers",
@@ -812,6 +814,8 @@ _RU = {
"rdp_quality_lan": "LAN (лучшее)",
"rdp_quality_broadband": "Broadband",
"rdp_quality_modem": "Низкое",
"rdp_resolution": "Разрешение",
"rdp_resolution_auto": "Авто (по размеру окна)",
"rdp_clipboard": "Буфер обмена",
"rdp_drives": "Проброс дисков (файлы)",
"rdp_printers": "Принтеры",
@@ -1222,6 +1226,8 @@ _ZH = {
"rdp_quality_lan": "局域网 (最佳)",
"rdp_quality_broadband": "宽带",
"rdp_quality_modem": "低带宽",
"rdp_resolution": "分辨率",
"rdp_resolution_auto": "自动 (适应窗口)",
"rdp_clipboard": "共享剪贴板",
"rdp_drives": "共享驱动器 (文件)",
"rdp_printers": "共享打印机",

176
core/icons.py Normal file
View File

@@ -0,0 +1,176 @@
"""
Icon registry — semantic Unicode symbols for all GUI elements.
Centralized icon management for buttons, tabs, menus, and type badges.
"""
# Semantic icon mapping
ICONS = {
# Navigation
"back": "\u2190", # ←
"up": "\u2191", # ↑
"refresh": "\u21bb", # ↻
# CRUD
"add": "\uff0b", #
"edit": "\u270e", # ✎
"delete": "\u2715", # ✕
"confirm": "\u2713", # ✓
# Transfer
"upload": "\u2b06", # ⬆
"download": "\u2b07", # ⬇
# Actions
"execute": "\u25b6", # ▶
"info": "\u2139", #
"clear": "\u232b", # ⌫
"search": "\U0001f50d", # 🔍
"hash": "#",
# Files
"folder": "\U0001f4c1", # 📁
"folder_open": "\U0001f4c2", # 📂
"save": "\U0001f4be", # 💾
# Keys & security
"key": "\U0001f511", # 🔑
"lock": "\U0001f510", # 🔐
"eye": "\U0001f441", # 👁
# Clipboard
"copy": "\U0001f4cb", # 📋
# Settings
"gear": "\u2699", # ⚙
"globe": "\U0001f310", # 🌐
# Status
"online": "\u25cf", # ●
"checking": "\u25d0", # ◐
"offline": "\u2014", # —
# Tabs
"terminal": "\u2328", # ⌨
"query": "\u25b6", # ▶
"dashboards": "\U0001f4ca", # 📊
"metrics": "\U0001f4c8", # 📈
"powershell": "\u2328", # ⌨
"launch": "\U0001f5a5", # 🖥
"totp": "\U0001f510", # 🔐
# Context menu
"connect": "\u25b6", # ▶
"browser": "\U0001f310", # 🌐
"status_check": "\u25cf",# ●
}
def icon(name: str) -> str:
"""Get icon symbol by semantic name."""
return ICONS.get(name, "")
def icon_text(name: str, label: str) -> str:
"""Format 'icon label' string for buttons/menus."""
sym = ICONS.get(name, "")
if sym:
return f"{sym} {label}"
return label
# Server type colors
TYPE_COLORS = {
"ssh": "#22c55e",
"telnet": "#a855f7",
"rdp": "#3b82f6",
"vnc": "#6366f1",
"winrm": "#0ea5e9",
"mariadb": "#f59e0b",
"mssql": "#ef4444",
"postgresql": "#3b82f6",
"redis": "#dc2626",
"grafana": "#f97316",
"prometheus": "#e11d48",
}
# Unicode symbols for each server type (reliable, no PIL needed)
TYPE_SYMBOLS = {
"ssh": "\U0001f5a5", # 🖥
"telnet": "\U0001f4df", # 📟
"rdp": "\U0001f5b5", # 🖵
"vnc": "\U0001f5b5", # 🖵
"winrm": "\u229e", # ⊞
"mariadb": "\U0001f4be", # 💾
"mssql": "\U0001f4be", # 💾
"postgresql": "\U0001f418",# 🐘
"redis": "\u25c6", # ◆
"grafana": "\U0001f4ca", # 📊
"prometheus": "\U0001f525", # 🔥
}
# Short text labels for sidebar badge
TYPE_LABELS = {
"ssh": "SSH",
"telnet": "TEL",
"rdp": "RDP",
"vnc": "VNC",
"winrm": "PS",
"mariadb": "MDB",
"mssql": "SQL",
"postgresql": "PG",
"redis": "RDS",
"grafana": "GRF",
"prometheus": "PRM",
}
def type_display(server_type: str) -> str:
"""Return 'symbol type' for dropdown display, e.g. '🖥 ssh'."""
sym = TYPE_SYMBOLS.get(server_type, "")
if sym:
return f"{sym} {server_type}"
return server_type
def type_from_display(display: str) -> str:
"""Extract raw type from display string, e.g. '🖥 ssh' -> 'ssh'."""
# Strip the leading symbol + space
for stype in TYPE_SYMBOLS:
suffix = f" {stype}"
if display.endswith(suffix) and len(display) == len(suffix) + len(TYPE_SYMBOLS.get(stype, "")):
return stype
# Fallback: return as-is (might already be raw type)
return display.strip()
# Tab icon mapping (tab_key -> icon_name)
TAB_ICONS = {
"terminal": "terminal",
"files": "folder",
"info": "info",
"keys": "key",
"totp": "totp",
"setup": "gear",
"query": "query",
"console": "terminal",
"dashboards": "dashboards",
"metrics": "metrics",
"powershell": "powershell",
"launch": "launch",
}
# Context menu icon mapping (i18n_key -> icon_name)
CTX_ICONS = {
"ctx_open_terminal": "terminal",
"ctx_browse_files": "folder",
"ctx_install_key": "key",
"ctx_open_powershell": "powershell",
"ctx_open_query": "query",
"ctx_open_console": "terminal",
"ctx_connect": "connect",
"ctx_open_browser": "browser",
"ctx_check_status": "status_check",
"ctx_copy_alias": "copy",
"edit": "edit",
"delete": "delete",
}

View File

@@ -250,8 +250,12 @@ class EmbeddedRDP:
self.on_failed = None # called on embed failure
self.on_disconnected = None # called when mstsc exits
def generate_rdp_file(self, width: int, height: int) -> str:
"""Build a .rdp temp file with all settings."""
def generate_rdp_file(self, window_w: int, window_h: int) -> str:
"""Build a .rdp temp file with all settings.
window_w/window_h: physical frame size for embedding.
Session resolution comes from settings["resolution"] (e.g. "1920x1080" or "auto").
"""
s = self.server
cfg = self.settings
hostname = s["ip"]
@@ -259,6 +263,17 @@ class EmbeddedRDP:
user = s.get("user", "Administrator")
password = s.get("password", "")
# Session resolution.
# "auto": use frame size — session matches the embed area exactly.
# Fixed (e.g. "1920x1080"): use that resolution, smart sizing scales
# the content to fit the frame.
resolution = cfg.get("resolution", "auto")
if resolution != "auto":
parts = resolution.split("x")
desk_w, desk_h = int(parts[0]), int(parts[1])
else:
desk_w, desk_h = window_w, window_h
quality = cfg.get("quality", "auto")
conn_type, bpp, no_wallpaper, no_themes, font_smooth, aero = _QUALITY_PRESETS.get(quality, _QUALITY_PRESETS["auto"])
@@ -269,10 +284,10 @@ class EmbeddedRDP:
lines = [
f"full address:s:{hostname}:{port}",
f"username:s:{user}",
f"desktopwidth:i:{width}",
f"desktopheight:i:{height}",
f"desktopwidth:i:{desk_w}",
f"desktopheight:i:{desk_h}",
"screen mode id:i:1", # windowed (required for embedding)
"smart sizing:i:1", # scale to window
"use multimon:i:0",
f"session bpp:i:{bpp}",
f"connection type:i:{conn_type}",
"compression:i:1",
@@ -297,6 +312,10 @@ class EmbeddedRDP:
"enablerdsaadauth:i:0",
]
# smart sizing scales the fixed-resolution bitmap to fit the mstsc window.
# No dynamic resolution — session resolution is set once at connect time.
lines.append("smart sizing:i:1")
if drives:
lines.append(f"drivestoredirect:s:{drives}")
@@ -314,8 +333,12 @@ class EmbeddedRDP:
log.info(f"Embedded RDP file: {self._rdp_file}")
return self._rdp_file
def launch(self, parent_hwnd: int, width: int = 1024, height: int = 768):
"""Launch mstsc and start background embed thread."""
def launch(self, parent_hwnd: int, window_w: int = 1024, window_h: int = 768):
"""Launch mstsc and start background embed thread.
window_w/window_h: physical size of the embedding frame.
Session resolution is set via settings["resolution"] in the .rdp file.
"""
self._parent_hwnd = parent_hwnd
# Pre-trust server certificate to suppress dialog
@@ -323,16 +346,17 @@ class EmbeddedRDP:
port = self.server.get("port", 3389)
_trust_rdp_server(hostname, port)
rdp_file = self.generate_rdp_file(width, height)
rdp_file = self.generate_rdp_file(window_w, window_h)
self._launch_time = time.time()
# Always launch mstsc at frame size — smart sizing scales the session
self._process = subprocess.Popen(
["mstsc.exe", rdp_file, f"/w:{width}", f"/h:{height}"],
["mstsc.exe", rdp_file, f"/w:{window_w}", f"/h:{window_h}"],
creationflags=0x00000010, # CREATE_NEW_CONSOLE suppressed
)
log.info(f"mstsc.exe launched, PID={self._process.pid}")
threading.Thread(target=self._find_and_embed, args=(parent_hwnd, width, height), daemon=True).start()
threading.Thread(target=self._find_and_embed, args=(parent_hwnd, window_w, window_h), daemon=True).start()
def _find_and_embed(self, parent_hwnd: int, width: int, height: int):
"""Background: poll for mstsc window using multi-strategy search, then embed.
@@ -567,10 +591,13 @@ class EmbeddedRDP:
# Resize to fill parent
user32.MoveWindow(hwnd, 0, 0, width, height, True)
# Apply style change
# Apply style change — recalculates non-client area
user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0,
SWP_FRAMECHANGED | SWP_NOZORDER | 0x0001 | 0x0002)
# Re-position after FRAMECHANGED to fix non-client area offset
user32.MoveWindow(hwnd, 0, 0, width, height, True)
# Focus
user32.SetFocus(hwnd)
@@ -593,9 +620,14 @@ class EmbeddedRDP:
"""Resize the embedded mstsc window."""
if not self._mstsc_hwnd or not self._connected:
return
if width < 200 or height < 150:
return # Ignore degenerate sizes
try:
import ctypes
ctypes.windll.user32.MoveWindow(self._mstsc_hwnd, 0, 0, width, height, True)
user32 = ctypes.windll.user32
if not user32.IsWindow(self._mstsc_hwnd):
return
user32.MoveWindow(self._mstsc_hwnd, 0, 0, width, height, True)
except Exception:
pass
@@ -610,7 +642,7 @@ class EmbeddedRDP:
pass
def detach(self):
"""Detach mstsc from parent — for fullscreen."""
"""Detach mstsc from parent (step 1). Call maximize() after a delay."""
if not self._mstsc_hwnd or not self._connected:
return
self._is_detached = True
@@ -619,29 +651,67 @@ class EmbeddedRDP:
import ctypes.wintypes
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
hwnd = self._mstsc_hwnd
# Thread input attachment for cross-process style changes
target_tid = user32.GetWindowThreadProcessId(hwnd, None)
our_tid = kernel32.GetCurrentThreadId()
attached = False
if target_tid != our_tid:
attached = bool(user32.AttachThreadInput(our_tid, target_tid, True))
# Reparent to desktop
user32.SetParent(hwnd, 0)
# Restore normal window style
# Remove WS_CHILD, set normal top-level window style
GWL_STYLE = -16
WS_OVERLAPPEDWINDOW = 0x00CF0000
WS_VISIBLE = 0x10000000
user32.SetWindowLongW(hwnd, -16, WS_OVERLAPPEDWINDOW | WS_VISIBLE)
# Maximize
user32.ShowWindow(hwnd, 3) # SW_MAXIMIZE
WS_CHILD = 0x40000000
style = user32.GetWindowLongW(hwnd, GWL_STYLE) & 0xFFFFFFFF
new_style = ((style & ~WS_CHILD) | WS_OVERLAPPEDWINDOW | WS_VISIBLE) & 0xFFFFFFFF
user32.SetWindowLongW(hwnd, GWL_STYLE, ctypes.c_long(new_style).value)
# Apply frame change
SWP_FRAMECHANGED = 0x0020
SWP_NOZORDER = 0x0004
SWP_NOSIZE = 0x0001
SWP_NOMOVE = 0x0002
user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0,
SWP_FRAMECHANGED | SWP_NOZORDER | 0x0001 | 0x0002)
user32.SetForegroundWindow(hwnd)
SWP_FRAMECHANGED | SWP_NOZORDER | SWP_NOSIZE | SWP_NOMOVE)
log.info("mstsc detached to fullscreen")
if attached:
user32.AttachThreadInput(our_tid, target_tid, False)
log.info("mstsc detached from parent")
except Exception as e:
log.error(f"Detach failed: {e}")
def maximize(self):
"""Maximize the detached mstsc window (step 2, call after delay)."""
if not self._mstsc_hwnd:
return
try:
import ctypes
user32 = ctypes.windll.user32
hwnd = self._mstsc_hwnd
# Bring to foreground
user32.SetForegroundWindow(hwnd)
# Try ShowWindow first
user32.ShowWindow(hwnd, 3) # SW_MAXIMIZE
# Fallback: explicit resize to screen
screen_w = user32.GetSystemMetrics(0)
screen_h = user32.GetSystemMetrics(1)
user32.MoveWindow(hwnd, 0, 0, screen_w, screen_h, True)
log.info(f"mstsc maximize attempted ({screen_w}x{screen_h})")
except Exception as e:
log.error(f"Maximize failed: {e}")
def reattach(self, parent_hwnd: int, width: int, height: int):
"""Re-embed mstsc back into the tkinter frame."""
if not self._mstsc_hwnd:

View File

@@ -57,6 +57,7 @@ class ServerStore:
self._last_backup_time: float = 0
self._last_backup_hash: str = ""
self._terminal_font_size: int = 11
self._window_geometry: str = ""
self._servers_file: str = DEFAULT_SERVERS_FILE
self._load_settings()
self._load()
@@ -77,6 +78,7 @@ class ServerStore:
i18n.set_language(lang)
self._check_interval = settings.get("check_interval", 60)
self._terminal_font_size = settings.get("terminal_font_size", 11)
self._window_geometry = settings.get("window_geometry", "")
except json.JSONDecodeError:
log.warning("Corrupted settings.json, using defaults")
except Exception as e:
@@ -90,6 +92,7 @@ class ServerStore:
"language": i18n.get_language(),
"check_interval": self._check_interval,
"terminal_font_size": self._terminal_font_size,
"window_geometry": self._window_geometry,
}
try:
tmp = SETTINGS_FILE + ".tmp"

View File

@@ -16,6 +16,8 @@ class AboutDialog(ctk.CTkToplevel):
self.resizable(False, False)
self.transient(master)
self.grab_set()
self.focus_force()
self.protocol("WM_DELETE_WINDOW", self._on_close)
# ── Header ──
ctk.CTkLabel(
@@ -73,5 +75,12 @@ class AboutDialog(ctk.CTkToplevel):
# ── Close button ──
ctk.CTkButton(
self, text=t("close"), width=120, command=self.destroy
self, text=t("close"), width=120, command=self._on_close
).pack(pady=(10, 20))
def _on_close(self):
try:
self.grab_release()
except Exception:
pass
self.destroy()

View File

@@ -10,6 +10,7 @@ from core.server_store import ServerStore
from core.status_checker import StatusChecker
from core import i18n
from core.i18n import t, LANGUAGES
from core.icons import icon, TAB_ICONS
from core.session_pool import SessionPool
from gui.sidebar import Sidebar
from gui.server_dialog import ServerDialog
@@ -59,13 +60,20 @@ TAB_CLASSES = {
}
def _tab_label(key: str) -> str:
"""Return tab label with icon prefix: '📁 Files'."""
icon_name = TAB_ICONS.get(key)
sym = icon(icon_name) if icon_name else ""
text = t(key)
return f"{sym} {text}" if sym else text
class App(ctk.CTk):
def __init__(self):
super().__init__()
# Window config
self.title("ServerManager")
self.geometry("1100x700")
self.minsize(900, 500)
ctk.set_appearance_mode("dark")
@@ -76,6 +84,13 @@ class App(ctk.CTk):
self.checker = StatusChecker(self.store)
self.session_pool = SessionPool(max_sessions=5) # Create session pool
# Restore saved window geometry or use default
saved_geo = self.store._window_geometry
if saved_geo:
self.geometry(saved_geo)
else:
self.geometry("1100x700")
# Layout
self._build_layout()
@@ -116,6 +131,8 @@ class App(ctk.CTk):
header_bar.pack_propagate(False)
# Language selector
self._lang_icon = ctk.CTkLabel(header_bar, text="\U0001f310", font=ctk.CTkFont(size=14), width=20)
self._lang_icon.pack(side="right", padx=(5, 0))
lang_values = list(LANGUAGES.values())
current_display = LANGUAGES.get(i18n.get_language(), "English")
self._lang_var = ctk.StringVar(value=current_display)
@@ -171,14 +188,14 @@ class App(ctk.CTk):
self.tabview.pack(fill="both", expand=True, padx=10, pady=10)
for key in self._tab_keys:
self.tabview.add(t(key))
self.tabview.add(_tab_label(key))
# 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))
parent = self.tabview.tab(_tab_label(key))
widget = self._create_tab_instance(cls, key, parent)
widget.pack(fill="both", expand=True)
self._tab_instances[key] = widget
@@ -186,7 +203,7 @@ class App(ctk.CTk):
# 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))
self.tabview.set(_tab_label(restore_tab_key))
except Exception:
pass
@@ -257,7 +274,7 @@ class App(ctk.CTk):
self.sidebar._select(alias)
if tab_key in self._tab_keys:
try:
self.tabview.set(t(tab_key))
self.tabview.set(_tab_label(tab_key))
except Exception:
pass
@@ -303,9 +320,9 @@ class App(ctk.CTk):
"""Get the i18n key of the currently active tab."""
try:
current_name = self.tabview.get()
# Match against current language translations
# Match against current language translations with icons
for key in self._tab_keys:
if t(key) == current_name:
if _tab_label(key) == current_name:
return key
except Exception:
pass
@@ -473,7 +490,7 @@ class App(ctk.CTk):
try:
current = self.tabview.get()
terminal = self._tab_instances.get("terminal")
if terminal and current == t("terminal"):
if terminal and current == _tab_label("terminal"):
terminal._terminal.focus_terminal()
else:
self.focus_set()
@@ -481,6 +498,12 @@ class App(ctk.CTk):
pass
def _on_close(self):
# Save window geometry (size + position)
try:
self.store._window_geometry = self.geometry()
self.store._save_settings()
except Exception:
pass
# Clean up tab instances
for key, widget in self._tab_instances.items():
if hasattr(widget, "on_close"):

View File

@@ -6,6 +6,7 @@ Form adapts visible fields based on selected server type.
import customtkinter as ctk
from core.server_store import SERVER_TYPES, DEFAULT_PORTS
from core.i18n import t
from core.icons import icon_text, type_display, type_from_display
# Which conditional fields to show for each server type.
@@ -21,7 +22,7 @@ FIELD_MAP = {
"redis": ["password", "db_index"],
"grafana": ["api_token", "use_ssl"],
"prometheus": ["use_ssl"],
"rdp": ["user", "password"],
"rdp": ["user", "password", "rdp_resolution", "rdp_quality", "rdp_clipboard", "rdp_drives", "rdp_printers"],
"vnc": ["password"],
}
@@ -51,10 +52,14 @@ class ServerDialog(ctk.CTkToplevel):
self.title(t("edit_server") if server else t("add_server"))
self.geometry("450x720")
self.resizable(False, False)
self.grab_set()
# Center on parent
# transient BEFORE grab_set — prevents focus lock on minimize
self.transient(master)
self.grab_set()
self.focus_force()
# Release grab on close (prevents stuck app)
self.protocol("WM_DELETE_WINDOW", self._on_close)
self._field_frames: dict[str, ctk.CTkFrame] = {}
self._build_ui(server)
@@ -80,9 +85,10 @@ class ServerDialog(ctk.CTkToplevel):
type_frame = ctk.CTkFrame(row, fg_color="transparent")
type_frame.pack(side="left", fill="x", expand=True, padx=(0, 5))
ctk.CTkLabel(type_frame, text=t("type"), anchor="w").pack(fill="x")
self.type_var = ctk.StringVar(value="ssh")
self._type_display_values = [type_display(t) for t in SERVER_TYPES]
self.type_var = ctk.StringVar(value=type_display("ssh"))
self.type_menu = ctk.CTkOptionMenu(
type_frame, values=SERVER_TYPES, variable=self.type_var,
type_frame, values=self._type_display_values, variable=self.type_var,
command=self._on_type_change
)
self.type_menu.pack(fill="x")
@@ -126,7 +132,7 @@ class ServerDialog(ctk.CTkToplevel):
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_inner, text=t("show"), width=60, command=self._toggle_password)
self.show_pass = ctk.CTkButton(pass_inner, text=icon_text("eye", t("show")), width=70, command=self._toggle_password)
self.show_pass.pack(side="right")
self._pass_visible = False
self._field_frames["password"] = f
@@ -160,6 +166,55 @@ class ServerDialog(ctk.CTkToplevel):
self.api_token_entry.pack(fill="x", **entry_pad)
self._field_frames["api_token"] = f
# --- rdp_resolution ---
f = ctk.CTkFrame(self, fg_color="transparent")
ctk.CTkLabel(f, text=t("rdp_resolution"), anchor="w").pack(fill="x", **pad)
self._rdp_resolution_var = ctk.StringVar(value=t("rdp_resolution_auto"))
resolution_values = [
t("rdp_resolution_auto"),
"800\u00d7600", "1024\u00d7768", "1280\u00d71024",
"1366\u00d7768", "1600\u00d7900", "1920\u00d71080",
]
self._rdp_resolution_menu = ctk.CTkOptionMenu(f, values=resolution_values, variable=self._rdp_resolution_var)
self._rdp_resolution_menu.pack(fill="x", **entry_pad)
self._field_frames["rdp_resolution"] = f
# --- rdp_quality ---
f = ctk.CTkFrame(self, fg_color="transparent")
ctk.CTkLabel(f, text=t("rdp_quality"), anchor="w").pack(fill="x", **pad)
self._rdp_quality_var = ctk.StringVar(value="auto")
quality_values = [
t("rdp_quality_auto"), t("rdp_quality_lan"),
t("rdp_quality_broadband"), t("rdp_quality_modem"),
]
self._rdp_quality_map = {
t("rdp_quality_auto"): "auto", t("rdp_quality_lan"): "lan",
t("rdp_quality_broadband"): "broadband", t("rdp_quality_modem"): "modem",
}
self._rdp_quality_rmap = {v: k for k, v in self._rdp_quality_map.items()}
self._rdp_quality_menu = ctk.CTkOptionMenu(f, values=quality_values, variable=self._rdp_quality_var)
self._rdp_quality_menu.pack(fill="x", **entry_pad)
self._rdp_quality_var.set(t("rdp_quality_auto"))
self._field_frames["rdp_quality"] = f
# --- rdp_clipboard ---
f = ctk.CTkFrame(self, fg_color="transparent")
self._rdp_clipboard_var = ctk.BooleanVar(value=True)
ctk.CTkCheckBox(f, text=t("rdp_clipboard"), variable=self._rdp_clipboard_var).pack(fill="x", padx=20, pady=(8, 2))
self._field_frames["rdp_clipboard"] = f
# --- rdp_drives ---
f = ctk.CTkFrame(self, fg_color="transparent")
self._rdp_drives_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(f, text=t("rdp_drives"), variable=self._rdp_drives_var).pack(fill="x", padx=20, pady=(4, 2))
self._field_frames["rdp_drives"] = f
# --- rdp_printers ---
f = ctk.CTkFrame(self, fg_color="transparent")
self._rdp_printers_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(f, text=t("rdp_printers"), variable=self._rdp_printers_var).pack(fill="x", padx=20, pady=(4, 2))
self._field_frames["rdp_printers"] = f
# --- use_ssl ---
f = ctk.CTkFrame(self, fg_color="transparent")
self.use_ssl_var = ctk.BooleanVar(value=False)
@@ -182,14 +237,14 @@ class ServerDialog(ctk.CTkToplevel):
# ── 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))
ctk.CTkButton(btn_frame, text=t("save"), command=self._save).pack(side="right", expand=True, padx=(5, 0))
ctk.CTkButton(btn_frame, text=icon_text("delete", t("cancel")), fg_color="#6b7280", command=self.destroy).pack(side="left", expand=True, padx=(0, 5))
ctk.CTkButton(btn_frame, text=icon_text("confirm", t("save")), command=self._save).pack(side="right", expand=True, padx=(5, 0))
# Fill values if editing
if server:
self.alias_entry.insert(0, server.get("alias", ""))
self.ip_entry.insert(0, server.get("ip", ""))
self.type_var.set(server.get("type", "ssh"))
self.type_var.set(type_display(server.get("type", "ssh")))
self.port_entry.insert(0, str(server.get("port", 22)))
self.user_entry.insert(0, server.get("user", ""))
self.password_entry.insert(0, server.get("password", ""))
@@ -201,6 +256,19 @@ class ServerDialog(ctk.CTkToplevel):
self.api_token_entry.insert(0, server.get("api_token", ""))
self.use_ssl_var.set(server.get("use_ssl", False))
# RDP settings
res_raw = server.get("rdp_resolution", "auto")
if res_raw == "auto":
self._rdp_resolution_var.set(t("rdp_resolution_auto"))
else:
self._rdp_resolution_var.set(res_raw.replace("x", "\u00d7"))
q_raw = server.get("rdp_quality", "auto")
q_display = self._rdp_quality_rmap.get(q_raw, t("rdp_quality_auto"))
self._rdp_quality_var.set(q_display)
self._rdp_clipboard_var.set(server.get("rdp_clipboard", True))
self._rdp_drives_var.set(server.get("rdp_drives", False))
self._rdp_printers_var.set(server.get("rdp_printers", False))
# Restore network interface selection
saved_ip = server.get("bind_interface")
if saved_ip:
@@ -219,7 +287,7 @@ class ServerDialog(ctk.CTkToplevel):
self._iface_var.set(unavail_label)
# Apply field visibility for initial type
self._apply_field_visibility(self.type_var.get())
self._apply_field_visibility(type_from_display(self.type_var.get()))
def _apply_field_visibility(self, server_type: str):
"""Hide all conditional fields, then show only those for the given type."""
@@ -231,15 +299,16 @@ class ServerDialog(ctk.CTkToplevel):
frame.pack_forget()
def _on_type_change(self, value):
default_port = DEFAULT_PORTS.get(value, 22)
raw_type = type_from_display(value)
default_port = DEFAULT_PORTS.get(raw_type, 22)
self.port_entry.delete(0, "end")
self.port_entry.insert(0, str(default_port))
self._apply_field_visibility(value)
self._apply_field_visibility(raw_type)
def _toggle_password(self):
self._pass_visible = not self._pass_visible
self.password_entry.configure(show="" if self._pass_visible else "*")
self.show_pass.configure(text=t("hide") if self._pass_visible else t("show"))
self.show_pass.configure(text=icon_text("eye", t("hide") if self._pass_visible else t("show")))
def _save(self):
alias = self.alias_entry.get().strip()
@@ -247,7 +316,7 @@ class ServerDialog(ctk.CTkToplevel):
port_str = self.port_entry.get().strip()
user = self.user_entry.get().strip()
password = self.password_entry.get()
server_type = self.type_var.get()
server_type = type_from_display(self.type_var.get())
totp_secret = self.totp_entry.get().strip()
notes = self.notes_entry.get().strip()
@@ -313,6 +382,23 @@ class ServerDialog(ctk.CTkToplevel):
if self.use_ssl_var.get():
server_data["use_ssl"] = True
# RDP settings
if "rdp_resolution" in visible:
res_display = self._rdp_resolution_var.get()
if res_display == t("rdp_resolution_auto"):
server_data["rdp_resolution"] = "auto"
else:
server_data["rdp_resolution"] = res_display.replace("\u00d7", "x")
if "rdp_quality" in visible:
q_display = self._rdp_quality_var.get()
server_data["rdp_quality"] = self._rdp_quality_map.get(q_display, "auto")
if "rdp_clipboard" in visible:
server_data["rdp_clipboard"] = self._rdp_clipboard_var.get()
if "rdp_drives" in visible:
server_data["rdp_drives"] = self._rdp_drives_var.get()
if "rdp_printers" in visible:
server_data["rdp_printers"] = self._rdp_printers_var.get()
try:
if self.editing:
if alias != self._original_alias and self.store.get_server(alias):
@@ -326,6 +412,14 @@ class ServerDialog(ctk.CTkToplevel):
except ValueError as e:
self._show_error(str(e))
def _on_close(self):
"""Release grab and destroy — prevents stuck app on minimize."""
try:
self.grab_release()
except Exception:
pass
self.destroy()
def _show_error(self, message: str):
# Simple error via title flash
self.title(t("error_prefix").format(msg=message))

View File

@@ -5,36 +5,11 @@ Sidebar — server list with search, add/edit/delete buttons, context menu.
import tkinter as tk
import customtkinter as ctk
from core.i18n import t
from core.icons import (
icon_text, TYPE_COLORS, TYPE_LABELS, CTX_ICONS, icon,
)
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",
}
# Context menu: type → list of (i18n_key, tab_key_or_None)
_CONTEXT_ACTIONS = {
@@ -89,11 +64,11 @@ class Sidebar(ctk.CTkFrame):
# Buttons
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=10, pady=10)
self.add_btn = ctk.CTkButton(btn_frame, text=t("add"), width=70, height=30, command=self._on_add)
self.add_btn = ctk.CTkButton(btn_frame, text=icon_text("add", t("add")), width=70, height=30, command=self._on_add)
self.add_btn.pack(side="left", padx=(0, 3))
self.edit_btn = ctk.CTkButton(btn_frame, text=t("edit"), width=70, height=30, fg_color="#6b7280", command=self._on_edit)
self.edit_btn = ctk.CTkButton(btn_frame, text=icon_text("edit", t("edit")), width=70, height=30, fg_color="#6b7280", command=self._on_edit)
self.edit_btn.pack(side="left", padx=3)
self.del_btn = ctk.CTkButton(btn_frame, text=t("delete"), width=70, height=30, fg_color="#ef4444", hover_color="#dc2626", command=self._on_delete)
self.del_btn = ctk.CTkButton(btn_frame, text=icon_text("delete", t("delete")), width=70, height=30, fg_color="#ef4444", hover_color="#dc2626", command=self._on_delete)
self.del_btn.pack(side="right", padx=(3, 0))
# Callbacks — set by app.py
@@ -111,9 +86,9 @@ class Sidebar(ctk.CTkFrame):
def update_language(self):
self.title_label.configure(text=t("servers"))
self.search_entry.configure(placeholder_text=t("search"))
self.add_btn.configure(text=t("add"))
self.edit_btn.configure(text=t("edit"))
self.del_btn.configure(text=t("delete"))
self.add_btn.configure(text=icon_text("add", t("add")))
self.edit_btn.configure(text=icon_text("edit", t("edit")))
self.del_btn.configure(text=icon_text("delete", t("delete")))
self._update_sessions_label()
def _refresh_list(self):
@@ -260,16 +235,18 @@ class Sidebar(ctk.CTkFrame):
# Type-specific actions
actions = _CONTEXT_ACTIONS.get(stype, [])
for label_key, tab_key in actions:
ctx_icon = icon(CTX_ICONS.get(label_key, ""))
label_text = f"{ctx_icon} {t(label_key)}" if ctx_icon else t(label_key)
if tab_key:
menu.add_command(
label=t(label_key),
label=label_text,
command=lambda a=alias, tk=tab_key: (
self.open_tab_callback(a, tk) if self.open_tab_callback else None
),
)
else:
menu.add_command(
label=t(label_key),
label=label_text,
command=lambda a=alias: (
self.open_browser_callback(a) if self.open_browser_callback else None
),
@@ -280,13 +257,13 @@ class Sidebar(ctk.CTkFrame):
# Universal actions
menu.add_command(
label=t("ctx_check_status"),
label=icon_text("status_check", t("ctx_check_status")),
command=lambda: (
self.check_status_callback(alias) if self.check_status_callback else None
),
)
menu.add_command(
label=t("ctx_copy_alias"),
label=icon_text("copy", t("ctx_copy_alias")),
command=lambda: self._copy_alias(alias),
)
@@ -294,11 +271,11 @@ class Sidebar(ctk.CTkFrame):
# Management
menu.add_command(
label=t("edit"),
label=icon_text("edit", t("edit")),
command=lambda: self.edit_callback(alias) if self.edit_callback else None,
)
menu.add_command(
label=t("delete"),
label=icon_text("delete", t("delete")),
command=lambda: self.delete_callback(alias) if self.delete_callback else None,
foreground="#ef4444",
)

View File

@@ -13,6 +13,7 @@ from tkinter import messagebox, filedialog
import customtkinter as ctk
from core.i18n import t
from core.icons import icon_text
from core.ssh_client import SFTPSession
from gui.widgets.file_list import FileListWidget
@@ -110,7 +111,7 @@ class FilesTab(ctk.CTkFrame):
# Browse button
self._browse_btn = ctk.CTkButton(
left_header, text=t("browse"), width=60, height=28,
left_header, text=icon_text("folder_open", t("browse")), width=75, height=28,
command=self._browse_local,
)
self._browse_btn.pack(side="left", padx=2)
@@ -204,13 +205,13 @@ class FilesTab(ctk.CTkFrame):
toolbar.pack(fill="x", padx=10, pady=4)
self._upload_btn = ctk.CTkButton(
toolbar, text=f"{t('upload')} \u2192", width=110, height=30,
toolbar, text=icon_text("upload", t("upload")), width=110, height=30,
command=self._upload_selected,
)
self._upload_btn.pack(side="left", padx=(0, 4))
self._download_btn = ctk.CTkButton(
toolbar, text=f"\u2190 {t('download')}", width=110, height=30,
toolbar, text=icon_text("download", t("download")), width=110, height=30,
command=self._download_selected,
)
self._download_btn.pack(side="left", padx=4)
@@ -219,20 +220,20 @@ class FilesTab(ctk.CTkFrame):
sep.pack(side="left", padx=8)
self._mkdir_btn = ctk.CTkButton(
toolbar, text=t("new_folder"), width=100, height=30,
toolbar, text=icon_text("folder", t("new_folder")), width=110, height=30,
command=self._mkdir_remote,
)
self._mkdir_btn.pack(side="left", padx=4)
self._delete_btn = ctk.CTkButton(
toolbar, text=t("delete_files"), width=80, height=30,
toolbar, text=icon_text("delete", t("delete_files")), width=90, height=30,
fg_color="#dc2626", hover_color="#b91c1c",
command=self._delete_remote,
)
self._delete_btn.pack(side="left", padx=4)
self._rename_btn = ctk.CTkButton(
toolbar, text=t("rename_file"), width=100, height=30,
toolbar, text=icon_text("edit", t("rename_file")), width=110, height=30,
command=self._rename_remote,
)
self._rename_btn.pack(side="left", padx=4)

View File

@@ -9,6 +9,7 @@ from tkinter import ttk
import customtkinter as ctk
from core.grafana_client import GrafanaClient
from core.i18n import t
from core.icons import icon_text
class GrafanaTab(ctk.CTkFrame):
@@ -30,7 +31,7 @@ class GrafanaTab(ctk.CTkFrame):
font=ctk.CTkFont(size=18, weight="bold"))
title.pack(side="left")
self._refresh_btn = ctk.CTkButton(header_frame, text=t("grafana_refresh"), width=100,
self._refresh_btn = ctk.CTkButton(header_frame, text=icon_text("refresh", t("grafana_refresh")), width=110,
command=self._refresh)
self._refresh_btn.pack(side="right")
@@ -129,7 +130,7 @@ class GrafanaTab(ctk.CTkFrame):
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")))
state="normal", text=icon_text("refresh", t("grafana_refresh"))))
threading.Thread(target=_do, daemon=True).start()

View File

@@ -4,6 +4,7 @@ Info tab — display server details, edit button.
import customtkinter as ctk
from core.i18n import t
from core.icons import icon_text
class InfoTab(ctk.CTkFrame):
@@ -55,7 +56,7 @@ class InfoTab(ctk.CTkFrame):
self._fields[key] = val
# Edit button
self.edit_btn = ctk.CTkButton(self, text=t("edit_server_btn"), command=self._on_edit)
self.edit_btn = ctk.CTkButton(self, text=icon_text("edit", t("edit_server_btn")), command=self._on_edit)
self.edit_btn.pack(pady=15)
def set_server(self, alias: str | None):

View File

@@ -7,6 +7,7 @@ import threading
import customtkinter as ctk
from core.ssh_client import SSHClientWrapper
from core.i18n import t
from core.icons import icon_text
class KeysTab(ctk.CTkFrame):
@@ -29,13 +30,13 @@ class KeysTab(ctk.CTkFrame):
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=15, pady=5)
self.gen_btn = ctk.CTkButton(btn_frame, text=t("generate_key"), command=self._generate)
self.gen_btn = ctk.CTkButton(btn_frame, text=icon_text("key", t("generate_key")), command=self._generate)
self.gen_btn.pack(side="left", padx=(0, 10))
self.install_btn = ctk.CTkButton(btn_frame, text=t("install_on_server"), fg_color="#22c55e", hover_color="#16a34a", command=self._install)
self.install_btn = ctk.CTkButton(btn_frame, text=icon_text("upload", t("install_on_server")), fg_color="#22c55e", hover_color="#16a34a", command=self._install)
self.install_btn.pack(side="left")
self.copy_btn = ctk.CTkButton(btn_frame, text=t("copy_public_key"), fg_color="#6b7280", command=self._copy_key)
self.copy_btn = ctk.CTkButton(btn_frame, text=icon_text("copy", t("copy_public_key")), fg_color="#6b7280", command=self._copy_key)
self.copy_btn.pack(side="right")
# Status log

View File

@@ -77,6 +77,22 @@ class LaunchTab(ctk.CTkFrame):
)
card_title.pack(fill="x", padx=15, pady=(12, 8))
# Resolution
r_row = ctk.CTkFrame(self._settings_card, fg_color="transparent")
r_row.pack(fill="x", padx=15, pady=3)
ctk.CTkLabel(r_row, text=t("rdp_resolution"), width=140, anchor="w").pack(side="left")
self._resolution_var = ctk.StringVar(value=t("rdp_resolution_auto"))
resolution_values = [
t("rdp_resolution_auto"),
"800\u00d7600", "1024\u00d7768", "1280\u00d71024",
"1366\u00d7768", "1600\u00d7900", "1920\u00d71080",
]
self._resolution_menu = ctk.CTkOptionMenu(
r_row, values=resolution_values,
variable=self._resolution_var, width=180,
)
self._resolution_menu.pack(side="left")
# Quality
q_row = ctk.CTkFrame(self._settings_card, fg_color="transparent")
q_row.pack(fill="x", padx=15, pady=3)
@@ -184,6 +200,11 @@ class LaunchTab(ctk.CTkFrame):
self._info_label.configure(text=t("launch_rdp_info").format(alias=alias))
self._settings_card.pack(fill="x", padx=40, pady=(0, 15))
# Load saved RDP settings from server
res_raw = server.get("rdp_resolution", "auto")
if res_raw == "auto":
self._resolution_var.set(t("rdp_resolution_auto"))
else:
self._resolution_var.set(res_raw.replace("x", "\u00d7"))
self._quality_var.set(self._quality_labels.get(
server.get("rdp_quality", "auto"), self._quality_labels["auto"]
))
@@ -235,6 +256,13 @@ class LaunchTab(ctk.CTkFrame):
"printers": self._printers_var.get(),
}
# Parse resolution
res = self._resolution_var.get()
if res == t("rdp_resolution_auto"):
settings["resolution"] = "auto"
else:
settings["resolution"] = res.replace("\u00d7", "x")
self._embedded_rdp = EmbeddedRDP(server, settings)
# Set callbacks
@@ -251,6 +279,9 @@ class LaunchTab(ctk.CTkFrame):
self._rdp_frame.update_idletasks()
parent_hwnd = self._rdp_frame.winfo_id()
# Always use frame size for the mstsc window;
# session resolution is handled inside generate_rdp_file via settings
w = max(self._rdp_frame.winfo_width(), 800)
h = max(self._rdp_frame.winfo_height(), 600)
@@ -262,9 +293,26 @@ class LaunchTab(ctk.CTkFrame):
text=t("rdp_connected").format(alias=self._current_alias),
text_color="#22c55e",
)
# Re-normalize position after embed settles
self.after(300, self._normalize_rdp_position)
self.after(1000, self._normalize_rdp_position)
# Start monitoring
self._start_monitor()
def _normalize_rdp_position(self):
"""Force mstsc to fill the frame at (0,0) — fixes post-embed offset."""
if not self._embedded_rdp or not self._embedded_rdp.connected:
return
if self._is_fullscreen:
return
try:
w = self._rdp_frame.winfo_width()
h = self._rdp_frame.winfo_height()
if w > 10 and h > 10:
self._embedded_rdp.resize(w, h)
except Exception:
pass
def _on_rdp_failed(self, error: str):
"""Called when embedding failed."""
self._toolbar_status.configure(
@@ -335,12 +383,18 @@ class LaunchTab(ctk.CTkFrame):
h = self._rdp_frame.winfo_height()
self._embedded_rdp.reattach(parent_hwnd, w, h)
else:
# Go fullscreen — detach
# Go fullscreen — detach, then maximize after event loop settles
self._is_fullscreen = True
self._fullscreen_btn.configure(
text=icon_text("back", t("rdp_exit_fullscreen")),
)
self._embedded_rdp.detach()
self.after(300, self._maximize_detached)
def _maximize_detached(self):
"""Called after delay — maximize the detached mstsc window."""
if self._embedded_rdp:
self._embedded_rdp.maximize()
# ── Resize handling with debounce ─────────────────────────────

View File

@@ -8,6 +8,7 @@ import threading
import customtkinter as ctk
from core.winrm_client import WinRMClient
from core.i18n import t
from core.icons import icon_text
class PowershellTab(ctk.CTkFrame):
@@ -68,7 +69,7 @@ class PowershellTab(ctk.CTkFrame):
self._entry.bind("<Down>", lambda e: self._history_navigate(1))
self._exec_btn = ctk.CTkButton(
input_row, text=t("ps_execute"), width=90,
input_row, text=icon_text("execute", t("ps_execute")), width=100,
command=self._execute,
)
self._exec_btn.pack(side="right")

View File

@@ -8,6 +8,7 @@ from tkinter import ttk
import customtkinter as ctk
from core.prometheus_client import PrometheusClient
from core.i18n import t
from core.icons import icon_text
class PrometheusTab(ctk.CTkFrame):
@@ -34,7 +35,7 @@ class PrometheusTab(ctk.CTkFrame):
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,
self._exec_btn = ctk.CTkButton(query_frame, text=icon_text("execute", t("prom_execute")), width=100,
command=self._execute_query)
self._exec_btn.pack(side="left")
@@ -56,7 +57,7 @@ class PrometheusTab(ctk.CTkFrame):
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,
self._refresh_btn = ctk.CTkButton(targets_header, text=icon_text("refresh", t("prom_refresh")), width=100,
command=self._refresh_all)
self._refresh_btn.pack(side="right")
@@ -198,7 +199,7 @@ class PrometheusTab(ctk.CTkFrame):
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")))
state="normal", text=icon_text("refresh", t("prom_refresh"))))
threading.Thread(target=_do, daemon=True).start()

View File

@@ -11,6 +11,7 @@ from tkinter import ttk, filedialog
import customtkinter as ctk
from core.i18n import t
from core.icons import icon_text
from core.sql_client import SQLClient
@@ -73,7 +74,7 @@ class QueryTab(ctk.CTkFrame):
self._exec_btn = ctk.CTkButton(
btn_row,
text=f"{t('query_execute')} (F5)",
text=icon_text("execute", t("query_execute")),
command=self._execute_query,
width=130,
fg_color="#2563eb",
@@ -83,7 +84,7 @@ class QueryTab(ctk.CTkFrame):
self._clear_btn = ctk.CTkButton(
btn_row,
text=t("query_clear"),
text=icon_text("clear", t("query_clear")),
command=self._clear_all,
width=80,
fg_color="#6b7280",
@@ -93,7 +94,7 @@ class QueryTab(ctk.CTkFrame):
self._export_btn = ctk.CTkButton(
btn_row,
text=t("query_export_csv"),
text=icon_text("save", t("query_export_csv")),
command=self._export_csv,
width=110,
fg_color="#059669",

View File

@@ -6,6 +6,7 @@ import threading
import customtkinter as ctk
from core.redis_client import RedisClient
from core.i18n import t
from core.icons import icon_text
class RedisTab(ctk.CTkFrame):
@@ -66,26 +67,26 @@ class RedisTab(ctk.CTkFrame):
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,
self._exec_btn = ctk.CTkButton(btn_frame, text=icon_text("execute", t("redis_execute")), width=100,
command=self._execute_command)
self._exec_btn.pack(side="left", padx=(0, 5))
self._info_btn = ctk.CTkButton(btn_frame, text="INFO", width=70,
self._info_btn = ctk.CTkButton(btn_frame, text=icon_text("info", "INFO"), width=80,
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,
self._dbsize_btn = ctk.CTkButton(btn_frame, text=icon_text("hash", "DBSIZE"), width=90,
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,
self._scan_btn = ctk.CTkButton(btn_frame, text=icon_text("search", "SCAN"), width=80,
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,
self._clear_btn = ctk.CTkButton(btn_frame, text=icon_text("clear", t("redis_clear")), width=80,
fg_color="#374151", hover_color="#1f2937",
command=self._clear_output)
self._clear_btn.pack(side="right")

View File

@@ -10,6 +10,7 @@ from tkinter import filedialog, messagebox
import customtkinter as ctk
from core.claude_setup import check_status, install_all, install_ssh_script, install_skill, generate_ssh_key
from core.i18n import t
from core.icons import icon_text
from core.logger import log
@@ -70,7 +71,7 @@ class SetupTab(ctk.CTkFrame):
btn_frame.pack(fill="x", padx=20, pady=15)
self.install_all_btn = ctk.CTkButton(
btn_frame, text=t("install_everything"),
btn_frame, text=icon_text("confirm", t("install_everything")),
font=ctk.CTkFont(size=14, weight="bold"),
height=40, fg_color="#22c55e", hover_color="#16a34a",
command=self._install_all
@@ -81,16 +82,16 @@ class SetupTab(ctk.CTkFrame):
ind_frame = ctk.CTkFrame(btn_frame, fg_color="transparent")
ind_frame.pack(fill="x")
self.ssh_py_btn = ctk.CTkButton(ind_frame, text=t("install_ssh_py"), width=100, fg_color="#6b7280",
self.ssh_py_btn = ctk.CTkButton(ind_frame, text=icon_text("confirm", t("install_ssh_py")), width=110, fg_color="#6b7280",
command=self._install_script)
self.ssh_py_btn.pack(side="left", padx=(0, 5))
self.skill_btn = ctk.CTkButton(ind_frame, text=t("install_skill"), width=100, fg_color="#6b7280",
self.skill_btn = ctk.CTkButton(ind_frame, text=icon_text("confirm", t("install_skill")), width=110, fg_color="#6b7280",
command=self._install_skill)
self.skill_btn.pack(side="left", padx=5)
self.ssh_key_btn = ctk.CTkButton(ind_frame, text=t("install_ssh_key"), width=100, fg_color="#6b7280",
self.ssh_key_btn = ctk.CTkButton(ind_frame, text=icon_text("confirm", t("install_ssh_key")), width=110, fg_color="#6b7280",
command=self._gen_key)
self.ssh_key_btn.pack(side="left", padx=5)
self.refresh_btn = ctk.CTkButton(ind_frame, text=t("refresh"), width=80, fg_color="#3b82f6",
self.refresh_btn = ctk.CTkButton(ind_frame, text=icon_text("refresh", t("refresh")), width=90, fg_color="#3b82f6",
command=self._refresh_status)
self.refresh_btn.pack(side="right")
@@ -146,7 +147,7 @@ class SetupTab(ctk.CTkFrame):
)
self._path_label.pack(side="left", fill="x", expand=True, padx=(5, 10))
self.change_path_btn = ctk.CTkButton(
path_row, text=t("change_path"), width=100, fg_color="#6b7280",
path_row, text=icon_text("folder", t("change_path")), width=120, fg_color="#6b7280",
command=self._change_config_path
)
self.change_path_btn.pack(side="right")
@@ -156,7 +157,7 @@ class SetupTab(ctk.CTkFrame):
backup_row.pack(fill="x", padx=15, pady=(5, 10))
self.backup_btn = ctk.CTkButton(
backup_row, text=t("backup_now"), width=100, fg_color="#3b82f6",
backup_row, text=icon_text("save", t("backup_now")), width=120, fg_color="#3b82f6",
command=self._backup_now
)
self.backup_btn.pack(side="left", padx=(0, 10))
@@ -171,7 +172,7 @@ class SetupTab(ctk.CTkFrame):
self._backup_menu.pack(side="left", padx=(0, 10))
self.restore_btn = ctk.CTkButton(
backup_row, text=t("restore"), width=80, fg_color="#ef4444", hover_color="#dc2626",
backup_row, text=icon_text("refresh", t("restore")), width=100, fg_color="#ef4444", hover_color="#dc2626",
command=self._restore_backup
)
self.restore_btn.pack(side="left")
@@ -181,25 +182,25 @@ class SetupTab(ctk.CTkFrame):
ie_row.pack(fill="x", padx=15, pady=(0, 10))
self.export_config_btn = ctk.CTkButton(
ie_row, text=t("export_config"), width=120, fg_color="#6b7280",
ie_row, text=icon_text("upload", t("export_config")), width=130, fg_color="#6b7280",
command=self._export_config
)
self.export_config_btn.pack(side="left", padx=(0, 5))
self.import_config_btn = ctk.CTkButton(
ie_row, text=t("import_config"), width=120, fg_color="#6b7280",
ie_row, text=icon_text("download", t("import_config")), width=130, fg_color="#6b7280",
command=self._import_config
)
self.import_config_btn.pack(side="left", padx=5)
self.export_backup_btn = ctk.CTkButton(
ie_row, text=t("export_backup"), width=120, fg_color="#6b7280",
ie_row, text=icon_text("upload", t("export_backup")), width=130, fg_color="#6b7280",
command=self._export_backup
)
self.export_backup_btn.pack(side="left", padx=5)
self.import_backup_btn = ctk.CTkButton(
ie_row, text=t("import_backup"), width=120, fg_color="#6b7280",
ie_row, text=icon_text("download", t("import_backup")), width=130, fg_color="#6b7280",
command=self._import_backup
)
self.import_backup_btn.pack(side="left", padx=5)

View File

@@ -6,6 +6,7 @@ Live countdown, one-click copy, per-server secrets.
import threading
import customtkinter as ctk
from core.i18n import t
from core.icons import icon_text
class TOTPTab(ctk.CTkFrame):
@@ -81,7 +82,7 @@ class TOTPTab(ctk.CTkFrame):
# Copy button
self.copy_btn = ctk.CTkButton(
self, text=t("totp_copy"), width=200, height=40,
self, text=icon_text("copy", t("totp_copy")), width=200, height=40,
font=ctk.CTkFont(size=14),
fg_color="#22c55e", hover_color="#16a34a",
command=self._copy_code
@@ -108,7 +109,7 @@ class TOTPTab(ctk.CTkFrame):
self.secret_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
self.show_secret_btn = ctk.CTkButton(
entry_row, text=t("show"), width=70,
entry_row, text=icon_text("eye", t("show")), width=80,
fg_color="#6b7280", hover_color="#4b5563",
command=self._toggle_secret
)
@@ -116,13 +117,13 @@ class TOTPTab(ctk.CTkFrame):
self._secret_visible = False
self.save_secret_btn = ctk.CTkButton(
entry_row, text=t("totp_save_secret"), width=100,
entry_row, text=icon_text("confirm", t("totp_save_secret")), width=110,
command=self._save_secret
)
self.save_secret_btn.pack(side="left", padx=(0, 5))
self.remove_secret_btn = ctk.CTkButton(
entry_row, text=t("totp_remove_secret"), width=100,
entry_row, text=icon_text("delete", t("totp_remove_secret")), width=110,
fg_color="#ef4444", hover_color="#dc2626",
command=self._remove_secret
)
@@ -130,7 +131,7 @@ class TOTPTab(ctk.CTkFrame):
# Generate random secret button
self.gen_secret_btn = ctk.CTkButton(
secret_frame, text=t("totp_generate_secret"), width=180,
secret_frame, text=icon_text("key", t("totp_generate_secret")), width=200,
fg_color="#6b7280", hover_color="#4b5563",
command=self._generate_secret
)
@@ -266,7 +267,9 @@ class TOTPTab(ctk.CTkFrame):
def _toggle_secret(self):
self._secret_visible = not self._secret_visible
self.secret_entry.configure(show="" if self._secret_visible else "*")
self.show_secret_btn.configure(text=t("hide") if self._secret_visible else t("show"))
self.show_secret_btn.configure(
text=icon_text("eye", t("hide") if self._secret_visible else t("show"))
)
def _save_secret(self):
if not self._current_alias:
@@ -328,12 +331,12 @@ class TOTPTab(ctk.CTkFrame):
def update_language(self):
self.title_label.configure(text=t("totp_title"))
self.desc_label.configure(text=t("totp_desc"))
self.copy_btn.configure(text=t("totp_copy"))
self.save_secret_btn.configure(text=t("totp_save_secret"))
self.remove_secret_btn.configure(text=t("totp_remove_secret"))
self.gen_secret_btn.configure(text=t("totp_generate_secret"))
self.copy_btn.configure(text=icon_text("copy", t("totp_copy")))
self.save_secret_btn.configure(text=icon_text("confirm", t("totp_save_secret")))
self.remove_secret_btn.configure(text=icon_text("delete", t("totp_remove_secret")))
self.gen_secret_btn.configure(text=icon_text("key", t("totp_generate_secret")))
self.show_secret_btn.configure(
text=t("hide") if self._secret_visible else t("show")
text=icon_text("eye", t("hide") if self._secret_visible else t("show"))
)
if not self._current_alias:
self.server_label.configure(text=t("no_server_selected"))

View File

@@ -9,6 +9,7 @@ COLORS = {
"offline": "#ef4444", # red
"unknown": "#6b7280", # gray
"disabled": "#9ca3af", # light gray
"checking": "#f59e0b", # yellow
}
@@ -24,5 +25,10 @@ class StatusBadge(ctk.CTkLabel):
def _update_color(self):
color = COLORS.get(self._status, COLORS["unknown"])
symbol = "\u2014" if self._status == "disabled" else "\u25cf"
if self._status == "disabled":
symbol = "\u2014" # —
elif self._status == "checking":
symbol = "\u25d0" # ◐
else:
symbol = "\u25cf" # ●
self.configure(text=symbol, text_color=color, font=("", 14))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

30
tools/setup_openssh.bat Normal file
View File

@@ -0,0 +1,30 @@
@echo off
:: Setup OpenSSH Server on port 61374
:: Run as Administrator!
echo === Installing OpenSSH Server ===
powershell -Command "Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0"
echo === Starting sshd to generate config ===
powershell -Command "Start-Service sshd; Stop-Service sshd"
set CFG=C:\ProgramData\ssh\sshd_config
echo === Setting port 61374 ===
powershell -Command "(Get-Content '%CFG%') -replace '^#?Port\s+\d+', 'Port 61374' | Set-Content '%CFG%'"
echo === Enabling password auth ===
powershell -Command "(Get-Content '%CFG%') -replace '^#?PasswordAuthentication\s+\w+', 'PasswordAuthentication yes' | Set-Content '%CFG%'"
echo === Opening firewall port 61374 ===
powershell -Command "New-NetFirewallRule -Name 'OpenSSH-Server-61374' -DisplayName 'OpenSSH Server (port 61374)' -Direction Inbound -Protocol TCP -LocalPort 61374 -Action Allow"
echo === Starting sshd + autostart ===
powershell -Command "Start-Service sshd; Set-Service -Name sshd -StartupType Automatic"
echo === Verifying ===
powershell -Command "netstat -an | Select-String '61374'"
echo.
echo Done! SSH listening on port 61374
pause

View File

@@ -170,6 +170,7 @@ unset SSH_ASKPASS && unset DISPLAY && ssh ALIAS "command"
- **Auto-sudo** (SSH): если user на сервере не root — команды автоматически оборачиваются в `sudo -S`, пароль подаётся через stdin. Тебе НЕ нужно добавлять `sudo` в команду
- **--no-sudo** (SSH): если команда не требует root (например `ls`, `cat`), используй `--no-sudo` для скорости
- **Windows SSH — автосанитизация**: при подключении к Windows серверам (определяется по alias/notes/os) Linux-команды автоматически транслируются в Windows-эквиваленты. Можно спокойно писать `ls`, `cat`, `grep`, `ps`, `df` — скрипт сам переведёт в `dir`, `type`, `Select-String`, `Get-Process`, и т.д. Pipe-цепочки (`cat file | grep error`) автоматически оборачиваются в PowerShell. Кодировка принудительно UTF-8 (`chcp 65001`). Команды `powershell ...`, `pwsh ...`, `cmd /c ...` проходят без изменений (passthrough). `sudo` автоматически удаляется, `chmod`/`chown` пропускаются с предупреждением
- **Timeout**: 120 секунд на SSH-команду, 10 секунд на SQL/Redis/HTTP-запросы, 15 секунд на подключение
- **SSH-ключ**: пробуется первым, fallback на пароль если ключ не подходит
- **Прогресс**: upload/download файлов >=1MB показывают 25/50/75% milestone, итог с размером/временем/скоростью

View File

@@ -1,6 +1,6 @@
"""Version info for ServerManager."""
__version__ = "1.8.36"
__version__ = "1.8.52"
__app_name__ = "ServerManager"
__author__ = "aibot777"
__description__ = "Desktop GUI for managing remote servers"