Compare commits

..

33 Commits

Author SHA1 Message Date
chrome-storm-c442
b37e696094 v1.9.13: remove cleanup_gitea_releases — keep all Gitea releases
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:35:17 -05:00
chrome-storm-c442
289ce65431 chore: remove 12 old exe from git + use git rm in cleanup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:32:01 -05:00
chrome-storm-c442
704ce3bef2 v1.9.12: cleanup orphan Gitea tags alongside old releases
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:29:53 -05:00
chrome-storm-c442
00f3b76d2a v1.9.11: always check updates on startup + cleanup Gitea releases
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:27:21 -05:00
chrome-storm-c442
efbbfa13ee v1.9.10: cleanup old Gitea releases — keep first + last 5
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:21:33 -05:00
chrome-storm-c442
3c4d02c5ec v1.9.9: fix stale server data after Edit — force reconnect with fresh credentials
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:11:36 -05:00
chrome-storm-c442
5b4672dfe3 v1.9.8: S3 resumable download — Range GET with .s3part resume on disconnect
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:33:48 -05:00
chrome-storm-c442
f445953a82 v1.9.7: S3 folder download — recursive with preserved directory structure
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:19:07 -05:00
chrome-storm-c442
2a56ececd1 v1.9.6: S3 navigation — Backspace/Alt+Left to go back, right-click empty area menu
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:12:51 -05:00
chrome-storm-c442
f233d5cf70 v1.9.5: S3 — new folder, folder delete with confirmation, folder drag-and-drop
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:07:40 -05:00
chrome-storm-c442
61461767fd v1.9.4: S3 optimizations — skip redundant health checks, folder drag-and-drop
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 07:59:26 -05:00
chrome-storm-c442
bc2a3bc6b5 v1.9.3: S3 context menu — two link types: temp 48h + permanent direct
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 07:47:01 -05:00
chrome-storm-c442
e403da4f9d v1.9.2: S3 right-click context menu — copy presigned download link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 07:36:21 -05:00
chrome-storm-c442
1e729fcf3a v1.9.1: PNG Material Design icons — 28 icons, dark/light theme, HiDPI, graceful Unicode fallback
- 56 PNG icons (28 unique × 2 color variants) from Material Design Icons (round style, 96×96px)
- core/icons.py: ctk_icon(), make_icon_button(), reconfigure_icon_button() with CTkImage cache
- Updated 15 GUI files: app.py, sidebar.py, server_dialog.py, all tabs
- build.py: auto-include assets/icons/ in PyInstaller bundle, patch rollover at 99→minor+1
- tools/download_icons.py: icon download script
- Automatic dark↔light theme switching via CTkImage dual-image support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 07:27:49 -05:00
chrome-storm-c442
9b0e4c76a3 v1.9.0: S3 server type — bucket/object browser, drag-and-drop upload, resilient transfers
New server type: S3 (MinIO, AWS, any S3-compatible storage)
- core/s3_client.py: boto3 client with auto-reconnect, 10 retries, exponential backoff, multipart upload/download, tcp_keepalive
- gui/tabs/s3_tab.py: object browser (Treeview), bucket selector, folder navigation, drag-and-drop upload from Explorer (windnd), progress bar with %, multi-file upload
- CLI: --s3-buckets, --s3-ls, --s3-upload, --s3-download, --s3-delete with retry
- ServerDialog: access_key, secret_key, bucket fields
- Registration: server_store, connection_factory, status_checker, icons, app, i18n (EN/RU/ZH)
- Fix: build.py cleanup_old_releases now sorts by semver (was lexicographic, broke v1.8.100+)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:32:03 -05:00
chrome-storm-c442
f2dc978c57 v1.8.99: fix update script — delete-before-copy, size verification, error logging
Previous BAT script falsely reported success because `if exist` checked
the pre-existing file, not the copy result. Now: deletes old DST first,
copies with /B (binary), verifies size matches expected, logs all errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:12:54 -05:00
chrome-storm-c442
c9e3ee8fc5 server groups: grouped sidebar, GroupDialog, context menus, i18n + cleanup old releases
- Groups CRUD in sidebar (create, rename, change color, reorder, delete)
- Collapsible group headers with color dots and server count
- "Move to Group" context menu on servers
- Group dropdown in ServerDialog (add/edit)
- 17 i18n keys (EN/RU/ZH)
- Search auto-expands groups
- Cleaned up old release binaries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:12:35 -05:00
chrome-storm-c442
2f84429b10 v1.8.98: fix ghost "eue" text in terminal — strip DCS/APC/PM/SOS sequences
pyte 0.8.2 doesn't handle DCS string sequences (ESC P ... ST). When
nano/ncurses sends DECRQSS queries, pyte consumed the introducer but
rendered the payload as visible text. Added regex pre-filter to strip
DCS/APC/PM/SOS sequences before feeding data to pyte.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:49:35 -05:00
chrome-storm-c442
72c7da5765 fix: install.sh — ignore chmod error on existing dirs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 06:29:14 -05:00
chrome-storm-c442
b7e9c80690 v1.8.97: add install.sh — CLI installer for headless Linux servers
Installs ssh.py, encryption.py, Claude Code skill, Python dependencies.
Supports local source dir or downloads from Gitea.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 06:28:18 -05:00
chrome-storm-c442
7af788b72e v1.8.96: persist sidebar width across restarts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 05:47:08 -05:00
chrome-storm-c442
08307fbe9b v1.8.95: resizable sidebar via PanedWindow — drag to widen server list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 05:42:41 -05:00
chrome-storm-c442
2174168200 v1.8.94: test release for update mechanism verification
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 04:15:06 -05:00
chrome-storm-c442
273666236a v1.8.93: rewrite update script from VBS+PowerShell to plain BAT with logging
VBS→PowerShell chain was silently failing. BAT is simpler, doesn't depend
on execution policy, and writes detailed log to %TEMP%\sm_update.log
for debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 04:13:36 -05:00
chrome-storm-c442
bc48aea1e4 v1.8.92: fix Ctrl+S/Q and all Ctrl+key combos with non-Latin keyboard layouts
Ctrl+key shortcuts now use physical keycodes as fallback, so Ctrl+S (save
in nano), Ctrl+Q (XON), Ctrl+A/E/R/W/T etc. work regardless of whether
the keyboard layout is English, Russian, or any other.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 04:10:30 -05:00
chrome-storm-c442
bc7be1057b v1.8.91: terminal background pure black (#000000) instead of dark blue
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 04:06:46 -05:00
chrome-storm-c442
d0f84d00eb v1.8.90: fix update script — remove $ErrorActionPreference=Stop, launch via cmd start
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:14:06 -05:00
chrome-storm-c442
1218f4d72d v1.8.89: fix DLL error on auto-update — clean all stale _MEI dirs, launch via explorer.exe
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:11:02 -05:00
chrome-storm-c442
acc93f98e3 v1.8.86: fix update DLL error — cleanup old _MEI dir, delay before launch, retry copy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 00:50:04 -05:00
chrome-storm-c442
57ea90f210 v1.8.85: build.py auto-publishes Gitea release with exe asset
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 00:45:53 -05:00
chrome-storm-c442
85977863ec v1.8.84: skill-ssh.md — server type routing table for Redis/SQL/WinRM/etc
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 00:41:04 -05:00
chrome-storm-c442
6b08dbe85f chore: clean old releases, keep v1.0.0 + v1.8.79-v1.8.83
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:46:33 -05:00
chrome-storm-c442
0fbf8dc811 v1.8.83: test build for os.startfile update verification
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:39:22 -05:00
101 changed files with 3728 additions and 200 deletions

View File

@@ -26,6 +26,7 @@ ServerManager — **кроссплатформенное** Desktop GUI (CustomTk
| grafana | `grafana_client.py` (requests) | Dashboards, Info, Setup | `--grafana-dashboards`, `--grafana-alerts` |
| prometheus | `prometheus_client.py` (requests) | Metrics, Info, Setup | `--prom-query`, `--prom-targets`, `--prom-alerts` |
| winrm | `winrm_client.py` (pywinrm) | PowerShell, Info, Setup | `--ps`, `--cmd` |
| s3 | `s3_client.py` (boto3) | Objects, Info, Setup | `--s3-buckets`, `--s3-ls`, `--s3-upload`, `--s3-download`, `--s3-delete` |
| rdp/vnc | `remote_desktop.py` | Launch, Info, Setup | — (запуск внешнего клиента) |
## БЕЗОПАСНОСТЬ
@@ -77,6 +78,7 @@ core/ # Бизнес-логика
├── grafana_client.py # Grafana REST API
├── prometheus_client.py # Prometheus REST API
├── telnet_client.py # Telnet (тот же интерфейс что ShellSession)
├── s3_client.py # S3/MinIO (boto3)
├── winrm_client.py # PowerShell/CMD через WinRM
├── remote_desktop.py # RDP/VNC (запуск внешнего клиента)
├── connection_factory.py # Фабрика: тип → клиент (lazy imports)
@@ -97,6 +99,7 @@ gui/
│ ├── query_tab.py # SQL-редактор + Treeview + Export CSV
│ ├── redis_tab.py # Redis-консоль + история
│ ├── grafana_tab.py # Дашборды + алерты
│ ├── s3_tab.py # S3 браузер объектов
│ ├── prometheus_tab.py # PromQL + targets
│ ├── powershell_tab.py # PS/CMD (WinRM)
│ ├── launch_tab.py # RDP/VNC кнопка Connect

BIN
assets/icons/dark/add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
assets/icons/dark/check.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

BIN
assets/icons/dark/close.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 B

BIN
assets/icons/dark/code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 B

BIN
assets/icons/dark/edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 B

BIN
assets/icons/dark/info.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
assets/icons/dark/lock.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
assets/icons/dark/save.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
assets/icons/light/add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 B

BIN
assets/icons/light/code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 B

BIN
assets/icons/light/edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

BIN
assets/icons/light/info.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
assets/icons/light/lock.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/icons/light/save.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

166
build.py
View File

@@ -8,11 +8,16 @@ Usage:
python build.py --clean # clean build artifacts first
"""
import base64
import json
import os
import re
import sys
import shutil
import platform
import subprocess
import urllib.error
import urllib.request
# Add project root
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -31,7 +36,13 @@ def auto_bump_version() -> str:
sys.exit(1)
major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3))
new_patch = patch + 1
# Patch grows up to 99, then minor+1 and patch resets to 1
if patch >= 99:
minor += 1
new_patch = 1
else:
new_patch = patch + 1
new_version = f"{major}.{minor}.{new_patch}"
content = re.sub(
@@ -106,6 +117,13 @@ def build():
"--add-data", f"core/encryption.py{os.pathsep}core",
]
# PNG icons for GUI (Material Design)
icons_dir = os.path.join(PROJECT_DIR, "assets", "icons")
if os.path.isdir(icons_dir):
cmd_parts.extend(["--add-data", f"assets/icons{os.pathsep}assets/icons"])
else:
print("WARNING: assets/icons/ not found, building without PNG icons")
# Icon
icon_path = os.path.join(PROJECT_DIR, "assets", "icon.ico")
if os.path.exists(icon_path):
@@ -164,26 +182,166 @@ def build():
# Auto-deploy: sync shared files so Claude Code always has the latest
deploy_shared_files()
# Publish release to Gitea
publish_gitea_release(dst)
def _get_gitea_auth() -> dict:
"""Get Gitea auth headers from git remote 'sensey'."""
try:
_flags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
r = subprocess.run(
["git", "remote", "get-url", "sensey"],
capture_output=True, text=True, cwd=PROJECT_DIR, creationflags=_flags,
)
m = re.match(r"https://([^:]+):([^@]+)@", r.stdout.strip())
if m:
user, pw = m.groups()
token = base64.b64encode(f"{user}:{pw}".encode()).decode()
return {"Authorization": f"Basic {token}"}
except Exception:
pass
return {}
_GITEA_API = "https://git.sensey24.ru/api/v1/repos/aibot777/server-manager"
def _generate_changelog() -> str:
"""Generate changelog from git commits since previous tag."""
try:
# Get all tags sorted by semver
raw = subprocess.check_output(
["git", "tag", "--list", "v*"],
text=True, stderr=subprocess.DEVNULL,
).strip()
tags = [t for t in raw.splitlines() if re.match(r'^v\d+\.\d+\.\d+$', t)]
tags.sort(key=lambda t: tuple(int(x) for x in t[1:].split(".")))
current_tag = f"v{__version__}"
# Find previous tag (exclude current if it exists)
prev_tags = [t for t in tags if t != current_tag]
if prev_tags:
prev_tag = prev_tags[-1]
log_range = f"{prev_tag}..HEAD"
else:
log_range = "HEAD~20..HEAD"
# Get commits
raw_log = subprocess.check_output(
["git", "log", log_range, "--oneline", "--no-merges"],
text=True, stderr=subprocess.DEVNULL,
).strip()
if not raw_log:
return f"Release {current_tag}"
# Format: strip commit hash, keep message
lines = []
for line in raw_log.splitlines():
parts = line.split(" ", 1)
if len(parts) == 2:
lines.append(f"- {parts[1]}")
return f"## What's New in {current_tag}\n\n" + "\n".join(lines)
except Exception as exc:
print(f"Changelog generation failed: {exc}")
return f"Release v{__version__}"
def publish_gitea_release(exe_path: str):
"""Create a Gitea release and upload the exe as asset."""
auth = _get_gitea_auth()
if not auth:
print("Gitea publish skipped: no auth (git remote 'sensey' not found)")
return
tag = f"v{__version__}"
filename = os.path.basename(exe_path)
changelog = _generate_changelog()
# Create release
try:
data = json.dumps({
"tag_name": tag,
"name": tag,
"body": changelog,
}).encode()
req = urllib.request.Request(
f"{_GITEA_API}/releases",
data=data,
headers={**auth, "Content-Type": "application/json"},
method="POST",
)
resp = urllib.request.urlopen(req, timeout=30)
release = json.loads(resp.read())
release_id = release["id"]
except urllib.error.HTTPError as e:
if e.code == 409:
print(f"Gitea release {tag} already exists, skipping")
else:
print(f"Gitea release creation failed: {e}")
return
except Exception as e:
print(f"Gitea release creation failed: {e}")
return
# Upload asset
try:
with open(exe_path, "rb") as f:
file_data = f.read()
req = urllib.request.Request(
f"{_GITEA_API}/releases/{release_id}/assets?name={filename}",
data=file_data,
headers={**auth, "Content-Type": "application/octet-stream"},
method="POST",
)
resp = urllib.request.urlopen(req, timeout=180)
asset = json.loads(resp.read())
size_mb = asset["size"] / (1024 * 1024)
print(f"Gitea release published: {tag} ({filename}, {size_mb:.1f} MB)")
except Exception as e:
print(f"Gitea asset upload failed: {e}")
def _version_key(path: str):
"""Extract (major, minor, patch) tuple for semver sorting."""
m = re.search(r'v(\d+)\.(\d+)\.(\d+)', os.path.basename(path))
if m:
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
return (0, 0, 0)
def cleanup_old_releases():
"""Keep the first release (v1.0.0) and the last 5 releases, delete the rest."""
import glob
pattern = os.path.join(RELEASES_DIR, f"{__app_name__}-v*")
all_exes = sorted(glob.glob(pattern))
all_exes = sorted(glob.glob(pattern), key=_version_key)
if len(all_exes) <= 6: # first + 5 = 6, nothing to clean
return
# First release is always all_exes[0] (sorted, v1.0.0 < v1.8.x)
# First release is always all_exes[0] (sorted by semver, v1.0.0 < v1.8.x)
first = all_exes[0]
last_5 = all_exes[-5:]
keep = set([first] + last_5)
removed = []
_flags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
for f in all_exes:
if f not in keep:
os.remove(f)
# Use git rm so deletion is staged for commit
try:
subprocess.run(
["git", "rm", "-f", "--quiet", f],
cwd=PROJECT_DIR, creationflags=_flags,
capture_output=True,
)
except Exception:
# Fallback: just delete the file
if os.path.exists(f):
os.remove(f)
removed.append(os.path.basename(f))
if removed:

View File

@@ -37,6 +37,10 @@ def create_connection(server: dict, key_path: str = ""):
from core.winrm_client import WinRMClient
return WinRMClient(server)
if server_type == "s3":
from core.s3_client import S3Client
return S3Client(server)
if server_type in ("rdp", "vnc"):
from core.remote_desktop import RemoteDesktopLauncher
return RemoteDesktopLauncher()

View File

@@ -359,6 +359,53 @@ _EN = {
"redis_disconnected": "Not connected",
"redis_error": "Error: {error}",
# S3 tab
"objects": "Objects",
"access_key": "Access Key",
"secret_key": "Secret Key",
"placeholder_secret_key": "Secret key...",
"bucket": "Bucket",
"s3_objects": "S3 Objects",
"s3_bucket": "Bucket:",
"s3_back": "Back",
"s3_refresh": "Refresh",
"s3_upload": "Upload",
"s3_download": "Download",
"s3_delete": "Delete",
"s3_col_name": "Name",
"s3_col_size": "Size",
"s3_col_modified": "Modified",
"s3_connecting": "Connecting...",
"s3_connect_failed": "Connection failed",
"s3_loading": "Loading...",
"s3_items_count": "{count} items",
"s3_uploading": "Uploading...",
"s3_upload_failed": "Upload failed",
"s3_downloading": "Downloading...",
"s3_download_ok": "Download complete",
"s3_download_failed": "Download failed",
"s3_deleting": "Deleting...",
"s3_delete_failed": "Delete failed",
"s3_drop_hint": "Drag files here to upload",
"s3_uploading_n": "Uploading {count} files...",
"s3_uploaded_n": "Uploaded {count} files",
"s3_upload_partial": "Uploaded {ok}/{total} files",
"s3_new_folder": "New Folder",
"s3_folder_name_prompt": "Folder name:",
"s3_creating_folder": "Creating folder...",
"s3_folder_failed": "Failed to create folder",
"s3_delete_folder_confirm": "Delete folder \"{folder}\" and all its contents?",
"s3_deleted_n": "Deleted {count} objects",
"s3_download_folder_title": "Save folder to...",
"s3_downloading_n": "Downloading {count} files...",
"s3_downloaded_n": "Downloaded {count} files",
"s3_download_partial": "Downloaded {ok}/{total} files",
"s3_copy_link_48h": "Copy Link (48h)",
"s3_copy_link_permanent": "Copy Direct Link",
"s3_generating_link": "Generating link...",
"s3_link_copied": "Link copied to clipboard",
"s3_link_failed": "Failed to generate link",
# Grafana tab
"grafana_refresh": "Refresh",
"grafana_dashboards": "Dashboards",
@@ -477,6 +524,22 @@ _EN = {
"update_mode_auto": "Full Auto",
"update_no_updates": "You're up to date!",
"update_not_frozen": "Updates only work in packaged (exe) mode",
# Groups
"group": "Group",
"no_group": "No group",
"ungrouped": "Ungrouped",
"add_group": "Add Group",
"edit_group": "Edit Group",
"rename_group": "Rename",
"delete_group": "Delete Group",
"delete_group_confirm": "Delete group '{name}'? Servers will become ungrouped.",
"group_name": "Group Name",
"group_color": "Color",
"group_name_required": "Group name is required",
"move_to_group": "Move to Group",
"move_up": "Move Up",
"move_down": "Move Down",
"change_color": "Change Color",
}
_RU = {
@@ -813,6 +876,53 @@ _RU = {
"redis_disconnected": "Не подключено",
"redis_error": "Ошибка: {error}",
# S3 tab
"objects": "Объекты",
"access_key": "Access Key",
"secret_key": "Secret Key",
"placeholder_secret_key": "Секретный ключ...",
"bucket": "Бакет",
"s3_objects": "Объекты S3",
"s3_bucket": "Бакет:",
"s3_back": "Назад",
"s3_refresh": "Обновить",
"s3_upload": "Загрузить",
"s3_download": "Скачать",
"s3_delete": "Удалить",
"s3_col_name": "Имя",
"s3_col_size": "Размер",
"s3_col_modified": "Изменён",
"s3_connecting": "Подключение...",
"s3_connect_failed": "Ошибка подключения",
"s3_loading": "Загрузка...",
"s3_items_count": "{count} объектов",
"s3_uploading": "Загрузка файла...",
"s3_upload_failed": "Ошибка загрузки",
"s3_downloading": "Скачивание...",
"s3_download_ok": "Скачивание завершено",
"s3_download_failed": "Ошибка скачивания",
"s3_deleting": "Удаление...",
"s3_delete_failed": "Ошибка удаления",
"s3_drop_hint": "Перетащите файлы сюда для загрузки",
"s3_uploading_n": "Загрузка {count} файлов...",
"s3_uploaded_n": "Загружено {count} файлов",
"s3_upload_partial": "Загружено {ok}/{total} файлов",
"s3_new_folder": "Новая папка",
"s3_folder_name_prompt": "Имя папки:",
"s3_creating_folder": "Создание папки...",
"s3_folder_failed": "Ошибка создания папки",
"s3_delete_folder_confirm": "Удалить папку \"{folder}\" со всем содержимым?",
"s3_deleted_n": "Удалено {count} объектов",
"s3_download_folder_title": "Сохранить папку в...",
"s3_downloading_n": "Скачивание {count} файлов...",
"s3_downloaded_n": "Скачано {count} файлов",
"s3_download_partial": "Скачано {ok}/{total} файлов",
"s3_copy_link_48h": "Ссылка (48ч)",
"s3_copy_link_permanent": "Прямая ссылка",
"s3_generating_link": "Генерация ссылки...",
"s3_link_copied": "Ссылка скопирована",
"s3_link_failed": "Ошибка генерации ссылки",
# Grafana tab
"grafana_refresh": "Обновить",
"grafana_dashboards": "Дашборды",
@@ -931,6 +1041,22 @@ _RU = {
"update_mode_auto": "Полный авто",
"update_no_updates": "У вас последняя версия!",
"update_not_frozen": "Обновления работают только в exe-режиме",
# Groups
"group": "Группа",
"no_group": "Без группы",
"ungrouped": "Без группы",
"add_group": "Добавить группу",
"edit_group": "Редактировать группу",
"rename_group": "Переименовать",
"delete_group": "Удалить группу",
"delete_group_confirm": "Удалить группу '{name}'? Серверы станут без группы.",
"group_name": "Название группы",
"group_color": "Цвет",
"group_name_required": "Название группы обязательно",
"move_to_group": "Переместить в группу",
"move_up": "Вверх",
"move_down": "Вниз",
"change_color": "Изменить цвет",
}
_ZH = {
@@ -1267,6 +1393,53 @@ _ZH = {
"redis_disconnected": "未连接",
"redis_error": "错误: {error}",
# S3 tab
"objects": "对象",
"access_key": "Access Key",
"secret_key": "Secret Key",
"placeholder_secret_key": "密钥...",
"bucket": "存储桶",
"s3_objects": "S3 对象",
"s3_bucket": "存储桶:",
"s3_back": "返回",
"s3_refresh": "刷新",
"s3_upload": "上传",
"s3_download": "下载",
"s3_delete": "删除",
"s3_col_name": "名称",
"s3_col_size": "大小",
"s3_col_modified": "修改时间",
"s3_connecting": "连接中...",
"s3_connect_failed": "连接失败",
"s3_loading": "加载中...",
"s3_items_count": "{count} 个对象",
"s3_uploading": "上传中...",
"s3_upload_failed": "上传失败",
"s3_downloading": "下载中...",
"s3_download_ok": "下载完成",
"s3_download_failed": "下载失败",
"s3_deleting": "删除中...",
"s3_delete_failed": "删除失败",
"s3_drop_hint": "拖拽文件到此处上传",
"s3_uploading_n": "正在上传 {count} 个文件...",
"s3_uploaded_n": "已上传 {count} 个文件",
"s3_upload_partial": "已上传 {ok}/{total} 个文件",
"s3_new_folder": "新建文件夹",
"s3_folder_name_prompt": "文件夹名称:",
"s3_creating_folder": "创建文件夹中...",
"s3_folder_failed": "创建文件夹失败",
"s3_delete_folder_confirm": "删除文件夹 \"{folder}\" 及其所有内容?",
"s3_deleted_n": "已删除 {count} 个对象",
"s3_download_folder_title": "保存文件夹到...",
"s3_downloading_n": "正在下载 {count} 个文件...",
"s3_downloaded_n": "已下载 {count} 个文件",
"s3_download_partial": "已下载 {ok}/{total} 个文件",
"s3_copy_link_48h": "复制链接 (48小时)",
"s3_copy_link_permanent": "复制直接链接",
"s3_generating_link": "生成链接中...",
"s3_link_copied": "链接已复制",
"s3_link_failed": "生成链接失败",
# Grafana tab
"grafana_refresh": "刷新",
"grafana_dashboards": "仪表盘",
@@ -1385,6 +1558,22 @@ _ZH = {
"update_mode_auto": "全自动",
"update_no_updates": "已是最新版本!",
"update_not_frozen": "更新仅在打包(exe)模式下有效",
# Groups
"group": "分组",
"no_group": "无分组",
"ungrouped": "未分组",
"add_group": "添加分组",
"edit_group": "编辑分组",
"rename_group": "重命名",
"delete_group": "删除分组",
"delete_group_confirm": "删除分组 '{name}'? 服务器将变为未分组。",
"group_name": "分组名称",
"group_color": "颜色",
"group_name_required": "分组名称为必填项",
"move_to_group": "移动到分组",
"move_up": "上移",
"move_down": "下移",
"change_color": "更改颜色",
}
_TRANSLATIONS = {

View File

@@ -1,8 +1,42 @@
"""
Icon registry — semantic Unicode symbols for all GUI elements.
Icon registry — semantic Unicode symbols + PNG Material Design icons.
Centralized icon management for buttons, tabs, menus, and type badges.
PNG icons (assets/icons/dark/ + light/) auto-switch with CTk dark/light theme.
If PNG files or PIL are missing, all functions gracefully fall back to Unicode.
"""
import os
import sys
from typing import Optional
# ── Asset path resolution ──────────────────────────────
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
_ASSETS_DIR = os.path.join(sys._MEIPASS, "assets", "icons")
else:
_ASSETS_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"assets", "icons",
)
_HAS_PNG = os.path.isdir(_ASSETS_DIR)
_HAS_PIL: Optional[bool] = None # lazy-checked on first ctk_icon() call
# ── Semantic name → Material icon filename ─────────────
ICON_FILES = {
"back": "arrow_back", "up": "arrow_upward", "refresh": "refresh",
"add": "add", "edit": "edit", "delete": "delete", "confirm": "check",
"upload": "file_upload", "download": "file_download",
"execute": "play_arrow", "info": "info", "clear": "backspace",
"search": "search", "folder": "folder", "folder_open": "folder_open",
"save": "save", "key": "vpn_key", "lock": "lock", "eye": "visibility",
"copy": "content_copy", "gear": "settings", "globe": "language",
"terminal": "code", "query": "play_arrow", "dashboards": "dashboard",
"metrics": "trending_up", "powershell": "code", "launch": "computer",
"totp": "lock", "objects": "storage", "connect": "play_arrow",
"browser": "language", "close": "close",
}
# Semantic icon mapping
ICONS = {
# Navigation
@@ -57,6 +91,7 @@ ICONS = {
"powershell": "\u2328", # ⌨
"launch": "\U0001f5a5", # 🖥
"totp": "\U0001f510", # 🔐
"objects": "\U0001faa3", # 🪣
# Context menu
"connect": "\u25b6", # ▶
@@ -91,6 +126,7 @@ TYPE_COLORS = {
"redis": "#dc2626",
"grafana": "#f97316",
"prometheus": "#e11d48",
"s3": "#16a34a",
}
# Unicode symbols for each server type (reliable, no PIL needed)
@@ -106,6 +142,7 @@ TYPE_SYMBOLS = {
"redis": "\u25c6", # ◆
"grafana": "\U0001f4ca", # 📊
"prometheus": "\U0001f525", # 🔥
"s3": "\U0001faa3", # 🪣
}
# Short text labels for sidebar badge
@@ -121,6 +158,7 @@ TYPE_LABELS = {
"redis": "RDS",
"grafana": "GRF",
"prometheus": "PRM",
"s3": "S3",
}
@@ -157,6 +195,7 @@ TAB_ICONS = {
"metrics": "metrics",
"powershell": "powershell",
"launch": "launch",
"objects": "objects",
}
# Context menu icon mapping (i18n_key -> icon_name)
@@ -174,3 +213,83 @@ CTX_ICONS = {
"edit": "edit",
"delete": "delete",
}
# ── CTkImage cache + loader ────────────────────────────
_icon_cache: dict[tuple, object] = {}
def ctk_icon(name: str, size: int = 20) -> Optional[object]:
"""PNG icon as CTkImage, or None (fallback to Unicode).
Lazy-imports PIL and customtkinter on first call so that
icons.py stays usable as a pure data module for CLI tools.
"""
global _HAS_PIL
if _HAS_PIL is None:
try:
from PIL import Image as _img # noqa: F401
import customtkinter as _ctk # noqa: F401
_HAS_PIL = True
except ImportError:
_HAS_PIL = False
if not _HAS_PIL or not _HAS_PNG:
return None
cache_key = (name, size)
if cache_key in _icon_cache:
return _icon_cache[cache_key]
file_stem = ICON_FILES.get(name)
if not file_stem:
return None
from PIL import Image
import customtkinter as ctk
dark_path = os.path.join(_ASSETS_DIR, "dark", f"{file_stem}.png")
light_path = os.path.join(_ASSETS_DIR, "light", f"{file_stem}.png")
if not os.path.exists(dark_path) or not os.path.exists(light_path):
return None
try:
light_img = Image.open(light_path) # black icons for light bg
dark_img = Image.open(dark_path) # white icons for dark bg
result = ctk.CTkImage(
light_image=light_img, dark_image=dark_img,
size=(size, size),
)
_icon_cache[cache_key] = result
return result
except Exception:
return None
def make_icon_button(parent, icon_name: str, label: str,
icon_size: int = 16, **kwargs):
"""CTkButton with PNG icon or Unicode fallback."""
import customtkinter as _ctk
img = ctk_icon(icon_name, icon_size)
if img:
return _ctk.CTkButton(parent, text=label, image=img,
compound="left", **kwargs)
return _ctk.CTkButton(parent, text=icon_text(icon_name, label), **kwargs)
def make_icon_label(parent, icon_name: str, icon_size: int = 18, **kwargs):
"""CTkLabel with PNG icon or Unicode fallback."""
import customtkinter as _ctk
img = ctk_icon(icon_name, icon_size)
if img:
return _ctk.CTkLabel(parent, text="", image=img, **kwargs)
return _ctk.CTkLabel(parent, text=ICONS.get(icon_name, ""), **kwargs)
def reconfigure_icon_button(btn, icon_name: str, label: str,
icon_size: int = 16):
"""Update existing button text + image (for update_language)."""
img = ctk_icon(icon_name, icon_size)
if img:
btn.configure(text=label, image=img)
else:
btn.configure(text=icon_text(icon_name, label))

520
core/s3_client.py Normal file
View File

@@ -0,0 +1,520 @@
"""
S3 client wrapper — duck-typed, lazy-imports boto3 module.
Works with any S3-compatible storage (AWS, MinIO, etc.).
Resilience features:
- Adaptive retry with exponential backoff (up to 10 attempts)
- Multipart upload/download with configurable chunk size
- Auto-reconnect on connection loss (network switch, Wi-Fi change)
- boto3 TransferConfig tuned for unstable connections
"""
import os
import time
from core.logger import log
_boto3 = None
_botocore = None
# Retry / resilience constants
_MAX_RETRIES = 10
_BASE_DELAY = 2.0 # seconds
_MAX_DELAY = 60.0 # seconds
_MULTIPART_THRESHOLD = 8 * 1024 * 1024 # 8 MB — use multipart above this
_MULTIPART_CHUNKSIZE = 8 * 1024 * 1024 # 8 MB chunks
_MAX_CONCURRENCY = 4 # parallel parts (low for unstable links)
def _get_boto3():
global _boto3, _botocore
if _boto3 is None:
import boto3 as _b
import botocore as _bc
_boto3 = _b
_botocore = _bc
return _boto3
def _get_transfer_config():
"""TransferConfig tuned for unreliable connections."""
from boto3.s3.transfer import TransferConfig
return TransferConfig(
multipart_threshold=_MULTIPART_THRESHOLD,
multipart_chunksize=_MULTIPART_CHUNKSIZE,
max_concurrency=_MAX_CONCURRENCY,
num_download_attempts=_MAX_RETRIES,
)
def _retry_delay(attempt: int) -> float:
"""Exponential backoff: 2, 4, 8, 16, 32, 60, 60, ..."""
delay = min(_BASE_DELAY * (2 ** attempt), _MAX_DELAY)
return delay
class S3Client:
"""Manage a single S3 connection. No ABC — duck typing."""
def __init__(self, server: dict):
self._server = server
self._endpoint = server.get("ip", "")
# If endpoint doesn't start with http, add https
if self._endpoint and not self._endpoint.startswith("http"):
use_ssl = server.get("use_ssl", True)
scheme = "https" if use_ssl else "http"
port = int(server.get("port", 443))
if (scheme == "https" and port == 443) or (scheme == "http" and port == 80):
self._endpoint = f"{scheme}://{self._endpoint}"
else:
self._endpoint = f"{scheme}://{self._endpoint}:{port}"
self._access_key = server.get("access_key", "")
self._secret_key = server.get("secret_key", "")
self._bucket = server.get("bucket", "")
self._use_ssl = server.get("use_ssl", True)
self._client = None
self._transfer_config = None
self._last_ok: float = 0 # timestamp of last successful operation
# -- lifecycle --------------------------------------------------------
def connect(self) -> bool:
try:
b3 = _get_boto3()
import botocore.config
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
config = botocore.config.Config(
signature_version="s3v4",
connect_timeout=15,
read_timeout=60,
retries={"max_attempts": 5, "mode": "adaptive"},
tcp_keepalive=True,
)
self._client = b3.client(
"s3",
endpoint_url=self._endpoint,
aws_access_key_id=self._access_key,
aws_secret_access_key=self._secret_key,
config=config,
verify=False,
)
self._transfer_config = _get_transfer_config()
# Test connection
self._client.list_buckets()
self._last_ok = time.time()
log.info("S3 connected %s", self._endpoint)
return True
except Exception as exc:
log.error("S3 connect failed: %s", exc)
self._client = None
return False
def _reconnect(self) -> bool:
"""Try to re-establish the S3 connection after a drop."""
log.warning("S3 reconnecting to %s...", self._endpoint)
self._client = None
return self.connect()
def _ensure_connected(self) -> bool:
"""Check connection, reconnect if needed.
Skips health-check if last success was <30s ago (avoids redundant RTTs).
"""
if self._client is None:
return self._reconnect()
if time.time() - self._last_ok < 30:
return True
try:
self._client.list_buckets()
self._last_ok = time.time()
return True
except Exception:
return self._reconnect()
def disconnect(self):
self._client = None
log.info("S3 disconnected")
def check_connection(self) -> bool:
try:
if self._client is None:
return False
self._client.list_buckets()
return True
except Exception:
return False
# -- bucket operations ------------------------------------------------
def list_buckets(self) -> list[dict]:
"""Return list of {'Name': str, 'CreationDate': datetime}."""
if not self._ensure_connected():
return []
try:
resp = self._client.list_buckets()
self._last_ok = time.time()
return resp.get("Buckets", [])
except Exception as exc:
log.error("S3 list_buckets failed: %s", exc)
return []
# -- object operations ------------------------------------------------
def list_objects(self, bucket: str = "", prefix: str = "",
delimiter: str = "/") -> tuple[list[dict], list[str]]:
"""List objects and common prefixes in a bucket/prefix.
Returns (objects, prefixes) where:
- objects: list of {'Key', 'Size', 'LastModified'}
- prefixes: list of prefix strings (subdirectories)
"""
if not self._ensure_connected():
return [], []
bucket = bucket or self._bucket
if not bucket:
return [], []
try:
objects = []
prefixes = []
paginator = self._client.get_paginator("list_objects_v2")
kwargs = {"Bucket": bucket, "Delimiter": delimiter}
if prefix:
kwargs["Prefix"] = prefix
for page in paginator.paginate(**kwargs):
for obj in page.get("Contents", []):
# Skip the prefix itself
if obj["Key"] != prefix:
objects.append(obj)
for cp in page.get("CommonPrefixes", []):
prefixes.append(cp["Prefix"])
self._last_ok = time.time()
return objects, prefixes
except Exception as exc:
log.error("S3 list_objects failed: %s", exc)
return [], []
def upload_file(self, local_path: str, bucket: str, key: str,
progress_cb=None, status_cb=None) -> bool:
"""Upload a local file to S3 with retry and resume.
progress_cb(bytes_transferred) — called periodically for progress bar.
status_cb(message) — called with status messages (retry info, etc.).
Uses multipart upload for files > 8 MB.
On failure, retries up to 10 times with exponential backoff.
boto3 multipart automatically resumes failed parts.
"""
if not self._ensure_connected():
return False
file_size = os.path.getsize(local_path)
for attempt in range(_MAX_RETRIES):
try:
self._client.upload_file(
local_path, bucket, key,
Config=self._transfer_config,
Callback=progress_cb,
)
log.info("S3 uploaded %s -> s3://%s/%s (%d bytes)",
local_path, bucket, key, file_size)
return True
except Exception as exc:
delay = _retry_delay(attempt)
log.warning("S3 upload attempt %d/%d failed: %s (retry in %.0fs)",
attempt + 1, _MAX_RETRIES, exc, delay)
if status_cb:
status_cb(f"Retry {attempt + 1}/{_MAX_RETRIES} in {delay:.0f}s...")
# Reset progress for retry (callback accumulates)
if progress_cb and attempt < _MAX_RETRIES - 1:
# We can't easily reset boto3's internal counter,
# but the GUI tracks total bytes itself
pass
time.sleep(delay)
# Reconnect before retry
if not self._reconnect():
log.error("S3 reconnect failed on attempt %d", attempt + 1)
continue
log.error("S3 upload failed after %d attempts: %s -> s3://%s/%s",
_MAX_RETRIES, local_path, bucket, key)
return False
def download_file(self, bucket: str, key: str, local_path: str,
progress_cb=None, status_cb=None) -> bool:
"""Download with resume support using S3 Range GET.
On disconnect, keeps the .s3part file and resumes from where it
stopped. ETag is checked to detect if the remote file changed
(in that case the partial file is discarded and download restarts).
progress_cb(bytes_delta) — called with each chunk size.
status_cb(message) — called with retry / resume info.
"""
if not self._ensure_connected():
return False
# --- 1. HEAD — get size and ETag ---
try:
head = self._client.head_object(Bucket=bucket, Key=key)
total_size = head["ContentLength"]
etag = head.get("ETag", "")
except Exception as exc:
log.error("S3 head_object failed: %s", exc)
return False
# Small files (< 1 MB) — simple download, no resume overhead
if total_size < 1024 * 1024:
return self._download_file_simple(
bucket, key, local_path, progress_cb, status_cb)
# --- 2. Check .s3part (partial download) ---
temp_path = local_path + ".s3part"
meta_path = local_path + ".s3meta"
start_byte = 0
if os.path.exists(temp_path):
saved_etag = ""
if os.path.exists(meta_path):
try:
with open(meta_path, "r") as f:
saved_etag = f.read().strip()
except Exception:
pass
if saved_etag == etag and etag:
start_byte = os.path.getsize(temp_path)
if start_byte >= total_size:
# Already fully downloaded
os.replace(temp_path, local_path)
self._cleanup_meta(meta_path)
self._last_ok = time.time()
return True
log.info("S3 resuming from byte %d / %d", start_byte, total_size)
if status_cb:
mb = start_byte / (1024 * 1024)
status_cb(f"Resuming from {mb:.1f} MB...")
else:
# ETag changed — file was modified on server, start fresh
try:
os.remove(temp_path)
except OSError:
pass
start_byte = 0
# Save ETag for future resume
try:
with open(meta_path, "w") as f:
f.write(etag)
except Exception:
pass
# Report already-downloaded bytes so progress bar is correct
if progress_cb and start_byte > 0:
progress_cb(start_byte)
# --- 3. Download loop with retry ---
chunk_size = _MULTIPART_CHUNKSIZE # 8 MB
for attempt in range(_MAX_RETRIES):
try:
if start_byte >= total_size:
break
range_header = f"bytes={start_byte}-"
resp = self._client.get_object(
Bucket=bucket, Key=key, Range=range_header)
with open(temp_path, "ab") as f:
for chunk in resp["Body"].iter_chunks(chunk_size=chunk_size):
f.write(chunk)
f.flush()
start_byte += len(chunk)
if progress_cb:
progress_cb(len(chunk))
# --- 4. Verify size ---
actual = os.path.getsize(temp_path)
if actual != total_size:
log.warning("S3 size mismatch: got %d, expected %d",
actual, total_size)
# Don't delete — maybe we can resume next attempt
if actual < total_size:
start_byte = actual
continue
# actual > total_size — corrupted, restart
try:
os.remove(temp_path)
except OSError:
pass
start_byte = 0
continue
# --- 5. Atomic rename ---
os.replace(temp_path, local_path)
self._cleanup_meta(meta_path)
self._last_ok = time.time()
log.info("S3 downloaded s3://%s/%s -> %s (%d bytes, resumed)",
bucket, key, local_path, total_size)
return True
except Exception as exc:
# Update start_byte from actual file size
if os.path.exists(temp_path):
start_byte = os.path.getsize(temp_path)
delay = _retry_delay(attempt)
log.warning("S3 download attempt %d/%d failed at byte %d: %s",
attempt + 1, _MAX_RETRIES, start_byte, exc)
if status_cb:
pct = (start_byte / total_size * 100) if total_size else 0
status_cb(f"Retry {attempt+1}/{_MAX_RETRIES} at {pct:.0f}%...")
time.sleep(delay)
self._reconnect()
# Adaptive chunk: reduce on repeated failures
if attempt >= 2 and chunk_size > 1024 * 1024:
chunk_size = 1024 * 1024 # 1 MB
log.info("S3 reducing chunk size to 1 MB")
log.error("S3 download failed after %d attempts: s3://%s/%s -> %s",
_MAX_RETRIES, bucket, key, local_path)
return False
def _download_file_simple(self, bucket: str, key: str, local_path: str,
progress_cb=None, status_cb=None) -> bool:
"""Simple download for small files (no resume overhead)."""
for attempt in range(_MAX_RETRIES):
try:
self._client.download_file(
bucket, key, local_path,
Config=self._transfer_config,
Callback=progress_cb,
)
self._last_ok = time.time()
log.info("S3 downloaded s3://%s/%s -> %s", bucket, key, local_path)
return True
except Exception as exc:
delay = _retry_delay(attempt)
log.warning("S3 download attempt %d/%d failed: %s (retry in %.0fs)",
attempt + 1, _MAX_RETRIES, exc, delay)
if status_cb:
status_cb(f"Retry {attempt + 1}/{_MAX_RETRIES} in {delay:.0f}s...")
time.sleep(delay)
if not self._reconnect():
log.error("S3 reconnect failed on attempt %d", attempt + 1)
continue
log.error("S3 download failed after %d attempts: s3://%s/%s -> %s",
_MAX_RETRIES, bucket, key, local_path)
return False
@staticmethod
def _cleanup_meta(meta_path: str):
"""Remove .s3meta file silently."""
try:
os.remove(meta_path)
except OSError:
pass
def delete_object(self, bucket: str, key: str) -> bool:
"""Delete an object from S3."""
if not self._ensure_connected():
return False
try:
self._client.delete_object(Bucket=bucket, Key=key)
log.info("S3 deleted s3://%s/%s", bucket, key)
return True
except Exception as exc:
log.error("S3 delete failed: %s", exc)
return False
def generate_presigned_url(self, bucket: str, key: str,
expires_in: int = 3600) -> str | None:
"""Generate a presigned download URL for an object.
expires_in: URL lifetime in seconds (default 1 hour).
Returns URL string or None on failure.
"""
if not self._ensure_connected():
return None
try:
url = self._client.generate_presigned_url(
"get_object",
Params={"Bucket": bucket, "Key": key},
ExpiresIn=expires_in,
)
return url
except Exception as exc:
log.error("S3 presigned URL failed: %s", exc)
return None
def list_all_objects(self, bucket: str, prefix: str = "") -> list[dict]:
"""List ALL objects under prefix recursively (no delimiter).
Returns list of {'Key', 'Size', 'LastModified'}.
"""
if not self._ensure_connected():
return []
try:
objects = []
paginator = self._client.get_paginator("list_objects_v2")
kwargs = {"Bucket": bucket}
if prefix:
kwargs["Prefix"] = prefix
for page in paginator.paginate(**kwargs):
for obj in page.get("Contents", []):
# Skip "folder" markers (zero-byte keys ending with /)
if obj["Key"].endswith("/") and obj.get("Size", 0) == 0:
continue
objects.append(obj)
self._last_ok = time.time()
return objects
except Exception as exc:
log.error("S3 list_all_objects failed: %s", exc)
return []
def delete_prefix(self, bucket: str, prefix: str) -> int:
"""Recursively delete all objects under a prefix. Returns count deleted."""
if not self._ensure_connected():
return 0
try:
deleted = 0
paginator = self._client.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
objects = page.get("Contents", [])
if not objects:
continue
delete_req = {
"Objects": [{"Key": obj["Key"]} for obj in objects],
"Quiet": True,
}
self._client.delete_objects(Bucket=bucket, Delete=delete_req)
deleted += len(objects)
self._last_ok = time.time()
log.info("S3 deleted prefix s3://%s/%s (%d objects)", bucket, prefix, deleted)
return deleted
except Exception as exc:
log.error("S3 delete prefix failed: %s", exc)
return 0
def create_folder(self, bucket: str, key: str) -> bool:
"""Create a folder (empty object with trailing slash) in S3."""
if not self._ensure_connected():
return False
try:
self._client.put_object(Bucket=bucket, Key=key, Body=b"")
self._last_ok = time.time()
log.info("S3 created folder s3://%s/%s", bucket, key)
return True
except Exception as exc:
log.error("S3 create folder failed: %s", exc)
return False
def get_direct_url(self, bucket: str, key: str) -> str:
"""Build a direct (permanent) URL: endpoint/bucket/key."""
endpoint = self._endpoint.rstrip("/")
return f"{endpoint}/{bucket}/{key}"
def get_object_size(self, bucket: str, key: str) -> int:
"""Get size of an object in bytes."""
if not self._ensure_connected():
return 0
try:
resp = self._client.head_object(Bucket=bucket, Key=key)
return resp.get("ContentLength", 0)
except Exception:
return 0

View File

@@ -26,7 +26,7 @@ BACKUP_DIR = os.path.join(SHARED_DIR, "backups")
LOCAL_CONFIG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config")
EXAMPLE_FILE = os.path.join(LOCAL_CONFIG_DIR, "servers.example.json")
SERVER_TYPES = ["ssh", "telnet", "rdp", "vnc", "winrm", "mariadb", "mssql", "postgresql", "redis", "grafana", "prometheus"]
SERVER_TYPES = ["ssh", "telnet", "rdp", "vnc", "winrm", "mariadb", "mssql", "postgresql", "redis", "grafana", "prometheus", "s3"]
DEFAULT_PORTS = {
"ssh": 22,
@@ -40,6 +40,7 @@ DEFAULT_PORTS = {
"redis": 6379,
"grafana": 3000,
"prometheus": 9090,
"s3": 443,
}
# Auto-backup interval: 10 minutes
@@ -58,6 +59,7 @@ class ServerStore:
self._last_backup_hash: str = ""
self._terminal_font_size: int = 11
self._window_geometry: str = ""
self._sidebar_width: int = 250
self._servers_file: str = DEFAULT_SERVERS_FILE
# Update settings
self._update_mode: str = "auto-download" # "notify-only" | "auto-download" | "full-auto"
@@ -83,6 +85,7 @@ class ServerStore:
self._check_interval = settings.get("check_interval", 60)
self._terminal_font_size = settings.get("terminal_font_size", 11)
self._window_geometry = settings.get("window_geometry", "")
self._sidebar_width = settings.get("sidebar_width", 250)
self._update_mode = settings.get("update_mode", "auto-download")
self._last_update_check = settings.get("last_update_check", 0)
self._skip_version = settings.get("skip_version", "")
@@ -100,6 +103,7 @@ class ServerStore:
"check_interval": self._check_interval,
"terminal_font_size": self._terminal_font_size,
"window_geometry": self._window_geometry,
"sidebar_width": self._sidebar_width,
"update_mode": self._update_mode,
"last_update_check": self._last_update_check,
"skip_version": self._skip_version,
@@ -394,6 +398,89 @@ class ServerStore:
self._save()
self._notify()
# ── Groups CRUD ─────────────────────────────────
def get_groups(self) -> list[dict]:
"""Return all groups sorted by order."""
groups = list(self._data.get("groups", []))
groups.sort(key=lambda g: g.get("order", 0))
return groups
def get_group(self, group_id: str) -> Optional[dict]:
for g in self._data.get("groups", []):
if g["id"] == group_id:
return dict(g)
return None
def add_group(self, name: str, color: str = "#6b7280") -> dict:
"""Create a new group, return the created dict."""
import uuid
groups = self._data.setdefault("groups", [])
max_order = max((g.get("order", 0) for g in groups), default=-1)
group = {
"id": uuid.uuid4().hex[:8],
"name": name,
"color": color,
"collapsed": False,
"order": max_order + 1,
}
groups.append(group)
self._save()
self._notify()
return group
def update_group(self, group_id: str, **kwargs):
"""Update group fields (name, color, collapsed, order)."""
for g in self._data.get("groups", []):
if g["id"] == group_id:
for k, v in kwargs.items():
if k in ("name", "color", "collapsed", "order"):
g[k] = v
self._save()
self._notify()
return
raise ValueError(f"Group '{group_id}' not found")
def remove_group(self, group_id: str):
"""Delete group. Servers in it become ungrouped."""
self._data["groups"] = [g for g in self._data.get("groups", []) if g["id"] != group_id]
for s in self._data.get("servers", []):
if s.get("group") == group_id:
s.pop("group", None)
self._save()
self._notify()
def reorder_groups(self, ordered_ids: list[str]):
"""Set group order based on list of IDs."""
id_to_order = {gid: i for i, gid in enumerate(ordered_ids)}
for g in self._data.get("groups", []):
if g["id"] in id_to_order:
g["order"] = id_to_order[g["id"]]
self._save()
self._notify()
def set_server_group(self, alias: str, group_id: Optional[str]):
"""Move a server to a group (or None to ungroup)."""
for s in self._data.get("servers", []):
if s["alias"] == alias:
if group_id:
s["group"] = group_id
else:
s.pop("group", None)
self._save()
self._notify()
return
raise ValueError(f"Server '{alias}' not found")
def get_servers_in_group(self, group_id: Optional[str]) -> list[dict]:
"""Return servers in a group. None = ungrouped."""
all_servers = self._data.get("servers", [])
group_ids = {g["id"] for g in self._data.get("groups", [])}
if group_id is None:
return [s for s in all_servers
if not s.get("group") or s.get("group") not in group_ids]
return [s for s in all_servers if s.get("group") == group_id]
def get_ssh_key_path(self) -> str:
path = self._data.get("ssh_key", {}).get("path", "~/.ssh/id_ed25519")
return os.path.expanduser(path)

View File

@@ -9,6 +9,15 @@ from typing import Dict, Optional, Tuple
from core.ssh_client import ShellSession, SFTPSession
_CRITICAL_KEYS = ('ip', 'port', 'username', 'password', 'type',
'access_key', 'secret_key', 'use_ssl')
def _server_changed(old: dict, new: dict) -> bool:
"""Check if critical connection fields differ."""
return any(old.get(k) != new.get(k) for k in _CRITICAL_KEYS)
class SessionData:
"""Container for session data including the actual sessions and their metadata."""
def __init__(self, alias: str, server: dict, key_path: str):
@@ -70,6 +79,11 @@ class SessionPool:
self._sessions[alias] = session_data
else:
session_data = self._sessions[alias]
# Invalidate if server connection data changed
if _server_changed(session_data.server, server):
session_data.cleanup()
session_data.server = server
session_data.key_path = key_path
# Update access time for LRU
self._update_last_access(alias)
@@ -108,6 +122,11 @@ class SessionPool:
self._sessions[alias] = session_data
else:
session_data = self._sessions[alias]
# Invalidate if server connection data changed
if _server_changed(session_data.server, server):
session_data.cleanup()
session_data.server = server
session_data.key_path = key_path
# Update access time for LRU
self._update_last_access(alias)

View File

@@ -19,6 +19,7 @@ _SSH_TYPE = {"ssh"}
_SQL_TYPES = {"mariadb", "mssql", "postgresql"}
_REDIS_TYPE = {"redis"}
_HTTP_TYPES = {"grafana", "prometheus", "winrm"}
_S3_TYPE = {"s3"}
_TCP_TYPES = {"telnet", "rdp", "vnc"}
@@ -60,6 +61,8 @@ class StatusChecker:
return self._check_http(server, "/-/healthy")
if server_type == "winrm":
return self._check_http(server, "/wsman")
if server_type in _S3_TYPE:
return self._check_s3(server)
if server_type in _TCP_TYPES:
return self._check_tcp(server)
@@ -106,6 +109,17 @@ class StatusChecker:
except Exception:
return False
def _check_s3(self, server: dict) -> bool:
"""Check S3 via list_buckets."""
try:
from core.s3_client import S3Client
client = S3Client(server)
result = client.connect()
client.disconnect()
return result
except Exception:
return False
def _check_http(self, server: dict, path: str) -> bool:
"""Check HTTP(S) endpoint."""
try:

View File

@@ -262,39 +262,120 @@ class UpdateChecker:
return self._apply_linux(binary_path, current_exe)
def _apply_windows(self, new_exe: str, current_exe: str) -> bool:
"""Windows update: create hidden .vbs that runs .bat silently."""
"""Windows update: BAT script waits for exit, copies, launches."""
try:
tmp_dir = tempfile.gettempdir()
bat_path = os.path.join(tmp_dir, "sm_update.bat")
vbs_path = os.path.join(tmp_dir, "sm_update.vbs")
log_path = os.path.join(tmp_dir, "sm_update.log")
pid = os.getpid()
new_size = os.path.getsize(new_exe)
bat_content = f"""@echo off
:wait
tasklist /fi "PID eq {pid}" 2>NUL | find "{pid}" >NUL
if %ERRORLEVEL% == 0 (
timeout /t 1 /nobreak >NUL
goto wait
setlocal enabledelayedexpansion
chcp 65001 >nul 2>&1
set "LOGFILE={log_path}"
set "SRC={new_exe}"
set "DST={current_exe}"
set "PID={pid}"
set "TMPDIR={tmp_dir}"
set "EXPECTED_SIZE={new_size}"
echo [%date% %time%] Update script started >> "%LOGFILE%"
echo [%date% %time%] PID to wait for: %PID% >> "%LOGFILE%"
echo [%date% %time%] SRC: %SRC% >> "%LOGFILE%"
echo [%date% %time%] DST: %DST% >> "%LOGFILE%"
echo [%date% %time%] Expected size: %EXPECTED_SIZE% >> "%LOGFILE%"
:wait_loop
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
if %errorlevel%==0 (
echo [%date% %time%] Waiting for PID %PID% to exit... >> "%LOGFILE%"
timeout /t 1 /nobreak >nul
goto wait_loop
)
copy /Y "{new_exe}" "{current_exe}" >NUL
if %ERRORLEVEL% NEQ 0 (
exit /b 1
echo [%date% %time%] Process exited, waiting 3s... >> "%LOGFILE%"
timeout /t 3 /nobreak >nul
rem Clean stale _MEI directories
echo [%date% %time%] Cleaning _MEI directories... >> "%LOGFILE%"
for /d %%D in ("%TMPDIR%\\_MEI*") do (
rmdir /s /q "%%D" >nul 2>&1
)
start "" "{current_exe}"
del "{bat_path}"
del "%~f0"
timeout /t 1 /nobreak >nul
rem Log source file size
for %%F in ("%SRC%") do (
echo [%date% %time%] SRC file size: %%~zF >> "%LOGFILE%"
)
rem Delete old DST first so copy is clean
echo [%date% %time%] Deleting old DST... >> "%LOGFILE%"
del /f /q "%DST%" >nul 2>&1
if exist "%DST%" (
echo [%date% %time%] WARNING: could not delete old DST >> "%LOGFILE%"
)
timeout /t 1 /nobreak >nul
rem Copy with retry and size verification
echo [%date% %time%] Starting copy... >> "%LOGFILE%"
set COPIED=0
for /L %%i in (1,1,5) do (
if !COPIED!==0 (
echo [%date% %time%] Copy attempt %%i... >> "%LOGFILE%"
copy /Y /B "%SRC%" "%DST%" >> "%LOGFILE%" 2>&1
if exist "%DST%" (
for %%F in ("%DST%") do set DST_SIZE=%%~zF
echo [%date% %time%] DST size after copy: !DST_SIZE! >> "%LOGFILE%"
if "!DST_SIZE!"=="%EXPECTED_SIZE%" (
echo [%date% %time%] Size verified OK >> "%LOGFILE%"
set COPIED=1
) else (
echo [%date% %time%] Size mismatch! Expected %EXPECTED_SIZE%, got !DST_SIZE! >> "%LOGFILE%"
del /f /q "%DST%" >nul 2>&1
timeout /t 2 /nobreak >nul
)
) else (
echo [%date% %time%] Copy failed - DST does not exist >> "%LOGFILE%"
timeout /t 2 /nobreak >nul
)
)
)
if %COPIED%==0 (
echo [%date% %time%] FAILED: could not copy after 5 attempts >> "%LOGFILE%"
pause
goto cleanup
)
echo [%date% %time%] Copy successful, launching... >> "%LOGFILE%"
timeout /t 2 /nobreak >nul
rem Launch new exe
echo [%date% %time%] Starting: %DST% >> "%LOGFILE%"
start "" "%DST%"
echo [%date% %time%] Launch command issued >> "%LOGFILE%"
timeout /t 2 /nobreak >nul
:cleanup
rem Delete downloaded update file
del /f /q "%SRC%" >nul 2>&1
echo [%date% %time%] Cleanup done >> "%LOGFILE%"
rem Self-delete
del /f /q "%~f0" >nul 2>&1
"""
with open(bat_path, "w") as f:
with open(bat_path, "w", encoding="utf-8") as f:
f.write(bat_content)
# VBS wrapper runs .bat completely hidden (no CMD flash)
vbs_content = f'CreateObject("Wscript.Shell").Run """{bat_path}""", 0, False\n'
with open(vbs_path, "w") as f:
f.write(vbs_content)
log.info(f"Update BAT: {bat_path}, log: {log_path}")
# Launch VBS via os.startfile — most reliable on Windows,
# works from frozen exe without PATH issues
os.startfile(vbs_path)
# Launch BAT minimized
subprocess.Popen(
["cmd.exe", "/c", "start", "/min", "", bat_path],
creationflags=_SUBPROCESS_FLAGS,
)
return True
except Exception as e:
@@ -320,12 +401,15 @@ del "%~f0"
return
time.sleep(0.1)
first_run = True
while self._running:
# Check if enough time passed since last check
# On first run after startup, always check regardless of interval
last_check = self.store.get_last_update_check()
now = time.time()
if not last_check or (now - last_check) >= _CHECK_INTERVAL:
if first_run or not last_check or (now - last_check) >= _CHECK_INTERVAL:
first_run = False
info = self.check_now()
if info and self._gui_callback:
mode = self.store.get_update_mode()

View File

@@ -11,7 +11,7 @@ from core.status_checker import StatusChecker
from core.updater import UpdateChecker
from core import i18n
from core.i18n import t, LANGUAGES
from core.icons import icon, TAB_ICONS
from core.icons import icon, TAB_ICONS, ctk_icon
from core.session_pool import SessionPool
from gui.sidebar import Sidebar
from gui.server_dialog import ServerDialog
@@ -28,6 +28,7 @@ from gui.tabs.grafana_tab import GrafanaTab
from gui.tabs.prometheus_tab import PrometheusTab
from gui.tabs.powershell_tab import PowershellTab
from gui.tabs.launch_tab import LaunchTab
from gui.tabs.s3_tab import S3Tab
# Tab sets per server type — determines which tabs are shown
TAB_REGISTRY = {
@@ -42,6 +43,7 @@ TAB_REGISTRY = {
"prometheus": ["metrics", "info", "setup"],
"rdp": ["launch", "info", "setup"],
"vnc": ["launch", "info", "setup"],
"s3": ["objects", "info", "setup"],
}
# Map tab key → widget class (used as lazy factory)
@@ -58,6 +60,7 @@ TAB_CLASSES = {
"metrics": PrometheusTab,
"powershell": PowershellTab,
"launch": LaunchTab,
"objects": S3Tab,
}
@@ -116,19 +119,27 @@ class App(ctk.CTk):
self.protocol("WM_DELETE_WINDOW", self._on_close)
def _build_layout(self):
# PanedWindow — resizable sidebar | main area
self._paned = tkinter.PanedWindow(
self, orient="horizontal", sashwidth=4,
bg="#2b2b2b", sashrelief="flat", opaqueresize=True,
)
self._paned.pack(fill="both", expand=True)
# Sidebar
self.sidebar = Sidebar(self, self.store, on_select=self._on_server_select, session_pool=self.session_pool)
self.sidebar.pack(side="left", fill="y")
self.sidebar = Sidebar(self._paned, self.store, on_select=self._on_server_select, session_pool=self.session_pool)
self._paned.add(self.sidebar, minsize=180, width=self.store._sidebar_width)
self.sidebar.add_callback = self._add_server
self.sidebar.edit_callback = self._edit_server
self.sidebar.delete_callback = self._delete_server
self.sidebar.add_group_callback = self._add_group
self.sidebar.open_tab_callback = self._context_open_tab
self.sidebar.check_status_callback = self._context_check_status
self.sidebar.open_browser_callback = self._context_open_browser
# Main area
self._main_frame = ctk.CTkFrame(self, fg_color="transparent")
self._main_frame.pack(side="right", fill="both", expand=True)
self._main_frame = ctk.CTkFrame(self._paned, fg_color="transparent")
self._paned.add(self._main_frame, minsize=500)
# Header bar (language + about)
header_bar = ctk.CTkFrame(self._main_frame, fg_color="transparent", height=40)
@@ -136,7 +147,11 @@ class App(ctk.CTk):
header_bar.pack_propagate(False)
# Language selector
self._lang_icon = ctk.CTkLabel(header_bar, text="\U0001f310", font=ctk.CTkFont(size=14), width=20)
_lang_img = ctk_icon("globe", 18)
self._lang_icon = ctk.CTkLabel(
header_bar, text="" if _lang_img else "\U0001f310",
image=_lang_img, font=ctk.CTkFont(size=14), width=20,
)
self._lang_icon.pack(side="right", padx=(5, 0))
lang_values = list(LANGUAGES.values())
current_display = LANGUAGES.get(i18n.get_language(), "English")
@@ -148,16 +163,20 @@ class App(ctk.CTk):
self.lang_menu.pack(side="right", padx=(5, 0))
# Check Updates button
_sync_img = ctk_icon("refresh", 18)
self._update_check_btn = ctk.CTkButton(
header_bar, text="\u21bb", width=30, height=30,
header_bar, text="" if _sync_img else "\u21bb",
image=_sync_img, width=30, height=30,
corner_radius=15, fg_color="#6b7280", hover_color="#4b5563",
command=self._check_updates_manual,
)
self._update_check_btn.pack(side="right", padx=(5, 0))
# About button
_info_img = ctk_icon("info", 18)
self.about_btn = ctk.CTkButton(
header_bar, text="", width=30, height=30,
header_bar, text="" if _info_img else "",
image=_info_img, width=30, height=30,
corner_radius=15, fg_color="#6b7280", hover_color="#4b5563",
command=self._show_about
)
@@ -265,6 +284,11 @@ class App(ctk.CTk):
dialog = ServerDialog(self, self.store)
self.wait_window(dialog)
def _add_group(self):
from gui.group_dialog import GroupDialog
dialog = GroupDialog(self, self.store)
self.wait_window(dialog)
def _edit_server(self, alias: str):
server = self.store.get_server(alias)
if server:
@@ -275,9 +299,19 @@ class App(ctk.CTk):
self.sidebar._select(new_alias)
self.session_pool.rename_server(alias, new_alias)
else:
info = self._tab_instances.get("info")
if info and hasattr(info, "refresh"):
info.refresh()
# Data may have changed (IP, port, password) — force reconnect
self._force_reconnect(alias)
def _force_reconnect(self, alias: str):
"""Force tabs to reconnect after server data changed."""
# Invalidate cached SSH/SFTP sessions in pool
self.session_pool.disconnect_session(alias)
# Reset _current_alias so set_server() bypasses early return
for widget in self._tab_instances.values():
if getattr(widget, '_current_alias', None) == alias:
widget._current_alias = None
# Re-trigger server selection (calls set_server on all tabs)
self._on_server_select(alias)
def _delete_server(self, alias: str):
if messagebox.askyesno(t("delete_server"), t("delete_confirm").format(alias=alias)):
@@ -634,9 +668,16 @@ class App(ctk.CTk):
pass
def _on_close(self):
# Save window geometry (size + position)
# Save window geometry (size + position) and sidebar width
try:
self.store._window_geometry = self.geometry()
# Save sidebar width from PanedWindow sash position
try:
sash_pos = self._paned.sash_coord(0)
if sash_pos:
self.store._sidebar_width = sash_pos[0]
except Exception:
pass
self.store._save_settings()
except Exception:
pass

110
gui/group_dialog.py Normal file
View File

@@ -0,0 +1,110 @@
"""
Dialog for creating / editing server groups.
"""
import customtkinter as ctk
from typing import Optional
from core.i18n import t
GROUP_COLORS = [
"#ef4444", # red
"#f97316", # orange
"#f59e0b", # amber
"#22c55e", # green
"#3b82f6", # blue
"#6366f1", # indigo
"#a855f7", # purple
"#ec4899", # pink
]
class GroupDialog(ctk.CTkToplevel):
"""Small dialog: group name + color picker (8 circles)."""
def __init__(self, master, store, group: Optional[dict] = None):
super().__init__(master)
self.store = store
self._group = group # None = add, dict = edit
self.result: Optional[dict] = None
self.title(t("edit_group") if group else t("add_group"))
self.geometry("340x200")
self.resizable(False, False)
self.transient(master)
self.grab_set()
# ── Name ──
ctk.CTkLabel(self, text=t("group_name"), anchor="w").pack(
fill="x", padx=20, pady=(15, 2))
self._name_var = ctk.StringVar(value=group["name"] if group else "")
self._name_entry = ctk.CTkEntry(self, textvariable=self._name_var)
self._name_entry.pack(fill="x", padx=20)
self._name_entry.focus()
# ── Color picker ──
ctk.CTkLabel(self, text=t("group_color"), anchor="w").pack(
fill="x", padx=20, pady=(10, 2))
color_frame = ctk.CTkFrame(self, fg_color="transparent")
color_frame.pack(fill="x", padx=20)
self._selected_color = group["color"] if group else GROUP_COLORS[0]
self._color_buttons: list[ctk.CTkButton] = []
for color in GROUP_COLORS:
btn = ctk.CTkButton(
color_frame, text="", width=28, height=28,
fg_color=color, hover_color=color,
border_width=3,
border_color=color,
corner_radius=14,
command=lambda c=color: self._pick_color(c),
)
btn.pack(side="left", padx=2)
self._color_buttons.append(btn)
self._highlight_selected_color()
# ── Buttons ──
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=20, pady=(15, 10))
ctk.CTkButton(btn_frame, text=t("cancel"), width=80,
fg_color="gray", command=self.destroy).pack(side="left")
ctk.CTkButton(btn_frame, text=t("save"), width=80,
command=self._save).pack(side="right")
# Enter to save
self.bind("<Return>", lambda e: self._save())
def _pick_color(self, color: str):
self._selected_color = color
self._highlight_selected_color()
def _highlight_selected_color(self):
for btn in self._color_buttons:
fg = btn.cget("fg_color")
if fg == self._selected_color:
btn.configure(border_color="white")
else:
btn.configure(border_color=fg)
def _save(self):
name = self._name_var.get().strip()
if not name:
self._name_entry.focus()
return
if self._group:
# Edit mode
self.store.update_group(
self._group["id"], name=name, color=self._selected_color)
self.result = {"id": self._group["id"], "name": name,
"color": self._selected_color}
else:
# Add mode
group = self.store.add_group(name, self._selected_color)
self.result = group
self.destroy()

View File

@@ -6,7 +6,7 @@ Form adapts visible fields based on selected server type.
import customtkinter as ctk
from core.server_store import SERVER_TYPES, DEFAULT_PORTS
from core.i18n import t
from core.icons import icon_text, type_display, type_from_display
from core.icons import icon_text, type_display, type_from_display, make_icon_button, reconfigure_icon_button
# Which conditional fields to show for each server type.
@@ -24,6 +24,7 @@ FIELD_MAP = {
"prometheus": ["use_ssl"],
"rdp": ["user", "password", "rdp_resolution", "rdp_quality", "rdp_clipboard", "rdp_drives", "rdp_printers"],
"vnc": ["password"],
"s3": ["access_key", "secret_key", "bucket", "use_ssl"],
}
@@ -99,6 +100,33 @@ class ServerDialog(ctk.CTkToplevel):
self.port_entry = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port"))
self.port_entry.pack(fill="x")
# ── Group selector (only when groups exist) ──
self._group_id_map: dict[str, str | None] = {}
self._group_var = ctk.StringVar(value=t("no_group"))
groups = self.store.get_groups()
if groups:
group_frame = ctk.CTkFrame(self, fg_color="transparent")
group_frame.pack(fill="x", padx=20, pady=(5, 5))
ctk.CTkLabel(group_frame, text=t("group"), anchor="w").pack(fill="x")
no_group_label = t("no_group")
group_values = [no_group_label]
self._group_id_map[no_group_label] = None
for g in groups:
display = f"\u25cf {g['name']}"
group_values.append(display)
self._group_id_map[display] = g["id"]
ctk.CTkOptionMenu(group_frame, values=group_values,
variable=self._group_var).pack(fill="x")
# Pre-select if editing
if server and server.get("group"):
for display, gid in self._group_id_map.items():
if gid == server.get("group"):
self._group_var.set(display)
break
# ── Conditional fields container — all packed here, shown/hidden dynamically ──
# We use self as parent but wrap each field group in a frame for easy show/hide.
@@ -132,7 +160,7 @@ class ServerDialog(ctk.CTkToplevel):
pass_inner.pack(fill="x", padx=20, pady=(2, 5))
self.password_entry = ctk.CTkEntry(pass_inner, show="*", placeholder_text=t("placeholder_password"))
self.password_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
self.show_pass = ctk.CTkButton(pass_inner, text=icon_text("eye", t("show")), width=70, command=self._toggle_password)
self.show_pass = make_icon_button(pass_inner, "eye", t("show"), width=70, command=self._toggle_password)
self.show_pass.pack(side="right")
self._pass_visible = False
self._field_frames["password"] = f
@@ -215,6 +243,27 @@ class ServerDialog(ctk.CTkToplevel):
ctk.CTkCheckBox(f, text=t("rdp_printers"), variable=self._rdp_printers_var).pack(fill="x", padx=20, pady=(4, 2))
self._field_frames["rdp_printers"] = f
# --- access_key ---
f = ctk.CTkFrame(self, fg_color="transparent")
ctk.CTkLabel(f, text=t("access_key"), anchor="w").pack(fill="x", **pad)
self.access_key_entry = ctk.CTkEntry(f, placeholder_text="AKIAIOSFODNN7EXAMPLE")
self.access_key_entry.pack(fill="x", **entry_pad)
self._field_frames["access_key"] = f
# --- secret_key ---
f = ctk.CTkFrame(self, fg_color="transparent")
ctk.CTkLabel(f, text=t("secret_key"), anchor="w").pack(fill="x", **pad)
self.secret_key_entry = ctk.CTkEntry(f, show="*", placeholder_text=t("placeholder_secret_key"))
self.secret_key_entry.pack(fill="x", **entry_pad)
self._field_frames["secret_key"] = f
# --- bucket ---
f = ctk.CTkFrame(self, fg_color="transparent")
ctk.CTkLabel(f, text=t("bucket"), anchor="w").pack(fill="x", **pad)
self.bucket_entry = ctk.CTkEntry(f, placeholder_text="my-bucket")
self.bucket_entry.pack(fill="x", **entry_pad)
self._field_frames["bucket"] = f
# --- use_ssl ---
f = ctk.CTkFrame(self, fg_color="transparent")
self.use_ssl_var = ctk.BooleanVar(value=False)
@@ -237,8 +286,8 @@ class ServerDialog(ctk.CTkToplevel):
# ── Always visible: Buttons ──
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=20, pady=(15, 20))
ctk.CTkButton(btn_frame, text=icon_text("delete", t("cancel")), fg_color="#6b7280", command=self.destroy).pack(side="left", expand=True, padx=(0, 5))
ctk.CTkButton(btn_frame, text=icon_text("confirm", t("save")), command=self._save).pack(side="right", expand=True, padx=(5, 0))
make_icon_button(btn_frame, "close", t("cancel"), fg_color="#6b7280", command=self.destroy).pack(side="left", expand=True, padx=(0, 5))
make_icon_button(btn_frame, "confirm", t("save"), command=self._save).pack(side="right", expand=True, padx=(5, 0))
# Fill values if editing
if server:
@@ -255,6 +304,9 @@ class ServerDialog(ctk.CTkToplevel):
self.db_index_entry.insert(0, str(server.get("db_index", "")))
self.api_token_entry.insert(0, server.get("api_token", ""))
self.use_ssl_var.set(server.get("use_ssl", False))
self.access_key_entry.insert(0, server.get("access_key", ""))
self.secret_key_entry.insert(0, server.get("secret_key", ""))
self.bucket_entry.insert(0, server.get("bucket", ""))
# RDP settings
res_raw = server.get("rdp_resolution", "auto")
@@ -308,7 +360,7 @@ class ServerDialog(ctk.CTkToplevel):
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=icon_text("eye", t("hide") if self._pass_visible else t("show")))
reconfigure_icon_button(self.show_pass, "eye", t("hide") if self._pass_visible else t("show"))
def _save(self):
alias = self.alias_entry.get().strip()
@@ -347,6 +399,12 @@ class ServerDialog(ctk.CTkToplevel):
}
if totp_secret:
server_data["totp_secret"] = totp_secret
# Group assignment
if self._group_id_map:
selected_group = self._group_id_map.get(self._group_var.get())
if selected_group:
server_data["group"] = selected_group
if self.skip_check_var.get():
server_data["skip_check"] = True
@@ -378,6 +436,21 @@ class ServerDialog(ctk.CTkToplevel):
if token:
server_data["api_token"] = token
if "access_key" in visible:
ak = self.access_key_entry.get().strip()
if ak:
server_data["access_key"] = ak
if "secret_key" in visible:
sk = self.secret_key_entry.get()
if sk:
server_data["secret_key"] = sk
if "bucket" in visible:
bkt = self.bucket_entry.get().strip()
if bkt:
server_data["bucket"] = bkt
if "use_ssl" in visible:
if self.use_ssl_var.get():
server_data["use_ssl"] = True

View File

@@ -1,15 +1,21 @@
"""
Sidebar — server list with search, add/edit/delete buttons, context menu.
Sidebar — server list with groups, search, add/edit/delete buttons, context menu.
"""
import tkinter as tk
from tkinter import messagebox
import customtkinter as ctk
from core.i18n import t
from core.icons import (
icon_text, TYPE_COLORS, TYPE_LABELS, CTX_ICONS, icon,
make_icon_button, reconfigure_icon_button,
)
from gui.widgets.status_badge import StatusBadge
GROUP_COLORS = [
"#ef4444", "#f97316", "#f59e0b", "#22c55e",
"#3b82f6", "#6366f1", "#a855f7", "#ec4899",
]
# Context menu: type → list of (i18n_key, tab_key_or_None)
_CONTEXT_ACTIONS = {
@@ -37,6 +43,7 @@ class Sidebar(ctk.CTkFrame):
self._server_frames: dict[str, ctk.CTkFrame] = {}
self._badges: dict[str, StatusBadge] = {}
self._session_indicators: dict[str, ctk.CTkLabel] = {}
self._group_headers: dict[str, ctk.CTkFrame] = {}
self.pack_propagate(False)
@@ -44,11 +51,21 @@ class Sidebar(ctk.CTkFrame):
self.title_label = ctk.CTkLabel(self, text=t("servers"), font=ctk.CTkFont(size=18, weight="bold"))
self.title_label.pack(padx=15, pady=(15, 5))
# Search
# Search + Add Group button
search_frame = ctk.CTkFrame(self, fg_color="transparent")
search_frame.pack(fill="x", padx=10, pady=(5, 10))
self.search_var = ctk.StringVar()
self.search_var.trace_add("write", lambda *_: self._refresh_list())
self.search_entry = ctk.CTkEntry(self, placeholder_text=t("search"), textvariable=self.search_var)
self.search_entry.pack(fill="x", padx=10, pady=(5, 10))
self.search_entry = ctk.CTkEntry(search_frame, placeholder_text=t("search"), textvariable=self.search_var)
self.search_entry.pack(side="left", fill="x", expand=True)
self._add_group_btn = ctk.CTkButton(
search_frame, text="+", width=30, height=30,
font=ctk.CTkFont(size=14, weight="bold"),
command=self._on_add_group,
)
self._add_group_btn.pack(side="right", padx=(5, 0))
# Server list
self.list_frame = ctk.CTkScrollableFrame(self, fg_color="transparent")
@@ -64,17 +81,18 @@ class Sidebar(ctk.CTkFrame):
# 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=icon_text("add", t("add")), width=70, height=30, command=self._on_add)
self.add_btn = make_icon_button(btn_frame, "add", t("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=icon_text("edit", t("edit")), width=70, height=30, fg_color="#6b7280", command=self._on_edit)
self.edit_btn = make_icon_button(btn_frame, "edit", t("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=icon_text("delete", t("delete")), width=70, height=30, fg_color="#ef4444", hover_color="#dc2626", command=self._on_delete)
self.del_btn = make_icon_button(btn_frame, "delete", t("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 — set by app.py
self.add_callback = None
self.edit_callback = None
self.delete_callback = None
self.add_group_callback = None
self.open_tab_callback = None # (alias, tab_key) → select server + switch tab
self.check_status_callback = None # (alias) → check single server
self.open_browser_callback = None # (alias) → open server URL in browser
@@ -86,11 +104,13 @@ class Sidebar(ctk.CTkFrame):
def update_language(self):
self.title_label.configure(text=t("servers"))
self.search_entry.configure(placeholder_text=t("search"))
self.add_btn.configure(text=icon_text("add", t("add")))
self.edit_btn.configure(text=icon_text("edit", t("edit")))
self.del_btn.configure(text=icon_text("delete", t("delete")))
reconfigure_icon_button(self.add_btn, "add", t("add"))
reconfigure_icon_button(self.edit_btn, "edit", t("edit"))
reconfigure_icon_button(self.del_btn, "delete", t("delete"))
self._update_sessions_label()
# ── Refresh / Render ──────────────────────────────
def _refresh_list(self):
# Clear
for widget in self.list_frame.winfo_children():
@@ -98,25 +118,117 @@ class Sidebar(ctk.CTkFrame):
self._server_frames.clear()
self._badges.clear()
self._session_indicators.clear()
self._group_headers.clear()
# Get active sessions from pool
active_aliases = set()
if self.session_pool:
active_aliases = set(self.session_pool.get_active_sessions())
active_aliases = self._get_active_aliases()
search = self.search_var.get().lower()
servers = self.store.get_all()
groups = self.store.get_groups()
if not groups:
# No groups — flat list (backward compatible)
self._render_server_list(self.store.get_all(), search, active_aliases)
else:
# Grouped layout
for group in groups:
group_servers = self.store.get_servers_in_group(group["id"])
filtered = self._filter_servers(group_servers, search)
# Skip empty groups when searching
if search and not filtered:
continue
self._render_group_header(group, len(group_servers))
# Show servers if not collapsed (or always when searching)
if not group.get("collapsed") or search:
self._render_server_list(filtered, search, active_aliases, indent=True)
# Ungrouped servers
ungrouped = self.store.get_servers_in_group(None)
filtered_ungrouped = self._filter_servers(ungrouped, search)
if filtered_ungrouped:
self._render_ungrouped_header(len(ungrouped))
self._render_server_list(filtered_ungrouped, search, active_aliases, indent=True)
self._highlight_selected()
self._update_sessions_label()
def _get_active_aliases(self) -> set:
if self.session_pool:
return set(self.session_pool.get_active_sessions())
return set()
def _filter_servers(self, servers: list[dict], search: str) -> list[dict]:
if not search:
return servers
return [s for s in servers
if search in s["alias"].lower() or search in s.get("ip", "").lower()]
def _render_group_header(self, group: dict, total_count: int):
"""Render a collapsible group header."""
frame = ctk.CTkFrame(self.list_frame, height=32,
fg_color=("gray90", "gray17"), cursor="hand2")
frame.pack(fill="x", padx=2, pady=(6, 1))
frame.pack_propagate(False)
# Collapse arrow
arrow_text = "\u25bc" if not group.get("collapsed") else "\u25b6"
arrow = ctk.CTkLabel(frame, text=arrow_text, width=16,
font=ctk.CTkFont(size=10), text_color="#9ca3af")
arrow.pack(side="left", padx=(8, 2))
# Color dot
color_dot = ctk.CTkLabel(frame, text="\u25cf", width=14,
font=ctk.CTkFont(size=12),
text_color=group.get("color", "#6b7280"))
color_dot.pack(side="left", padx=(0, 4))
# Group name
name_label = ctk.CTkLabel(frame, text=group["name"],
font=ctk.CTkFont(size=12, weight="bold"),
anchor="w")
name_label.pack(side="left", fill="x", expand=True)
# Count badge
count_label = ctk.CTkLabel(frame, text=f"({total_count})",
font=ctk.CTkFont(size=10),
text_color="#6b7280", width=30)
count_label.pack(side="right", padx=(0, 8))
# Click handlers
gid = group["id"]
for widget in [frame, arrow, color_dot, name_label, count_label]:
widget.bind("<Button-1>", lambda e, g=gid: self._toggle_group(g))
widget.bind("<Button-3>", lambda e, g=gid: self._show_group_context_menu(e, g))
self._group_headers[gid] = frame
def _render_ungrouped_header(self, total_count: int):
"""Render a non-collapsible 'Ungrouped' header."""
frame = ctk.CTkFrame(self.list_frame, height=28,
fg_color="transparent")
frame.pack(fill="x", padx=2, pady=(6, 1))
frame.pack_propagate(False)
ctk.CTkLabel(frame, text=t("ungrouped"),
font=ctk.CTkFont(size=11), text_color="#6b7280",
anchor="w").pack(side="left", padx=(10, 0))
ctk.CTkLabel(frame, text=f"({total_count})",
font=ctk.CTkFont(size=10), text_color="#6b7280",
width=30).pack(side="right", padx=(0, 8))
def _render_server_list(self, servers: list[dict], search: str,
active_aliases: set, indent: bool = False):
"""Render server items. If indent=True, add left padding for group nesting."""
pad_left = 12 if indent else 2
for server in servers:
alias = server["alias"]
ip = server["ip"]
ip = server.get("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(fill="x", padx=(pad_left, 2), pady=2)
frame.pack_propagate(False)
# Status badge
@@ -130,8 +242,7 @@ class Sidebar(ctk.CTkFrame):
type_badge = ctk.CTkLabel(
frame, text=type_label_text,
font=ctk.CTkFont(size=9, weight="bold"),
text_color=type_color,
width=30
text_color=type_color, width=30
)
type_badge.pack(side="left", padx=(0, 2), pady=10)
@@ -142,16 +253,20 @@ class Sidebar(ctk.CTkFrame):
)
session_ind.pack(side="right", padx=(0, 8), pady=10)
if alias in active_aliases:
session_ind.configure(text="\u25cf", text_color="#22c55e") # green dot
session_ind.configure(text="\u25cf", text_color="#22c55e")
self._session_indicators[alias] = session_ind
# 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 = ctk.CTkLabel(info, text=alias,
font=ctk.CTkFont(size=13, weight="bold"),
anchor="w")
name_label.pack(fill="x")
detail_label = ctk.CTkLabel(info, text=ip, font=ctk.CTkFont(size=10), text_color="#9ca3af", anchor="w")
detail_label = ctk.CTkLabel(info, text=ip,
font=ctk.CTkFont(size=10),
text_color="#9ca3af", anchor="w")
detail_label.pack(fill="x")
# Click handlers
@@ -161,8 +276,94 @@ class Sidebar(ctk.CTkFrame):
self._server_frames[alias] = frame
self._highlight_selected()
self._update_sessions_label()
# ── Group operations ──────────────────────────────
def _toggle_group(self, group_id: str):
"""Toggle collapse/expand for a group."""
group = self.store.get_group(group_id)
if group:
self.store.update_group(group_id, collapsed=not group.get("collapsed", False))
def _on_add_group(self):
"""Open GroupDialog to create a new group."""
if self.add_group_callback:
self.add_group_callback()
def _show_group_context_menu(self, event, group_id: str):
"""Right-click context menu for a group header."""
group = self.store.get_group(group_id)
if not group:
return
menu = tk.Menu(self, tearoff=0, bg="#2d2d44", fg="#d3d7cf",
activebackground="#44447a", activeforeground="#ffffff",
font=("Segoe UI", 10))
menu.add_command(label=t("rename_group"),
command=lambda: self._rename_group(group_id))
# Color submenu
color_menu = tk.Menu(menu, tearoff=0, bg="#2d2d44", fg="#d3d7cf",
activebackground="#44447a", activeforeground="#ffffff",
font=("Segoe UI", 10))
for color in GROUP_COLORS:
# Show colored dot + hex
color_menu.add_command(
label=f"\u25cf {color}",
command=lambda c=color: self.store.update_group(group_id, color=c),
)
menu.add_cascade(label=t("change_color"), menu=color_menu)
menu.add_separator()
groups = self.store.get_groups()
idx = next((i for i, g in enumerate(groups) if g["id"] == group_id), -1)
if idx > 0:
menu.add_command(label=t("move_up"),
command=lambda: self._move_group(group_id, -1))
if idx < len(groups) - 1:
menu.add_command(label=t("move_down"),
command=lambda: self._move_group(group_id, 1))
menu.add_separator()
menu.add_command(label=t("delete_group"),
command=lambda: self._delete_group(group_id),
foreground="#ef4444")
try:
menu.tk_popup(event.x_root, event.y_root)
finally:
menu.grab_release()
def _rename_group(self, group_id: str):
"""Open GroupDialog in edit mode."""
from gui.group_dialog import GroupDialog
group = self.store.get_group(group_id)
if group:
dialog = GroupDialog(self.winfo_toplevel(), self.store, group=group)
self.winfo_toplevel().wait_window(dialog)
def _delete_group(self, group_id: str):
"""Delete group after confirmation."""
group = self.store.get_group(group_id)
if not group:
return
msg = t("delete_group_confirm").format(name=group["name"])
if messagebox.askyesno(t("delete_group"), msg):
self.store.remove_group(group_id)
def _move_group(self, group_id: str, direction: int):
"""Move group up (-1) or down (+1)."""
groups = self.store.get_groups()
ids = [g["id"] for g in groups]
idx = ids.index(group_id)
new_idx = idx + direction
if 0 <= new_idx < len(ids):
ids[idx], ids[new_idx] = ids[new_idx], ids[idx]
self.store.reorder_groups(ids)
# ── Server selection ──────────────────────────────
def _select(self, alias: str):
self._selected_alias = alias
@@ -180,12 +381,13 @@ class Sidebar(ctk.CTkFrame):
def get_selected(self) -> str | None:
return self._selected_alias
# ── Status / sessions ─────────────────────────────
def update_statuses(self):
for alias, badge in self._badges.items():
badge.set_status(self.store.get_status(alias))
def update_session_indicators(self):
"""Update active session indicators from session pool."""
if not self.session_pool:
return
active_aliases = set(self.session_pool.get_active_sessions())
@@ -197,7 +399,6 @@ class Sidebar(ctk.CTkFrame):
self._update_sessions_label()
def _update_sessions_label(self):
"""Update the active sessions count label."""
if self.session_pool:
count = len(self.session_pool.get_active_sessions())
if count > 0:
@@ -207,6 +408,8 @@ class Sidebar(ctk.CTkFrame):
else:
self._sessions_label.configure(text="")
# ── Buttons ───────────────────────────────────────
def _on_add(self):
if self.add_callback:
self.add_callback()
@@ -219,6 +422,8 @@ class Sidebar(ctk.CTkFrame):
if self.delete_callback and self._selected_alias:
self.delete_callback(self._selected_alias)
# ── Context menu (server) ─────────────────────────
def _show_context_menu(self, event, alias: str):
"""Show right-click context menu for a server item."""
self._select(alias)
@@ -255,6 +460,25 @@ class Sidebar(ctk.CTkFrame):
if actions:
menu.add_separator()
# "Move to Group" submenu
groups = self.store.get_groups()
if groups:
move_menu = tk.Menu(menu, tearoff=0, bg="#2d2d44", fg="#d3d7cf",
activebackground="#44447a", activeforeground="#ffffff",
font=("Segoe UI", 10))
for g in groups:
move_menu.add_command(
label=f"\u25cf {g['name']}",
command=lambda a=alias, gid=g["id"]: self.store.set_server_group(a, gid),
)
move_menu.add_separator()
move_menu.add_command(
label=t("no_group"),
command=lambda a=alias: self.store.set_server_group(a, None),
)
menu.add_cascade(label=t("move_to_group"), menu=move_menu)
menu.add_separator()
# Universal actions
menu.add_command(
label=icon_text("status_check", t("ctx_check_status")),

View File

@@ -13,7 +13,7 @@ from tkinter import messagebox, filedialog
import customtkinter as ctk
from core.i18n import t
from core.icons import icon_text
from core.icons import icon_text, ctk_icon, make_icon_button
from core.ssh_client import SFTPSession
from gui.widgets.file_list import FileListWidget
@@ -90,28 +90,34 @@ class FilesTab(ctk.CTkFrame):
ctk.CTkLabel(left_header, text=t("local_files"),
font=ctk.CTkFont(size=13, weight="bold")).pack(side="left")
_back_img = ctk_icon("back", 16)
self._local_back_btn = ctk.CTkButton(
left_header, text="\u2190", width=30, height=28,
left_header, text="" if _back_img else "\u2190",
image=_back_img, width=30, height=28,
command=self._local_go_back,
)
self._local_back_btn.pack(side="left", padx=(8, 2))
_up_img = ctk_icon("up", 16)
self._local_up_btn = ctk.CTkButton(
left_header, text="\u2191", width=30, height=28,
left_header, text="" if _up_img else "\u2191",
image=_up_img, width=30, height=28,
command=self._local_go_up,
)
self._local_up_btn.pack(side="left", padx=2)
# Local refresh button
_ref_img = ctk_icon("refresh", 16)
self._local_refresh_btn = ctk.CTkButton(
left_header, text="\u21BB", width=30, height=28,
left_header, text="" if _ref_img else "\u21BB",
image=_ref_img, width=30, height=28,
command=self._refresh_local,
)
self._local_refresh_btn.pack(side="left", padx=2)
# Browse button
self._browse_btn = ctk.CTkButton(
left_header, text=icon_text("folder_open", t("browse")), width=75, height=28,
self._browse_btn = make_icon_button(
left_header, "folder_open", t("browse"), width=75, height=28,
command=self._browse_local,
)
self._browse_btn.pack(side="left", padx=2)
@@ -158,19 +164,22 @@ class FilesTab(ctk.CTkFrame):
font=ctk.CTkFont(size=13, weight="bold")).pack(side="left")
self._remote_back_btn = ctk.CTkButton(
right_header, text="\u2190", width=30, height=28,
right_header, text="" if _back_img else "\u2190",
image=_back_img, width=30, height=28,
command=self._remote_go_back,
)
self._remote_back_btn.pack(side="left", padx=(8, 2))
self._remote_up_btn = ctk.CTkButton(
right_header, text="\u2191", width=30, height=28,
right_header, text="" if _up_img else "\u2191",
image=_up_img, width=30, height=28,
command=self._remote_go_up,
)
self._remote_up_btn.pack(side="left", padx=2)
self._remote_refresh_btn = ctk.CTkButton(
right_header, text="\u21BB", width=30, height=28,
right_header, text="" if _ref_img else "\u21BB",
image=_ref_img, width=30, height=28,
command=self._refresh_remote,
)
self._remote_refresh_btn.pack(side="left", padx=2)
@@ -204,14 +213,14 @@ class FilesTab(ctk.CTkFrame):
toolbar = ctk.CTkFrame(self, fg_color="transparent")
toolbar.pack(fill="x", padx=10, pady=4)
self._upload_btn = ctk.CTkButton(
toolbar, text=icon_text("upload", t("upload")), width=110, height=30,
self._upload_btn = make_icon_button(
toolbar, "upload", t("upload"), width=110, height=30,
command=self._upload_selected,
)
self._upload_btn.pack(side="left", padx=(0, 4))
self._download_btn = ctk.CTkButton(
toolbar, text=icon_text("download", t("download")), width=110, height=30,
self._download_btn = make_icon_button(
toolbar, "download", t("download"), width=110, height=30,
command=self._download_selected,
)
self._download_btn.pack(side="left", padx=4)
@@ -219,21 +228,21 @@ class FilesTab(ctk.CTkFrame):
sep = ctk.CTkFrame(toolbar, width=2, height=24, fg_color="gray40")
sep.pack(side="left", padx=8)
self._mkdir_btn = ctk.CTkButton(
toolbar, text=icon_text("folder", t("new_folder")), width=110, height=30,
self._mkdir_btn = make_icon_button(
toolbar, "folder", t("new_folder"), width=110, height=30,
command=self._mkdir_remote,
)
self._mkdir_btn.pack(side="left", padx=4)
self._delete_btn = ctk.CTkButton(
toolbar, text=icon_text("delete", t("delete_files")), width=90, height=30,
self._delete_btn = make_icon_button(
toolbar, "delete", t("delete_files"), width=90, height=30,
fg_color="#dc2626", hover_color="#b91c1c",
command=self._delete_remote,
)
self._delete_btn.pack(side="left", padx=4)
self._rename_btn = ctk.CTkButton(
toolbar, text=icon_text("edit", t("rename_file")), width=110, height=30,
self._rename_btn = make_icon_button(
toolbar, "edit", t("rename_file"), width=110, height=30,
command=self._rename_remote,
)
self._rename_btn.pack(side="left", padx=4)

View File

@@ -9,7 +9,7 @@ from tkinter import ttk
import customtkinter as ctk
from core.grafana_client import GrafanaClient
from core.i18n import t
from core.icons import icon_text
from core.icons import icon_text, make_icon_button, reconfigure_icon_button
from gui.tabs.query_tab import apply_dark_scrollbar_style
@@ -33,8 +33,8 @@ class GrafanaTab(ctk.CTkFrame):
font=ctk.CTkFont(size=18, weight="bold"))
title.pack(side="left")
self._refresh_btn = ctk.CTkButton(header_frame, text=icon_text("refresh", t("grafana_refresh")), width=110,
command=self._refresh)
self._refresh_btn = make_icon_button(header_frame, "refresh", t("grafana_refresh"), width=110,
command=self._refresh)
self._refresh_btn.pack(side="right")
# ── Dashboards section ──
@@ -131,8 +131,10 @@ class GrafanaTab(ctk.CTkFrame):
except Exception as e:
self.after(0, lambda: self._set_status(f"(error) {e}", "#ef4444"))
finally:
self.after(0, lambda: self._refresh_btn.configure(
state="normal", text=icon_text("refresh", t("grafana_refresh"))))
self.after(0, lambda: (
self._refresh_btn.configure(state="normal"),
reconfigure_icon_button(self._refresh_btn, "refresh", t("grafana_refresh")),
))
threading.Thread(target=_do, daemon=True).start()

View File

@@ -4,7 +4,7 @@ Info tab — display server details, edit button.
import customtkinter as ctk
from core.i18n import t
from core.icons import icon_text
from core.icons import icon_text, make_icon_button
class InfoTab(ctk.CTkFrame):
@@ -56,7 +56,7 @@ class InfoTab(ctk.CTkFrame):
self._fields[key] = val
# Edit button
self.edit_btn = ctk.CTkButton(self, text=icon_text("edit", t("edit_server_btn")), command=self._on_edit)
self.edit_btn = make_icon_button(self, "edit", t("edit_server_btn"), command=self._on_edit)
self.edit_btn.pack(pady=15)
def set_server(self, alias: str | None):

View File

@@ -7,7 +7,7 @@ import threading
import customtkinter as ctk
from core.ssh_client import SSHClientWrapper
from core.i18n import t
from core.icons import icon_text
from core.icons import icon_text, make_icon_button
class KeysTab(ctk.CTkFrame):
@@ -30,13 +30,13 @@ class KeysTab(ctk.CTkFrame):
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=icon_text("key", t("generate_key")), command=self._generate)
self.gen_btn = make_icon_button(btn_frame, "key", t("generate_key"), command=self._generate)
self.gen_btn.pack(side="left", padx=(0, 10))
self.install_btn = ctk.CTkButton(btn_frame, text=icon_text("upload", t("install_on_server")), fg_color="#22c55e", hover_color="#16a34a", command=self._install)
self.install_btn = make_icon_button(btn_frame, "upload", t("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=icon_text("copy", t("copy_public_key")), fg_color="#6b7280", command=self._copy_key)
self.copy_btn = make_icon_button(btn_frame, "copy", t("copy_public_key"), fg_color="#6b7280", command=self._copy_key)
self.copy_btn.pack(side="right")
# Status log

View File

@@ -10,7 +10,7 @@ import threading
import customtkinter as ctk
from core.remote_desktop import RemoteDesktopLauncher
from core.i18n import t
from core.icons import icon_text
from core.icons import icon_text, make_icon_button, reconfigure_icon_button
from core.logger import log
@@ -33,15 +33,15 @@ class LaunchTab(ctk.CTkFrame):
# ── Toolbar (shown when RDP connected) ──
self._toolbar = ctk.CTkFrame(self, height=36, fg_color="transparent")
self._disconnect_btn = ctk.CTkButton(
self._toolbar, text=icon_text("delete", t("rdp_disconnect")),
self._disconnect_btn = make_icon_button(
self._toolbar, "delete", t("rdp_disconnect"),
width=120, height=30, fg_color="#ef4444", hover_color="#dc2626",
command=self._disconnect,
)
self._disconnect_btn.pack(side="left", padx=(8, 4))
self._fullscreen_btn = ctk.CTkButton(
self._toolbar, text=icon_text("launch", t("rdp_fullscreen")),
self._fullscreen_btn = make_icon_button(
self._toolbar, "launch", t("rdp_fullscreen"),
width=130, height=30, fg_color="#6b7280", hover_color="#4b5563",
command=self._toggle_fullscreen,
)
@@ -135,8 +135,9 @@ class LaunchTab(ctk.CTkFrame):
).pack(fill="x", padx=15, pady=(3, 12))
# Connect button
self._connect_btn = ctk.CTkButton(
self._settings_panel, text=icon_text("execute", t("launch_connect")),
self._connect_btn = make_icon_button(
self._settings_panel, "execute", t("launch_connect"),
icon_size=20,
font=ctk.CTkFont(size=18, weight="bold"),
width=220, height=50,
command=self._on_connect,
@@ -374,9 +375,7 @@ class LaunchTab(ctk.CTkFrame):
if self._is_fullscreen:
# Exit fullscreen — reattach
self._is_fullscreen = False
self._fullscreen_btn.configure(
text=icon_text("launch", t("rdp_fullscreen")),
)
reconfigure_icon_button(self._fullscreen_btn, "launch", t("rdp_fullscreen"))
self._rdp_frame.update_idletasks()
parent_hwnd = self._rdp_frame.winfo_id()
w = self._rdp_frame.winfo_width()
@@ -385,9 +384,7 @@ class LaunchTab(ctk.CTkFrame):
else:
# Go fullscreen — detach, then maximize after event loop settles
self._is_fullscreen = True
self._fullscreen_btn.configure(
text=icon_text("back", t("rdp_exit_fullscreen")),
)
reconfigure_icon_button(self._fullscreen_btn, "back", t("rdp_exit_fullscreen"))
self._embedded_rdp.detach()
self.after(300, self._maximize_detached)

View File

@@ -8,7 +8,7 @@ import threading
import customtkinter as ctk
from core.winrm_client import WinRMClient
from core.i18n import t
from core.icons import icon_text
from core.icons import icon_text, make_icon_button
class PowershellTab(ctk.CTkFrame):
@@ -68,8 +68,8 @@ class PowershellTab(ctk.CTkFrame):
self._entry.bind("<Up>", lambda e: self._history_navigate(-1))
self._entry.bind("<Down>", lambda e: self._history_navigate(1))
self._exec_btn = ctk.CTkButton(
input_row, text=icon_text("execute", t("ps_execute")), width=100,
self._exec_btn = make_icon_button(
input_row, "execute", t("ps_execute"), width=100,
command=self._execute,
)
self._exec_btn.pack(side="right")

View File

@@ -8,7 +8,7 @@ from tkinter import ttk
import customtkinter as ctk
from core.prometheus_client import PrometheusClient
from core.i18n import t
from core.icons import icon_text
from core.icons import icon_text, make_icon_button, reconfigure_icon_button
from gui.tabs.query_tab import apply_dark_scrollbar_style
@@ -37,8 +37,8 @@ class PrometheusTab(ctk.CTkFrame):
self._query_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
self._query_entry.bind("<Return>", lambda e: self._execute_query())
self._exec_btn = ctk.CTkButton(query_frame, text=icon_text("execute", t("prom_execute")), width=100,
command=self._execute_query)
self._exec_btn = make_icon_button(query_frame, "execute", t("prom_execute"), width=100,
command=self._execute_query)
self._exec_btn.pack(side="left")
# ── Query results ──
@@ -59,8 +59,8 @@ class PrometheusTab(ctk.CTkFrame):
font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
targets_label.pack(side="left")
self._refresh_btn = ctk.CTkButton(targets_header, text=icon_text("refresh", t("prom_refresh")), width=100,
command=self._refresh_all)
self._refresh_btn = make_icon_button(targets_header, "refresh", t("prom_refresh"), width=100,
command=self._refresh_all)
self._refresh_btn.pack(side="right")
targets_frame = ctk.CTkFrame(self, fg_color="transparent")
@@ -201,8 +201,10 @@ class PrometheusTab(ctk.CTkFrame):
except Exception as e:
self.after(0, lambda: self._set_status(f"(error) {e}", "#ef4444"))
finally:
self.after(0, lambda: self._refresh_btn.configure(
state="normal", text=icon_text("refresh", t("prom_refresh"))))
self.after(0, lambda: (
self._refresh_btn.configure(state="normal"),
reconfigure_icon_button(self._refresh_btn, "refresh", t("prom_refresh")),
))
threading.Thread(target=_do, daemon=True).start()

View File

@@ -14,7 +14,7 @@ from tkinter import ttk, filedialog
import customtkinter as ctk
from core.i18n import t
from core.icons import icon_text
from core.icons import icon_text, make_icon_button
from core.sql_client import SQLClient
_TREE_THEME_APPLIED = False
@@ -227,9 +227,8 @@ class QueryTab(ctk.CTkFrame):
btn_row = ctk.CTkFrame(right_ctk, fg_color="transparent")
btn_row.pack(fill="x", padx=8, pady=4)
self._exec_btn = ctk.CTkButton(
btn_row,
text=icon_text("execute", t("query_execute")),
self._exec_btn = make_icon_button(
btn_row, "execute", t("query_execute"),
command=self._execute_query,
width=130,
fg_color="#2563eb",
@@ -237,9 +236,8 @@ class QueryTab(ctk.CTkFrame):
)
self._exec_btn.pack(side="left", padx=(0, 6))
self._clear_btn = ctk.CTkButton(
btn_row,
text=icon_text("clear", t("query_clear")),
self._clear_btn = make_icon_button(
btn_row, "clear", t("query_clear"),
command=self._clear_all,
width=80,
fg_color="#6b7280",
@@ -247,9 +245,8 @@ class QueryTab(ctk.CTkFrame):
)
self._clear_btn.pack(side="left", padx=(0, 6))
self._export_btn = ctk.CTkButton(
btn_row,
text=icon_text("save", t("query_export_csv")),
self._export_btn = make_icon_button(
btn_row, "save", t("query_export_csv"),
command=self._export_csv,
width=110,
fg_color="#059669",

View File

@@ -6,7 +6,7 @@ import threading
import customtkinter as ctk
from core.redis_client import RedisClient
from core.i18n import t
from core.icons import icon_text
from core.icons import icon_text, make_icon_button
class RedisTab(ctk.CTkFrame):
@@ -67,28 +67,28 @@ class RedisTab(ctk.CTkFrame):
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=15, pady=5)
self._exec_btn = ctk.CTkButton(btn_frame, text=icon_text("execute", t("redis_execute")), width=100,
command=self._execute_command)
self._exec_btn = make_icon_button(btn_frame, "execute", t("redis_execute"), width=100,
command=self._execute_command)
self._exec_btn.pack(side="left", padx=(0, 5))
self._info_btn = ctk.CTkButton(btn_frame, text=icon_text("info", "INFO"), width=80,
fg_color="#6b7280", hover_color="#4b5563",
command=lambda: self._run_quick("INFO"))
self._info_btn = make_icon_button(btn_frame, "info", "INFO", width=80,
fg_color="#6b7280", hover_color="#4b5563",
command=lambda: self._run_quick("INFO"))
self._info_btn.pack(side="left", padx=(0, 5))
self._dbsize_btn = ctk.CTkButton(btn_frame, text=icon_text("hash", "DBSIZE"), width=90,
fg_color="#6b7280", hover_color="#4b5563",
command=lambda: self._run_quick("DBSIZE"))
self._dbsize_btn = make_icon_button(btn_frame, "info", "DBSIZE", width=90,
fg_color="#6b7280", hover_color="#4b5563",
command=lambda: self._run_quick("DBSIZE"))
self._dbsize_btn.pack(side="left", padx=(0, 5))
self._scan_btn = ctk.CTkButton(btn_frame, text=icon_text("search", "SCAN"), width=80,
fg_color="#6b7280", hover_color="#4b5563",
command=lambda: self._run_quick("SCAN 0 COUNT 100"))
self._scan_btn = make_icon_button(btn_frame, "search", "SCAN", width=80,
fg_color="#6b7280", hover_color="#4b5563",
command=lambda: self._run_quick("SCAN 0 COUNT 100"))
self._scan_btn.pack(side="left", padx=(0, 5))
self._clear_btn = ctk.CTkButton(btn_frame, text=icon_text("clear", t("redis_clear")), width=80,
fg_color="#374151", hover_color="#1f2937",
command=self._clear_output)
self._clear_btn = make_icon_button(btn_frame, "clear", t("redis_clear"), width=80,
fg_color="#374151", hover_color="#1f2937",
command=self._clear_output)
self._clear_btn.pack(side="right")
# ── Output console ──

781
gui/tabs/s3_tab.py Normal file
View File

@@ -0,0 +1,781 @@
"""
S3 tab — bucket/object browser with upload, download, delete actions.
Supports drag-and-drop from OS file manager for upload.
"""
import os
import sys
import tempfile
import threading
import tkinter as tk
from tkinter import ttk, filedialog
import customtkinter as ctk
from core.s3_client import S3Client
from core.i18n import t
from core.icons import icon_text, make_icon_button
from gui.tabs.query_tab import apply_dark_scrollbar_style
def _human_size(size_bytes: int) -> str:
"""Format bytes to human-readable string."""
if size_bytes < 1024:
return f"{size_bytes} B"
for unit in ("KB", "MB", "GB", "TB"):
size_bytes /= 1024
if size_bytes < 1024:
return f"{size_bytes:.1f} {unit}"
return f"{size_bytes:.1f} PB"
def _setup_drop(widget, callback):
"""Setup OS drag-and-drop onto a widget. Cross-platform with graceful fallback."""
if sys.platform == "win32":
try:
import windnd
windnd.hook_dropfiles(widget, func=callback)
return True
except Exception:
pass
# tkinterdnd2 fallback (works on Linux/macOS if installed)
try:
widget.drop_target_register("DND_Files")
widget.dnd_bind("<<Drop>>", lambda e: callback(_parse_dnd_paths(e.data)))
return True
except Exception:
pass
return False
def _parse_dnd_paths(data: str) -> list[str]:
"""Parse tkinterdnd2 drop data into list of file paths."""
paths = []
# tkinterdnd2 wraps paths with spaces in braces: {C:/path with spaces/file.txt}
i = 0
while i < len(data):
if data[i] == '{':
end = data.index('}', i)
paths.append(data[i + 1:end])
i = end + 2 # skip } and space
elif data[i] == ' ':
i += 1
else:
end = data.find(' ', i)
if end == -1:
end = len(data)
paths.append(data[i:end])
i = end + 1
return paths
class S3Tab(ctk.CTkFrame):
def __init__(self, master, store):
super().__init__(master, fg_color="transparent")
self.store = store
self._current_alias: str | None = None
self._client: S3Client | None = None
self._current_bucket: str = ""
self._current_prefix: str = ""
self._nav_stack: list[str] = []
self._dnd_active = False
self._build_ui()
def _build_ui(self):
apply_dark_scrollbar_style()
# ── Header ──
header = ctk.CTkFrame(self, fg_color="transparent")
header.pack(fill="x", padx=15, pady=(15, 5))
self._title_label = ctk.CTkLabel(
header, text=t("s3_objects"),
font=ctk.CTkFont(size=18, weight="bold"),
)
self._title_label.pack(side="left")
self._status_label = ctk.CTkLabel(
header, text="", font=ctk.CTkFont(size=12),
text_color="#9ca3af",
)
self._status_label.pack(side="left", padx=(15, 0))
# Buttons
btn_frame = ctk.CTkFrame(header, fg_color="transparent")
btn_frame.pack(side="right")
self._back_btn = make_icon_button(
btn_frame, "back", t("s3_back"), width=80,
command=self._go_back, state="disabled",
)
self._back_btn.pack(side="left", padx=(0, 5))
self._refresh_btn = make_icon_button(
btn_frame, "refresh", t("s3_refresh"), width=100,
command=self._refresh,
)
self._refresh_btn.pack(side="left", padx=(0, 5))
self._mkdir_btn = make_icon_button(
btn_frame, "add", t("s3_new_folder"), width=120,
command=self._create_folder,
)
self._mkdir_btn.pack(side="left", padx=(0, 5))
self._upload_btn = make_icon_button(
btn_frame, "upload", t("s3_upload"), width=100,
command=self._upload,
)
self._upload_btn.pack(side="left", padx=(0, 5))
self._download_btn = make_icon_button(
btn_frame, "download", t("s3_download"), width=110,
command=self._download,
)
self._download_btn.pack(side="left", padx=(0, 5))
self._delete_btn = make_icon_button(
btn_frame, "delete", t("s3_delete"), width=100,
fg_color="#dc2626", hover_color="#b91c1c",
command=self._delete,
)
self._delete_btn.pack(side="left")
# ── Bucket selector row ──
bucket_frame = ctk.CTkFrame(self, fg_color="transparent")
bucket_frame.pack(fill="x", padx=15, pady=(5, 5))
ctk.CTkLabel(bucket_frame, text=t("s3_bucket"),
font=ctk.CTkFont(size=12, weight="bold")).pack(side="left", padx=(0, 5))
self._bucket_var = ctk.StringVar(value="")
self._bucket_menu = ctk.CTkOptionMenu(
bucket_frame, variable=self._bucket_var, values=[""],
width=200, command=self._on_bucket_change,
)
self._bucket_menu.pack(side="left", padx=(0, 15))
# Path display
self._path_label = ctk.CTkLabel(
bucket_frame, text="/", font=ctk.CTkFont(family="Consolas", size=12),
text_color="#60a5fa",
)
self._path_label.pack(side="left", fill="x", expand=True)
# ── Progress bar (hidden by default) ──
self._progress_frame = ctk.CTkFrame(self, fg_color="transparent")
# Don't pack yet — shown only during transfer
self._progress_label = ctk.CTkLabel(
self._progress_frame, text="", font=ctk.CTkFont(size=11),
text_color="#9ca3af",
)
self._progress_label.pack(side="left", padx=(0, 10))
self._progress_bar = ctk.CTkProgressBar(self._progress_frame, width=300, height=14)
self._progress_bar.set(0)
self._progress_bar.pack(side="left", fill="x", expand=True)
self._progress_pct = ctk.CTkLabel(
self._progress_frame, text="0%", font=ctk.CTkFont(size=11, weight="bold"),
text_color="#60a5fa", width=45,
)
self._progress_pct.pack(side="left", padx=(10, 0))
self._transfer_bytes = 0
self._transfer_total = 0
# ── Treeview for objects ──
self._tree_frame = ctk.CTkFrame(self, fg_color="#1e1e1e", corner_radius=8)
self._tree_frame.pack(fill="both", expand=True, padx=15, pady=(5, 15))
columns = ("name", "size", "modified")
self._tree = ttk.Treeview(
self._tree_frame, columns=columns, show="headings",
selectmode="browse", style="Dark.Treeview",
)
self._tree.heading("name", text=t("s3_col_name"))
self._tree.heading("size", text=t("s3_col_size"))
self._tree.heading("modified", text=t("s3_col_modified"))
self._tree.column("name", width=400, minwidth=200)
self._tree.column("size", width=100, minwidth=80, anchor="e")
self._tree.column("modified", width=180, minwidth=120)
scrollbar = ttk.Scrollbar(self._tree_frame, orient="vertical",
command=self._tree.yview,
style="Dark.Vertical.TScrollbar")
self._tree.configure(yscrollcommand=scrollbar.set)
self._tree.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# Double-click to enter prefix (folder) or download file
self._tree.bind("<Double-1>", self._on_double_click)
# Backspace / Alt+Left to go back
self._tree.bind("<BackSpace>", lambda e: self._go_back())
self._tree.bind("<Alt-Left>", lambda e: self._go_back())
# Right-click context menu
self._ctx_menu = tk.Menu(self._tree, tearoff=0)
self._ctx_menu.add_command(
label=icon_text("copy", t("s3_copy_link_48h")),
command=self._copy_link_48h,
)
self._ctx_menu.add_command(
label=icon_text("copy", t("s3_copy_link_permanent")),
command=self._copy_link_permanent,
)
self._ctx_menu.add_separator()
self._ctx_menu.add_command(
label=icon_text("download", t("s3_download")),
command=self._download,
)
self._ctx_menu.add_separator()
self._ctx_menu.add_command(
label=icon_text("add", t("s3_new_folder")),
command=self._create_folder,
)
self._ctx_menu.add_separator()
self._ctx_menu.add_command(
label=icon_text("delete", t("s3_delete")),
command=self._delete,
)
self._tree.bind("<Button-3>", self._on_right_click)
# Empty area context menu (back + new folder)
self._bg_menu = tk.Menu(self._tree, tearoff=0)
self._bg_menu.add_command(
label=icon_text("back", t("s3_back")),
command=self._go_back,
)
self._bg_menu.add_command(
label=icon_text("add", t("s3_new_folder")),
command=self._create_folder,
)
# Dark treeview style
style = ttk.Style()
style.configure("Dark.Treeview",
background="#2b2b2b", foreground="#e5e5e5",
fieldbackground="#2b2b2b", borderwidth=0, rowheight=26)
style.configure("Dark.Treeview.Heading",
background="#333333", foreground="#e5e5e5",
borderwidth=0, relief="flat")
style.map("Dark.Treeview",
background=[("selected", "#1e3a5f")],
foreground=[("selected", "#ffffff")])
# ── Drop zone overlay (shown when no files / as hint) ──
self._drop_hint = ctk.CTkLabel(
self._tree_frame,
text=t("s3_drop_hint"),
font=ctk.CTkFont(size=14),
text_color="#6b7280",
)
# Setup OS drag-and-drop on the treeview area
self.after(200, self._init_dnd)
def _init_dnd(self):
"""Initialize drag-and-drop after widget is mapped."""
try:
self._dnd_active = _setup_drop(self._tree, self._on_files_dropped)
if not self._dnd_active:
# Try on the frame too
self._dnd_active = _setup_drop(self._tree_frame, self._on_files_dropped)
except Exception:
self._dnd_active = False
def _on_files_dropped(self, files):
"""Handle files/folders dropped from OS file manager."""
if not self._client or not self._current_bucket:
return
# windnd gives list of bytes on Windows
raw_paths = []
for f in files:
if isinstance(f, bytes):
raw_paths.append(f.decode("utf-8", errors="replace"))
else:
raw_paths.append(str(f))
# Collect (local_path, s3_key_suffix) pairs
upload_pairs: list[tuple[str, str]] = []
for p in raw_paths:
if os.path.isfile(p):
upload_pairs.append((p, os.path.basename(p)))
elif os.path.isdir(p):
base = os.path.basename(p.rstrip("/\\"))
for root, _dirs, fnames in os.walk(p):
for fn in fnames:
full = os.path.join(root, fn)
rel = os.path.relpath(full, os.path.dirname(p))
rel = rel.replace("\\", "/")
upload_pairs.append((full, rel))
if not upload_pairs:
return
self._upload_pairs(upload_pairs)
def _show_progress(self, label: str, total_bytes: int):
"""Show and reset the progress bar."""
self._transfer_bytes = 0
self._transfer_total = max(total_bytes, 1)
self._progress_bar.set(0)
self._progress_pct.configure(text="0%")
self._progress_label.configure(text=label)
self._progress_frame.pack(fill="x", padx=15, pady=(2, 2),
before=self._tree_frame)
def _hide_progress(self):
"""Hide the progress bar."""
self._progress_frame.pack_forget()
def _on_progress(self, chunk_bytes: int):
"""Called from transfer thread — schedule GUI update."""
self._transfer_bytes += chunk_bytes
self.after(0, self._update_progress)
def _update_progress(self):
"""Update progress bar on GUI thread."""
if self._transfer_total <= 0:
return
ratio = min(self._transfer_bytes / self._transfer_total, 1.0)
self._progress_bar.set(ratio)
pct = int(ratio * 100)
self._progress_pct.configure(text=f"{pct}%")
size_str = f"{_human_size(self._transfer_bytes)} / {_human_size(self._transfer_total)}"
label = self._progress_label.cget("text").split("")[0]
self._progress_label.configure(text=f"{label}{size_str}")
def _on_transfer_status(self, message: str):
"""Called from transfer thread with retry/status info."""
# Note: do NOT reset _transfer_bytes here — resumable download
# reports already-downloaded bytes via progress_cb, so resetting
# would break the progress bar on resume.
self.after(0, lambda: self._status_label.configure(text=message))
def _upload_files(self, paths: list[str]):
"""Upload multiple files to current prefix (flat — no subdirs)."""
pairs = [(p, os.path.basename(p)) for p in paths if os.path.isfile(p)]
if pairs:
self._upload_pairs(pairs)
def _upload_pairs(self, pairs: list[tuple[str, str]]):
"""Upload (local_path, relative_key) pairs to current prefix."""
if not self._client or not self._current_bucket:
return
total_files = len(pairs)
total_bytes = sum(os.path.getsize(p) for p, _ in pairs if os.path.isfile(p))
label = (t("s3_uploading_n").format(count=total_files) if total_files > 1
else t("s3_uploading"))
self._status_label.configure(text=label)
self._show_progress(label, total_bytes)
def _do():
ok_count = 0
for local_path, rel_key in pairs:
key = self._current_prefix + rel_key
if self._client.upload_file(
local_path, self._current_bucket, key,
progress_cb=self._on_progress,
status_cb=self._on_transfer_status):
ok_count += 1
self.after(0, lambda: self._on_upload_done(ok_count, total_files))
threading.Thread(target=_do, daemon=True).start()
def _on_upload_done(self, ok_count: int, total: int):
self._hide_progress()
if ok_count == total:
self._status_label.configure(
text=t("s3_uploaded_n").format(count=ok_count))
else:
self._status_label.configure(
text=t("s3_upload_partial").format(ok=ok_count, total=total))
self._refresh()
# -- server switch ----------------------------------------------------
def set_server(self, alias: str | None):
"""Called when user selects a server in sidebar."""
if alias == self._current_alias:
return
self._current_alias = alias
if not alias:
self._client = None
self._tree.delete(*self._tree.get_children())
self._status_label.configure(text="")
return
self._client = None
self._current_prefix = ""
self._nav_stack.clear()
self._tree.delete(*self._tree.get_children())
self._status_label.configure(text=t("s3_connecting"))
server = self.store.get_server(alias)
if not server:
return
self._current_bucket = server.get("bucket", "")
def _connect():
client = S3Client(server)
ok = client.connect()
if ok:
self._client = client
self.after(0, self._load_buckets)
else:
self.after(0, lambda: self._status_label.configure(
text=t("s3_connect_failed")))
threading.Thread(target=_connect, daemon=True).start()
def _load_buckets(self):
if not self._client:
return
def _fetch():
buckets = self._client.list_buckets()
names = [b["Name"] for b in buckets]
self.after(0, lambda: self._update_buckets(names))
threading.Thread(target=_fetch, daemon=True).start()
def _update_buckets(self, names: list[str]):
if not names:
names = [""]
self._bucket_menu.configure(values=names)
if self._current_bucket and self._current_bucket in names:
self._bucket_var.set(self._current_bucket)
elif names:
self._bucket_var.set(names[0])
self._current_bucket = names[0]
self._refresh()
def _on_bucket_change(self, value: str):
self._current_bucket = value
self._current_prefix = ""
self._nav_stack.clear()
self._refresh()
# -- navigation -------------------------------------------------------
def _refresh(self):
if not self._client or not self._current_bucket:
return
self._status_label.configure(text=t("s3_loading"))
self._path_label.configure(text=f"/{self._current_prefix}" if self._current_prefix else "/")
def _fetch():
objects, prefixes = self._client.list_objects(
self._current_bucket, self._current_prefix)
self.after(0, lambda: self._display(objects, prefixes))
threading.Thread(target=_fetch, daemon=True).start()
def _display(self, objects: list[dict], prefixes: list[str]):
self._tree.delete(*self._tree.get_children())
# Folders first
for prefix in sorted(prefixes):
display_name = prefix[len(self._current_prefix):]
if display_name.endswith("/"):
display_name = display_name[:-1]
self._tree.insert("", "end", values=(
f"\U0001f4c1 {display_name}/", "", ""),
tags=("folder",),
)
# Files
for obj in sorted(objects, key=lambda o: o["Key"]):
name = obj["Key"][len(self._current_prefix):]
size = _human_size(obj.get("Size", 0))
modified = ""
if obj.get("LastModified"):
modified = obj["LastModified"].strftime("%Y-%m-%d %H:%M:%S")
self._tree.insert("", "end", values=(name, size, modified),
tags=("file",))
count = len(objects) + len(prefixes)
self._status_label.configure(text=t("s3_items_count").format(count=count))
self._back_btn.configure(
state="normal" if self._current_prefix else "disabled")
# Show drop hint if empty
if count == 0:
self._drop_hint.place(relx=0.5, rely=0.5, anchor="center")
else:
self._drop_hint.place_forget()
def _on_double_click(self, event):
sel = self._tree.selection()
if not sel:
return
item = self._tree.item(sel[0])
name = item["values"][0] if item["values"] else ""
if not isinstance(name, str):
name = str(name)
# Check if it's a folder
if name.endswith("/"):
# Strip folder icon
clean = name.replace("\U0001f4c1 ", "").strip()
self._nav_stack.append(self._current_prefix)
self._current_prefix = self._current_prefix + clean
if not self._current_prefix.endswith("/"):
self._current_prefix += "/"
self._refresh()
else:
# Double-click file = download
self._download()
def _on_right_click(self, event):
"""Show context menu on right-click."""
item_id = self._tree.identify_row(event.y)
if not item_id:
# Clicked on empty area — show background menu
self._bg_menu.entryconfigure(0,
state="normal" if self._current_prefix else "disabled")
self._bg_menu.tk_popup(event.x_root, event.y_root)
return
self._tree.selection_set(item_id)
# Check if it's a file (not a folder)
item = self._tree.item(item_id)
name = item["values"][0] if item["values"] else ""
if not isinstance(name, str):
name = str(name)
is_folder = name.endswith("/")
# Enable/disable menu items based on type
file_state = "normal" if not is_folder else "disabled"
self._ctx_menu.entryconfigure(0, state=file_state) # Link 48h
self._ctx_menu.entryconfigure(1, state=file_state) # Link permanent
self._ctx_menu.entryconfigure(3, state="normal") # Download (files + folders)
# 5 = New Folder — always enabled
# 7 = Delete — enabled for both files and folders
self._ctx_menu.tk_popup(event.x_root, event.y_root)
def _get_selected_key(self) -> str | None:
"""Return the full S3 key for the selected file, or None."""
sel = self._tree.selection()
if not sel or not self._client or not self._current_bucket:
return None
item = self._tree.item(sel[0])
name = item["values"][0] if item["values"] else ""
if not isinstance(name, str):
name = str(name)
if name.endswith("/"):
return None
return self._current_prefix + name
def _copy_link_48h(self):
"""Generate presigned URL (48h) and copy to clipboard."""
key = self._get_selected_key()
if not key:
return
self._status_label.configure(text=t("s3_generating_link"))
def _do():
url = self._client.generate_presigned_url(
self._current_bucket, key, expires_in=48 * 3600)
self.after(0, lambda: self._on_link_ready(url, "48h"))
threading.Thread(target=_do, daemon=True).start()
def _copy_link_permanent(self):
"""Build direct (permanent) URL and copy to clipboard."""
key = self._get_selected_key()
if not key:
return
url = self._client.get_direct_url(self._current_bucket, key)
if url:
self.clipboard_clear()
self.clipboard_append(url)
self._status_label.configure(text=t("s3_link_copied"))
else:
self._status_label.configure(text=t("s3_link_failed"))
def _on_link_ready(self, url: str | None, kind: str = ""):
if url:
self.clipboard_clear()
self.clipboard_append(url)
self._status_label.configure(text=t("s3_link_copied"))
else:
self._status_label.configure(text=t("s3_link_failed"))
def _create_folder(self):
"""Prompt for folder name and create it as an empty prefix in S3."""
if not self._client or not self._current_bucket:
return
dialog = ctk.CTkInputDialog(
text=t("s3_folder_name_prompt"),
title=t("s3_new_folder"),
)
name = dialog.get_input()
if not name or not name.strip():
return
name = name.strip().strip("/")
key = self._current_prefix + name + "/"
self._status_label.configure(text=t("s3_creating_folder"))
def _do():
ok = self._client.create_folder(self._current_bucket, key)
if ok:
self.after(0, self._refresh)
else:
self.after(0, lambda: self._status_label.configure(
text=t("s3_folder_failed")))
threading.Thread(target=_do, daemon=True).start()
def _go_back(self):
if self._nav_stack:
self._current_prefix = self._nav_stack.pop()
else:
self._current_prefix = ""
self._refresh()
# -- actions ----------------------------------------------------------
def _upload(self):
if not self._client or not self._current_bucket:
return
paths = filedialog.askopenfilenames()
if not paths:
return
self._upload_files(list(paths))
def _download(self):
sel = self._tree.selection()
if not sel or not self._client or not self._current_bucket:
return
item = self._tree.item(sel[0])
name = item["values"][0] if item["values"] else ""
if not isinstance(name, str):
name = str(name)
if name.endswith("/"):
self._download_folder(name)
else:
self._download_file(name)
def _download_file(self, name: str):
"""Download a single file."""
key = self._current_prefix + name
save_path = filedialog.asksaveasfilename(initialfile=name)
if not save_path:
return
total_bytes = self._client.get_object_size(self._current_bucket, key)
label = t("s3_downloading")
self._status_label.configure(text=label)
self._show_progress(label, total_bytes)
def _do():
ok = self._client.download_file(
self._current_bucket, key, save_path,
progress_cb=self._on_progress,
status_cb=self._on_transfer_status)
self.after(0, lambda: self._on_download_done(ok))
threading.Thread(target=_do, daemon=True).start()
def _download_folder(self, display_name: str):
"""Download all objects under a prefix, preserving directory structure."""
clean = display_name.replace("\U0001f4c1 ", "").strip()
prefix = self._current_prefix + clean
# Ask user to pick a local directory
dest_dir = filedialog.askdirectory(title=t("s3_download_folder_title"))
if not dest_dir:
return
self._status_label.configure(text=t("s3_downloading"))
def _do():
# List all objects recursively under this prefix
objects = self._client.list_all_objects(self._current_bucket, prefix)
if not objects:
self.after(0, lambda: self._status_label.configure(
text=t("s3_download_failed")))
return
total_bytes = sum(o.get("Size", 0) for o in objects)
self.after(0, lambda: self._show_progress(
t("s3_downloading_n").format(count=len(objects)), total_bytes))
ok_count = 0
for obj in objects:
obj_key = obj["Key"]
# Relative path from the selected folder
rel = obj_key[len(self._current_prefix):]
local_path = os.path.join(dest_dir, rel.replace("/", os.sep))
os.makedirs(os.path.dirname(local_path), exist_ok=True)
if self._client.download_file(
self._current_bucket, obj_key, local_path,
progress_cb=self._on_progress,
status_cb=self._on_transfer_status):
ok_count += 1
total = len(objects)
self.after(0, lambda: self._on_folder_download_done(ok_count, total))
threading.Thread(target=_do, daemon=True).start()
def _on_folder_download_done(self, ok_count: int, total: int):
self._hide_progress()
if ok_count == total:
self._status_label.configure(
text=t("s3_downloaded_n").format(count=ok_count))
else:
self._status_label.configure(
text=t("s3_download_partial").format(ok=ok_count, total=total))
def _on_download_done(self, success: bool):
self._hide_progress()
if success:
self._status_label.configure(text=t("s3_download_ok"))
else:
self._status_label.configure(text=t("s3_download_failed"))
def _delete(self):
sel = self._tree.selection()
if not sel or not self._client or not self._current_bucket:
return
item = self._tree.item(sel[0])
name = item["values"][0] if item["values"] else ""
if not isinstance(name, str):
name = str(name)
if name.endswith("/"):
# Folder — ask confirmation
clean = name.replace("\U0001f4c1 ", "").strip()
prefix = self._current_prefix + clean
from tkinter import messagebox
ok = messagebox.askyesno(
t("s3_delete"),
t("s3_delete_folder_confirm").format(folder=clean),
)
if not ok:
return
self._status_label.configure(text=t("s3_deleting"))
def _do_folder():
count = self._client.delete_prefix(self._current_bucket, prefix)
self.after(0, lambda: self._status_label.configure(
text=t("s3_deleted_n").format(count=count)))
self.after(0, self._refresh)
threading.Thread(target=_do_folder, daemon=True).start()
else:
# Single file
key = self._current_prefix + name
def _do():
ok = self._client.delete_object(self._current_bucket, key)
if ok:
self.after(0, self._refresh)
else:
self.after(0, lambda: self._status_label.configure(
text=t("s3_delete_failed")))
self._status_label.configure(text=t("s3_deleting"))
threading.Thread(target=_do, daemon=True).start()

View File

@@ -10,7 +10,7 @@ from tkinter import filedialog, messagebox
import customtkinter as ctk
from core.claude_setup import check_status, install_all, install_ssh_script, install_skill, generate_ssh_key
from core.i18n import t
from core.icons import icon_text
from core.icons import icon_text, make_icon_button
from core.logger import log
@@ -70,8 +70,8 @@ class SetupTab(ctk.CTkFrame):
btn_frame = ctk.CTkFrame(self._scroll, fg_color="transparent")
btn_frame.pack(fill="x", padx=20, pady=15)
self.install_all_btn = ctk.CTkButton(
btn_frame, text=icon_text("confirm", t("install_everything")),
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
@@ -82,16 +82,16 @@ class SetupTab(ctk.CTkFrame):
ind_frame = ctk.CTkFrame(btn_frame, fg_color="transparent")
ind_frame.pack(fill="x")
self.ssh_py_btn = ctk.CTkButton(ind_frame, text=icon_text("confirm", t("install_ssh_py")), width=110, fg_color="#6b7280",
self.ssh_py_btn = make_icon_button(ind_frame, "confirm", t("install_ssh_py"), width=110, fg_color="#6b7280",
command=self._install_script)
self.ssh_py_btn.pack(side="left", padx=(0, 5))
self.skill_btn = ctk.CTkButton(ind_frame, text=icon_text("confirm", t("install_skill")), width=110, fg_color="#6b7280",
self.skill_btn = make_icon_button(ind_frame, "confirm", t("install_skill"), width=110, fg_color="#6b7280",
command=self._install_skill)
self.skill_btn.pack(side="left", padx=5)
self.ssh_key_btn = ctk.CTkButton(ind_frame, text=icon_text("confirm", t("install_ssh_key")), width=110, fg_color="#6b7280",
self.ssh_key_btn = make_icon_button(ind_frame, "confirm", t("install_ssh_key"), width=110, fg_color="#6b7280",
command=self._gen_key)
self.ssh_key_btn.pack(side="left", padx=5)
self.refresh_btn = ctk.CTkButton(ind_frame, text=icon_text("refresh", t("refresh")), width=90, fg_color="#3b82f6",
self.refresh_btn = make_icon_button(ind_frame, "refresh", t("refresh"), width=90, fg_color="#3b82f6",
command=self._refresh_status)
self.refresh_btn.pack(side="right")
@@ -185,8 +185,8 @@ class SetupTab(ctk.CTkFrame):
font=ctk.CTkFont(family="Consolas", size=11)
)
self._path_label.pack(side="left", fill="x", expand=True, padx=(5, 10))
self.change_path_btn = ctk.CTkButton(
path_row, text=icon_text("folder", t("change_path")), width=120, fg_color="#6b7280",
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")
@@ -195,8 +195,8 @@ class SetupTab(ctk.CTkFrame):
backup_row = ctk.CTkFrame(config_frame, fg_color="transparent")
backup_row.pack(fill="x", padx=15, pady=(5, 10))
self.backup_btn = ctk.CTkButton(
backup_row, text=icon_text("save", t("backup_now")), width=120, fg_color="#3b82f6",
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))
@@ -210,8 +210,8 @@ class SetupTab(ctk.CTkFrame):
)
self._backup_menu.pack(side="left", padx=(0, 10))
self.restore_btn = ctk.CTkButton(
backup_row, text=icon_text("refresh", t("restore")), width=100, fg_color="#ef4444", hover_color="#dc2626",
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")
@@ -220,26 +220,26 @@ class SetupTab(ctk.CTkFrame):
ie_row = ctk.CTkFrame(config_frame, fg_color="transparent")
ie_row.pack(fill="x", padx=15, pady=(0, 10))
self.export_config_btn = ctk.CTkButton(
ie_row, text=icon_text("upload", t("export_config")), width=130, fg_color="#6b7280",
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 = ctk.CTkButton(
ie_row, text=icon_text("download", t("import_config")), width=130, fg_color="#6b7280",
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 = ctk.CTkButton(
ie_row, text=icon_text("upload", t("export_backup")), width=130, fg_color="#6b7280",
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 = ctk.CTkButton(
ie_row, text=icon_text("download", t("import_backup")), width=130, fg_color="#6b7280",
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)

View File

@@ -6,7 +6,7 @@ Live countdown, one-click copy, per-server secrets.
import threading
import customtkinter as ctk
from core.i18n import t
from core.icons import icon_text
from core.icons import icon_text, make_icon_button, reconfigure_icon_button
class TOTPTab(ctk.CTkFrame):
@@ -81,8 +81,8 @@ class TOTPTab(ctk.CTkFrame):
widget.bind("<Button-1>", lambda e: self._copy_code())
# Copy button
self.copy_btn = ctk.CTkButton(
self, text=icon_text("copy", t("totp_copy")), width=200, height=40,
self.copy_btn = make_icon_button(
self, "copy", t("totp_copy"), width=200, height=40,
font=ctk.CTkFont(size=14),
fg_color="#22c55e", hover_color="#16a34a",
command=self._copy_code
@@ -108,30 +108,30 @@ class TOTPTab(ctk.CTkFrame):
)
self.secret_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
self.show_secret_btn = ctk.CTkButton(
entry_row, text=icon_text("eye", t("show")), width=80,
self.show_secret_btn = make_icon_button(
entry_row, "eye", t("show"), width=80,
fg_color="#6b7280", hover_color="#4b5563",
command=self._toggle_secret
)
self.show_secret_btn.pack(side="left", padx=(0, 5))
self._secret_visible = False
self.save_secret_btn = ctk.CTkButton(
entry_row, text=icon_text("confirm", t("totp_save_secret")), width=110,
self.save_secret_btn = make_icon_button(
entry_row, "confirm", t("totp_save_secret"), width=110,
command=self._save_secret
)
self.save_secret_btn.pack(side="left", padx=(0, 5))
self.remove_secret_btn = ctk.CTkButton(
entry_row, text=icon_text("delete", t("totp_remove_secret")), width=110,
self.remove_secret_btn = make_icon_button(
entry_row, "delete", t("totp_remove_secret"), width=110,
fg_color="#ef4444", hover_color="#dc2626",
command=self._remove_secret
)
self.remove_secret_btn.pack(side="left")
# Generate random secret button
self.gen_secret_btn = ctk.CTkButton(
secret_frame, text=icon_text("key", t("totp_generate_secret")), width=200,
self.gen_secret_btn = make_icon_button(
secret_frame, "key", t("totp_generate_secret"), width=200,
fg_color="#6b7280", hover_color="#4b5563",
command=self._generate_secret
)
@@ -267,8 +267,8 @@ class TOTPTab(ctk.CTkFrame):
def _toggle_secret(self):
self._secret_visible = not self._secret_visible
self.secret_entry.configure(show="" if self._secret_visible else "*")
self.show_secret_btn.configure(
text=icon_text("eye", t("hide") if self._secret_visible else t("show"))
reconfigure_icon_button(
self.show_secret_btn, "eye", t("hide") if self._secret_visible else t("show")
)
def _save_secret(self):
@@ -331,12 +331,12 @@ class TOTPTab(ctk.CTkFrame):
def update_language(self):
self.title_label.configure(text=t("totp_title"))
self.desc_label.configure(text=t("totp_desc"))
self.copy_btn.configure(text=icon_text("copy", t("totp_copy")))
self.save_secret_btn.configure(text=icon_text("confirm", t("totp_save_secret")))
self.remove_secret_btn.configure(text=icon_text("delete", t("totp_remove_secret")))
self.gen_secret_btn.configure(text=icon_text("key", t("totp_generate_secret")))
self.show_secret_btn.configure(
text=icon_text("eye", t("hide") if self._secret_visible else t("show"))
reconfigure_icon_button(self.copy_btn, "copy", t("totp_copy"))
reconfigure_icon_button(self.save_secret_btn, "confirm", t("totp_save_secret"))
reconfigure_icon_button(self.remove_secret_btn, "delete", t("totp_remove_secret"))
reconfigure_icon_button(self.gen_secret_btn, "key", t("totp_generate_secret"))
reconfigure_icon_button(
self.show_secret_btn, "eye", t("hide") if self._secret_visible else t("show")
)
if not self._current_alias:
self.server_label.configure(text=t("no_server_selected"))

View File

@@ -6,6 +6,7 @@ DECCKM, bracketed paste, mouse tracking, and professional copy/paste UX.
import codecs
import copy
import re
import sys
import time
import tkinter as tk
@@ -35,8 +36,8 @@ _ANSI_COLORS = {
}
_DEFAULT_FG = "#d3d7cf"
_DEFAULT_BG = "#1a1a2e"
_CURSOR_FG = "#1a1a2e"
_DEFAULT_BG = "#000000"
_CURSOR_FG = "#000000"
_CURSOR_BG = "#d3d7cf"
# ── Private mode constants (pyte stores private modes shifted <<5) ─────────
@@ -44,6 +45,16 @@ _CURSOR_BG = "#d3d7cf"
_DECCKM = 1 << 5 # Application cursor keys
_BRACKETED_PASTE = 2004 << 5 # Bracketed paste mode
_MOUSE_BASIC = 1000 << 5 # Basic mouse tracking
# ── Strip DCS/APC/PM/SOS string sequences that pyte doesn't handle ────────
# These would otherwise leak payload bytes as visible text on screen.
# Matches: ESC P ... ST | ESC _ ... ST | ESC ^ ... ST | ESC X ... ST
# Also matches C1 variants (0x90, 0x9f, 0x9e, 0x98) and BEL as terminator.
_DCS_STRING_RE = re.compile(
r"(?:\x1b[P_^X]|\x90|\x9f|\x9e|\x98)" # string introducer
r"[^\x1b\x07]*" # payload (anything except ESC/BEL)
r"(?:\x1b\\|\x07|\x9c)?" # string terminator (ST/BEL) or end of data
)
_MOUSE_BTN_TRACK = 1002 << 5 # Button-event mouse tracking
_MOUSE_ANY = 1003 << 5 # Any-event mouse tracking
_MOUSE_SGR = 1006 << 5 # SGR extended mouse encoding
@@ -343,6 +354,11 @@ class TerminalWidget(tk.Frame):
def feed(self, data: bytes):
"""Feed raw bytes from SSH into pyte, schedule debounced render."""
text = self._utf8_decoder.decode(data)
if not text:
return
# Strip DCS/APC/PM/SOS sequences that pyte can't parse
# (their payload leaks as visible text otherwise)
text = _DCS_STRING_RE.sub("", text)
if not text:
return
self._stream.feed(text)
@@ -712,6 +728,7 @@ class TerminalWidget(tk.Frame):
return "break"
# ── Ctrl+Key by keycode — works with any keyboard layout ──
# Physical keycodes for Ctrl+key (layout-independent, works with Russian etc.)
_CTRL_KEYCODE_MAP = {
67: '_on_ctrl_c', # C
86: '_on_ctrl_v', # V
@@ -720,6 +737,32 @@ class TerminalWidget(tk.Frame):
90: '_on_ctrl_z', # Z
}
# Physical keycodes → Ctrl byte: handles keys NOT in _CTRL_KEYCODE_MAP
# so that Ctrl+S, Ctrl+Q, Ctrl+A etc. work with any keyboard layout
_CTRL_KEYCODE_BYTE = {
65: b"\x01", # A
66: b"\x02", # B
69: b"\x05", # E
70: b"\x06", # F
71: b"\x07", # G
72: b"\x08", # H
73: b"\x09", # I
74: b"\x0a", # J
75: b"\x0b", # K
77: b"\x0d", # M
78: b"\x0e", # N
79: b"\x0f", # O
80: b"\x10", # P
81: b"\x11", # Q (XON)
82: b"\x12", # R
83: b"\x13", # S (XOFF / save in nano)
84: b"\x14", # T
85: b"\x15", # U
87: b"\x17", # W
88: b"\x18", # X
89: b"\x19", # Y
}
def _on_ctrl_key(self, event):
"""Route Ctrl+key by physical keycode (layout-independent)."""
if not self._keyboard_enabled:
@@ -732,6 +775,12 @@ class TerminalWidget(tk.Frame):
if event.state & 0x1 and event.keycode == 86:
return self._on_ctrl_v(event)
return getattr(self, handler_name)(event)
# Fallback: send Ctrl+byte for keys not handled above (e.g. Ctrl+S, Ctrl+Q)
# This ensures shortcuts work with any keyboard layout (Russian, etc.)
ctrl_byte = self._CTRL_KEYCODE_BYTE.get(event.keycode)
if ctrl_byte:
self._send(ctrl_byte)
return "break"
return None # let other bindings handle it
# ── Ctrl+C: copy or SIGINT (double-press within 1.5s) ──

414
plans/server-groups.md Normal file
View File

@@ -0,0 +1,414 @@
# Server Groups — полный план UI/UX и реализации
## Контекст
ServerManager хранит серверы в плоском списке. При 10+ серверах разных типов (SSH, SQL, Redis, VPN, API, тестовые) становится неудобно. Нужна организация по группам: "Production", "Testing", "VPN Servers", "Hosting" и т.д.
## Исследование рынка
| Инструмент | Подход |
|---|---|
| MobaXterm, mRemoteNG, SecureCRT | Дерево папок, вложенность, drag-and-drop |
| Termius | Группы (вложенные) + теги + цвета — золотой стандарт |
| Royal TS | Папки + наследование credentials от папки |
| AWS/Azure/GCP | Resource Groups + теги (key:value) |
| Zabbix, Datadog | Host Groups + теги, сервер в нескольких группах |
**Выбранный паттерн: Группы с цветами (как Termius)** — один уровень групп, цветовая кодировка, сворачивание/разворачивание.
---
## 1. Модель данных
### Группы хранятся в `servers.json` (тот же зашифрованный файл):
```json
{
"servers": [...],
"ssh_key": {...},
"groups": [
{
"id": "a1b2c3d4",
"name": "Production",
"color": "#ef4444",
"collapsed": false,
"order": 0
},
{
"id": "e5f6g7h8",
"name": "Testing",
"color": "#22c55e",
"collapsed": false,
"order": 1
}
]
}
```
### Сервер получает поле `group`:
```json
{
"alias": "my-prod-server",
"ip": "1.2.3.4",
"type": "ssh",
"group": "a1b2c3d4",
...
}
```
- `group` ссылается на `groups[].id`
- Без `group` или пустой = "Без группы"
- При удалении группы серверы становятся ungrouped
### Почему в `servers.json`, а не `settings.json`:
- Группы тесно связаны с серверами
- Одна атомарная операция save
- `ssh.py` просто игнорирует ключ `groups` — обратная совместимость
---
## 2. API в ServerStore
```python
# Группы CRUD
get_groups() -> list[dict] # Все группы, отсортированы по order
get_group(group_id) -> dict | None # Одна группа по ID
add_group(name, color) -> dict # Создать, вернуть dict
update_group(group_id, **kwargs) # Обновить name/color/collapsed/order
remove_group(group_id) # Удалить, серверы → ungrouped
reorder_groups(ordered_ids: list[str]) # Установить порядок
# Серверы в группах
set_server_group(alias, group_id|None) # Переместить сервер в группу
get_servers_in_group(group_id|None) # Серверы группы (None = ungrouped)
```
---
## 3. UI Sidebar — Сгруппированный вид
### Было (плоский список):
```
+-------------------------------------+
| Servers |
+-------------------------------------+
| [ Search... ] |
+-------------------------------------+
| ● SSH investor 1.2.3.4 |
| ● SSH main-ovh 5.6.7.8 |
| ● SSH API TOR... 9.10.11.12 |
| ● RDP 116 Windows 13.14.15.16 |
| ● RDS Reddis main 5.6.7.8 |
| ● MDB Maria Db... 5.6.7.8 |
| ● SSH sensey24.ru 17.18.19.20 |
| |
| Active: 2 |
| [+ Add] [Edit] [Delete] |
+-------------------------------------+
```
### Станет (группы):
```
+------------------------------------------+
| Servers |
+------------------------------------------+
| [ Search... ] [+ Grp] |
+------------------------------------------+
| |
| v ● Production (3) |
| +----------------------------------+ |
| | ○ SSH main-ovh 5.6.7.8 | |
| +----------------------------------+ |
| | ○ RDS Reddis main 5.6.7.8 | |
| +----------------------------------+ |
| | ○ MDB Maria Db 5.6.7.8 | |
| +----------------------------------+ |
| |
| > ● VPN/Proxy (2) |
| (свёрнуто — серверы скрыты) |
| |
| v ● Hosting (3) |
| +----------------------------------+ |
| | ○ SSH sensey24.ru 17.18.19.20 | |
| +----------------------------------+ |
| | ○ SSH git.sensey 17.18.19.20 | |
| +----------------------------------+ |
| | ○ SSH 1gb server 21.22.23.24 | |
| +----------------------------------+ |
| |
| v ● Testing (1) |
| +----------------------------------+ |
| | ○ SSH thehost 25.26.27.28 | |
| +----------------------------------+ |
| |
| Без группы (1) |
| +----------------------------------+ |
| | ○ RDP 116 Windows 13.14.15.16 | |
| +----------------------------------+ |
| |
| Active: 2 |
| [+ Add] [Edit] [Delete] |
+------------------------------------------+
```
### Заголовок группы (детально):
```
+----------------------------------------------+
| v ● Production (3) |
| ^ ^ ^ ^ |
| | | | | |
| | цвет название кол-во |
| | группы группы серверов |
| стрелка |
| свернуть/развернуть |
+----------------------------------------------+
```
- `v` / `>` — кликабельная стрелка toggle
- `●` — цветная точка (`\u25cf`) цветом группы
- Название — жирный шрифт
- `(3)` — серый, кол-во серверов
- Вся строка кликабельна для toggle
- ПКМ — контекстное меню группы
### Серверы внутри группы:
- Отступ `padx=(12, 2)` для визуальной вложенности
- Всё остальное как сейчас: StatusBadge, TypeBadge, Name, IP
### Поведение:
- **Нет групп** → плоский список (как сейчас, полная обратная совместимость)
- **Есть группы** → автоматически сгруппированный вид
- **Поиск** → все группы разворачиваются, пустые группы скрываются
- **"Без группы"** → только если есть группы И есть серверы без группы
---
## 4. Создание группы
### Кнопка `[+ Grp]` рядом с поиском:
```
| [ Search... ] [+ Grp] |
```
### GroupDialog (новый файл `gui/group_dialog.py`):
```
+----------------------------------+
| New Group |
+----------------------------------+
| |
| Name: |
| [ Production ] |
| |
| Color: |
| (●)(●)(●)(●)(●)(●)(●)(●) |
| red org amb grn blu ind pur pnk |
| |
| [ Cancel ] [ Save ] |
+----------------------------------+
```
Палитра из 8 цветов:
```python
GROUP_COLORS = [
"#ef4444", # красный
"#f97316", # оранжевый
"#f59e0b", # янтарный
"#22c55e", # зелёный
"#3b82f6", # синий
"#6366f1", # индиго
"#a855f7", # фиолетовый
"#ec4899", # розовый
]
```
Каждый цвет — кнопка-кружок. Выбранный — с рамкой.
---
## 5. Перемещение серверов между группами
### Способ 1: ПКМ на сервере → "Переместить в группу"
```
+-----------------------------+
| Open Terminal |
| Browse Files |
| Install Key |
|-----------------------------|
| Move to Group > |
| +---------------------+ |
| | ● Production | |
| | ● VPN/Proxy | |
| | ● Hosting | |
| | ● Testing | |
| |---------------------| |
| | Без группы | |
| +---------------------+ |
|-----------------------------|
| Check Status |
| Copy Alias |
|-----------------------------|
| Edit |
| Delete |
+-----------------------------+
```
### Способ 2: Поле "Группа" в ServerDialog (создание/редактирование)
```
+----------------------------------+
| Add Server |
+----------------------------------+
| Alias: [ ] |
| IP: [ ] |
| Type: [ SSH v ] |
| Port: [ 22 ] |
| Group: [ ● Production v ] |
| +-----------------------+|
| | No group ||
| | ● Production ||
| | ● VPN/Proxy ||
| | ● Hosting ||
| | ● Testing ||
| +-----------------------+|
| User: [ root ] |
| Pass: [ •••••••• [👁] ] |
| ... |
+----------------------------------+
```
- Dropdown появляется только когда есть группы
- По умолчанию "No group"
- При редактировании — предвыбрана текущая группа
### Drag-and-drop:
**НЕ реализуем.** CustomTkinter не имеет нативного DnD для ScrollableFrame. Реализация через низкоуровневый tkinter DnD = хрупко и сложно. ПКМ "Move to Group" — надёжнее и понятнее.
---
## 6. Контекстное меню группы (ПКМ на заголовке)
```
+------------------------+
| Rename |
| Change Color > |
| +------------------+ |
| | ● Red | |
| | ● Orange | |
| | ● Amber | |
| | ● Green | |
| | ● Blue | |
| | ● Indigo | |
| | ● Purple | |
| | ● Pink | |
| +------------------+ |
|------------------------|
| Move Up |
| Move Down |
|------------------------|
| Delete Group |
+------------------------+
```
- **Rename** → открывает GroupDialog в режиме редактирования
- **Change Color** → подменю с 8 цветами
- **Move Up/Down** → меняет `order` местами с соседней группой
- **Delete Group** → подтверждение, серверы → ungrouped
---
## 7. i18n ключи (~17 штук)
| Ключ | EN | RU | ZH |
|------|----|----|-----|
| group | Group | Группа | 分组 |
| groups | Groups | Группы | 分组 |
| no_group | No group | Без группы | 无分组 |
| ungrouped | Ungrouped | Без группы | 未分组 |
| add_group | Add Group | Добавить группу | 添加分组 |
| edit_group | Edit Group | Редактировать группу | 编辑分组 |
| rename_group | Rename | Переименовать | 重命名 |
| delete_group | Delete Group | Удалить группу | 删除分组 |
| delete_group_confirm | Delete '{name}'? Servers become ungrouped. | Удалить '{name}'? Серверы станут без группы. | 删除'{name}'?服务器将变为未分组。 |
| group_name | Group Name | Название группы | 分组名称 |
| group_color | Color | Цвет | 颜色 |
| group_name_required | Group name is required | Название обязательно | 名称为必填项 |
| move_to_group | Move to Group | Переместить в группу | 移动到分组 |
| move_up | Move Up | Вверх | 上移 |
| move_down | Move Down | Вниз | 下移 |
| change_color | Change Color | Изменить цвет | 更改颜色 |
| new_group | New Group | Новая группа | 新建分组 |
---
## 8. Файлы и изменения
| Файл | Что меняется |
|------|-------------|
| `core/server_store.py` | +8 методов для Groups CRUD, `get_servers_in_group()`, `set_server_group()` |
| `gui/sidebar.py` | Рефакторинг `_refresh_list()` на grouped layout, заголовки групп, контекстные меню, collapse/expand, кнопка "+ Grp", "Move to Group" submenu |
| `gui/group_dialog.py` | **НОВЫЙ** — диалог создания/редактирования группы (имя + палитра цветов) |
| `gui/server_dialog.py` | +dropdown "Group" (виден только когда есть группы) |
| `core/i18n.py` | +17 ключей EN/RU/ZH |
| `gui/app.py` | Привязка `sidebar.add_group_callback` к открытию GroupDialog |
---
## 9. Порядок реализации
### Фаза 1: Данные (без UI)
1. Методы GroupsCRUD в `server_store.py`
2. Проверить что `ssh.py` не ломается
### Фаза 2: Sidebar — grouped rendering
1. Рефакторинг `_refresh_list()` на helper-методы
2. `_render_group_header()` с collapse toggle
3. Отступ для серверов внутри групп
4. Поиск с автораскрытием групп
### Фаза 3: Управление группами
1. `group_dialog.py`
2. Кнопка "+ Grp" в sidebar
3. Контекстное меню группы (rename, delete, reorder, color)
### Фаза 4: Назначение серверов группам
1. "Move to Group" в контекстном меню сервера
2. Dropdown "Group" в ServerDialog
### Фаза 5: i18n + polish
1. Все переводы
2. Edge cases: пустые группы, все ungrouped, одна группа
---
## 10. Edge Cases
- **Нет групп** → плоский список, полная обратная совместимость
- **Удаление группы** → серверы переходят в "Без группы"
- **Группа ссылается на удалённый ID** → сервер отображается как ungrouped
- **Поиск** → все группы разворачиваются, пустые скрываются
- **Миграция** → старый `servers.json` без `groups``get_groups()` возвращает `[]`
- **ssh.py** → игнорирует `groups` и `group` поля, нулевой impact
---
## 11. Верификация
1. `python build.py` — собрать exe
2. Без групп — плоский список как раньше
3. Создать группу "Production" красным цветом
4. Создать группу "Testing" зелёным
5. Переместить серверы в группы через ПКМ
6. Свернуть/развернуть группу
7. Поиск — группы разворачиваются
8. Добавить новый сервер — выбрать группу в диалоге
9. Удалить группу — серверы стали ungrouped
10. Переименовать группу, сменить цвет
11. Move Up/Down — порядок меняется
12. Переключить язык — все строки переведены

Binary file not shown.

View File

@@ -12,3 +12,5 @@ redis>=5.0.0
requests>=2.31.0
pywinrm>=0.4.3
telnetlib3>=2.0.0
boto3>=1.28.0
windnd>=1.0.7; sys_platform == "win32"

64
tools/download_icons.py Normal file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Download Material Design Icons PNG from GitHub.
Source: github.com/material-icons/material-icons-png (Apache 2.0)
Style: round-4x (96x96 px) — enough for 4x HiDPI
"""
import os
import urllib.request
import time
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE_URL = "https://raw.githubusercontent.com/material-icons/material-icons-png/master/png"
ICONS = [
"add", "arrow_back", "arrow_upward", "backspace", "check", "close",
"code", "computer", "content_copy", "dashboard", "delete", "edit",
"file_upload", "folder", "folder_open", "info",
"language", "lock", "play_arrow", "refresh", "save", "search",
"settings", "storage", "trending_up", "visibility", "vpn_key",
]
# Icons with different names on GitHub vs local filename
ICON_RENAMES = {
"get_app": "file_download", # Material "get_app" = download icon
}
# GitHub color dir → local theme dir
VARIANTS = {"white": "dark", "black": "light"}
def download():
done = 0
errors = 0
all_icons = [(name, name) for name in ICONS]
all_icons += [(gh_name, local_name) for gh_name, local_name in ICON_RENAMES.items()]
total = len(all_icons) * len(VARIANTS)
for gh_color, local_dir in VARIANTS.items():
out_dir = os.path.join(PROJECT_DIR, "assets", "icons", local_dir)
os.makedirs(out_dir, exist_ok=True)
for gh_name, local_name in all_icons:
done += 1
dst = os.path.join(out_dir, f"{local_name}.png")
if os.path.exists(dst):
print(f" [{done}/{total}] SKIP {local_dir}/{local_name}.png")
continue
url = f"{BASE_URL}/{gh_color}/{gh_name}/round-4x.png"
print(f" [{done}/{total}] GET {local_dir}/{local_name}.png ...", end=" ")
try:
urllib.request.urlretrieve(url, dst)
size_kb = os.path.getsize(dst) / 1024
print(f"OK ({size_kb:.1f} KB)")
except Exception as e:
print(f"FAIL: {e}")
errors += 1
time.sleep(0.1)
print(f"\nDone: {total - errors}/{total} icons downloaded")
if errors:
print(f" {errors} errors — re-run to retry")
if __name__ == "__main__":
download()

276
tools/install.sh Normal file
View File

@@ -0,0 +1,276 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────
# ServerManager CLI Installer for Linux (headless / no-GUI)
#
# Устанавливает:
# - ssh.py + encryption.py → ~/.server-connections/
# - servers.json + settings.json → ~/.server-connections/ (если есть)
# - CLAUDE.md → ~/.claude/
# - ssh.md (скилл) → ~/.claude/commands/
# - Python-зависимости для CLI (paramiko, cryptography, etc.)
#
# Запуск:
# curl -sSL https://git.sensey24.ru/aibot777/server-manager/raw/branch/master/tools/install.sh | bash
# или:
# bash install.sh
# или с указанием источника файлов:
# bash install.sh /path/to/server-manager/
# ─────────────────────────────────────────────────────────────────────
set -euo pipefail
# ── Colors ──
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; }
step() { echo -e "\n${CYAN}━━━ $* ━━━${NC}"; }
# ── Config ──
CONN_DIR="$HOME/.server-connections"
CLAUDE_DIR="$HOME/.claude"
COMMANDS_DIR="$CLAUDE_DIR/commands"
GITEA_RAW="https://git.sensey24.ru/aibot777/server-manager/raw/branch/master"
# Source directory (optional argument)
SRC_DIR="${1:-}"
# ── Banner ──
echo -e "${CYAN}"
echo "╔══════════════════════════════════════════════╗"
echo "║ ServerManager CLI Installer for Linux ║"
echo "║ github: git.sensey24.ru/aibot777 ║"
echo "╚══════════════════════════════════════════════╝"
echo -e "${NC}"
# ── Step 1: Check Python ──
step "1/5 Проверка Python"
PYTHON=""
for cmd in python3 python; do
if command -v "$cmd" &>/dev/null; then
ver=$("$cmd" --version 2>&1 | grep -oP '\d+\.\d+')
major=$(echo "$ver" | cut -d. -f1)
minor=$(echo "$ver" | cut -d. -f2)
if [ "$major" -ge 3 ] && [ "$minor" -ge 8 ]; then
PYTHON="$cmd"
ok "Python найден: $($cmd --version)"
break
fi
fi
done
if [ -z "$PYTHON" ]; then
error "Python 3.8+ не найден!"
echo " Установите: sudo apt install python3 python3-pip"
echo " или: sudo yum install python3 python3-pip"
exit 1
fi
# Check pip
PIP=""
for cmd in pip3 pip; do
if command -v "$cmd" &>/dev/null; then
PIP="$cmd"
break
fi
done
if [ -z "$PIP" ]; then
# Try python -m pip
if $PYTHON -m pip --version &>/dev/null; then
PIP="$PYTHON -m pip"
else
error "pip не найден!"
echo " Установите: sudo apt install python3-pip"
exit 1
fi
fi
ok "pip найден: $($PIP --version 2>&1 | head -1)"
# ── Step 2: Install Python dependencies ──
step "2/5 Установка Python-зависимостей"
CLI_DEPS=(
"paramiko>=3.4.0"
"cryptography>=41.0.0"
"pymysql>=1.1.0"
"psycopg2-binary>=2.9.9"
"redis>=5.0.0"
"requests>=2.31.0"
)
for dep in "${CLI_DEPS[@]}"; do
pkg=$(echo "$dep" | sed 's/[>=<].*//')
if $PYTHON -c "import $pkg" 2>/dev/null; then
ok "$pkg уже установлен"
else
info "Устанавливаю $dep..."
if $PIP install "$dep" --quiet 2>/dev/null; then
ok "$dep установлен"
else
warn "Не удалось установить $dep (попробуйте: $PIP install $dep)"
fi
fi
done
# ── Step 3: Create directories ──
step "3/5 Создание директорий"
mkdir -p "$CONN_DIR" "$COMMANDS_DIR"
chmod 700 "$CONN_DIR" 2>/dev/null || true
ok "$CONN_DIR"
ok "$COMMANDS_DIR"
# ── Step 4: Copy/Download files ──
step "4/5 Установка файлов"
copy_or_download() {
local src_relative="$1"
local dst="$2"
local perms="$3"
local desc="$4"
# Try local source first
if [ -n "$SRC_DIR" ] && [ -f "$SRC_DIR/$src_relative" ]; then
cp "$SRC_DIR/$src_relative" "$dst"
chmod "$perms" "$dst"
ok "$desc (из $SRC_DIR)"
return 0
fi
# Try download from Gitea
local url="$GITEA_RAW/$src_relative"
if command -v curl &>/dev/null; then
if curl -sSL -o "$dst" "$url" 2>/dev/null; then
# Verify not empty and not HTML error page
if [ -s "$dst" ] && ! head -1 "$dst" | grep -qi '<!doctype\|<html'; then
chmod "$perms" "$dst"
ok "$desc (скачан с Gitea)"
return 0
fi
rm -f "$dst"
fi
elif command -v wget &>/dev/null; then
if wget -q -O "$dst" "$url" 2>/dev/null; then
if [ -s "$dst" ] && ! head -1 "$dst" | grep -qi '<!doctype\|<html'; then
chmod "$perms" "$dst"
ok "$desc (скачан с Gitea)"
return 0
fi
rm -f "$dst"
fi
fi
warn "$desc — не удалось скачать. Скопируйте вручную."
return 1
}
# Core files (always install)
copy_or_download "tools/ssh.py" "$CONN_DIR/ssh.py" "755" "ssh.py"
copy_or_download "core/encryption.py" "$CONN_DIR/encryption.py" "644" "encryption.py"
# Claude Code skill
copy_or_download "tools/skill-ssh.md" "$COMMANDS_DIR/ssh.md" "644" "ssh.md (скилл /ssh)"
# CLAUDE.md
if [ -n "$SRC_DIR" ] && [ -f "$SRC_DIR/CLAUDE.md" ]; then
cp "$SRC_DIR/CLAUDE.md" "$CLAUDE_DIR/CLAUDE.md"
chmod 644 "$CLAUDE_DIR/CLAUDE.md"
ok "CLAUDE.md"
fi
# servers.json — only copy if exists locally, never download (contains encrypted creds)
if [ -n "$SRC_DIR" ] && [ -f "$SRC_DIR/servers.json" ]; then
cp "$SRC_DIR/servers.json" "$CONN_DIR/servers.json"
chmod 600 "$CONN_DIR/servers.json"
ok "servers.json (зашифрованный)"
elif [ ! -f "$CONN_DIR/servers.json" ]; then
warn "servers.json не найден — скопируйте с основной машины:"
echo " scp user@main:~/.server-connections/servers.json $CONN_DIR/"
fi
# settings.json
if [ -n "$SRC_DIR" ] && [ -f "$SRC_DIR/settings.json" ]; then
cp "$SRC_DIR/settings.json" "$CONN_DIR/settings.json"
chmod 600 "$CONN_DIR/settings.json"
ok "settings.json"
elif [ ! -f "$CONN_DIR/settings.json" ]; then
# Create minimal settings
echo '{"language":"en","check_interval":60}' > "$CONN_DIR/settings.json"
chmod 600 "$CONN_DIR/settings.json"
ok "settings.json (создан по умолчанию)"
fi
# ── Step 5: Verify ──
step "5/5 Проверка установки"
ALL_OK=true
if [ -f "$CONN_DIR/ssh.py" ] && [ -x "$CONN_DIR/ssh.py" ]; then
ok "ssh.py — исполняемый"
else
error "ssh.py — не найден или не исполняемый"
ALL_OK=false
fi
if [ -f "$CONN_DIR/encryption.py" ]; then
ok "encryption.py"
else
error "encryption.py — не найден"
ALL_OK=false
fi
if [ -f "$COMMANDS_DIR/ssh.md" ]; then
ok "ssh.md скилл"
else
warn "ssh.md скилл — не найден"
fi
if [ -f "$CONN_DIR/servers.json" ]; then
ok "servers.json"
else
warn "servers.json — отсутствует (нужно скопировать вручную)"
fi
# Test ssh.py
info "Тест ssh.py..."
if $PYTHON "$CONN_DIR/ssh.py" --list &>/dev/null; then
ok "ssh.py --list работает"
else
if [ ! -f "$CONN_DIR/servers.json" ]; then
warn "ssh.py не может запуститься (нет servers.json)"
else
warn "ssh.py вернул ошибку — проверьте зависимости"
fi
fi
# ── Summary ──
echo ""
echo -e "${CYAN}━━━ Готово ━━━${NC}"
echo ""
if $ALL_OK; then
echo -e "${GREEN}Установка завершена успешно!${NC}"
else
echo -e "${YELLOW}Установка завершена с предупреждениями.${NC}"
fi
echo ""
echo "Файлы:"
echo " $CONN_DIR/ssh.py — CLI-утилита"
echo " $CONN_DIR/encryption.py — модуль шифрования"
echo " $CONN_DIR/servers.json — серверы (зашифрованные)"
echo " $COMMANDS_DIR/ssh.md — скилл /ssh для Claude Code"
echo ""
echo "Использование:"
echo " python3 ~/.server-connections/ssh.py --list"
echo " python3 ~/.server-connections/ssh.py --info ALIAS"
echo " python3 ~/.server-connections/ssh.py ALIAS \"command\""
echo ""
echo "Claude Code скилл: /ssh"
echo ""

View File

@@ -1,7 +1,7 @@
# Скилл /ssh — управление удалёнными серверами
Ты управляешь удалёнными серверами через универсальную CLI-утилиту.
Поддерживаются: SSH, SQL (MariaDB/MSSQL/PostgreSQL), Redis, Grafana, Prometheus, WinRM (PowerShell/CMD).
Поддерживаются: SSH, SQL (MariaDB/MSSQL/PostgreSQL), Redis, S3, Grafana, Prometheus, WinRM (PowerShell/CMD).
## ВАЖНО — Безопасность
@@ -19,6 +19,35 @@
Пользователь передаёт через `$ARGUMENTS`. Разбери и выполни.
## КРИТИЧНО — Команды зависят от типа сервера
`--list` возвращает колонку `Type` для каждого сервера. **Тип определяет какие команды использовать:**
| Тип | Команды |
|-----|---------|
| `ssh` | `ALIAS "command"`, `--upload`, `--download`, `--ping`, `--install-key` |
| `telnet` | `ALIAS "command"` (как ssh, но без SFTP/sudo/ключей) |
| `mariadb` / `mssql` / `postgresql` | `--sql`, `--sql-databases`, `--sql-tables` |
| `redis` | `--redis`, `--redis-info`, `--redis-keys` |
| `s3` | `--s3-buckets`, `--s3-ls`, `--s3-upload`, `--s3-download`, `--s3-delete` |
| `grafana` | `--grafana-dashboards`, `--grafana-alerts` |
| `prometheus` | `--prom-query`, `--prom-targets`, `--prom-alerts` |
| `winrm` | `--ps`, `--cmd` |
| `rdp` / `vnc` | Только GUI (запуск внешнего клиента), CLI-команд нет |
**`ALIAS "command"` — ТОЛЬКО для типа `ssh`.** Для Redis — `--redis`, для SQL — `--sql`, для WinRM — `--ps`/`--cmd` и т.д.
```bash
# Тип redis → --redis-info, НЕ ALIAS "INFO"
python ~/.server-connections/ssh.py --redis-info "Reddis main ovh"
# Тип mariadb → --sql-databases, НЕ ALIAS "SHOW DATABASES"
python ~/.server-connections/ssh.py --sql-databases "Maria Db Connection main ovh"
# Тип ssh → ALIAS "command"
python ~/.server-connections/ssh.py investor "uptime"
```
## Общие команды
### Список серверов (безопасный — alias, тип, ключ, заметки)
@@ -50,7 +79,7 @@ python ~/.server-connections/ssh.py --remove ALIAS
### Добавить сервер
```bash
python ~/.server-connections/ssh.py --add ALIAS IP PORT USER PASSWORD [--type ssh|telnet|mariadb|mssql|postgresql|redis|grafana|prometheus|winrm|rdp] [--note "описание"] [--database DB] [--token TOKEN]
python ~/.server-connections/ssh.py --add ALIAS IP PORT USER PASSWORD [--type ssh|telnet|mariadb|mssql|postgresql|redis|grafana|prometheus|winrm|rdp|s3] [--note "описание"] [--database DB] [--token TOKEN]
```
- Автоматически устанавливает SSH-ключ после добавления
- Обновляет `~/.ssh/config`
@@ -130,6 +159,34 @@ python ~/.server-connections/ssh.py --redis-info ALIAS
python ~/.server-connections/ssh.py --redis-keys ALIAS "user:*"
```
## S3-команды (тип: s3)
### Список бакетов
```bash
python ~/.server-connections/ssh.py --s3-buckets ALIAS
```
### Список объектов
```bash
python ~/.server-connections/ssh.py --s3-ls ALIAS bucket
python ~/.server-connections/ssh.py --s3-ls ALIAS bucket/prefix/
```
### Загрузить файл в S3
```bash
python ~/.server-connections/ssh.py --s3-upload ALIAS "D:/local/file" bucket/key
```
### Скачать файл из S3
```bash
python ~/.server-connections/ssh.py --s3-download ALIAS bucket/key "D:/local/file"
```
### Удалить объект
```bash
python ~/.server-connections/ssh.py --s3-delete ALIAS bucket/key
```
## Grafana-команды (тип: grafana)
### Список дашбордов

View File

@@ -14,7 +14,7 @@ Usage (SSH):
python ssh.py --status
python ssh.py --info ALIAS # full info (no passwords)
python ssh.py --set-note ALIAS "desc" # update server notes
python ssh.py --add ALIAS IP PORT USER PASSWORD [--type ssh|telnet|mariadb|mssql|postgresql|redis|grafana|prometheus|winrm|rdp] [--note "desc"] [--database DB] [--token TOKEN]
python ssh.py --add ALIAS IP PORT USER PASSWORD [--type ssh|telnet|mariadb|mssql|postgresql|redis|grafana|prometheus|winrm|rdp|s3] [--note "desc"] [--database DB] [--token TOKEN]
python ssh.py --remove ALIAS
SQL (type: mariadb / mssql / postgresql):
@@ -36,6 +36,13 @@ Prometheus (type: prometheus):
python ssh.py --prom-targets ALIAS # list targets
python ssh.py --prom-alerts ALIAS # list alerts
S3 (type: s3):
python ssh.py --s3-buckets ALIAS # list buckets
python ssh.py --s3-ls ALIAS [bucket[/prefix]] # list objects
python ssh.py --s3-upload ALIAS local bucket/key # upload file
python ssh.py --s3-download ALIAS bucket/key local # download file
python ssh.py --s3-delete ALIAS bucket/key # delete object
WinRM (type: winrm):
python ssh.py --ps ALIAS "Get-Process" # PowerShell via WinRM
python ssh.py --cmd ALIAS "dir" # CMD via WinRM
@@ -964,7 +971,7 @@ def add_server(args):
i += 1
# Validate server type
valid_types = ["ssh", "telnet", "mariadb", "mssql", "postgresql", "redis", "grafana", "prometheus", "winrm", "rdp"]
valid_types = ["ssh", "telnet", "mariadb", "mssql", "postgresql", "redis", "grafana", "prometheus", "winrm", "rdp", "s3"]
if stype not in valid_types:
print(f"ERROR: Invalid server type '{stype}'. Valid types: {', '.join(valid_types)}")
sys.exit(1)
@@ -991,6 +998,13 @@ def add_server(args):
elif stype in ["redis", "grafana", "prometheus"]:
if token:
new_server["token"] = token
elif stype == "s3":
# S3: user=access_key, password=secret_key, ip=endpoint
new_server["access_key"] = user
new_server["secret_key"] = password
new_server["use_ssl"] = True
if database:
new_server["bucket"] = database
elif stype in ["winrm", "rdp"]:
# WinRM/RDP may have additional auth fields
new_server["auth_method"] = "password" # default
@@ -1254,6 +1268,197 @@ def redis_keys(server: dict, pattern: str):
r.close()
# ── S3 commands ──────────────────────────────────
def _get_s3_client(server: dict):
"""Create and connect a boto3 S3 client from server dict."""
try:
import boto3
import botocore.config
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
except ImportError:
print("ERROR: boto3 not installed. Run: pip install boto3", file=sys.stderr)
sys.exit(1)
endpoint = server.get("ip", "")
if endpoint and not endpoint.startswith("http"):
use_ssl = server.get("use_ssl", True)
scheme = "https" if use_ssl else "http"
port = int(server.get("port", 443))
if (scheme == "https" and port == 443) or (scheme == "http" and port == 80):
endpoint = f"{scheme}://{endpoint}"
else:
endpoint = f"{scheme}://{endpoint}:{port}"
config = botocore.config.Config(
signature_version="s3v4",
connect_timeout=15,
read_timeout=60,
retries={"max_attempts": 5, "mode": "adaptive"},
tcp_keepalive=True,
)
return boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=server.get("access_key", ""),
aws_secret_access_key=server.get("secret_key", ""),
config=config,
verify=False,
)
def s3_buckets(server: dict):
"""List all S3 buckets."""
client = _get_s3_client(server)
try:
resp = client.list_buckets()
buckets = resp.get("Buckets", [])
if not buckets:
print("(no buckets)")
return
print(f"{'Name':<40} {'Created'}")
print("-" * 65)
for b in buckets:
created = b.get("CreationDate", "")
if created:
created = created.strftime("%Y-%m-%d %H:%M:%S")
print(f"{b['Name']:<40} {created}")
print(f"\n({len(buckets)} bucket{'s' if len(buckets) != 1 else ''})")
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
def s3_ls(server: dict, path: str = ""):
"""List objects in a bucket[/prefix]."""
client = _get_s3_client(server)
# Parse bucket/prefix from path
parts = path.split("/", 1) if path else []
bucket = parts[0] if parts else server.get("bucket", "")
prefix = parts[1] if len(parts) > 1 else ""
if not bucket:
print("ERROR: No bucket specified. Usage: --s3-ls ALIAS bucket[/prefix]", file=sys.stderr)
sys.exit(1)
try:
paginator = client.get_paginator("list_objects_v2")
kwargs = {"Bucket": bucket, "Delimiter": "/"}
if prefix:
if not prefix.endswith("/"):
prefix += "/"
kwargs["Prefix"] = prefix
total = 0
for page in paginator.paginate(**kwargs):
for cp in page.get("CommonPrefixes", []):
p = cp["Prefix"]
if prefix:
p = p[len(prefix):]
print(f" DIR {p}")
total += 1
for obj in page.get("Contents", []):
key = obj["Key"]
if key == prefix:
continue
name = key[len(prefix):] if prefix else key
size = obj.get("Size", 0)
modified = obj.get("LastModified", "")
if modified:
modified = modified.strftime("%Y-%m-%d %H:%M")
print(f"{size:>10} {modified} {name}")
total += 1
print(f"\n({total} item{'s' if total != 1 else ''})")
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
def _s3_transfer_config():
from boto3.s3.transfer import TransferConfig
return TransferConfig(
multipart_threshold=8 * 1024 * 1024,
multipart_chunksize=8 * 1024 * 1024,
max_concurrency=4,
num_download_attempts=10,
)
def s3_upload(server: dict, local_path: str, remote_path: str):
"""Upload a file to S3 with retry and resume."""
# Parse bucket/key
parts = remote_path.split("/", 1)
bucket = parts[0] if parts else server.get("bucket", "")
key = parts[1] if len(parts) > 1 else os.path.basename(local_path)
if not bucket:
print("ERROR: No bucket. Usage: --s3-upload ALIAS local bucket/key", file=sys.stderr)
sys.exit(1)
if not os.path.isfile(local_path):
print(f"ERROR: File not found: {local_path}", file=sys.stderr)
sys.exit(1)
size = os.path.getsize(local_path)
config = _s3_transfer_config()
max_retries = 10
for attempt in range(max_retries):
client = _get_s3_client(server)
try:
print(f"Uploading {local_path} -> s3://{bucket}/{key} ({size} bytes)...")
client.upload_file(local_path, bucket, key, Config=config)
print("OK")
return
except Exception as e:
delay = min(2 * (2 ** attempt), 60)
print(f"Attempt {attempt + 1}/{max_retries} failed: {e}", file=sys.stderr)
if attempt < max_retries - 1:
print(f"Retrying in {delay}s...", file=sys.stderr)
time.sleep(delay)
else:
print(f"ERROR: Upload failed after {max_retries} attempts", file=sys.stderr)
sys.exit(1)
def s3_download(server: dict, remote_path: str, local_path: str):
"""Download an object from S3 with retry."""
parts = remote_path.split("/", 1)
bucket = parts[0] if parts else server.get("bucket", "")
key = parts[1] if len(parts) > 1 else ""
if not bucket or not key:
print("ERROR: Usage: --s3-download ALIAS bucket/key local_path", file=sys.stderr)
sys.exit(1)
config = _s3_transfer_config()
max_retries = 10
for attempt in range(max_retries):
client = _get_s3_client(server)
try:
print(f"Downloading s3://{bucket}/{key} -> {local_path}...")
client.download_file(bucket, key, local_path, Config=config)
size = os.path.getsize(local_path)
print(f"OK ({size} bytes)")
return
except Exception as e:
delay = min(2 * (2 ** attempt), 60)
print(f"Attempt {attempt + 1}/{max_retries} failed: {e}", file=sys.stderr)
if attempt < max_retries - 1:
print(f"Retrying in {delay}s...", file=sys.stderr)
time.sleep(delay)
else:
print(f"ERROR: Download failed after {max_retries} attempts", file=sys.stderr)
sys.exit(1)
def s3_delete(server: dict, remote_path: str):
"""Delete an object from S3."""
client = _get_s3_client(server)
parts = remote_path.split("/", 1)
bucket = parts[0] if parts else server.get("bucket", "")
key = parts[1] if len(parts) > 1 else ""
if not bucket or not key:
print("ERROR: Usage: --s3-delete ALIAS bucket/key", file=sys.stderr)
sys.exit(1)
try:
client.delete_object(Bucket=bucket, Key=key)
print(f"Deleted s3://{bucket}/{key}")
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
# ── Grafana commands ──────────────────────────────────
def _grafana_request(server: dict, endpoint: str) -> dict:
@@ -1531,6 +1736,34 @@ def main():
redis_keys(servers[alias], sys.argv[3])
sys.exit(0)
# ── S3 commands ──
if cmd == "--s3-buckets" and len(sys.argv) >= 3:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
s3_buckets(servers[alias])
sys.exit(0)
if cmd == "--s3-ls" and len(sys.argv) >= 3:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
path = sys.argv[3] if len(sys.argv) >= 4 else ""
s3_ls(servers[alias], path)
sys.exit(0)
if cmd == "--s3-upload" and len(sys.argv) >= 5:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
s3_upload(servers[alias], sys.argv[3], sys.argv[4])
sys.exit(0)
if cmd == "--s3-download" and len(sys.argv) >= 5:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
s3_download(servers[alias], sys.argv[3], sys.argv[4])
sys.exit(0)
if cmd == "--s3-delete" and len(sys.argv) >= 4:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
s3_delete(servers[alias], sys.argv[3])
sys.exit(0)
# ── Grafana commands ──
if cmd == "--grafana-dashboards" and len(sys.argv) >= 3:
_, servers = load_servers()

Some files were not shown because too many files have changed in this diff Show More