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>
36
CHANGELOG.md
@@ -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
@@ -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.
|
||||||
@@ -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`
|
||||||
|
|
||||||
### 使用方法
|
### 使用方法
|
||||||
|
|
||||||
|
|||||||
2
build.py
@@ -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",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
63
core/i18n.py
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 ─────────────────────────
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
400
gui/widgets/terminal_widget.py
Normal 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
@@ -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()
|
||||||
BIN
releases/ServerManager-v1.1.0-win-x64.exe
Normal file
BIN
releases/ServerManager-v1.2.0-win-x64.exe
Normal file
BIN
releases/ServerManager-v1.4.0-win-x64.exe
Normal 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
|
After Width: | Height: | Size: 248 KiB |
BIN
screenshot2.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
screenshot3.png
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
screenshot4.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
screenshot5.png
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
screenshot6.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
screenshot7.png
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
screenshot_2fa.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
ss_2fa_new.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
ss_final.png
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
ss_newui.png
Normal file
|
After Width: | Height: | Size: 219 KiB |
@@ -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"
|
||||||
|
|||||||