Files
server-manager/gui/tabs/setup_tab.py
2026-03-07 07:28:11 +00:00

495 lines
19 KiB
Python

"""
Setup tab — one-click installation for local AI agent integration.
Includes configuration path management and backup/restore.
"""
import os
import threading
from datetime import datetime
from tkinter import filedialog, messagebox
import customtkinter as ctk
from core.claude_setup import (
check_status,
generate_ssh_key,
install_all,
install_claude_skill,
install_codex_skill,
install_ssh_script,
)
from core.i18n import t
from core.icons import icon_text, make_icon_button
from core.logger import log
class SetupTab(ctk.CTkFrame):
def __init__(self, master, store):
super().__init__(master, fg_color="transparent")
self.store = store
# Scrollable container for all content
self._scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
self._scroll.pack(fill="both", expand=True)
# Header
self.header_label = ctk.CTkLabel(
self._scroll, text=t("agent_integration"),
font=ctk.CTkFont(size=20, weight="bold")
)
self.header_label.pack(padx=20, pady=(20, 5))
self.desc_label = ctk.CTkLabel(
self._scroll, text=t("agent_desc"),
text_color="#9ca3af", justify="center"
)
self.desc_label.pack(padx=20, pady=(0, 15))
# Status card
self.status_frame = ctk.CTkFrame(self._scroll)
self.status_frame.pack(fill="x", padx=20, pady=10)
self.status_title = ctk.CTkLabel(
self.status_frame, text=t("status"),
font=ctk.CTkFont(size=14, weight="bold"), anchor="w"
)
self.status_title.pack(fill="x", padx=15, pady=(10, 5))
self._status_labels: dict[str, ctk.CTkLabel] = {}
self._status_text_labels: dict[str, ctk.CTkLabel] = {}
status_items = [
("shared_dir", "status_shared_dir"),
("servers_json", "status_servers_json"),
("ssh_script", "status_ssh_script"),
("encryption", "status_encryption"),
("claude_skill_installed", "status_claude_skill"),
("codex_skill_installed", "status_codex_skill"),
("codex_wrapper_installed", "status_codex_wrapper"),
("ssh_key_exists", "status_ssh_key"),
]
for key, i18n_key in status_items:
row = ctk.CTkFrame(self.status_frame, fg_color="transparent")
row.pack(fill="x", padx=15, pady=2)
indicator = ctk.CTkLabel(row, text="\u25cf", width=20, text_color="#6b7280")
indicator.pack(side="left")
text_label = ctk.CTkLabel(row, text=t(i18n_key), anchor="w")
text_label.pack(side="left", fill="x", expand=True)
self._status_labels[key] = indicator
self._status_text_labels[key] = (text_label, i18n_key)
# Buttons
btn_frame = ctk.CTkFrame(self._scroll, fg_color="transparent")
btn_frame.pack(fill="x", padx=20, pady=15)
self.install_all_btn = make_icon_button(
btn_frame, "confirm", t("install_everything"),
font=ctk.CTkFont(size=14, weight="bold"),
height=40, fg_color="#22c55e", hover_color="#16a34a",
command=self._install_all
)
self.install_all_btn.pack(fill="x", pady=(0, 10))
# Individual buttons row
ind_frame = ctk.CTkFrame(btn_frame, fg_color="transparent")
ind_frame.pack(fill="x")
top_btn_row = ctk.CTkFrame(ind_frame, fg_color="transparent")
top_btn_row.pack(fill="x", pady=(0, 5))
self.ssh_py_btn = make_icon_button(
top_btn_row, "confirm", t("install_ssh_py"), width=120, fg_color="#6b7280",
command=self._install_script
)
self.ssh_py_btn.pack(side="left", padx=(0, 5))
self.claude_skill_btn = make_icon_button(
top_btn_row, "confirm", t("install_claude_skill"), width=130, fg_color="#6b7280",
command=self._install_claude_skill
)
self.claude_skill_btn.pack(side="left", padx=5)
self.codex_skill_btn = make_icon_button(
top_btn_row, "confirm", t("install_codex_skill"), width=130, fg_color="#6b7280",
command=self._install_codex_skill
)
self.codex_skill_btn.pack(side="left", padx=5)
bottom_btn_row = ctk.CTkFrame(ind_frame, fg_color="transparent")
bottom_btn_row.pack(fill="x")
self.ssh_key_btn = make_icon_button(
bottom_btn_row, "confirm", t("install_ssh_key"), width=120, fg_color="#6b7280",
command=self._gen_key
)
self.ssh_key_btn.pack(side="left", padx=(0, 5))
self.refresh_btn = make_icon_button(
bottom_btn_row, "refresh", t("refresh"), width=90, fg_color="#3b82f6",
command=self._refresh_status
)
self.refresh_btn.pack(side="right")
# ── Monitoring section ─────────────────────────
monitor_frame = ctk.CTkFrame(self._scroll)
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
# ── Updates section ─────────────────────────
update_frame = ctk.CTkFrame(self._scroll)
update_frame.pack(fill="x", padx=20, pady=(5, 5))
self.update_title = ctk.CTkLabel(
update_frame, text=t("update_mode"),
font=ctk.CTkFont(size=14, weight="bold"), anchor="w"
)
self.update_title.pack(fill="x", padx=15, pady=(10, 5))
update_row = ctk.CTkFrame(update_frame, fg_color="transparent")
update_row.pack(fill="x", padx=15, pady=(0, 10))
self._update_mode_buttons: dict[str, ctk.CTkButton] = {}
current_mode = store.get_update_mode()
update_modes = [
("notify-only", "update_mode_notify"),
("auto-download", "update_mode_download"),
("full-auto", "update_mode_auto"),
]
for mode, key in update_modes:
is_active = (mode == current_mode)
btn = ctk.CTkButton(
update_row, text=t(key), width=120, height=28,
fg_color="#3b82f6" if is_active else "#6b7280",
hover_color="#2563eb" if is_active else "#4b5563",
command=lambda m=mode: self._set_update_mode(m)
)
btn.pack(side="left", padx=2)
self._update_mode_buttons[mode] = btn
# Check for updates button
self._check_updates_btn = ctk.CTkButton(
update_row, text=t("update_check"), width=140, height=28,
fg_color="#3b82f6", hover_color="#2563eb",
command=self._check_updates,
)
self._check_updates_btn.pack(side="right")
# ── Configuration section ─────────────────────
config_frame = ctk.CTkFrame(self._scroll)
config_frame.pack(fill="x", padx=20, pady=(5, 5))
self.config_title = ctk.CTkLabel(
config_frame, text=t("configuration"),
font=ctk.CTkFont(size=14, weight="bold"), anchor="w"
)
self.config_title.pack(fill="x", padx=15, pady=(10, 5))
# Config path row
path_row = ctk.CTkFrame(config_frame, fg_color="transparent")
path_row.pack(fill="x", padx=15, pady=5)
self.config_label = ctk.CTkLabel(path_row, text=t("config_label"), anchor="w", width=60)
self.config_label.pack(side="left")
self._path_label = ctk.CTkLabel(
path_row, text=store.get_config_path(),
anchor="w", text_color="#9ca3af",
font=ctk.CTkFont(family="Consolas", size=11)
)
self._path_label.pack(side="left", fill="x", expand=True, padx=(5, 10))
self.change_path_btn = make_icon_button(
path_row, "folder", t("change_path"), width=120, fg_color="#6b7280",
command=self._change_config_path
)
self.change_path_btn.pack(side="right")
# Backup row
backup_row = ctk.CTkFrame(config_frame, fg_color="transparent")
backup_row.pack(fill="x", padx=15, pady=(5, 10))
self.backup_btn = make_icon_button(
backup_row, "save", t("backup_now"), width=120, fg_color="#3b82f6",
command=self._backup_now
)
self.backup_btn.pack(side="left", padx=(0, 10))
self._backup_var = ctk.StringVar(value=t("select_backup"))
backups = store.list_backups()
values = backups if backups else [t("no_backups")]
self._backup_menu = ctk.CTkOptionMenu(
backup_row, variable=self._backup_var,
values=values, width=250
)
self._backup_menu.pack(side="left", padx=(0, 10))
self.restore_btn = make_icon_button(
backup_row, "refresh", t("restore"), width=100, fg_color="#ef4444", hover_color="#dc2626",
command=self._restore_backup
)
self.restore_btn.pack(side="left")
# Import/Export row
ie_row = ctk.CTkFrame(config_frame, fg_color="transparent")
ie_row.pack(fill="x", padx=15, pady=(0, 10))
self.export_config_btn = make_icon_button(
ie_row, "upload", t("export_config"), width=130, fg_color="#6b7280",
command=self._export_config
)
self.export_config_btn.pack(side="left", padx=(0, 5))
self.import_config_btn = make_icon_button(
ie_row, "download", t("import_config"), width=130, fg_color="#6b7280",
command=self._import_config
)
self.import_config_btn.pack(side="left", padx=5)
self.export_backup_btn = make_icon_button(
ie_row, "upload", t("export_backup"), width=130, fg_color="#6b7280",
command=self._export_backup
)
self.export_backup_btn.pack(side="left", padx=5)
self.import_backup_btn = make_icon_button(
ie_row, "download", t("import_backup"), width=130, fg_color="#6b7280",
command=self._import_backup
)
self.import_backup_btn.pack(side="left", padx=5)
# Log
self.log = ctk.CTkTextbox(self._scroll, height=150, font=ctk.CTkFont(family="Consolas", size=11), state="disabled")
self.log.pack(fill="x", padx=20, pady=(5, 20))
# Initial status check
self._refresh_status()
def _set_update_mode(self, mode: str):
self.store.set_update_mode(mode)
for m, btn in self._update_mode_buttons.items():
if m == mode:
btn.configure(fg_color="#3b82f6", hover_color="#2563eb")
else:
btn.configure(fg_color="#6b7280", hover_color="#4b5563")
self._log(f"{t('update_mode')}: {t('update_mode_notify') if mode == 'notify-only' else t('update_mode_download') if mode == 'auto-download' else t('update_mode_auto')}")
def _check_updates(self):
self._check_updates_btn.configure(state="disabled", text=t("update_checking"))
def _do():
try:
# Access updater via the app (grandparent of tab)
app = self.winfo_toplevel()
if hasattr(app, "updater"):
info = app.updater.check_now()
if info:
self.after(0, lambda: self._log(t("update_available").format(version=info["version"])))
self.after(0, lambda: app._handle_update_event("available", info, None))
else:
self.after(0, lambda: self._log(t("update_no_updates")))
else:
self.after(0, lambda: self._log("Updater not available"))
except Exception as e:
self.after(0, lambda: self._log(f"Error: {e}"))
finally:
self.after(0, lambda: self._check_updates_btn.configure(state="normal", text=t("update_check")))
threading.Thread(target=_do, daemon=True).start()
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")
self.log.configure(state="disabled")
self.log.see("end")
def _refresh_status(self):
status = check_status()
for key, label in self._status_labels.items():
if status.get(key, False):
label.configure(text="\u25cf", text_color="#22c55e") # green
else:
label.configure(text="\u25cf", text_color="#ef4444") # red
def _install_all(self):
self.install_all_btn.configure(state="disabled", text=t("installing_all"))
def _do():
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()
def _install_script(self):
msg = install_ssh_script()
self._log(msg)
self._refresh_status()
def _install_claude_skill(self):
msg = install_claude_skill()
self._log(msg)
self._refresh_status()
def _install_codex_skill(self):
msg = install_codex_skill()
self._log(msg)
self._refresh_status()
def _install_skill(self):
msg = install_claude_skill()
self._log(msg)
self._refresh_status()
def _gen_key(self):
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 ─────────────────────────
def _change_config_path(self):
path = filedialog.askopenfilename(
title=t("select_servers_json"),
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
initialdir=os.path.dirname(self.store.get_config_path())
)
if path:
self.store.set_config_path(path)
self._path_label.configure(text=path)
self._log(t("config_changed").format(path=path))
def _backup_now(self):
try:
name = self.store.create_backup()
self._log(t("backup_created").format(name=name))
self._refresh_backups()
except Exception as e:
self._log(t("backup_failed").format(e=e))
def _restore_backup(self):
selected = self._backup_var.get()
if not selected or selected in (t("select_backup"), t("no_backups")):
self._log(t("no_backup_selected"))
return
if not messagebox.askyesno(t("restore_backup_title"), t("restore_confirm").format(name=selected)):
return
try:
self.store.restore_backup(selected)
self._log(t("restored").format(name=selected))
except Exception as e:
self._log(t("restore_failed").format(e=e))
def _refresh_backups(self):
backups = self.store.list_backups()
if backups:
self._backup_menu.configure(values=backups)
self._backup_var.set(backups[0])
else:
self._backup_menu.configure(values=[t("no_backups")])
self._backup_var.set(t("no_backups"))
# ── Import / Export handlers ─────────────────────
def _export_config(self):
default_name = f"servers_export_{datetime.now().strftime('%Y-%m-%d')}.json"
path = filedialog.asksaveasfilename(
title=t("export_config_title"),
defaultextension=".json",
initialfile=default_name,
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
)
if not path:
return
try:
self.store.export_config(path)
self._log(t("export_config_ok").format(path=path))
except Exception as e:
self._log(t("export_config_failed").format(e=e))
def _import_config(self):
path = filedialog.askopenfilename(
title=t("import_config_title"),
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
)
if not path:
return
if not messagebox.askyesno(t("import_config_title"), t("import_config_confirm")):
return
try:
self.store.import_config(path)
self._log(t("import_config_ok").format(path=path))
except Exception as e:
self._log(t("import_config_failed").format(e=e))
def _export_backup(self):
selected = self._backup_var.get()
if not selected or selected in (t("select_backup"), t("no_backups")):
self._log(t("no_backup_selected"))
return
path = filedialog.asksaveasfilename(
title=t("export_backup_title"),
defaultextension=".json",
initialfile=selected,
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
)
if not path:
return
try:
self.store.export_backup(selected, path)
self._log(t("export_backup_ok").format(path=path))
except Exception as e:
self._log(t("export_backup_failed").format(e=e))
def _import_backup(self):
path = filedialog.askopenfilename(
title=t("import_backup_title"),
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
)
if not path:
return
try:
name = self.store.import_backup(path)
self._refresh_backups()
self._log(t("import_backup_ok").format(name=name))
except Exception as e:
self._log(t("import_backup_failed").format(e=e))