Files
server-manager/gui/app.py
chrome-storm-c442 4e9012e2ab v1.8.26: right-click context menu on sidebar servers
Type-adaptive menu: SSH (terminal/files/keys), SQL (query editor),
Redis (console), Grafana/Prometheus (open browser), WinRM (PowerShell),
RDP/VNC (connect). Universal: check status, copy alias, edit, delete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:00:00 -05:00

495 lines
18 KiB
Python

"""
Main application window — sidebar + tabview layout.
"""
import tkinter
import customtkinter as ctk
from tkinter import messagebox
from core.server_store import ServerStore
from core.status_checker import StatusChecker
from core import i18n
from core.i18n import t, LANGUAGES
from core.session_pool import SessionPool
from gui.sidebar import Sidebar
from gui.server_dialog import ServerDialog
from gui.about_dialog import AboutDialog
from gui.tabs.terminal_tab import TerminalTab
from gui.tabs.files_tab import FilesTab
from gui.tabs.info_tab import InfoTab
from gui.tabs.keys_tab import KeysTab
from gui.tabs.setup_tab import SetupTab
from gui.tabs.totp_tab import TOTPTab
from gui.tabs.query_tab import QueryTab
from gui.tabs.redis_tab import RedisTab
from gui.tabs.grafana_tab import GrafanaTab
from gui.tabs.prometheus_tab import PrometheusTab
from gui.tabs.powershell_tab import PowershellTab
from gui.tabs.launch_tab import LaunchTab
# Tab sets per server type — determines which tabs are shown
TAB_REGISTRY = {
"ssh": ["terminal", "files", "info", "keys", "totp", "setup"],
"telnet": ["terminal", "info", "setup"],
"winrm": ["powershell", "info", "setup"],
"mariadb": ["query", "info", "setup"],
"mssql": ["query", "info", "setup"],
"postgresql": ["query", "info", "setup"],
"redis": ["console", "info", "setup"],
"grafana": ["dashboards", "info", "setup"],
"prometheus": ["metrics", "info", "setup"],
"rdp": ["launch", "info", "setup"],
"vnc": ["launch", "info", "setup"],
}
# Map tab key → widget class (used as lazy factory)
TAB_CLASSES = {
"terminal": TerminalTab,
"files": FilesTab,
"info": InfoTab,
"keys": KeysTab,
"totp": TOTPTab,
"setup": SetupTab,
"query": QueryTab,
"console": RedisTab,
"dashboards": GrafanaTab,
"metrics": PrometheusTab,
"powershell": PowershellTab,
"launch": LaunchTab,
}
class App(ctk.CTk):
def __init__(self):
super().__init__()
# Window config
self.title("ServerManager")
self.geometry("1100x700")
self.minsize(900, 500)
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
# Core
self.store = ServerStore()
self.checker = StatusChecker(self.store)
self.session_pool = SessionPool(max_sessions=5) # Create session pool
# Layout
self._build_layout()
# Status checker
self.checker.set_gui_callback(lambda: self.after(0, self._on_status_update))
self.checker.start()
self.checker.check_all_now()
# Fix Ctrl+V/C/A/X for non-Latin keyboard layouts (e.g. Russian)
# Tkinter maps <<Paste>> to <Control-v> by keysym, which fails when
# the layout produces non-Latin characters. This fix uses keycodes instead.
self._setup_keyboard_layout_fix()
# Add Undo/Redo support for Entry widgets (tk.Entry has no built-in undo)
self._setup_entry_undo()
# Cleanup on close
self.protocol("WM_DELETE_WINDOW", self._on_close)
def _build_layout(self):
# Sidebar
self.sidebar = Sidebar(self, self.store, on_select=self._on_server_select, session_pool=self.session_pool)
self.sidebar.pack(side="left", fill="y")
self.sidebar.add_callback = self._add_server
self.sidebar.edit_callback = self._edit_server
self.sidebar.delete_callback = self._delete_server
self.sidebar.open_tab_callback = self._context_open_tab
self.sidebar.check_status_callback = self._context_check_status
self.sidebar.open_browser_callback = self._context_open_browser
# Main area
self._main_frame = ctk.CTkFrame(self, fg_color="transparent")
self._main_frame.pack(side="right", fill="both", expand=True)
# Header bar (language + about)
header_bar = ctk.CTkFrame(self._main_frame, fg_color="transparent", height=40)
header_bar.pack(fill="x", padx=10, pady=(8, 0))
header_bar.pack_propagate(False)
# Language selector
lang_values = list(LANGUAGES.values())
current_display = LANGUAGES.get(i18n.get_language(), "English")
self._lang_var = ctk.StringVar(value=current_display)
self.lang_menu = ctk.CTkOptionMenu(
header_bar, values=lang_values, variable=self._lang_var,
width=110, height=30, command=self._change_language
)
self.lang_menu.pack(side="right", padx=(5, 0))
# About button
self.about_btn = ctk.CTkButton(
header_bar, text="", width=30, height=30,
corner_radius=15, fg_color="#6b7280", hover_color="#4b5563",
command=self._show_about
)
self.about_btn.pack(side="right", padx=(5, 5))
# Initialize tab tracking
self.tabview = None
self._tab_keys = []
self._tab_instances = {}
# Build default SSH tab set
self._rebuild_tabs(TAB_REGISTRY["ssh"])
def _rebuild_tabs(self, tab_keys: list[str], restore_tab_key: str | None = None):
"""Destroy current tabview and rebuild with the given tab keys."""
# Remember current active tab
if restore_tab_key is None:
restore_tab_key = self._get_current_tab_key() if self._tab_keys else None
# Destroy old tab instances
for key, widget in self._tab_instances.items():
try:
widget.pack_forget()
widget.destroy()
except Exception:
pass
self._tab_instances = {}
# Destroy old tabview
if self.tabview is not None:
try:
self.tabview.destroy()
except Exception:
pass
# Store new tab key list
self._tab_keys = list(tab_keys)
# Create new tabview
self.tabview = ctk.CTkTabview(self._main_frame, command=self._on_tab_changed)
self.tabview.pack(fill="both", expand=True, padx=10, pady=10)
for key in self._tab_keys:
self.tabview.add(t(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))
widget = self._create_tab_instance(cls, key, parent)
widget.pack(fill="both", expand=True)
self._tab_instances[key] = widget
# Restore previously active tab if still available
if restore_tab_key and restore_tab_key in self._tab_keys:
try:
self.tabview.set(t(restore_tab_key))
except Exception:
pass
def _create_tab_instance(self, cls, key: str, parent):
"""Create a tab widget instance with the correct constructor args."""
if cls in (TerminalTab, FilesTab):
return cls(parent, self.store, self.session_pool)
elif cls is InfoTab:
return cls(parent, self.store, edit_callback=self._edit_server)
elif cls is SetupTab:
return cls(parent, self.store)
elif cls in (KeysTab, TOTPTab):
return cls(parent, self.store)
else:
# QueryTab, RedisTab, GrafanaTab, PrometheusTab, PowershellTab, LaunchTab
return cls(parent, self.store)
def _on_server_select(self, alias: str):
# Determine server type and required tabs
if alias:
server = self.store.get_server(alias)
server_type = server.get("type", "ssh") if server else "ssh"
else:
server_type = "ssh"
new_tab_keys = TAB_REGISTRY.get(server_type, TAB_REGISTRY["ssh"])
# Rebuild tabs only if the tab set changed
if new_tab_keys != self._tab_keys:
self._rebuild_tabs(new_tab_keys)
# Notify each tab instance about the selected server
for key, widget in self._tab_instances.items():
if hasattr(widget, "set_server"):
widget.set_server(alias)
# Update session indicators after a short delay (connection is async)
self.after(1500, self.sidebar.update_session_indicators)
def _add_server(self):
dialog = ServerDialog(self, self.store)
self.wait_window(dialog)
def _edit_server(self, alias: str):
server = self.store.get_server(alias)
if server:
dialog = ServerDialog(self, self.store, server=server)
self.wait_window(dialog)
if dialog.result and dialog.result.get("alias") != alias:
new_alias = dialog.result["alias"]
self.sidebar._select(new_alias)
self.session_pool.rename_server(alias, new_alias)
else:
info = self._tab_instances.get("info")
if info and hasattr(info, "refresh"):
info.refresh()
def _delete_server(self, alias: str):
if messagebox.askyesno(t("delete_server"), t("delete_confirm").format(alias=alias)):
# Clean up sessions when deleting server
self.session_pool.cleanup_deleted_server(alias)
self.store.remove_server(alias)
self._on_server_select(None)
def _context_open_tab(self, alias: str, tab_key: str):
"""Context menu: select server and switch to a specific tab."""
self._on_server_select(alias)
self.sidebar._select(alias)
if tab_key in self._tab_keys:
try:
self.tabview.set(t(tab_key))
except Exception:
pass
def _context_check_status(self, alias: str):
"""Context menu: check single server status in background."""
import threading
server = self.store.get_server(alias)
if not server:
return
def _check():
online = self.checker.check_one(server)
self.store.set_status(alias, "online" if online else "offline")
self.after(0, self.sidebar.update_statuses)
threading.Thread(target=_check, daemon=True).start()
def _context_open_browser(self, alias: str):
"""Context menu: open Grafana/Prometheus in browser."""
import webbrowser
server = self.store.get_server(alias)
if not server:
return
use_ssl = server.get("use_ssl", False)
scheme = "https" if use_ssl else "http"
port = server.get("port", 3000)
url = f"{scheme}://{server['ip']}:{port}"
webbrowser.open(url)
def _on_status_update(self):
self.sidebar.update_statuses()
self.sidebar.update_session_indicators()
info = self._tab_instances.get("info")
if info and hasattr(info, "refresh"):
info.refresh()
def _show_about(self):
AboutDialog(self)
def _get_current_tab_key(self) -> str:
"""Get the i18n key of the currently active tab."""
try:
current_name = self.tabview.get()
# Match against current language translations
for key in self._tab_keys:
if t(key) == current_name:
return key
except Exception:
pass
return self._tab_keys[0]
def _change_language(self, display_name: str):
# Remember current tab KEY before language switch
active_tab_key = self._get_current_tab_key()
# Find lang code from display name
lang_code = "en"
for code, name in LANGUAGES.items():
if name == display_name:
lang_code = code
break
i18n.set_language(lang_code)
self.store._save_settings()
self._apply_language(active_tab_key)
def _apply_language(self, restore_tab_key: str | None = None):
# Remember selected server
alias = self.sidebar.get_selected()
# Use provided key or default to first tab
current_key = restore_tab_key or (self._tab_keys[0] if self._tab_keys else "terminal")
# Save FilesTab state if it exists
files_tab = self._tab_instances.get("files")
saved_remote_path = None
saved_local_path = None
had_sftp = False
if files_tab:
saved_remote_path = files_tab._remote_path
saved_local_path = files_tab._local_path
had_sftp = files_tab._sftp is not None and files_tab._sftp.connected
# Disconnect all sessions in the pool
self.session_pool.disconnect_all()
# Rebuild tabs with translated names (same tab keys, just new language)
self._rebuild_tabs(self._tab_keys, restore_tab_key=current_key)
# Restore FilesTab state if it exists in new tab set
files_tab = self._tab_instances.get("files")
if files_tab:
files_tab._local_path = saved_local_path
files_tab._refresh_local()
if alias and had_sftp:
files_tab._remote_path = saved_remote_path
files_tab.set_server(alias)
elif alias:
files_tab.set_server(alias)
# Restore server selection for all other tabs
if alias:
for key, widget in self._tab_instances.items():
if key == "files":
continue # Already handled above
if hasattr(widget, "set_server"):
widget.set_server(alias)
# Update sidebar
self.sidebar.update_language()
def _setup_entry_undo(self):
"""Add Undo/Redo support for tk.Entry widgets (they have no built-in undo).
Tracks text changes per-widget and restores on <<Undo>>/<<Redo>>.
"""
undo_stacks: dict[str, list[str]] = {}
redo_stacks: dict[str, list[str]] = {}
def _save_state(event):
w = event.widget
wid = str(w)
try:
current = w.get()
except Exception:
return
stack = undo_stacks.setdefault(wid, [])
if not stack or stack[-1] != current:
stack.append(current)
if len(stack) > 100:
stack.pop(0)
# Clear redo on new input
redo_stacks.pop(wid, None)
def _on_undo(event):
w = event.widget
wid = str(w)
try:
current = w.get()
except Exception:
return "break"
stack = undo_stacks.get(wid, [])
# Pop current state
while stack and stack[-1] == current:
stack.pop()
if stack:
prev = stack[-1]
redo_stacks.setdefault(wid, []).append(current)
w.delete(0, "end")
w.insert(0, prev)
return "break"
def _on_redo(event):
w = event.widget
wid = str(w)
try:
current = w.get()
except Exception:
return "break"
stack = redo_stacks.get(wid, [])
if stack:
next_val = stack.pop()
undo_stacks.setdefault(wid, []).append(current)
w.delete(0, "end")
w.insert(0, next_val)
return "break"
# Add class-level bindings for Entry
self.tk.eval('''
bind Entry <<Undo>> {}
bind Entry <<Redo>> {}
''')
tkinter.Misc.bind_class(self, "Entry", "<Key>", _save_state, "+")
tkinter.Misc.bind_class(self, "Entry", "<<Undo>>", _on_undo)
tkinter.Misc.bind_class(self, "Entry", "<<Redo>>", _on_redo)
def _setup_keyboard_layout_fix(self):
"""Fix Ctrl+V/C/X/A/Z for non-Latin keyboard layouts (Russian, Chinese, etc.).
Tkinter binds <<Paste>> to <Control-v> by keysym. When the keyboard
layout is Russian, pressing Ctrl+V produces <Control-м> which doesn't
match. This fix intercepts <Control-Key> at the 'all' level by keycode
(layout-independent) and generates the correct virtual events.
"""
# Keycode → virtual event mapping (physical keys, layout-independent)
keycode_to_event = {
86: "<<Paste>>", # V
67: "<<Copy>>", # C
88: "<<Cut>>", # X
65: "<<SelectAll>>", # A
90: "<<Undo>>", # Z
89: "<<Redo>>", # Y
}
def _on_ctrl_key_global(event):
# Only act when Ctrl is held (not Ctrl+Shift, etc.)
if not (event.state & 0x4):
return
# Skip if keysym is already Latin (standard handling works)
if event.keysym in ('v', 'V', 'c', 'C', 'x', 'X', 'a', 'A',
'z', 'Z', 'y', 'Y'):
return
virtual = keycode_to_event.get(event.keycode)
if virtual:
event.widget.event_generate(virtual)
return "break"
# Bypass CTk's bind_all restriction by calling tkinter.Misc directly
tkinter.Misc.bind_all(self, "<Control-Key>", _on_ctrl_key_global, "+")
def _on_tab_changed(self):
"""Handle tab switch — manage terminal focus."""
try:
current = self.tabview.get()
terminal = self._tab_instances.get("terminal")
if terminal and current == t("terminal"):
terminal._terminal.focus_terminal()
else:
self.focus_set()
except Exception:
pass
def _on_close(self):
# Clean up tab instances
for key, widget in self._tab_instances.items():
if hasattr(widget, "on_close"):
try:
widget.on_close()
except Exception:
pass
# Disconnect all sessions before closing
self.session_pool.disconnect_all()
self.checker.stop()
self.destroy()