v1.5.0: network interface binding, SSH fixes, terminal, release script

- Add network interface selection per server (VPN/multi-NIC support)
- Fix "Install Everything" button hanging on error
- Add interactive SSH terminal with PTY (pyte + xterm-256color)
- Add release.py for automated versioning and changelog generation
- Add CLAUDE.md with project instructions
- Add screenshots and release binaries for v1.1–v1.4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-23 14:06:41 -05:00
parent 0c89e77417
commit a83a97c9d5
33 changed files with 1221 additions and 173 deletions

View File

@@ -1,5 +1,41 @@
# Changelog # Changelog
## [1.5.0] - 2026-02-23
### Added
- **Network interface binding** — choose which NIC (IP) to use per server (VPN, multi-NIC setups)
- Dropdown in server add/edit dialog (via `psutil.net_if_addrs()`)
- `bind_interface` saved per server, used in SSH connect via `socket.bind()`
- Works on Windows/macOS/Linux without admin rights
- Status checker and terminal automatically use the bound interface
- `psutil` dependency in requirements.txt
### Fixed
- **"Install Everything" button** no longer hangs on error — try/except/finally ensures button always resets
- `generate_ssh_key()` handles missing paramiko gracefully (returns error message instead of crashing thread)
- `install_all()` catches per-step exceptions — all steps run even if one fails
- `_gen_key()` in Setup tab now catches and displays errors in the log
### Changed
- `check_connection()` refactored — uses shared `_connect_client()` instead of duplicated logic
- `_connect_client()` supports `bind_interface` with socket binding and proper socket recreation on auth retry
- Logging added to `install_ssh_script()`, `install_skill()`, `generate_ssh_key()`, `install_all()`
- `build.py` — added `--hidden-import psutil`
- `CLAUDE.md` — added version sync checklist
- `version.py` → 1.5.0
## [1.4.0] - 2026-02-23
### Added
- Interactive SSH terminal with PTY (xterm-256color)
- `pyte` terminal emulator integration
- `ShellSession` class for persistent shell sessions
- Configurable monitoring intervals (30s, 60s, 2min, 5min)
- Skip status check option per server
### Changed
- `version.py` → 1.4.0
## [1.3.0] - 2026-02-23 ## [1.3.0] - 2026-02-23
### Added ### Added

17
CLAUDE.md Normal file
View File

@@ -0,0 +1,17 @@
# CLAUDE.md — инструкции для Claude Code
## Текущая версия: 1.5.0
## Версионирование
Файл версии: `version.py``__version__`
Semver: **MAJOR** (ломающие изменения) | **MINOR** (новая фича) | **PATCH** (багфикс, мелкие правки)
**Порядок релиза:**
1. Внести изменения в код и закоммитить
2. `python release.py minor` (или `patch` / `major` / `2.0.0`)
3. `python build.py --clean`
4. Закоммитить и запушить
Скрипт `release.py` автоматически обновляет все 4 файла (`version.py`, `CHANGELOG.md`, `README.md`, `CLAUDE.md`) и генерирует changelog из git log.

View File

@@ -53,7 +53,7 @@ pip install pyinstaller
python build.py python build.py
``` ```
Output goes to `releases/ServerManager-v1.3.0-{platform}.exe` Output goes to `releases/ServerManager-v1.5.0-{platform}.exe`
### Usage ### Usage
@@ -223,7 +223,7 @@ pip install pyinstaller
python build.py python build.py
``` ```
Результат в `releases/ServerManager-v1.3.0-{платформа}.exe` Результат в `releases/ServerManager-v1.5.0-{платформа}.exe`
### Использование ### Использование
@@ -363,7 +363,7 @@ pip install pyinstaller
python build.py python build.py
``` ```
输出至 `releases/ServerManager-v1.3.0-{平台}.exe` 输出至 `releases/ServerManager-v1.5.0-{平台}.exe`
### 使用方法 ### 使用方法

View File

@@ -79,6 +79,8 @@ def build():
"--hidden-import", "customtkinter", "--hidden-import", "customtkinter",
"--hidden-import", "PIL", "--hidden-import", "PIL",
"--hidden-import", "pyotp", "--hidden-import", "pyotp",
"--hidden-import", "pyte",
"--hidden-import", "psutil",
"--collect-all", "customtkinter", "--collect-all", "customtkinter",
]) ])

View File

@@ -7,6 +7,7 @@ for Claude Code to manage servers via the shared servers.json.
import os import os
import sys import sys
import shutil import shutil
from core.logger import log
SHARED_DIR = os.path.expanduser("~/.server-connections") SHARED_DIR = os.path.expanduser("~/.server-connections")
@@ -47,6 +48,7 @@ def install_ssh_script() -> str:
dst = os.path.join(SHARED_DIR, "ssh.py") dst = os.path.join(SHARED_DIR, "ssh.py")
if os.path.exists(SSH_SCRIPT_SRC): if os.path.exists(SSH_SCRIPT_SRC):
shutil.copy2(SSH_SCRIPT_SRC, dst) shutil.copy2(SSH_SCRIPT_SRC, dst)
log.info(f"ssh.py installed: {dst}")
results.append(f"ssh.py installed: {dst}") results.append(f"ssh.py installed: {dst}")
elif os.path.exists(dst): elif os.path.exists(dst):
results.append(f"ssh.py already exists: {dst}") results.append(f"ssh.py already exists: {dst}")
@@ -57,6 +59,7 @@ def install_ssh_script() -> str:
enc_dst = os.path.join(SHARED_DIR, "encryption.py") enc_dst = os.path.join(SHARED_DIR, "encryption.py")
if os.path.exists(ENCRYPTION_SRC): if os.path.exists(ENCRYPTION_SRC):
shutil.copy2(ENCRYPTION_SRC, enc_dst) shutil.copy2(ENCRYPTION_SRC, enc_dst)
log.info(f"encryption.py installed: {enc_dst}")
results.append(f"encryption.py installed: {enc_dst}") results.append(f"encryption.py installed: {enc_dst}")
elif os.path.exists(enc_dst): elif os.path.exists(enc_dst):
results.append(f"encryption.py already exists: {enc_dst}") results.append(f"encryption.py already exists: {enc_dst}")
@@ -71,6 +74,7 @@ def install_skill() -> str:
os.makedirs(SKILL_DST_DIR, exist_ok=True) os.makedirs(SKILL_DST_DIR, exist_ok=True)
if os.path.exists(SKILL_SRC): if os.path.exists(SKILL_SRC):
shutil.copy2(SKILL_SRC, SKILL_DST) shutil.copy2(SKILL_SRC, SKILL_DST)
log.info(f"Skill installed: {SKILL_DST}")
return f"Skill installed: {SKILL_DST}" return f"Skill installed: {SKILL_DST}"
# Fallback: check existing # Fallback: check existing
if os.path.exists(SKILL_DST): if os.path.exists(SKILL_DST):
@@ -79,6 +83,7 @@ def install_skill() -> str:
skill_content = _generate_skill_content() skill_content = _generate_skill_content()
with open(SKILL_DST, "w", encoding="utf-8") as f: with open(SKILL_DST, "w", encoding="utf-8") as f:
f.write(skill_content) f.write(skill_content)
log.info(f"Skill generated: {SKILL_DST}")
return f"Skill generated: {SKILL_DST}" return f"Skill generated: {SKILL_DST}"
@@ -89,7 +94,13 @@ def generate_ssh_key() -> str:
os.makedirs(os.path.dirname(SSH_KEY_PATH), exist_ok=True) os.makedirs(os.path.dirname(SSH_KEY_PATH), exist_ok=True)
import paramiko try:
import paramiko
except ImportError:
msg = "ERROR: paramiko is not installed — cannot generate SSH key"
log.error(msg)
return msg
key = paramiko.Ed25519Key.generate() key = paramiko.Ed25519Key.generate()
key.write_private_key_file(SSH_KEY_PATH) key.write_private_key_file(SSH_KEY_PATH)
@@ -97,15 +108,30 @@ def generate_ssh_key() -> str:
with open(SSH_KEY_PATH + ".pub", "w") as f: with open(SSH_KEY_PATH + ".pub", "w") as f:
f.write(pub_key + "\n") f.write(pub_key + "\n")
log.info(f"SSH key generated: {SSH_KEY_PATH}")
return f"Key generated: {SSH_KEY_PATH}" return f"Key generated: {SSH_KEY_PATH}"
def install_all() -> list[str]: def install_all() -> list[str]:
"""Full setup — install everything.""" """Full setup — install everything."""
results = [] results = []
results.append(install_ssh_script())
results.append(install_skill()) steps = [
results.append(generate_ssh_key()) ("ssh_script", install_ssh_script),
("skill", install_skill),
("ssh_key", generate_ssh_key),
]
for name, func in steps:
try:
log.info(f"install_all: running {name}")
result = func()
results.append(result)
except Exception as e:
msg = f"ERROR ({name}): {e}"
log.error(msg)
results.append(msg)
return results return results

View File

@@ -107,6 +107,12 @@ _EN = {
"clear": "Clear", "clear": "Clear",
"no_server_selected": "[!] No server selected", "no_server_selected": "[!] No server selected",
"server_not_found": "[!] Server '{alias}' not found", "server_not_found": "[!] Server '{alias}' not found",
"term_connecting": "Connecting to {alias}...",
"term_connected": "Connected to {alias}",
"term_disconnected": "Disconnected",
"term_reconnecting": "Reconnecting ({n}/{max})...",
"term_connect_failed": "Connection failed: {error}",
"term_reconnect_fail": "Disconnected (reconnect failed)",
# Files # Files
"upload": "Upload", "upload": "Upload",
@@ -209,6 +215,21 @@ _EN = {
"totp_secret_dialog": "TOTP Secret", "totp_secret_dialog": "TOTP Secret",
"placeholder_totp_secret": "Base32 secret (optional)", "placeholder_totp_secret": "Base32 secret (optional)",
"port_out_of_range": "Port must be 1-65535", "port_out_of_range": "Port must be 1-65535",
# Monitoring
"monitoring": "Monitoring",
"check_interval": "Check interval",
"skip_check": "Skip status checks",
"skip_check_desc": "Don't check this server's availability",
"interval_30s": "30s",
"interval_60s": "60s",
"interval_120s": "2min",
"interval_300s": "5min",
"status_disabled": "disabled",
# Network interface
"network_interface": "Network Interface",
"auto_default": "Auto (default)",
} }
_RU = { _RU = {
@@ -293,6 +314,12 @@ _RU = {
"clear": "Очистить", "clear": "Очистить",
"no_server_selected": "[!] Сервер не выбран", "no_server_selected": "[!] Сервер не выбран",
"server_not_found": "[!] Сервер '{alias}' не найден", "server_not_found": "[!] Сервер '{alias}' не найден",
"term_connecting": "Подключение к {alias}...",
"term_connected": "Подключено к {alias}",
"term_disconnected": "Отключено",
"term_reconnecting": "Переподключение ({n}/{max})...",
"term_connect_failed": "Ошибка подключения: {error}",
"term_reconnect_fail": "Отключено (не удалось переподключиться)",
# Files # Files
"upload": "Загрузить", "upload": "Загрузить",
@@ -395,6 +422,21 @@ _RU = {
"totp_secret_dialog": "TOTP-секрет", "totp_secret_dialog": "TOTP-секрет",
"placeholder_totp_secret": "Base32 секрет (необязательно)", "placeholder_totp_secret": "Base32 секрет (необязательно)",
"port_out_of_range": "Порт должен быть от 1 до 65535", "port_out_of_range": "Порт должен быть от 1 до 65535",
# Monitoring
"monitoring": "Мониторинг",
"check_interval": "Интервал проверки",
"skip_check": "Не проверять доступность",
"skip_check_desc": "Исключить сервер из автопроверки",
"interval_30s": "30с",
"interval_60s": "60с",
"interval_120s": "2мин",
"interval_300s": "5мин",
"status_disabled": "отключено",
# Network interface
"network_interface": "Сетевой интерфейс",
"auto_default": "Авто (по умолчанию)",
} }
_ZH = { _ZH = {
@@ -479,6 +521,12 @@ _ZH = {
"clear": "清除", "clear": "清除",
"no_server_selected": "[!] 未选择服务器", "no_server_selected": "[!] 未选择服务器",
"server_not_found": "[!] 未找到服务器 '{alias}'", "server_not_found": "[!] 未找到服务器 '{alias}'",
"term_connecting": "正在连接 {alias}...",
"term_connected": "已连接到 {alias}",
"term_disconnected": "已断开",
"term_reconnecting": "重新连接中 ({n}/{max})...",
"term_connect_failed": "连接失败:{error}",
"term_reconnect_fail": "已断开(重连失败)",
# Files # Files
"upload": "上传", "upload": "上传",
@@ -581,6 +629,21 @@ _ZH = {
"totp_secret_dialog": "TOTP密钥", "totp_secret_dialog": "TOTP密钥",
"placeholder_totp_secret": "Base32密钥可选", "placeholder_totp_secret": "Base32密钥可选",
"port_out_of_range": "端口必须在1-65535之间", "port_out_of_range": "端口必须在1-65535之间",
# Monitoring
"monitoring": "监控",
"check_interval": "检查间隔",
"skip_check": "跳过状态检查",
"skip_check_desc": "不检查此服务器的可用性",
"interval_30s": "30秒",
"interval_60s": "60秒",
"interval_120s": "2分钟",
"interval_300s": "5分钟",
"status_disabled": "已禁用",
# Network interface
"network_interface": "网络接口",
"auto_default": "自动(默认)",
} }
_TRANSLATIONS = { _TRANSLATIONS = {

View File

@@ -44,6 +44,7 @@ class ServerStore:
def __init__(self): def __init__(self):
self._data: dict = {"servers": [], "ssh_key": {"type": "ed25519", "path": "~/.ssh/id_ed25519"}} self._data: dict = {"servers": [], "ssh_key": {"type": "ed25519", "path": "~/.ssh/id_ed25519"}}
self._observers: list[Callable] = [] self._observers: list[Callable] = []
self._check_interval: int = 60
self._statuses: dict[str, str] = {} # alias -> "online" | "offline" | "unknown" self._statuses: dict[str, str] = {} # alias -> "online" | "offline" | "unknown"
self._statuses_lock = threading.Lock() self._statuses_lock = threading.Lock()
self._file_lock = threading.Lock() self._file_lock = threading.Lock()
@@ -66,6 +67,7 @@ class ServerStore:
from core import i18n from core import i18n
lang = settings.get("language", "en") lang = settings.get("language", "en")
i18n.set_language(lang) i18n.set_language(lang)
self._check_interval = settings.get("check_interval", 60)
except json.JSONDecodeError: except json.JSONDecodeError:
log.warning("Corrupted settings.json, using defaults") log.warning("Corrupted settings.json, using defaults")
except Exception as e: except Exception as e:
@@ -77,6 +79,7 @@ class ServerStore:
settings = { settings = {
"servers_path": self._servers_file, "servers_path": self._servers_file,
"language": i18n.get_language(), "language": i18n.get_language(),
"check_interval": self._check_interval,
} }
try: try:
tmp = SETTINGS_FILE + ".tmp" tmp = SETTINGS_FILE + ".tmp"
@@ -284,6 +287,13 @@ class ServerStore:
# ── Status management (thread-safe) ─────────────── # ── Status management (thread-safe) ───────────────
def get_check_interval(self) -> int:
return self._check_interval
def set_check_interval(self, seconds: int):
self._check_interval = max(10, min(600, seconds))
self._save_settings()
def set_status(self, alias: str, status: str): def set_status(self, alias: str, status: str):
with self._statuses_lock: with self._statuses_lock:
self._statuses[alias] = status self._statuses[alias] = status

View File

@@ -4,10 +4,170 @@ SSH client wrapper — connect, exec, sftp, key management via paramiko.
import os import os
import platform import platform
import socket
import threading
import time
import paramiko import paramiko
from core.logger import log from core.logger import log
def _create_bound_socket(bind_ip: str, hostname: str, port: int, timeout: int) -> socket.socket:
"""Create a TCP socket bound to a specific local IP address."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.bind((bind_ip, 0))
sock.connect((hostname, port))
return sock
def _connect_client(server: dict, key_path: str, timeout: int = 15) -> paramiko.SSHClient:
"""Create and authenticate a paramiko SSHClient. Shared by SSHClientWrapper and ShellSession."""
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
hostname = server["ip"]
port = server.get("port", 22)
bind_ip = server.get("bind_interface")
kwargs = {
"hostname": hostname,
"port": port,
"username": server.get("user", "root"),
"timeout": timeout,
"banner_timeout": timeout,
}
if bind_ip:
kwargs["sock"] = _create_bound_socket(bind_ip, hostname, port, timeout)
# Try key first
if key_path and os.path.exists(key_path):
try:
kwargs["key_filename"] = key_path
client.connect(**kwargs)
return client
except paramiko.AuthenticationException:
log.debug(f"Key auth failed for {server.get('alias', '?')}, trying password")
del kwargs["key_filename"]
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
if bind_ip:
kwargs["sock"] = _create_bound_socket(bind_ip, hostname, port, timeout)
except Exception as e:
log.debug(f"Key connect failed: {e}")
del kwargs["key_filename"]
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
if bind_ip:
kwargs["sock"] = _create_bound_socket(bind_ip, hostname, port, timeout)
# Fallback to password
password = server.get("password", "")
if password:
kwargs["password"] = password
kwargs["look_for_keys"] = False
kwargs["allow_agent"] = False
client.connect(**kwargs)
return client
raise Exception(f"No auth method for {server.get('alias', 'unknown')}")
class ShellSession:
"""Persistent interactive shell session over SSH."""
def __init__(self, server: dict, key_path: str, cols: int = 80, rows: int = 24):
self.server = server
self.key_path = key_path
self.cols = cols
self.rows = rows
self._client: paramiko.SSHClient | None = None
self._channel: paramiko.Channel | None = None
self._running = False
self._read_thread: threading.Thread | None = None
# Callbacks — set by the owner
self.on_data = None # on_data(data: bytes)
self.on_disconnect = None # on_disconnect()
@property
def connected(self) -> bool:
return (
self._channel is not None
and self._channel.get_transport() is not None
and self._channel.get_transport().is_active()
)
def connect(self):
self._client = _connect_client(self.server, self.key_path)
self._channel = self._client.invoke_shell(
term="xterm-256color",
width=self.cols,
height=self.rows,
)
self._channel.settimeout(0.1)
self._running = True
self._read_thread = threading.Thread(target=self._read_loop, daemon=True)
self._read_thread.start()
def _read_loop(self):
try:
while self._running:
try:
data = self._channel.recv(4096)
if not data:
break
if self.on_data:
self.on_data(data)
except TimeoutError:
continue
except OSError:
break
except Exception as e:
log.debug(f"ShellSession read loop error: {e}")
finally:
if self._running:
self._running = False
if self.on_disconnect:
self.on_disconnect()
def send(self, data: bytes):
if self._channel and self._running:
try:
self._channel.sendall(data)
except OSError:
pass
def resize(self, cols: int, rows: int):
self.cols = cols
self.rows = rows
if self._channel and self._running:
try:
self._channel.resize_pty(width=cols, height=rows)
except OSError:
pass
def disconnect(self):
self._running = False
if self._channel:
try:
self._channel.close()
except Exception:
pass
self._channel = None
if self._client:
try:
self._client.close()
except Exception:
pass
self._client = None
def reconnect(self):
self.disconnect()
time.sleep(0.2)
self.connect()
class SSHClientWrapper: class SSHClientWrapper:
def __init__(self, server: dict, key_path: str = ""): def __init__(self, server: dict, key_path: str = ""):
self.server = server self.server = server
@@ -15,46 +175,9 @@ class SSHClientWrapper:
self._client: paramiko.SSHClient | None = None self._client: paramiko.SSHClient | None = None
def connect(self) -> paramiko.SSHClient: def connect(self) -> paramiko.SSHClient:
client = paramiko.SSHClient() client = _connect_client(self.server, self.key_path)
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) self._client = client
return client
kwargs = {
"hostname": self.server["ip"],
"port": self.server.get("port", 22),
"username": self.server.get("user", "root"),
"timeout": 15,
"banner_timeout": 15,
}
# Try key first
if os.path.exists(self.key_path):
try:
kwargs["key_filename"] = self.key_path
client.connect(**kwargs)
self._client = client
return client
except paramiko.AuthenticationException:
log.debug(f"Key auth failed for {self.server.get('alias', '?')}, trying password")
del kwargs["key_filename"]
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
except Exception as e:
log.debug(f"Key connect failed: {e}")
del kwargs["key_filename"]
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
# Fallback to password
password = self.server.get("password", "")
if password:
kwargs["password"] = password
kwargs["look_for_keys"] = False
kwargs["allow_agent"] = False
client.connect(**kwargs)
self._client = client
return client
raise Exception(f"No auth method for {self.server.get('alias', 'unknown')}")
def disconnect(self): def disconnect(self):
if self._client: if self._client:
@@ -73,11 +196,11 @@ class SSHClientWrapper:
need_sudo = use_sudo and user != "root" need_sudo = use_sudo and user != "root"
if need_sudo: if need_sudo:
full_cmd = f"sudo -S -p '' bash -c {_shell_quote(command)}" full_cmd = f"export TERM=xterm; sudo -S -p '' bash -c {_shell_quote(command)}"
else: else:
full_cmd = command full_cmd = f"export TERM=xterm; {command}"
stdin, stdout, stderr = client.exec_command(full_cmd, timeout=120) stdin, stdout, stderr = client.exec_command(full_cmd, timeout=120, get_pty=True)
if need_sudo: if need_sudo:
password = self.server.get("password", "") password = self.server.get("password", "")
@@ -131,38 +254,9 @@ class SSHClientWrapper:
def check_connection(self) -> bool: def check_connection(self) -> bool:
try: try:
client = paramiko.SSHClient() client = _connect_client(self.server, self.key_path, timeout=5)
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.close()
return True
kwargs = {
"hostname": self.server["ip"],
"port": self.server.get("port", 22),
"username": self.server.get("user", "root"),
"timeout": 5,
"banner_timeout": 5,
}
if os.path.exists(self.key_path):
try:
kwargs["key_filename"] = self.key_path
client.connect(**kwargs)
client.close()
return True
except Exception:
del kwargs["key_filename"]
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
password = self.server.get("password", "")
if password:
kwargs["password"] = password
kwargs["look_for_keys"] = False
kwargs["allow_agent"] = False
client.connect(**kwargs)
client.close()
return True
return False
except Exception: except Exception:
return False return False

View File

@@ -15,9 +15,8 @@ from core.logger import log
class StatusChecker: class StatusChecker:
def __init__(self, store: "ServerStore", interval: int = 60): def __init__(self, store: "ServerStore"):
self.store = store self.store = store
self.interval = interval
self._running = False self._running = False
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._gui_callback = None self._gui_callback = None
@@ -48,18 +47,25 @@ class StatusChecker:
def _loop(self): def _loop(self):
while self._running: while self._running:
self._check_cycle() self._check_cycle()
for _ in range(self.interval * 10): interval = self.store.get_check_interval()
for _ in range(interval * 10):
if not self._running: if not self._running:
return return
time.sleep(0.1) time.sleep(0.1)
def _check_cycle(self): def _check_cycle(self):
servers = self.store.get_all() servers = self.store.get_all()
ssh_servers = [s for s in servers if s.get("type", "ssh") == "ssh"]
# Mark non-SSH as unknown # Mark skipped servers as disabled
for s in servers: for s in servers:
if s.get("type", "ssh") != "ssh": if s.get("skip_check", False):
self.store.set_status(s["alias"], "disabled")
ssh_servers = [s for s in servers if s.get("type", "ssh") == "ssh" and not s.get("skip_check", False)]
# Mark non-SSH (non-skipped) as unknown
for s in servers:
if s.get("type", "ssh") != "ssh" and not s.get("skip_check", False):
self.store.set_status(s["alias"], "unknown") self.store.set_status(s["alias"], "unknown")
if not ssh_servers: if not ssh_servers:

View File

@@ -34,7 +34,7 @@ class App(ctk.CTk):
# Core # Core
self.store = ServerStore() self.store = ServerStore()
self.checker = StatusChecker(self.store, interval=60) self.checker = StatusChecker(self.store)
# Layout # Layout
self._build_layout() self._build_layout()
@@ -171,6 +171,9 @@ class App(ctk.CTk):
# Use provided key or default to first tab # Use provided key or default to first tab
current_key = restore_tab_key or self._tab_keys[0] current_key = restore_tab_key or self._tab_keys[0]
# Disconnect terminal before destroying tabs
self.terminal_tab._disconnect()
# Detach tab contents # Detach tab contents
self.terminal_tab.pack_forget() self.terminal_tab.pack_forget()
self.files_tab.pack_forget() self.files_tab.pack_forget()
@@ -223,5 +226,6 @@ class App(ctk.CTk):
self.sidebar.update_language() self.sidebar.update_language()
def _on_close(self): def _on_close(self):
self.terminal_tab._disconnect()
self.checker.stop() self.checker.stop()
self.destroy() self.destroy()

View File

@@ -7,6 +7,20 @@ from core.server_store import SERVER_TYPES, DEFAULT_PORTS
from core.i18n import t from core.i18n import t
def _get_network_interfaces() -> list[tuple[str, str]]:
"""Return list of (name, ipv4_address) for available network interfaces."""
try:
import psutil
result = []
for name, addrs in psutil.net_if_addrs().items():
for addr in addrs:
if addr.family.name == "AF_INET" and addr.address != "127.0.0.1":
result.append((name, addr.address))
return result
except Exception:
return []
class ServerDialog(ctk.CTkToplevel): class ServerDialog(ctk.CTkToplevel):
def __init__(self, master, store, server: dict | None = None): def __init__(self, master, store, server: dict | None = None):
super().__init__(master) super().__init__(master)
@@ -15,7 +29,7 @@ class ServerDialog(ctk.CTkToplevel):
self.result = None self.result = None
self.title(t("edit_server") if server else t("add_server")) self.title(t("edit_server") if server else t("add_server"))
self.geometry("450x580") self.geometry("450x680")
self.resizable(False, False) self.resizable(False, False)
self.grab_set() self.grab_set()
@@ -58,6 +72,20 @@ class ServerDialog(ctk.CTkToplevel):
self.port_entry = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port")) self.port_entry = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port"))
self.port_entry.pack(fill="x") self.port_entry.pack(fill="x")
# Network interface
ctk.CTkLabel(self, text=t("network_interface"), anchor="w").pack(fill="x", **pad)
self._iface_map: dict[str, str] = {} # display_name -> ip
ifaces = _get_network_interfaces()
auto_label = t("auto_default")
iface_values = [auto_label]
for name, ip in ifaces:
label = f"{name} ({ip})"
iface_values.append(label)
self._iface_map[label] = ip
self._iface_var = ctk.StringVar(value=auto_label)
self._iface_menu = ctk.CTkOptionMenu(self, values=iface_values, variable=self._iface_var)
self._iface_menu.pack(fill="x", **entry_pad)
# User # User
ctk.CTkLabel(self, text=t("username"), anchor="w").pack(fill="x", **pad) ctk.CTkLabel(self, text=t("username"), anchor="w").pack(fill="x", **pad)
self.user_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_user")) self.user_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_user"))
@@ -79,6 +107,13 @@ class ServerDialog(ctk.CTkToplevel):
font=ctk.CTkFont(family="Consolas", size=12)) font=ctk.CTkFont(family="Consolas", size=12))
self.totp_entry.pack(fill="x", **entry_pad) self.totp_entry.pack(fill="x", **entry_pad)
# Skip status checks
self.skip_check_var = ctk.BooleanVar(value=False)
self.skip_check_cb = ctk.CTkCheckBox(
self, text=t("skip_check"), variable=self.skip_check_var
)
self.skip_check_cb.pack(fill="x", padx=20, pady=(8, 2))
# Notes # Notes
ctk.CTkLabel(self, text=t("notes"), anchor="w").pack(fill="x", **pad) ctk.CTkLabel(self, text=t("notes"), anchor="w").pack(fill="x", **pad)
self.notes_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_notes")) self.notes_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_notes"))
@@ -100,8 +135,26 @@ class ServerDialog(ctk.CTkToplevel):
self.user_entry.insert(0, server.get("user", "")) self.user_entry.insert(0, server.get("user", ""))
self.password_entry.insert(0, server.get("password", "")) self.password_entry.insert(0, server.get("password", ""))
self.totp_entry.insert(0, server.get("totp_secret", "")) self.totp_entry.insert(0, server.get("totp_secret", ""))
self.skip_check_var.set(server.get("skip_check", False))
self.notes_entry.insert(0, server.get("notes", "")) self.notes_entry.insert(0, server.get("notes", ""))
# Restore network interface selection
saved_ip = server.get("bind_interface")
if saved_ip:
found = False
for label, ip in self._iface_map.items():
if ip == saved_ip:
self._iface_var.set(label)
found = True
break
if not found:
unavail_label = f"? ({saved_ip})"
self._iface_map[unavail_label] = saved_ip
current_values = self._iface_menu.cget("values")
current_values.append(unavail_label)
self._iface_menu.configure(values=current_values)
self._iface_var.set(unavail_label)
def _on_type_change(self, value): def _on_type_change(self, value):
default_port = DEFAULT_PORTS.get(value, 22) default_port = DEFAULT_PORTS.get(value, 22)
self.port_entry.delete(0, "end") self.port_entry.delete(0, "end")
@@ -149,6 +202,14 @@ class ServerDialog(ctk.CTkToplevel):
} }
if totp_secret: if totp_secret:
server_data["totp_secret"] = totp_secret server_data["totp_secret"] = totp_secret
if self.skip_check_var.get():
server_data["skip_check"] = True
# Network interface binding
iface_selection = self._iface_var.get()
bind_ip = self._iface_map.get(iface_selection)
if bind_ip:
server_data["bind_interface"] = bind_ip
try: try:
if self.editing: if self.editing:

View File

@@ -9,6 +9,7 @@ from tkinter import filedialog, messagebox
import customtkinter as ctk import customtkinter as ctk
from core.claude_setup import check_status, install_all, install_ssh_script, install_skill, generate_ssh_key from core.claude_setup import check_status, install_all, install_ssh_script, install_skill, generate_ssh_key
from core.i18n import t from core.i18n import t
from core.logger import log
class SetupTab(ctk.CTkFrame): class SetupTab(ctk.CTkFrame):
@@ -88,6 +89,35 @@ class SetupTab(ctk.CTkFrame):
command=self._refresh_status) command=self._refresh_status)
self.refresh_btn.pack(side="right") self.refresh_btn.pack(side="right")
# ── Monitoring section ─────────────────────────
monitor_frame = ctk.CTkFrame(self)
monitor_frame.pack(fill="x", padx=20, pady=(5, 5))
self.monitor_title = ctk.CTkLabel(
monitor_frame, text=t("monitoring"),
font=ctk.CTkFont(size=14, weight="bold"), anchor="w"
)
self.monitor_title.pack(fill="x", padx=15, pady=(10, 5))
interval_row = ctk.CTkFrame(monitor_frame, fg_color="transparent")
interval_row.pack(fill="x", padx=15, pady=(0, 10))
self.interval_label = ctk.CTkLabel(interval_row, text=t("check_interval"), anchor="w")
self.interval_label.pack(side="left", padx=(0, 10))
self._interval_buttons: dict[int, ctk.CTkButton] = {}
current_interval = store.get_check_interval()
for seconds, key in [(30, "interval_30s"), (60, "interval_60s"), (120, "interval_120s"), (300, "interval_300s")]:
is_active = (seconds == current_interval)
btn = ctk.CTkButton(
interval_row, text=t(key), width=60, height=28,
fg_color="#3b82f6" if is_active else "#6b7280",
hover_color="#2563eb" if is_active else "#4b5563",
command=lambda s=seconds: self._set_interval(s)
)
btn.pack(side="left", padx=2)
self._interval_buttons[seconds] = btn
# ── Configuration section ───────────────────── # ── Configuration section ─────────────────────
config_frame = ctk.CTkFrame(self) config_frame = ctk.CTkFrame(self)
config_frame.pack(fill="x", padx=20, pady=(5, 5)) config_frame.pack(fill="x", padx=20, pady=(5, 5))
@@ -148,6 +178,14 @@ class SetupTab(ctk.CTkFrame):
# Initial status check # Initial status check
self._refresh_status() self._refresh_status()
def _set_interval(self, seconds: int):
self.store.set_check_interval(seconds)
for s, btn in self._interval_buttons.items():
if s == seconds:
btn.configure(fg_color="#3b82f6", hover_color="#2563eb")
else:
btn.configure(fg_color="#6b7280", hover_color="#4b5563")
def _log(self, text: str): def _log(self, text: str):
self.log.configure(state="normal") self.log.configure(state="normal")
self.log.insert("end", text + "\n") self.log.insert("end", text + "\n")
@@ -166,12 +204,17 @@ class SetupTab(ctk.CTkFrame):
self.install_all_btn.configure(state="disabled", text=t("installing_all")) self.install_all_btn.configure(state="disabled", text=t("installing_all"))
def _do(): def _do():
results = install_all() try:
for msg in results: results = install_all()
self.after(0, lambda m=msg: self._log(m)) for msg in results:
self.after(0, self._refresh_status) self.after(0, lambda m=msg: self._log(m))
self.after(0, lambda: self._log("\n" + t("install_done"))) self.after(0, self._refresh_status)
self.after(0, lambda: self.install_all_btn.configure(state="normal", text=t("install_everything"))) self.after(0, lambda: self._log("\n" + t("install_done")))
except Exception as e:
log.error(f"install_all failed: {e}")
self.after(0, lambda: self._log(f"ERROR: {e}"))
finally:
self.after(0, lambda: self.install_all_btn.configure(state="normal", text=t("install_everything")))
threading.Thread(target=_do, daemon=True).start() threading.Thread(target=_do, daemon=True).start()
@@ -186,8 +229,12 @@ class SetupTab(ctk.CTkFrame):
self._refresh_status() self._refresh_status()
def _gen_key(self): def _gen_key(self):
msg = generate_ssh_key() try:
self._log(msg) msg = generate_ssh_key()
self._log(msg)
except Exception as e:
log.error(f"generate_ssh_key failed: {e}")
self._log(f"ERROR: {e}")
self._refresh_status() self._refresh_status()
# ── Configuration methods ───────────────────────── # ── Configuration methods ─────────────────────────

View File

@@ -1,10 +1,11 @@
""" """
Terminal tab — command input + output display. Terminal tab — persistent interactive SSH shell via ShellSession + TerminalWidget.
""" """
import threading import threading
import time
import customtkinter as ctk import customtkinter as ctk
from core.ssh_client import SSHClientWrapper from core.ssh_client import ShellSession
from core.i18n import t from core.i18n import t
@@ -13,93 +14,110 @@ class TerminalTab(ctk.CTkFrame):
super().__init__(master, fg_color="transparent") super().__init__(master, fg_color="transparent")
self.store = store self.store = store
self._current_alias: str | None = None self._current_alias: str | None = None
self._session: ShellSession | None = None
self._reconnect_count = 0
self._max_reconnect = 3
self._intentional_disconnect = False
# Output # Import here to avoid circular issues
self.output = ctk.CTkTextbox(self, font=ctk.CTkFont(family="Consolas", size=12), state="disabled") from gui.widgets.terminal_widget import TerminalWidget
self.output.pack(fill="both", expand=True, padx=10, pady=(10, 5))
# Input row self._terminal = TerminalWidget(
input_frame = ctk.CTkFrame(self, fg_color="transparent") self,
input_frame.pack(fill="x", padx=10, pady=(0, 10)) send_callback=self._send_to_shell,
resize_callback=self._on_resize,
self.sudo_var = ctk.BooleanVar(value=True) )
self.sudo_check = ctk.CTkCheckBox(input_frame, text=t("sudo"), variable=self.sudo_var, width=60) self._terminal.pack(fill="both", expand=True, padx=5, pady=5)
self.sudo_check.pack(side="left", padx=(0, 5)) self._terminal.set_status(t("term_disconnected"), "#888888")
self.cmd_entry = ctk.CTkEntry(input_frame, placeholder_text=t("enter_command"))
self.cmd_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
self.cmd_entry.bind("<Return>", lambda e: self._run_command())
self.run_btn = ctk.CTkButton(input_frame, text=t("run"), width=70, command=self._run_command)
self.run_btn.pack(side="left", padx=(0, 5))
self.clear_btn = ctk.CTkButton(input_frame, text=t("clear"), width=60, fg_color="#6b7280", command=self._clear)
self.clear_btn.pack(side="right")
def set_server(self, alias: str | None): def set_server(self, alias: str | None):
if alias == self._current_alias:
return
self._disconnect()
self._current_alias = alias self._current_alias = alias
if alias: if alias:
server = self.store.get_server(alias) self._connect()
user = server.get("user", "root") if server else "root" else:
self.sudo_var.set(user != "root") self._terminal.reset()
self._terminal.set_status(t("term_disconnected"), "#888888")
def _append_output(self, text: str, color: str = "white"): def _connect(self):
self.output.configure(state="normal")
self.output.insert("end", text)
self.output.configure(state="disabled")
self.output.see("end")
def _run_command(self):
if not self._current_alias: if not self._current_alias:
self._append_output(t("no_server_selected") + "\n")
return return
command = self.cmd_entry.get().strip()
if not command:
return
server = self.store.get_server(self._current_alias) server = self.store.get_server(self._current_alias)
if not server: if not server:
self._append_output(t("server_not_found").format(alias=self._current_alias) + "\n") self._terminal.set_status(
t("server_not_found").format(alias=self._current_alias), "#ff4444"
)
return return
self.cmd_entry.delete(0, "end") alias = self._current_alias
use_sudo = self.sudo_var.get() self._terminal.set_status(t("term_connecting").format(alias=alias), "#ccaa00")
prefix = f"[{self._current_alias}]$ " self._terminal.reset()
if use_sudo and server.get("user", "root") != "root": self._intentional_disconnect = False
prefix = f"[{self._current_alias}]# "
self._append_output(f"{prefix}{command}\n")
self.run_btn.configure(state="disabled", text="...") def _do_connect():
def _exec():
try: try:
key_path = self.store.get_ssh_key_path() key_path = self.store.get_ssh_key_path()
wrapper = SSHClientWrapper(server, key_path) cols, rows = self._terminal.get_size()
out, err, code = wrapper.exec_command(command, use_sudo=use_sudo) session = ShellSession(server, key_path, cols=cols, rows=rows)
session.on_data = self._on_data_received
def _show(): session.on_disconnect = self._on_disconnected
if out: session.connect()
self._append_output(out) self._session = session
if not out.endswith("\n"): self._reconnect_count = 0
self._append_output("\n") self.after(0, lambda: self._terminal.set_status(
if err: t("term_connected").format(alias=alias), "#44cc44"
self._append_output(f"STDERR: {err}\n") ))
if code != 0: self.after(0, self._terminal.focus_terminal)
self._append_output(f"[exit code: {code}]\n")
self._append_output("\n")
self.run_btn.configure(state="normal", text=t("run"))
self.after(0, _show)
except Exception as e: except Exception as e:
def _err(): self.after(0, lambda: self._terminal.set_status(
self._append_output(f"[ERROR] {e}\n\n") t("term_connect_failed").format(error=str(e)), "#ff4444"
self.run_btn.configure(state="normal", text=t("run")) ))
self.after(0, _err)
threading.Thread(target=_exec, daemon=True).start() threading.Thread(target=_do_connect, daemon=True).start()
def _clear(self): def _disconnect(self):
self.output.configure(state="normal") self._intentional_disconnect = True
self.output.delete("1.0", "end") if self._session:
self.output.configure(state="disabled") self._session.disconnect()
self._session = None
def _on_data_received(self, data: bytes):
self.after(0, lambda d=data: self._terminal.feed(d))
def _on_disconnected(self):
if self._intentional_disconnect:
self.after(0, lambda: self._terminal.set_status(
t("term_disconnected"), "#888888"
))
return
self._session = None
if self._reconnect_count < self._max_reconnect:
self._reconnect_count += 1
n = self._reconnect_count
mx = self._max_reconnect
self.after(0, lambda: self._terminal.set_status(
t("term_reconnecting").format(n=n, max=mx), "#ccaa00"
))
def _retry():
time.sleep(1)
if not self._intentional_disconnect and self._current_alias:
self.after(0, self._connect)
threading.Thread(target=_retry, daemon=True).start()
else:
self.after(0, lambda: self._terminal.set_status(
t("term_reconnect_fail"), "#ff4444"
))
def _send_to_shell(self, data: bytes):
if self._session and self._session.connected:
self._session.send(data)
def _on_resize(self, cols: int, rows: int):
if self._session and self._session.connected:
self._session.resize(cols, rows)

View File

@@ -8,6 +8,7 @@ COLORS = {
"online": "#22c55e", # green "online": "#22c55e", # green
"offline": "#ef4444", # red "offline": "#ef4444", # red
"unknown": "#6b7280", # gray "unknown": "#6b7280", # gray
"disabled": "#9ca3af", # light gray
} }
@@ -23,4 +24,5 @@ class StatusBadge(ctk.CTkLabel):
def _update_color(self): def _update_color(self):
color = COLORS.get(self._status, COLORS["unknown"]) color = COLORS.get(self._status, COLORS["unknown"])
self.configure(text="\u25cf", text_color=color, font=("", 14)) symbol = "\u2014" if self._status == "disabled" else "\u25cf"
self.configure(text=symbol, text_color=color, font=("", 14))

View File

@@ -0,0 +1,400 @@
"""
Terminal widget — pyte VT100 emulator + tkinter.Text with ANSI colors,
full keyboard mapping, diff-based rendering, and cursor display.
"""
import tkinter as tk
import tkinter.font as tkfont
import pyte
# 16 standard ANSI colors
_ANSI_COLORS = {
"black": "#000000",
"red": "#cc0000",
"green": "#4e9a06",
"brown": "#c4a000",
"blue": "#3465a4",
"magenta": "#75507b",
"cyan": "#06989a",
"white": "#d3d7cf",
"brightblack": "#555753",
"brightred": "#ef2929",
"brightgreen": "#8ae234",
"brightbrown": "#fce94f",
"brightblue": "#729fcf",
"brightmagenta": "#ad7fa8",
"brightcyan": "#34e2e2",
"brightwhite": "#eeeeec",
}
_DEFAULT_FG = "#d3d7cf"
_DEFAULT_BG = "#1a1a2e"
_CURSOR_FG = "#1a1a2e"
_CURSOR_BG = "#d3d7cf"
# Key → VT100 escape sequence
_KEY_MAP = {
"Up": "\x1b[A",
"Down": "\x1b[B",
"Right": "\x1b[C",
"Left": "\x1b[D",
"Home": "\x1b[H",
"End": "\x1b[F",
"Insert": "\x1b[2~",
"Delete": "\x1b[3~",
"Prior": "\x1b[5~", # PageUp
"Next": "\x1b[6~", # PageDown
"F1": "\x1bOP",
"F2": "\x1bOQ",
"F3": "\x1bOR",
"F4": "\x1bOS",
"F5": "\x1b[15~",
"F6": "\x1b[17~",
"F7": "\x1b[18~",
"F8": "\x1b[19~",
"F9": "\x1b[20~",
"F10": "\x1b[21~",
"F11": "\x1b[23~",
"F12": "\x1b[24~",
}
class TerminalWidget(tk.Frame):
"""VT100 terminal emulator widget using pyte + tkinter.Text."""
def __init__(self, master, cols=80, rows=24, send_callback=None,
resize_callback=None, **kwargs):
super().__init__(master, bg=_DEFAULT_BG, **kwargs)
self.send_callback = send_callback
self.resize_callback = resize_callback
self._cols = cols
self._rows = rows
# pyte screen + stream
self._screen = pyte.Screen(cols, rows)
self._screen.set_mode(pyte.modes.LNM)
self._stream = pyte.Stream(self._screen)
# Previous buffer for diff rendering
self._prev_buffer: dict[int, list] = {}
# Font
self._font = tkfont.Font(family="Consolas", size=11)
self._bold_font = tkfont.Font(family="Consolas", size=11, weight="bold")
# Text widget
self._text = tk.Text(
self,
bg=_DEFAULT_BG,
fg=_DEFAULT_FG,
font=self._font,
insertwidth=0,
highlightthickness=0,
borderwidth=0,
padx=4,
pady=4,
wrap="none",
cursor="xterm",
)
self._text.pack(fill="both", expand=True)
# Create color tags
self._setup_tags()
# Keyboard bindings
self._text.bind("<Key>", self._on_key)
self._text.bind("<Control-c>", self._on_ctrl_c)
self._text.bind("<Control-v>", self._on_ctrl_v)
self._text.bind("<Control-a>", lambda e: "break")
self._text.bind("<Control-d>", self._on_ctrl_d)
self._text.bind("<Control-l>", self._on_ctrl_l)
self._text.bind("<Control-z>", self._on_ctrl_z)
self._text.bind("<MouseWheel>", self._on_mousewheel)
# Resize handling
self._resize_after_id = None
self._text.bind("<Configure>", self._on_configure)
# Focus on click
self._text.bind("<Button-1>", lambda e: self._text.focus_set())
# Make text read-only (input goes through _on_key)
self._text.bind("<<Paste>>", lambda e: "break")
# Status bar
self._status_frame = tk.Frame(self, bg="#2d2d44", height=22)
self._status_frame.pack(fill="x", side="bottom")
self._status_frame.pack_propagate(False)
self._status_label = tk.Label(
self._status_frame, text="", fg="#888888", bg="#2d2d44",
font=("Consolas", 9), anchor="w", padx=6,
)
self._status_label.pack(fill="x")
def _setup_tags(self):
"""Pre-create tags for all ANSI color combinations."""
for name, color in _ANSI_COLORS.items():
self._text.tag_configure(f"fg_{name}", foreground=color)
self._text.tag_configure(f"bg_{name}", background=color)
self._text.tag_configure("bold", font=self._bold_font)
self._text.tag_configure("italic", font=tkfont.Font(family="Consolas", size=11, slant="italic"))
self._text.tag_configure("underline", underline=True)
self._text.tag_configure("cursor", foreground=_CURSOR_FG, background=_CURSOR_BG)
self._text.tag_configure("default", foreground=_DEFAULT_FG)
def set_status(self, text: str, color: str = "#888888"):
self._status_label.configure(text=text, fg=color)
def feed(self, data: bytes):
"""Feed raw bytes from SSH into pyte and re-render."""
try:
text = data.decode("utf-8", errors="replace")
except Exception:
text = data.decode("latin-1", errors="replace")
self._stream.feed(text)
self._render()
def reset(self):
"""Reset the terminal screen."""
self._screen.reset()
self._prev_buffer.clear()
self._render()
def focus_terminal(self):
self._text.focus_set()
def get_size(self) -> tuple[int, int]:
"""Return (cols, rows) based on current widget size."""
return self._cols, self._rows
def _render(self):
"""Diff-based rendering: only update changed lines."""
self._text.configure(state="normal")
screen = self._screen
cursor = screen.cursor
for row in range(screen.lines):
line = screen.buffer[row]
# Build list of (char, attrs) for the row
current = []
for col in range(screen.columns):
char_data = line[col]
current.append((char_data.data, char_data.fg, char_data.bg,
char_data.bold, char_data.italics, char_data.underscore))
prev = self._prev_buffer.get(row)
is_cursor_row = (row == cursor.y)
prev_was_cursor_row = hasattr(self, "_prev_cursor_y") and self._prev_cursor_y == row
if current == prev and not is_cursor_row and not prev_was_cursor_row:
continue
self._prev_buffer[row] = current
# Delete the line
line_start = f"{row + 1}.0"
line_end = f"{row + 1}.end"
self._text.delete(line_start, line_end)
# Ensure enough lines exist
while int(self._text.index("end-1c").split(".")[0]) <= row:
self._text.insert("end", "\n")
# Build line content with batched tags
col = 0
while col < len(current):
ch, fg, bg, bold, italic, underline = current[col]
# Batch consecutive chars with same attributes
batch = [ch]
while col + len(batch) < len(current):
nc = current[col + len(batch)]
if nc[1:] == (fg, bg, bold, italic, underline):
batch.append(nc[0])
else:
break
text = "".join(batch)
tags = self._make_tags(fg, bg, bold, italic, underline)
# Apply cursor tag
if is_cursor_row:
if col <= cursor.x < col + len(batch):
# Split at cursor position
pre_len = cursor.x - col
if pre_len > 0:
self._text.insert(f"{row + 1}.{col}", text[:pre_len], tags)
cursor_tags = ("cursor",) + tags
self._text.insert(f"{row + 1}.{cursor.x}", text[pre_len:pre_len + 1], cursor_tags)
post = text[pre_len + 1:]
if post:
self._text.insert(f"{row + 1}.{cursor.x + 1}", post, tags)
col += len(batch)
continue
self._text.insert(f"{row + 1}.{col}", text, tags)
col += len(batch)
# Trim excess lines
total_lines = int(self._text.index("end-1c").split(".")[0])
if total_lines > screen.lines:
self._text.delete(f"{screen.lines + 1}.0", "end")
self._prev_cursor_y = cursor.y
self._text.configure(state="disabled")
def _make_tags(self, fg, bg, bold, italic, underline) -> tuple:
tags = []
fg_color = self._resolve_color(fg, is_fg=True)
if fg_color:
tag = f"fg_{fg_color}" if fg_color in _ANSI_COLORS else None
if tag and tag.replace("fg_", "") in _ANSI_COLORS:
tags.append(tag)
bg_color = self._resolve_color(bg, is_fg=False)
if bg_color:
tag = f"bg_{bg_color}" if bg_color in _ANSI_COLORS else None
if tag and tag.replace("bg_", "") in _ANSI_COLORS:
tags.append(tag)
if bold:
tags.append("bold")
if italic:
tags.append("italic")
if underline:
tags.append("underline")
return tuple(tags)
def _resolve_color(self, color, is_fg=True):
if not color or color == "default":
return None
if color in _ANSI_COLORS:
return color
# pyte may return color names like "red", "green" etc directly
return None
def _on_key(self, event):
"""Handle keyboard input and send to SSH."""
# Ignore modifier-only keys
if event.keysym in ("Shift_L", "Shift_R", "Control_L", "Control_R",
"Alt_L", "Alt_R", "Meta_L", "Meta_R",
"Caps_Lock", "Num_Lock"):
return "break"
# Ctrl+key combinations
if event.state & 0x4: # Control
if event.keysym.lower() in ("c", "v", "d", "l", "z"):
return # handled by specific bindings
ch = event.keysym.lower()
if len(ch) == 1 and "a" <= ch <= "z":
self._send(bytes([ord(ch) - ord("a") + 1]))
return "break"
# Special keys
if event.keysym in _KEY_MAP:
self._send(_KEY_MAP[event.keysym].encode())
return "break"
# Tab
if event.keysym == "Tab":
self._send(b"\t")
return "break"
# Return / Enter
if event.keysym in ("Return", "KP_Enter"):
self._send(b"\r")
return "break"
# Backspace
if event.keysym == "BackSpace":
self._send(b"\x7f")
return "break"
# Escape
if event.keysym == "Escape":
self._send(b"\x1b")
return "break"
# Regular character
if event.char and ord(event.char) >= 32:
self._send(event.char.encode("utf-8"))
return "break"
return "break"
def _on_ctrl_c(self, event):
# Check if text is selected — if so, copy
try:
sel = self._text.get("sel.first", "sel.last")
if sel:
self.clipboard_clear()
self.clipboard_append(sel)
return "break"
except tk.TclError:
pass
# No selection — send SIGINT
self._send(b"\x03")
return "break"
def _on_ctrl_v(self, event):
try:
text = self.clipboard_get()
if text:
self._send(text.encode("utf-8"))
except tk.TclError:
pass
return "break"
def _on_ctrl_d(self, event):
self._send(b"\x04")
return "break"
def _on_ctrl_l(self, event):
self._send(b"\x0c")
return "break"
def _on_ctrl_z(self, event):
self._send(b"\x1a")
return "break"
def _on_mousewheel(self, event):
# Send scroll sequences for programs like less/man
if event.delta > 0:
self._send(b"\x1b[5~") # PageUp
else:
self._send(b"\x1b[6~") # PageDown
return "break"
def _send(self, data: bytes):
if self.send_callback:
self.send_callback(data)
def _on_configure(self, event):
"""Debounced resize handler."""
if self._resize_after_id:
self.after_cancel(self._resize_after_id)
self._resize_after_id = self.after(200, self._do_resize)
def _do_resize(self):
self._resize_after_id = None
w = self._text.winfo_width()
h = self._text.winfo_height()
if w < 20 or h < 20:
return
char_w = self._font.measure("M")
char_h = self._font.metrics("linespace")
if char_w <= 0 or char_h <= 0:
return
new_cols = max(20, (w - 8) // char_w)
new_rows = max(4, (h - 8) // char_h)
if new_cols != self._cols or new_rows != self._rows:
self._cols = new_cols
self._rows = new_rows
self._screen.resize(new_rows, new_cols)
self._prev_buffer.clear()
self._render()
if self.resize_callback:
self.resize_callback(new_cols, new_rows)

260
release.py Normal file
View File

@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""
Release script — auto-versioning and auto-changelog.
Usage:
python release.py minor # 1.5.0 → 1.6.0
python release.py patch # 1.5.0 → 1.5.1
python release.py major # 1.5.0 → 2.0.0
python release.py 2.0.0 # explicit version
python release.py minor --desc "Release desc" # custom description
"""
import argparse
import os
import re
import subprocess
import sys
from datetime import date
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def read_file(path: str) -> str:
with open(path, "r", encoding="utf-8") as f:
return f.read()
def write_file(path: str, content: str) -> None:
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def get_current_version() -> str:
"""Read __version__ from version.py."""
text = read_file(os.path.join(PROJECT_DIR, "version.py"))
m = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', text)
if not m:
print("ERROR: cannot parse __version__ from version.py")
sys.exit(1)
return m.group(1)
def parse_semver(ver: str) -> tuple[int, int, int]:
parts = ver.split(".")
if len(parts) != 3 or not all(p.isdigit() for p in parts):
print(f"ERROR: invalid semver '{ver}'")
sys.exit(1)
return int(parts[0]), int(parts[1]), int(parts[2])
def bump_version(current: str, bump_type: str) -> str:
major, minor, patch = parse_semver(current)
if bump_type == "major":
return f"{major + 1}.0.0"
elif bump_type == "minor":
return f"{major}.{minor + 1}.0"
elif bump_type == "patch":
return f"{major}.{minor}.{patch + 1}"
else:
# explicit version
parse_semver(bump_type) # validate
return bump_type
# ---------------------------------------------------------------------------
# Git log → changelog
# ---------------------------------------------------------------------------
def get_last_tag() -> str | None:
"""Return the most recent tag, or None."""
try:
result = subprocess.run(
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True, text=True, cwd=PROJECT_DIR,
)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
pass
return None
def get_commits_since(tag: str | None) -> list[str]:
"""Return list of commit messages since *tag* (or all if tag is None)."""
if tag:
cmd = ["git", "log", f"{tag}..HEAD", "--pretty=format:%s"]
else:
cmd = ["git", "log", "--pretty=format:%s"]
try:
result = subprocess.run(cmd, capture_output=True, text=True, cwd=PROJECT_DIR)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().splitlines()
except FileNotFoundError:
pass
return []
def categorize_commits(commits: list[str]) -> dict[str, list[str]]:
"""Group commits into Added / Fixed / Changed."""
cats: dict[str, list[str]] = {"Added": [], "Fixed": [], "Changed": []}
add_re = re.compile(r"^(add|feat|new)\b", re.IGNORECASE)
fix_re = re.compile(r"^fix\b", re.IGNORECASE)
for msg in commits:
msg = msg.strip()
if not msg:
continue
if add_re.search(msg):
cats["Added"].append(msg)
elif fix_re.search(msg):
cats["Fixed"].append(msg)
else:
cats["Changed"].append(msg)
return cats
def build_changelog_section(
new_version: str,
categories: dict[str, list[str]],
description: str | None,
) -> str:
"""Build markdown section for CHANGELOG.md."""
today = date.today().isoformat()
lines = [f"## [{new_version}] - {today}", ""]
if description:
lines.append(description)
lines.append("")
for cat in ("Added", "Fixed", "Changed"):
items = categories.get(cat, [])
if items:
lines.append(f"### {cat}")
for item in items:
lines.append(f"- {item}")
lines.append("")
# if nothing was categorized, add a placeholder
if not any(categories.values()):
lines.append("### Changed")
lines.append(f"- Bump version to {new_version}")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# File updaters
# ---------------------------------------------------------------------------
def update_version_py(new_version: str) -> None:
path = os.path.join(PROJECT_DIR, "version.py")
text = read_file(path)
text = re.sub(
r'(__version__\s*=\s*["\'])[^"\']+(["\'])',
rf"\g<1>{new_version}\2",
text,
)
write_file(path, text)
print(f" version.py → {new_version}")
def update_claude_md(new_version: str) -> None:
path = os.path.join(PROJECT_DIR, "CLAUDE.md")
if not os.path.exists(path):
print(" CLAUDE.md — not found, skipping")
return
text = read_file(path)
text = re.sub(
r"(##\s*Текущая версия:\s*)\S+",
rf"\g<1>{new_version}",
text,
)
write_file(path, text)
print(f" CLAUDE.md → {new_version}")
def update_readme(old_version: str, new_version: str) -> None:
path = os.path.join(PROJECT_DIR, "README.md")
if not os.path.exists(path):
print(" README.md — not found, skipping")
return
text = read_file(path)
old_tag = f"ServerManager-v{old_version}"
new_tag = f"ServerManager-v{new_version}"
if old_tag in text:
text = text.replace(old_tag, new_tag)
write_file(path, text)
print(f" README.md → {old_tag}{new_tag}")
else:
print(f" README.md — '{old_tag}' not found, skipping")
def update_changelog(section: str) -> None:
path = os.path.join(PROJECT_DIR, "CHANGELOG.md")
if os.path.exists(path):
old = read_file(path)
# Insert after the first line ("# Changelog\n")
header_re = re.compile(r"^(#\s*Changelog\s*\n)", re.MULTILINE)
m = header_re.search(old)
if m:
insert_pos = m.end()
new_text = old[:insert_pos] + "\n" + section + "\n" + old[insert_pos:].lstrip("\n")
else:
new_text = section + "\n" + old
else:
new_text = "# Changelog\n\n" + section
write_file(path, new_text)
print(f" CHANGELOG.md → new section added")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Auto-versioning and auto-changelog for ServerManager.",
)
parser.add_argument(
"version",
help="Bump type (major/minor/patch) or explicit version (e.g. 2.0.0)",
)
parser.add_argument(
"--desc",
default=None,
help="Custom description for the changelog section",
)
args = parser.parse_args()
old_version = get_current_version()
bump_type = args.version.lower()
if bump_type in ("major", "minor", "patch"):
new_version = bump_version(old_version, bump_type)
else:
new_version = bump_version(old_version, args.version)
print(f"\nRelease: {old_version}{new_version}\n")
# Collect git log
last_tag = get_last_tag()
commits = get_commits_since(last_tag)
categories = categorize_commits(commits)
section = build_changelog_section(new_version, categories, args.desc)
# Update files
print("Updating files:")
update_version_py(new_version)
update_claude_md(new_version)
update_readme(old_version, new_version)
update_changelog(section)
# Summary
print(f"\nDone! Version is now {new_version}.")
print("Next steps:")
print(" 1. Review changes: git diff")
print(" 2. Build: python build.py --clean")
print(" 3. Commit & push")
if __name__ == "__main__":
main()

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -3,3 +3,5 @@ paramiko>=3.4.0
pillow>=10.0.0 pillow>=10.0.0
cryptography>=41.0.0 cryptography>=41.0.0
pyotp>=2.9.0 pyotp>=2.9.0
pyte>=0.8.1
psutil>=5.9.0

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

BIN
screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

BIN
screenshot3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

BIN
screenshot4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

BIN
screenshot5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

BIN
screenshot6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

BIN
screenshot7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

BIN
screenshot_2fa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

BIN
ss_2fa_new.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

BIN
ss_final.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

BIN
ss_newui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

View File

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