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:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Credentials - NEVER commit
|
||||
config/servers.json
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
*.spec
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# ServerManager
|
||||
|
||||
Desktop GUI-приложение для управления удалёнными серверами. CustomTkinter + Paramiko.
|
||||
|
||||
## Возможности
|
||||
|
||||
- CRUD серверов (SSH, Telnet, RDP, MariaDB, MSSQL, PostgreSQL)
|
||||
- Терминал — выполнение команд через SSH с auto-sudo
|
||||
- SFTP — загрузка и скачивание файлов с прогресс-баром
|
||||
- SSH-ключи — генерация, установка, копирование
|
||||
- Мониторинг — фоновая проверка online/offline
|
||||
- Тёмная тема
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
## Конфигурация
|
||||
|
||||
При первом запуске создаётся `config/servers.json` из шаблона.
|
||||
Добавляйте серверы через GUI (кнопка "+ Add").
|
||||
|
||||
## Безопасность
|
||||
|
||||
- `config/servers.json` в `.gitignore` — никогда не коммитится
|
||||
- Пароли хранятся только локально
|
||||
- SSH-ключи (ed25519) — рекомендуемый метод аутентификации
|
||||
- sudo пароль передаётся через stdin (не виден в `ps aux`)
|
||||
17
config/servers.example.json
Normal file
17
config/servers.example.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"servers": [
|
||||
{
|
||||
"alias": "my-server",
|
||||
"ip": "1.2.3.4",
|
||||
"port": 22,
|
||||
"user": "root",
|
||||
"password": "YOUR_PASSWORD_HERE",
|
||||
"type": "ssh",
|
||||
"notes": "Example server"
|
||||
}
|
||||
],
|
||||
"ssh_key": {
|
||||
"type": "ed25519",
|
||||
"path": "~/.ssh/id_ed25519"
|
||||
}
|
||||
}
|
||||
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
25
core/connection_factory.py
Normal file
25
core/connection_factory.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Connection factory — stubs for non-SSH connection types.
|
||||
SSH is fully implemented via SSHClientWrapper.
|
||||
Other types are placeholders for future implementation.
|
||||
"""
|
||||
|
||||
from core.ssh_client import SSHClientWrapper
|
||||
|
||||
|
||||
def create_connection(server: dict, key_path: str = ""):
|
||||
"""Create a connection wrapper based on server type."""
|
||||
server_type = server.get("type", "ssh")
|
||||
|
||||
if server_type == "ssh":
|
||||
return SSHClientWrapper(server, key_path)
|
||||
|
||||
# Stubs for future types
|
||||
if server_type == "rdp":
|
||||
raise NotImplementedError("RDP connections — use mstsc.exe or rdesktop")
|
||||
if server_type == "telnet":
|
||||
raise NotImplementedError("Telnet connections — planned")
|
||||
if server_type in ("mariadb", "mssql", "postgresql"):
|
||||
raise NotImplementedError(f"{server_type.upper()} connections — planned")
|
||||
|
||||
raise ValueError(f"Unknown server type: {server_type}")
|
||||
97
core/server_store.py
Normal file
97
core/server_store.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Server store — CRUD + JSON persistence + observer pattern.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Callable, Optional
|
||||
|
||||
CONFIG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config")
|
||||
SERVERS_FILE = os.path.join(CONFIG_DIR, "servers.json")
|
||||
EXAMPLE_FILE = os.path.join(CONFIG_DIR, "servers.example.json")
|
||||
|
||||
SERVER_TYPES = ["ssh", "telnet", "rdp", "mariadb", "mssql", "postgresql"]
|
||||
|
||||
DEFAULT_PORTS = {
|
||||
"ssh": 22,
|
||||
"telnet": 23,
|
||||
"rdp": 3389,
|
||||
"mariadb": 3306,
|
||||
"mssql": 1433,
|
||||
"postgresql": 5432,
|
||||
}
|
||||
|
||||
|
||||
class ServerStore:
|
||||
def __init__(self):
|
||||
self._data: dict = {"servers": [], "ssh_key": {"type": "ed25519", "path": "~/.ssh/id_ed25519"}}
|
||||
self._observers: list[Callable] = []
|
||||
self._statuses: dict[str, str] = {} # alias -> "online" | "offline" | "unknown"
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
if os.path.exists(SERVERS_FILE):
|
||||
with open(SERVERS_FILE, "r", encoding="utf-8") as f:
|
||||
self._data = json.load(f)
|
||||
elif os.path.exists(EXAMPLE_FILE):
|
||||
with open(EXAMPLE_FILE, "r", encoding="utf-8") as f:
|
||||
self._data = json.load(f)
|
||||
self._save()
|
||||
|
||||
def _save(self):
|
||||
os.makedirs(CONFIG_DIR, exist_ok=True)
|
||||
with open(SERVERS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
def _notify(self):
|
||||
for cb in self._observers:
|
||||
try:
|
||||
cb()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def subscribe(self, callback: Callable):
|
||||
self._observers.append(callback)
|
||||
|
||||
def get_all(self) -> list[dict]:
|
||||
return list(self._data.get("servers", []))
|
||||
|
||||
def get_server(self, alias: str) -> Optional[dict]:
|
||||
for s in self._data.get("servers", []):
|
||||
if s["alias"] == alias:
|
||||
return dict(s)
|
||||
return None
|
||||
|
||||
def add_server(self, server: dict):
|
||||
if self.get_server(server["alias"]):
|
||||
raise ValueError(f"Server '{server['alias']}' already exists")
|
||||
self._data.setdefault("servers", []).append(server)
|
||||
self._save()
|
||||
self._notify()
|
||||
|
||||
def update_server(self, alias: str, updated: dict):
|
||||
servers = self._data.get("servers", [])
|
||||
for i, s in enumerate(servers):
|
||||
if s["alias"] == alias:
|
||||
servers[i] = updated
|
||||
self._save()
|
||||
self._notify()
|
||||
return
|
||||
raise ValueError(f"Server '{alias}' not found")
|
||||
|
||||
def remove_server(self, alias: str):
|
||||
self._data["servers"] = [s for s in self._data.get("servers", []) if s["alias"] != alias]
|
||||
self._statuses.pop(alias, None)
|
||||
self._save()
|
||||
self._notify()
|
||||
|
||||
def get_ssh_key_path(self) -> str:
|
||||
path = self._data.get("ssh_key", {}).get("path", "~/.ssh/id_ed25519")
|
||||
return os.path.expanduser(path)
|
||||
|
||||
# Status management
|
||||
def set_status(self, alias: str, status: str):
|
||||
self._statuses[alias] = status
|
||||
|
||||
def get_status(self, alias: str) -> str:
|
||||
return self._statuses.get(alias, "unknown")
|
||||
201
core/ssh_client.py
Normal file
201
core/ssh_client.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
SSH client wrapper — refactored from ssh.py.
|
||||
Handles connect, exec, sftp, key management via paramiko.
|
||||
"""
|
||||
|
||||
import os
|
||||
import paramiko
|
||||
|
||||
|
||||
class SSHClientWrapper:
|
||||
def __init__(self, server: dict, key_path: str = ""):
|
||||
self.server = server
|
||||
self.key_path = key_path or os.path.expanduser("~/.ssh/id_ed25519")
|
||||
self._client: paramiko.SSHClient | None = None
|
||||
|
||||
def connect(self) -> paramiko.SSHClient:
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
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 Exception:
|
||||
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):
|
||||
if self._client:
|
||||
try:
|
||||
self._client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._client = None
|
||||
|
||||
def exec_command(self, command: str, use_sudo: bool = True) -> tuple[str, str, int]:
|
||||
"""Execute command. Auto-sudo if user != root and use_sudo=True."""
|
||||
client = self.connect()
|
||||
try:
|
||||
user = self.server.get("user", "root")
|
||||
need_sudo = use_sudo and user != "root"
|
||||
|
||||
if need_sudo:
|
||||
full_cmd = f"sudo -S -p '' bash -c {_shell_quote(command)}"
|
||||
else:
|
||||
full_cmd = command
|
||||
|
||||
stdin, stdout, stderr = client.exec_command(full_cmd, timeout=120)
|
||||
|
||||
if need_sudo:
|
||||
password = self.server.get("password", "")
|
||||
stdin.write(password + "\n")
|
||||
stdin.flush()
|
||||
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
out = stdout.read().decode("utf-8", errors="replace")
|
||||
err = stderr.read().decode("utf-8", errors="replace")
|
||||
|
||||
# Strip sudo noise
|
||||
err_lines = [l for l in err.splitlines()
|
||||
if not l.startswith("[sudo]") and "password for" not in l.lower()]
|
||||
err = "\n".join(err_lines).strip()
|
||||
|
||||
return out, err, exit_code
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def upload(self, local_path: str, remote_path: str, progress_cb=None):
|
||||
client = self.connect()
|
||||
try:
|
||||
sftp = client.open_sftp()
|
||||
if progress_cb:
|
||||
sftp.put(local_path, remote_path, callback=progress_cb)
|
||||
else:
|
||||
sftp.put(local_path, remote_path)
|
||||
sftp.chmod(remote_path, 0o664)
|
||||
sftp.close()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def download(self, remote_path: str, local_path: str, progress_cb=None):
|
||||
client = self.connect()
|
||||
try:
|
||||
sftp = client.open_sftp()
|
||||
if progress_cb:
|
||||
sftp.get(remote_path, local_path, callback=progress_cb)
|
||||
else:
|
||||
sftp.get(remote_path, local_path)
|
||||
sftp.close()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def check_connection(self) -> bool:
|
||||
"""Quick connection test."""
|
||||
try:
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
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:
|
||||
return False
|
||||
|
||||
def install_key(self) -> str:
|
||||
"""Install SSH public key on server. Returns status message."""
|
||||
pub_key_path = self.key_path + ".pub"
|
||||
if not os.path.exists(pub_key_path):
|
||||
raise FileNotFoundError(f"No public key at {pub_key_path}")
|
||||
|
||||
with open(pub_key_path, "r") as f:
|
||||
pub_key = f.read().strip()
|
||||
|
||||
# Check if already installed
|
||||
out, _, _ = self.exec_command(
|
||||
f'grep -c "{pub_key}" ~/.ssh/authorized_keys 2>/dev/null || echo 0',
|
||||
use_sudo=False
|
||||
)
|
||||
if out.strip() != "0":
|
||||
return "Key already installed"
|
||||
|
||||
# Install
|
||||
command = (
|
||||
f'mkdir -p ~/.ssh && chmod 700 ~/.ssh && '
|
||||
f'echo "{pub_key}" >> ~/.ssh/authorized_keys && '
|
||||
f'chmod 600 ~/.ssh/authorized_keys && '
|
||||
f'echo "KEY_OK"'
|
||||
)
|
||||
out, err, code = self.exec_command(command, use_sudo=False)
|
||||
if "KEY_OK" in out:
|
||||
return "Key installed successfully"
|
||||
raise Exception(f"Key install failed: {err or out}")
|
||||
|
||||
def generate_key(self) -> str:
|
||||
"""Generate ed25519 SSH key pair if not exists."""
|
||||
if os.path.exists(self.key_path):
|
||||
return f"Key already exists: {self.key_path}"
|
||||
|
||||
key = paramiko.Ed25519Key.generate()
|
||||
key.write_private_key_file(self.key_path)
|
||||
|
||||
# Write public key
|
||||
pub_key = f"ssh-ed25519 {key.get_base64()} server-manager"
|
||||
with open(self.key_path + ".pub", "w") as f:
|
||||
f.write(pub_key + "\n")
|
||||
|
||||
return f"Key generated: {self.key_path}"
|
||||
|
||||
|
||||
def _shell_quote(s: str) -> str:
|
||||
return "'" + s.replace("'", "'\\''") + "'"
|
||||
74
core/status_checker.py
Normal file
74
core/status_checker.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Background status checker — daemon thread that pings servers periodically.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.server_store import ServerStore
|
||||
|
||||
from core.ssh_client import SSHClientWrapper
|
||||
|
||||
|
||||
class StatusChecker:
|
||||
def __init__(self, store: "ServerStore", interval: int = 60):
|
||||
self.store = store
|
||||
self.interval = interval
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._gui_callback = None # set by GUI for thread-safe updates
|
||||
|
||||
def start(self):
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
|
||||
def set_gui_callback(self, callback):
|
||||
"""Set callback for thread-safe GUI updates."""
|
||||
self._gui_callback = callback
|
||||
|
||||
def check_one(self, server: dict) -> bool:
|
||||
"""Check single server. Returns True if online."""
|
||||
key_path = self.store.get_ssh_key_path()
|
||||
wrapper = SSHClientWrapper(server, key_path)
|
||||
return wrapper.check_connection()
|
||||
|
||||
def check_all_now(self):
|
||||
"""Run a full check cycle immediately (in background thread)."""
|
||||
threading.Thread(target=self._check_cycle, daemon=True).start()
|
||||
|
||||
def _loop(self):
|
||||
while self._running:
|
||||
self._check_cycle()
|
||||
for _ in range(self.interval * 10):
|
||||
if not self._running:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
|
||||
def _check_cycle(self):
|
||||
servers = self.store.get_all()
|
||||
for server in servers:
|
||||
if not self._running:
|
||||
return
|
||||
alias = server["alias"]
|
||||
server_type = server.get("type", "ssh")
|
||||
|
||||
if server_type != "ssh":
|
||||
self.store.set_status(alias, "unknown")
|
||||
continue
|
||||
|
||||
online = self.check_one(server)
|
||||
self.store.set_status(alias, "online" if online else "offline")
|
||||
|
||||
if self._gui_callback:
|
||||
try:
|
||||
self._gui_callback()
|
||||
except Exception:
|
||||
pass
|
||||
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))
|
||||
21
main.py
Normal file
21
main.py
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ServerManager — GUI application for managing remote servers.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from gui.app import App
|
||||
|
||||
|
||||
def main():
|
||||
app = App()
|
||||
app.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
customtkinter>=5.2.0
|
||||
paramiko>=3.4.0
|
||||
pillow>=10.0.0
|
||||
Reference in New Issue
Block a user