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:
@@ -9,6 +9,7 @@ from tkinter import filedialog, messagebox
|
||||
import customtkinter as ctk
|
||||
from core.claude_setup import check_status, install_all, install_ssh_script, install_skill, generate_ssh_key
|
||||
from core.i18n import t
|
||||
from core.logger import log
|
||||
|
||||
|
||||
class SetupTab(ctk.CTkFrame):
|
||||
@@ -88,6 +89,35 @@ class SetupTab(ctk.CTkFrame):
|
||||
command=self._refresh_status)
|
||||
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 ─────────────────────
|
||||
config_frame = ctk.CTkFrame(self)
|
||||
config_frame.pack(fill="x", padx=20, pady=(5, 5))
|
||||
@@ -148,6 +178,14 @@ class SetupTab(ctk.CTkFrame):
|
||||
# Initial status check
|
||||
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):
|
||||
self.log.configure(state="normal")
|
||||
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"))
|
||||
|
||||
def _do():
|
||||
results = install_all()
|
||||
for msg in results:
|
||||
self.after(0, lambda m=msg: self._log(m))
|
||||
self.after(0, self._refresh_status)
|
||||
self.after(0, lambda: self._log("\n" + t("install_done")))
|
||||
self.after(0, lambda: self.install_all_btn.configure(state="normal", text=t("install_everything")))
|
||||
try:
|
||||
results = install_all()
|
||||
for msg in results:
|
||||
self.after(0, lambda m=msg: self._log(m))
|
||||
self.after(0, self._refresh_status)
|
||||
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()
|
||||
|
||||
@@ -186,8 +229,12 @@ class SetupTab(ctk.CTkFrame):
|
||||
self._refresh_status()
|
||||
|
||||
def _gen_key(self):
|
||||
msg = generate_ssh_key()
|
||||
self._log(msg)
|
||||
try:
|
||||
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()
|
||||
|
||||
# ── Configuration methods ─────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user