Initial commit: ServerManager GUI application
CustomTkinter desktop app for managing remote servers. Features: SSH terminal, SFTP file transfer, key management, background status monitoring, server CRUD with dark theme. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
gui/__init__.py
Normal file
0
gui/__init__.py
Normal file
107
gui/app.py
Normal file
107
gui/app.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Main application window — sidebar + tabview layout.
|
||||
"""
|
||||
|
||||
import customtkinter as ctk
|
||||
from tkinter import messagebox
|
||||
|
||||
from core.server_store import ServerStore
|
||||
from core.status_checker import StatusChecker
|
||||
from gui.sidebar import Sidebar
|
||||
from gui.server_dialog import ServerDialog
|
||||
from gui.tabs.terminal_tab import TerminalTab
|
||||
from gui.tabs.files_tab import FilesTab
|
||||
from gui.tabs.info_tab import InfoTab
|
||||
from gui.tabs.keys_tab import KeysTab
|
||||
|
||||
|
||||
class App(ctk.CTk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
# Window config
|
||||
self.title("ServerManager")
|
||||
self.geometry("1100x700")
|
||||
self.minsize(900, 500)
|
||||
|
||||
ctk.set_appearance_mode("dark")
|
||||
ctk.set_default_color_theme("blue")
|
||||
|
||||
# Core
|
||||
self.store = ServerStore()
|
||||
self.checker = StatusChecker(self.store, interval=60)
|
||||
|
||||
# Layout
|
||||
self._build_layout()
|
||||
|
||||
# Status checker
|
||||
self.checker.set_gui_callback(lambda: self.after(0, self._on_status_update))
|
||||
self.checker.start()
|
||||
self.checker.check_all_now()
|
||||
|
||||
# Cleanup on close
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
def _build_layout(self):
|
||||
# Sidebar
|
||||
self.sidebar = Sidebar(self, self.store, on_select=self._on_server_select)
|
||||
self.sidebar.pack(side="left", fill="y")
|
||||
self.sidebar.add_callback = self._add_server
|
||||
self.sidebar.edit_callback = self._edit_server
|
||||
self.sidebar.delete_callback = self._delete_server
|
||||
|
||||
# Main area
|
||||
main = ctk.CTkFrame(self, fg_color="transparent")
|
||||
main.pack(side="right", fill="both", expand=True)
|
||||
|
||||
# Tabview
|
||||
self.tabview = ctk.CTkTabview(main)
|
||||
self.tabview.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
|
||||
# Tabs
|
||||
self.tabview.add("Terminal")
|
||||
self.tabview.add("Files")
|
||||
self.tabview.add("Info")
|
||||
self.tabview.add("Keys")
|
||||
|
||||
self.terminal_tab = TerminalTab(self.tabview.tab("Terminal"), self.store)
|
||||
self.terminal_tab.pack(fill="both", expand=True)
|
||||
|
||||
self.files_tab = FilesTab(self.tabview.tab("Files"), self.store)
|
||||
self.files_tab.pack(fill="both", expand=True)
|
||||
|
||||
self.info_tab = InfoTab(self.tabview.tab("Info"), self.store, edit_callback=self._edit_server)
|
||||
self.info_tab.pack(fill="both", expand=True)
|
||||
|
||||
self.keys_tab = KeysTab(self.tabview.tab("Keys"), self.store)
|
||||
self.keys_tab.pack(fill="both", expand=True)
|
||||
|
||||
def _on_server_select(self, alias: str):
|
||||
self.terminal_tab.set_server(alias)
|
||||
self.files_tab.set_server(alias)
|
||||
self.info_tab.set_server(alias)
|
||||
self.keys_tab.set_server(alias)
|
||||
|
||||
def _add_server(self):
|
||||
dialog = ServerDialog(self, self.store)
|
||||
self.wait_window(dialog)
|
||||
|
||||
def _edit_server(self, alias: str):
|
||||
server = self.store.get_server(alias)
|
||||
if server:
|
||||
dialog = ServerDialog(self, self.store, server=server)
|
||||
self.wait_window(dialog)
|
||||
self.info_tab.refresh()
|
||||
|
||||
def _delete_server(self, alias: str):
|
||||
if messagebox.askyesno("Delete Server", f"Remove '{alias}'?"):
|
||||
self.store.remove_server(alias)
|
||||
self._on_server_select(None)
|
||||
|
||||
def _on_status_update(self):
|
||||
self.sidebar.update_statuses()
|
||||
self.info_tab.refresh()
|
||||
|
||||
def _on_close(self):
|
||||
self.checker.stop()
|
||||
self.destroy()
|
||||
152
gui/server_dialog.py
Normal file
152
gui/server_dialog.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Server add/edit dialog — modal window with all server fields.
|
||||
"""
|
||||
|
||||
import customtkinter as ctk
|
||||
from core.server_store import SERVER_TYPES, DEFAULT_PORTS
|
||||
|
||||
|
||||
class ServerDialog(ctk.CTkToplevel):
|
||||
def __init__(self, master, store, server: dict | None = None):
|
||||
super().__init__(master)
|
||||
self.store = store
|
||||
self.editing = server
|
||||
self.result = None
|
||||
|
||||
self.title("Edit Server" if server else "Add Server")
|
||||
self.geometry("450x520")
|
||||
self.resizable(False, False)
|
||||
self.grab_set()
|
||||
|
||||
# Center on parent
|
||||
self.transient(master)
|
||||
|
||||
self._build_ui(server)
|
||||
|
||||
def _build_ui(self, server: dict | None):
|
||||
pad = {"padx": 20, "pady": (5, 0)}
|
||||
entry_pad = {"padx": 20, "pady": (2, 5)}
|
||||
|
||||
# Alias
|
||||
ctk.CTkLabel(self, text="Alias", anchor="w").pack(fill="x", **pad)
|
||||
self.alias_entry = ctk.CTkEntry(self, placeholder_text="my-server")
|
||||
self.alias_entry.pack(fill="x", **entry_pad)
|
||||
|
||||
# IP
|
||||
ctk.CTkLabel(self, text="IP / Hostname", anchor="w").pack(fill="x", **pad)
|
||||
self.ip_entry = ctk.CTkEntry(self, placeholder_text="1.2.3.4")
|
||||
self.ip_entry.pack(fill="x", **entry_pad)
|
||||
|
||||
# Type + Port row
|
||||
row = ctk.CTkFrame(self, fg_color="transparent")
|
||||
row.pack(fill="x", padx=20, pady=(5, 5))
|
||||
|
||||
type_frame = ctk.CTkFrame(row, fg_color="transparent")
|
||||
type_frame.pack(side="left", fill="x", expand=True, padx=(0, 5))
|
||||
ctk.CTkLabel(type_frame, text="Type", anchor="w").pack(fill="x")
|
||||
self.type_var = ctk.StringVar(value="ssh")
|
||||
self.type_menu = ctk.CTkOptionMenu(
|
||||
type_frame, values=SERVER_TYPES, variable=self.type_var,
|
||||
command=self._on_type_change
|
||||
)
|
||||
self.type_menu.pack(fill="x")
|
||||
|
||||
port_frame = ctk.CTkFrame(row, fg_color="transparent")
|
||||
port_frame.pack(side="left", fill="x", expand=True, padx=(5, 0))
|
||||
ctk.CTkLabel(port_frame, text="Port", anchor="w").pack(fill="x")
|
||||
self.port_entry = ctk.CTkEntry(port_frame, placeholder_text="22")
|
||||
self.port_entry.pack(fill="x")
|
||||
|
||||
# User
|
||||
ctk.CTkLabel(self, text="Username", anchor="w").pack(fill="x", **pad)
|
||||
self.user_entry = ctk.CTkEntry(self, placeholder_text="root")
|
||||
self.user_entry.pack(fill="x", **entry_pad)
|
||||
|
||||
# Password
|
||||
ctk.CTkLabel(self, text="Password", anchor="w").pack(fill="x", **pad)
|
||||
pass_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
pass_frame.pack(fill="x", padx=20, pady=(2, 5))
|
||||
self.password_entry = ctk.CTkEntry(pass_frame, show="*", placeholder_text="password")
|
||||
self.password_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
|
||||
self.show_pass = ctk.CTkButton(pass_frame, text="Show", width=60, command=self._toggle_password)
|
||||
self.show_pass.pack(side="right")
|
||||
self._pass_visible = False
|
||||
|
||||
# Notes
|
||||
ctk.CTkLabel(self, text="Notes", anchor="w").pack(fill="x", **pad)
|
||||
self.notes_entry = ctk.CTkEntry(self, placeholder_text="optional description")
|
||||
self.notes_entry.pack(fill="x", **entry_pad)
|
||||
|
||||
# Buttons
|
||||
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
btn_frame.pack(fill="x", padx=20, pady=(15, 20))
|
||||
ctk.CTkButton(btn_frame, text="Cancel", fg_color="#6b7280", command=self.destroy).pack(side="left", expand=True, padx=(0, 5))
|
||||
ctk.CTkButton(btn_frame, text="Save", command=self._save).pack(side="right", expand=True, padx=(5, 0))
|
||||
|
||||
# Fill values if editing
|
||||
if server:
|
||||
self.alias_entry.insert(0, server.get("alias", ""))
|
||||
self.alias_entry.configure(state="disabled")
|
||||
self.ip_entry.insert(0, server.get("ip", ""))
|
||||
self.type_var.set(server.get("type", "ssh"))
|
||||
self.port_entry.insert(0, str(server.get("port", 22)))
|
||||
self.user_entry.insert(0, server.get("user", ""))
|
||||
self.password_entry.insert(0, server.get("password", ""))
|
||||
self.notes_entry.insert(0, server.get("notes", ""))
|
||||
|
||||
def _on_type_change(self, value):
|
||||
default_port = DEFAULT_PORTS.get(value, 22)
|
||||
self.port_entry.delete(0, "end")
|
||||
self.port_entry.insert(0, str(default_port))
|
||||
|
||||
def _toggle_password(self):
|
||||
self._pass_visible = not self._pass_visible
|
||||
self.password_entry.configure(show="" if self._pass_visible else "*")
|
||||
self.show_pass.configure(text="Hide" if self._pass_visible else "Show")
|
||||
|
||||
def _save(self):
|
||||
alias = self.alias_entry.get().strip()
|
||||
ip = self.ip_entry.get().strip()
|
||||
port_str = self.port_entry.get().strip()
|
||||
user = self.user_entry.get().strip()
|
||||
password = self.password_entry.get()
|
||||
server_type = self.type_var.get()
|
||||
notes = self.notes_entry.get().strip()
|
||||
|
||||
# Validation
|
||||
if not alias:
|
||||
self._show_error("Alias is required")
|
||||
return
|
||||
if not ip:
|
||||
self._show_error("IP is required")
|
||||
return
|
||||
try:
|
||||
port = int(port_str) if port_str else DEFAULT_PORTS.get(server_type, 22)
|
||||
except ValueError:
|
||||
self._show_error("Port must be a number")
|
||||
return
|
||||
|
||||
server_data = {
|
||||
"alias": alias,
|
||||
"ip": ip,
|
||||
"port": port,
|
||||
"user": user or "root",
|
||||
"password": password,
|
||||
"type": server_type,
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
try:
|
||||
if self.editing:
|
||||
self.store.update_server(alias, server_data)
|
||||
else:
|
||||
self.store.add_server(server_data)
|
||||
self.result = server_data
|
||||
self.destroy()
|
||||
except ValueError as e:
|
||||
self._show_error(str(e))
|
||||
|
||||
def _show_error(self, message: str):
|
||||
# Simple error via title flash
|
||||
self.title(f"Error: {message}")
|
||||
self.after(2000, lambda: self.title("Edit Server" if self.editing else "Add Server"))
|
||||
128
gui/sidebar.py
Normal file
128
gui/sidebar.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Sidebar — server list with search, add/edit/delete buttons.
|
||||
"""
|
||||
|
||||
import customtkinter as ctk
|
||||
from gui.widgets.status_badge import StatusBadge
|
||||
|
||||
|
||||
class Sidebar(ctk.CTkFrame):
|
||||
def __init__(self, master, store, on_select=None):
|
||||
super().__init__(master, width=250, corner_radius=0)
|
||||
self.store = store
|
||||
self.on_select = on_select
|
||||
self._selected_alias: str | None = None
|
||||
self._server_frames: dict[str, ctk.CTkFrame] = {}
|
||||
self._badges: dict[str, StatusBadge] = {}
|
||||
|
||||
self.pack_propagate(False)
|
||||
|
||||
# Title
|
||||
title = ctk.CTkLabel(self, text="Servers", font=ctk.CTkFont(size=18, weight="bold"))
|
||||
title.pack(padx=15, pady=(15, 5))
|
||||
|
||||
# Search
|
||||
self.search_var = ctk.StringVar()
|
||||
self.search_var.trace_add("write", lambda *_: self._refresh_list())
|
||||
search = ctk.CTkEntry(self, placeholder_text="Search...", textvariable=self.search_var)
|
||||
search.pack(fill="x", padx=10, pady=(5, 10))
|
||||
|
||||
# Server list
|
||||
self.list_frame = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||
self.list_frame.pack(fill="both", expand=True, padx=5, pady=0)
|
||||
|
||||
# Buttons
|
||||
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
btn_frame.pack(fill="x", padx=10, pady=10)
|
||||
self.add_btn = ctk.CTkButton(btn_frame, text="+ Add", width=70, height=30, command=self._on_add)
|
||||
self.add_btn.pack(side="left", padx=(0, 3))
|
||||
self.edit_btn = ctk.CTkButton(btn_frame, text="Edit", width=70, height=30, fg_color="#6b7280", command=self._on_edit)
|
||||
self.edit_btn.pack(side="left", padx=3)
|
||||
self.del_btn = ctk.CTkButton(btn_frame, text="Delete", width=70, height=30, fg_color="#ef4444", hover_color="#dc2626", command=self._on_delete)
|
||||
self.del_btn.pack(side="right", padx=(3, 0))
|
||||
|
||||
# Callbacks for add/edit/delete — set by app.py
|
||||
self.add_callback = None
|
||||
self.edit_callback = None
|
||||
self.delete_callback = None
|
||||
|
||||
# Subscribe to store changes
|
||||
self.store.subscribe(self._refresh_list)
|
||||
self._refresh_list()
|
||||
|
||||
def _refresh_list(self):
|
||||
# Clear
|
||||
for widget in self.list_frame.winfo_children():
|
||||
widget.destroy()
|
||||
self._server_frames.clear()
|
||||
self._badges.clear()
|
||||
|
||||
search = self.search_var.get().lower()
|
||||
servers = self.store.get_all()
|
||||
|
||||
for server in servers:
|
||||
alias = server["alias"]
|
||||
ip = server["ip"]
|
||||
stype = server.get("type", "ssh")
|
||||
|
||||
if search and search not in alias.lower() and search not in ip.lower():
|
||||
continue
|
||||
|
||||
frame = ctk.CTkFrame(self.list_frame, cursor="hand2", height=45)
|
||||
frame.pack(fill="x", padx=2, pady=2)
|
||||
frame.pack_propagate(False)
|
||||
|
||||
# Status badge
|
||||
badge = StatusBadge(frame, status=self.store.get_status(alias))
|
||||
badge.pack(side="left", padx=(10, 5), pady=10)
|
||||
self._badges[alias] = badge
|
||||
|
||||
# Info
|
||||
info = ctk.CTkFrame(frame, fg_color="transparent")
|
||||
info.pack(side="left", fill="both", expand=True, padx=5)
|
||||
|
||||
name_label = ctk.CTkLabel(info, text=alias, font=ctk.CTkFont(size=13, weight="bold"), anchor="w")
|
||||
name_label.pack(fill="x")
|
||||
detail = f"{ip} [{stype}]"
|
||||
detail_label = ctk.CTkLabel(info, text=detail, font=ctk.CTkFont(size=10), text_color="#9ca3af", anchor="w")
|
||||
detail_label.pack(fill="x")
|
||||
|
||||
# Click handlers
|
||||
for widget in [frame, info, name_label, detail_label, badge]:
|
||||
widget.bind("<Button-1>", lambda e, a=alias: self._select(a))
|
||||
|
||||
self._server_frames[alias] = frame
|
||||
|
||||
self._highlight_selected()
|
||||
|
||||
def _select(self, alias: str):
|
||||
self._selected_alias = alias
|
||||
self._highlight_selected()
|
||||
if self.on_select:
|
||||
self.on_select(alias)
|
||||
|
||||
def _highlight_selected(self):
|
||||
for alias, frame in self._server_frames.items():
|
||||
if alias == self._selected_alias:
|
||||
frame.configure(fg_color=("#3b82f6", "#1d4ed8"))
|
||||
else:
|
||||
frame.configure(fg_color=("gray85", "gray20"))
|
||||
|
||||
def get_selected(self) -> str | None:
|
||||
return self._selected_alias
|
||||
|
||||
def update_statuses(self):
|
||||
for alias, badge in self._badges.items():
|
||||
badge.set_status(self.store.get_status(alias))
|
||||
|
||||
def _on_add(self):
|
||||
if self.add_callback:
|
||||
self.add_callback()
|
||||
|
||||
def _on_edit(self):
|
||||
if self.edit_callback and self._selected_alias:
|
||||
self.edit_callback(self._selected_alias)
|
||||
|
||||
def _on_delete(self):
|
||||
if self.delete_callback and self._selected_alias:
|
||||
self.delete_callback(self._selected_alias)
|
||||
0
gui/tabs/__init__.py
Normal file
0
gui/tabs/__init__.py
Normal file
163
gui/tabs/files_tab.py
Normal file
163
gui/tabs/files_tab.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Files tab — SFTP upload/download.
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import customtkinter as ctk
|
||||
from tkinter import filedialog
|
||||
from core.ssh_client import SSHClientWrapper
|
||||
|
||||
|
||||
class FilesTab(ctk.CTkFrame):
|
||||
def __init__(self, master, store):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.store = store
|
||||
self._current_alias: str | None = None
|
||||
|
||||
# Upload section
|
||||
upload_label = ctk.CTkLabel(self, text="Upload", font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
|
||||
upload_label.pack(fill="x", padx=15, pady=(15, 5))
|
||||
|
||||
upload_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
upload_frame.pack(fill="x", padx=15, pady=(0, 5))
|
||||
|
||||
ctk.CTkLabel(upload_frame, text="Local:", width=60, anchor="w").pack(side="left")
|
||||
self.upload_local = ctk.CTkEntry(upload_frame, placeholder_text="/path/to/local/file")
|
||||
self.upload_local.pack(side="left", fill="x", expand=True, padx=5)
|
||||
ctk.CTkButton(upload_frame, text="Browse", width=70, command=self._browse_upload).pack(side="right")
|
||||
|
||||
upload_remote_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
upload_remote_frame.pack(fill="x", padx=15, pady=(0, 5))
|
||||
|
||||
ctk.CTkLabel(upload_remote_frame, text="Remote:", width=60, anchor="w").pack(side="left")
|
||||
self.upload_remote = ctk.CTkEntry(upload_remote_frame, placeholder_text="/remote/path/file")
|
||||
self.upload_remote.pack(side="left", fill="x", expand=True, padx=5)
|
||||
self.upload_btn = ctk.CTkButton(upload_remote_frame, text="Upload", width=70, command=self._upload)
|
||||
self.upload_btn.pack(side="right")
|
||||
|
||||
# Separator
|
||||
ctk.CTkFrame(self, height=2, fg_color="gray40").pack(fill="x", padx=15, pady=10)
|
||||
|
||||
# Download section
|
||||
download_label = ctk.CTkLabel(self, text="Download", font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
|
||||
download_label.pack(fill="x", padx=15, pady=(5, 5))
|
||||
|
||||
download_remote_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
download_remote_frame.pack(fill="x", padx=15, pady=(0, 5))
|
||||
|
||||
ctk.CTkLabel(download_remote_frame, text="Remote:", width=60, anchor="w").pack(side="left")
|
||||
self.download_remote = ctk.CTkEntry(download_remote_frame, placeholder_text="/remote/path/file")
|
||||
self.download_remote.pack(side="left", fill="x", expand=True, padx=5)
|
||||
|
||||
download_local_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
download_local_frame.pack(fill="x", padx=15, pady=(0, 5))
|
||||
|
||||
ctk.CTkLabel(download_local_frame, text="Local:", width=60, anchor="w").pack(side="left")
|
||||
self.download_local = ctk.CTkEntry(download_local_frame, placeholder_text="/path/to/save")
|
||||
self.download_local.pack(side="left", fill="x", expand=True, padx=5)
|
||||
ctk.CTkButton(download_local_frame, text="Browse", width=70, command=self._browse_download).pack(side="left", padx=(5, 0))
|
||||
self.download_btn = ctk.CTkButton(download_local_frame, text="Download", width=80, command=self._download)
|
||||
self.download_btn.pack(side="right")
|
||||
|
||||
# Progress
|
||||
self.progress = ctk.CTkProgressBar(self)
|
||||
self.progress.pack(fill="x", padx=15, pady=(10, 5))
|
||||
self.progress.set(0)
|
||||
|
||||
# Log
|
||||
self.log = ctk.CTkTextbox(self, height=150, font=ctk.CTkFont(family="Consolas", size=11), state="disabled")
|
||||
self.log.pack(fill="both", expand=True, padx=15, pady=(5, 15))
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
self._current_alias = alias
|
||||
|
||||
def _log_msg(self, text: str):
|
||||
self.log.configure(state="normal")
|
||||
self.log.insert("end", text + "\n")
|
||||
self.log.configure(state="disabled")
|
||||
self.log.see("end")
|
||||
|
||||
def _browse_upload(self):
|
||||
path = filedialog.askopenfilename()
|
||||
if path:
|
||||
self.upload_local.delete(0, "end")
|
||||
self.upload_local.insert(0, path)
|
||||
if not self.upload_remote.get():
|
||||
self.upload_remote.insert(0, "/tmp/" + os.path.basename(path))
|
||||
|
||||
def _browse_download(self):
|
||||
path = filedialog.asksaveasfilename()
|
||||
if path:
|
||||
self.download_local.delete(0, "end")
|
||||
self.download_local.insert(0, path)
|
||||
|
||||
def _upload(self):
|
||||
if not self._current_alias:
|
||||
self._log_msg("[!] No server selected")
|
||||
return
|
||||
local = self.upload_local.get().strip()
|
||||
remote = self.upload_remote.get().strip()
|
||||
if not local or not remote:
|
||||
self._log_msg("[!] Both paths required")
|
||||
return
|
||||
if not os.path.exists(local):
|
||||
self._log_msg(f"[!] File not found: {local}")
|
||||
return
|
||||
|
||||
server = self.store.get_server(self._current_alias)
|
||||
if not server:
|
||||
return
|
||||
|
||||
self.upload_btn.configure(state="disabled")
|
||||
self.progress.set(0)
|
||||
file_size = os.path.getsize(local)
|
||||
|
||||
def _progress(transferred, total):
|
||||
if total > 0:
|
||||
self.after(0, lambda: self.progress.set(transferred / total))
|
||||
|
||||
def _do():
|
||||
try:
|
||||
wrapper = SSHClientWrapper(server, self.store.get_ssh_key_path())
|
||||
wrapper.upload(local, remote, progress_cb=_progress)
|
||||
self.after(0, lambda: self._log_msg(f"OK: {local} -> {self._current_alias}:{remote}"))
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self._log_msg(f"[ERROR] {e}"))
|
||||
finally:
|
||||
self.after(0, lambda: self.upload_btn.configure(state="normal"))
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _download(self):
|
||||
if not self._current_alias:
|
||||
self._log_msg("[!] No server selected")
|
||||
return
|
||||
remote = self.download_remote.get().strip()
|
||||
local = self.download_local.get().strip()
|
||||
if not remote or not local:
|
||||
self._log_msg("[!] Both paths required")
|
||||
return
|
||||
|
||||
server = self.store.get_server(self._current_alias)
|
||||
if not server:
|
||||
return
|
||||
|
||||
self.download_btn.configure(state="disabled")
|
||||
self.progress.set(0)
|
||||
|
||||
def _progress(transferred, total):
|
||||
if total > 0:
|
||||
self.after(0, lambda: self.progress.set(transferred / total))
|
||||
|
||||
def _do():
|
||||
try:
|
||||
wrapper = SSHClientWrapper(server, self.store.get_ssh_key_path())
|
||||
wrapper.download(remote, local, progress_cb=_progress)
|
||||
self.after(0, lambda: self._log_msg(f"OK: {self._current_alias}:{remote} -> {local}"))
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self._log_msg(f"[ERROR] {e}"))
|
||||
finally:
|
||||
self.after(0, lambda: self.download_btn.configure(state="normal"))
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
66
gui/tabs/info_tab.py
Normal file
66
gui/tabs/info_tab.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Info tab — display server details, edit button.
|
||||
"""
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
|
||||
class InfoTab(ctk.CTkFrame):
|
||||
def __init__(self, master, store, edit_callback=None):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.store = store
|
||||
self.edit_callback = edit_callback
|
||||
self._current_alias: str | None = None
|
||||
|
||||
# Header
|
||||
self.header = ctk.CTkLabel(self, text="No server selected", font=ctk.CTkFont(size=20, weight="bold"))
|
||||
self.header.pack(padx=20, pady=(20, 10))
|
||||
|
||||
# Info card
|
||||
self.card = ctk.CTkFrame(self)
|
||||
self.card.pack(fill="x", padx=20, pady=10)
|
||||
|
||||
self._fields: dict[str, ctk.CTkLabel] = {}
|
||||
for label in ["Alias", "IP", "Port", "User", "Type", "Notes", "Status"]:
|
||||
row = ctk.CTkFrame(self.card, fg_color="transparent")
|
||||
row.pack(fill="x", padx=15, pady=4)
|
||||
ctk.CTkLabel(row, text=f"{label}:", width=80, anchor="w",
|
||||
font=ctk.CTkFont(size=12), text_color="#9ca3af").pack(side="left")
|
||||
val = ctk.CTkLabel(row, text="-", anchor="w", font=ctk.CTkFont(size=13))
|
||||
val.pack(side="left", fill="x", expand=True)
|
||||
self._fields[label] = val
|
||||
|
||||
# Edit button
|
||||
self.edit_btn = ctk.CTkButton(self, text="Edit Server", command=self._on_edit)
|
||||
self.edit_btn.pack(pady=15)
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
self._current_alias = alias
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
if not self._current_alias:
|
||||
self.header.configure(text="No server selected")
|
||||
for v in self._fields.values():
|
||||
v.configure(text="-")
|
||||
return
|
||||
|
||||
server = self.store.get_server(self._current_alias)
|
||||
if not server:
|
||||
return
|
||||
|
||||
self.header.configure(text=server["alias"])
|
||||
self._fields["Alias"].configure(text=server.get("alias", "-"))
|
||||
self._fields["IP"].configure(text=server.get("ip", "-"))
|
||||
self._fields["Port"].configure(text=str(server.get("port", 22)))
|
||||
self._fields["User"].configure(text=server.get("user", "root"))
|
||||
self._fields["Type"].configure(text=server.get("type", "ssh").upper())
|
||||
self._fields["Notes"].configure(text=server.get("notes", "-") or "-")
|
||||
|
||||
status = self.store.get_status(self._current_alias)
|
||||
color = {"online": "#22c55e", "offline": "#ef4444"}.get(status, "#9ca3af")
|
||||
self._fields["Status"].configure(text=status.upper(), text_color=color)
|
||||
|
||||
def _on_edit(self):
|
||||
if self.edit_callback and self._current_alias:
|
||||
self.edit_callback(self._current_alias)
|
||||
116
gui/tabs/keys_tab.py
Normal file
116
gui/tabs/keys_tab.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Keys tab — SSH key management: view, generate, install.
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import customtkinter as ctk
|
||||
from core.ssh_client import SSHClientWrapper
|
||||
|
||||
|
||||
class KeysTab(ctk.CTkFrame):
|
||||
def __init__(self, master, store):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.store = store
|
||||
self._current_alias: str | None = None
|
||||
|
||||
# Key info
|
||||
ctk.CTkLabel(self, text="SSH Key", font=ctk.CTkFont(size=16, weight="bold"), anchor="w").pack(fill="x", padx=15, pady=(15, 5))
|
||||
|
||||
self.key_path_label = ctk.CTkLabel(self, text="", anchor="w", text_color="#9ca3af")
|
||||
self.key_path_label.pack(fill="x", padx=15)
|
||||
|
||||
self.pub_key_box = ctk.CTkTextbox(self, height=80, font=ctk.CTkFont(family="Consolas", size=11), state="disabled")
|
||||
self.pub_key_box.pack(fill="x", padx=15, pady=(5, 10))
|
||||
|
||||
# Buttons
|
||||
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
btn_frame.pack(fill="x", padx=15, pady=5)
|
||||
|
||||
self.gen_btn = ctk.CTkButton(btn_frame, text="Generate Key", command=self._generate)
|
||||
self.gen_btn.pack(side="left", padx=(0, 10))
|
||||
|
||||
self.install_btn = ctk.CTkButton(btn_frame, text="Install on Server", fg_color="#22c55e", hover_color="#16a34a", command=self._install)
|
||||
self.install_btn.pack(side="left")
|
||||
|
||||
self.copy_btn = ctk.CTkButton(btn_frame, text="Copy Public Key", fg_color="#6b7280", command=self._copy_key)
|
||||
self.copy_btn.pack(side="right")
|
||||
|
||||
# Status log
|
||||
self.status_log = ctk.CTkTextbox(self, height=120, font=ctk.CTkFont(family="Consolas", size=11), state="disabled")
|
||||
self.status_log.pack(fill="both", expand=True, padx=15, pady=(10, 15))
|
||||
|
||||
self._refresh_key_info()
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
self._current_alias = alias
|
||||
|
||||
def _refresh_key_info(self):
|
||||
key_path = self.store.get_ssh_key_path()
|
||||
pub_path = key_path + ".pub"
|
||||
self.key_path_label.configure(text=f"Path: {key_path}")
|
||||
|
||||
self.pub_key_box.configure(state="normal")
|
||||
self.pub_key_box.delete("1.0", "end")
|
||||
|
||||
if os.path.exists(pub_path):
|
||||
with open(pub_path, "r") as f:
|
||||
pub_key = f.read().strip()
|
||||
self.pub_key_box.insert("1.0", pub_key)
|
||||
self.gen_btn.configure(state="disabled", text="Key exists")
|
||||
else:
|
||||
self.pub_key_box.insert("1.0", "No key found. Click 'Generate Key' to create one.")
|
||||
self.gen_btn.configure(state="normal", text="Generate Key")
|
||||
|
||||
self.pub_key_box.configure(state="disabled")
|
||||
|
||||
def _log(self, text: str):
|
||||
self.status_log.configure(state="normal")
|
||||
self.status_log.insert("end", text + "\n")
|
||||
self.status_log.configure(state="disabled")
|
||||
self.status_log.see("end")
|
||||
|
||||
def _generate(self):
|
||||
try:
|
||||
key_path = self.store.get_ssh_key_path()
|
||||
wrapper = SSHClientWrapper({"alias": "temp", "ip": "0", "user": "root"}, key_path)
|
||||
msg = wrapper.generate_key()
|
||||
self._log(msg)
|
||||
self._refresh_key_info()
|
||||
except Exception as e:
|
||||
self._log(f"[ERROR] {e}")
|
||||
|
||||
def _install(self):
|
||||
if not self._current_alias:
|
||||
self._log("[!] No server selected")
|
||||
return
|
||||
|
||||
server = self.store.get_server(self._current_alias)
|
||||
if not server:
|
||||
return
|
||||
|
||||
self.install_btn.configure(state="disabled", text="Installing...")
|
||||
|
||||
def _do():
|
||||
try:
|
||||
wrapper = SSHClientWrapper(server, self.store.get_ssh_key_path())
|
||||
msg = wrapper.install_key()
|
||||
self.after(0, lambda: self._log(f"[{self._current_alias}] {msg}"))
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self._log(f"[ERROR] {e}"))
|
||||
finally:
|
||||
self.after(0, lambda: self.install_btn.configure(state="normal", text="Install on Server"))
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _copy_key(self):
|
||||
key_path = self.store.get_ssh_key_path()
|
||||
pub_path = key_path + ".pub"
|
||||
if os.path.exists(pub_path):
|
||||
with open(pub_path, "r") as f:
|
||||
pub_key = f.read().strip()
|
||||
self.clipboard_clear()
|
||||
self.clipboard_append(pub_key)
|
||||
self._log("Public key copied to clipboard")
|
||||
else:
|
||||
self._log("[!] No public key to copy")
|
||||
104
gui/tabs/terminal_tab.py
Normal file
104
gui/tabs/terminal_tab.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Terminal tab — command input + output display.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import customtkinter as ctk
|
||||
from core.ssh_client import SSHClientWrapper
|
||||
|
||||
|
||||
class TerminalTab(ctk.CTkFrame):
|
||||
def __init__(self, master, store):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.store = store
|
||||
self._current_alias: str | None = None
|
||||
|
||||
# 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))
|
||||
|
||||
# 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="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="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="Run", width=70, command=self._run_command)
|
||||
self.run_btn.pack(side="left", padx=(0, 5))
|
||||
|
||||
self.clear_btn = ctk.CTkButton(input_frame, text="Clear", width=60, fg_color="#6b7280", command=self._clear)
|
||||
self.clear_btn.pack(side="right")
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
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")
|
||||
|
||||
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):
|
||||
if not self._current_alias:
|
||||
self._append_output("[!] 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(f"[!] Server '{self._current_alias}' not found\n")
|
||||
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")
|
||||
|
||||
self.run_btn.configure(state="disabled", text="...")
|
||||
|
||||
def _exec():
|
||||
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="Run")
|
||||
|
||||
self.after(0, _show)
|
||||
except Exception as e:
|
||||
def _err():
|
||||
self._append_output(f"[ERROR] {e}\n\n")
|
||||
self.run_btn.configure(state="normal", text="Run")
|
||||
self.after(0, _err)
|
||||
|
||||
threading.Thread(target=_exec, daemon=True).start()
|
||||
|
||||
def _clear(self):
|
||||
self.output.configure(state="normal")
|
||||
self.output.delete("1.0", "end")
|
||||
self.output.configure(state="disabled")
|
||||
0
gui/widgets/__init__.py
Normal file
0
gui/widgets/__init__.py
Normal file
26
gui/widgets/status_badge.py
Normal file
26
gui/widgets/status_badge.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Status badge widget — colored circle indicator for online/offline.
|
||||
"""
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
COLORS = {
|
||||
"online": "#22c55e", # green
|
||||
"offline": "#ef4444", # red
|
||||
"unknown": "#6b7280", # gray
|
||||
}
|
||||
|
||||
|
||||
class StatusBadge(ctk.CTkLabel):
|
||||
def __init__(self, master, status: str = "unknown", **kwargs):
|
||||
super().__init__(master, text="", width=12, height=12, **kwargs)
|
||||
self._status = status
|
||||
self._update_color()
|
||||
|
||||
def set_status(self, status: str):
|
||||
self._status = status
|
||||
self._update_color()
|
||||
|
||||
def _update_color(self):
|
||||
color = COLORS.get(self._status, COLORS["unknown"])
|
||||
self.configure(text="\u25cf", text_color=color, font=("", 14))
|
||||
Reference in New Issue
Block a user