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