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:
@@ -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
|
||||
|
||||
@@ -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
176
core/icons.py
Normal 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",
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
39
gui/app.py
39
gui/app.py
@@ -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"):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ─────────────────────────────
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 212 KiB |
30
tools/setup_openssh.bat
Normal file
30
tools/setup_openssh.bat
Normal 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
|
||||
@@ -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, итог с размером/временем/скоростью
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user