Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f7fbb759f | ||
|
|
16e69a2bd6 | ||
|
|
bc4cf2b7a3 | ||
|
|
35bdefba59 | ||
|
|
d33f573483 | ||
|
|
cf319c502e | ||
|
|
01ab318e4b | ||
|
|
f9a81a4825 | ||
|
|
3bafb0deb8 | ||
|
|
b37e696094 | ||
|
|
289ce65431 | ||
|
|
704ce3bef2 | ||
|
|
00f3b76d2a | ||
|
|
efbbfa13ee | ||
|
|
3c4d02c5ec | ||
|
|
5b4672dfe3 | ||
|
|
f445953a82 | ||
|
|
2a56ececd1 | ||
|
|
f233d5cf70 | ||
|
|
61461767fd | ||
|
|
bc2a3bc6b5 | ||
|
|
e403da4f9d | ||
|
|
1e729fcf3a | ||
|
|
9b0e4c76a3 | ||
|
|
f2dc978c57 | ||
|
|
c9e3ee8fc5 | ||
|
|
2f84429b10 | ||
|
|
72c7da5765 | ||
|
|
b7e9c80690 | ||
|
|
7af788b72e | ||
|
|
08307fbe9b | ||
|
|
2174168200 | ||
|
|
273666236a | ||
|
|
bc48aea1e4 | ||
|
|
bc7be1057b | ||
|
|
d0f84d00eb | ||
|
|
1218f4d72d | ||
|
|
acc93f98e3 | ||
|
|
57ea90f210 | ||
|
|
85977863ec | ||
|
|
6b08dbe85f | ||
|
|
0fbf8dc811 |
10
CLAUDE.md
@@ -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`, `--s3-url` |
|
||||
| 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
|
||||
@@ -136,6 +139,13 @@ tools/
|
||||
/ssh --redis ALIAS "GET key" # Redis-команда
|
||||
/ssh --redis-info ALIAS # Redis INFO
|
||||
/ssh --redis-keys ALIAS "pattern" # SCAN ключей
|
||||
# S3 / MinIO
|
||||
/ssh --s3-buckets ALIAS # Список бакетов
|
||||
/ssh --s3-ls ALIAS bucket[/prefix] # Список объектов
|
||||
/ssh --s3-upload ALIAS local bucket/key # Upload файла
|
||||
/ssh --s3-download ALIAS bucket/key local # Download файла
|
||||
/ssh --s3-delete ALIAS bucket/key # Удалить объект
|
||||
/ssh --s3-url ALIAS bucket/key [SEC] # Presigned URL (по умолчанию 1 час)
|
||||
# Grafana / Prometheus
|
||||
/ssh --grafana-dashboards ALIAS # Дашборды
|
||||
/ssh --prom-query ALIAS "up" # PromQL
|
||||
|
||||
BIN
assets/icons/dark/add.png
Normal file
|
After Width: | Height: | Size: 434 B |
BIN
assets/icons/dark/arrow_back.png
Normal file
|
After Width: | Height: | Size: 584 B |
BIN
assets/icons/dark/arrow_upward.png
Normal file
|
After Width: | Height: | Size: 808 B |
BIN
assets/icons/dark/backspace.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/icons/dark/check.png
Normal file
|
After Width: | Height: | Size: 563 B |
BIN
assets/icons/dark/close.png
Normal file
|
After Width: | Height: | Size: 695 B |
BIN
assets/icons/dark/code.png
Normal file
|
After Width: | Height: | Size: 679 B |
BIN
assets/icons/dark/computer.png
Normal file
|
After Width: | Height: | Size: 723 B |
BIN
assets/icons/dark/content_copy.png
Normal file
|
After Width: | Height: | Size: 730 B |
BIN
assets/icons/dark/dashboard.png
Normal file
|
After Width: | Height: | Size: 505 B |
BIN
assets/icons/dark/delete.png
Normal file
|
After Width: | Height: | Size: 647 B |
BIN
assets/icons/dark/edit.png
Normal file
|
After Width: | Height: | Size: 557 B |
BIN
assets/icons/dark/file_download.png
Normal file
|
After Width: | Height: | Size: 654 B |
BIN
assets/icons/dark/file_upload.png
Normal file
|
After Width: | Height: | Size: 605 B |
BIN
assets/icons/dark/folder.png
Normal file
|
After Width: | Height: | Size: 603 B |
BIN
assets/icons/dark/folder_open.png
Normal file
|
After Width: | Height: | Size: 718 B |
BIN
assets/icons/dark/info.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/icons/dark/language.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
assets/icons/dark/lock.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/icons/dark/play_arrow.png
Normal file
|
After Width: | Height: | Size: 762 B |
BIN
assets/icons/dark/refresh.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/icons/dark/save.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/icons/dark/search.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/icons/dark/settings.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/icons/dark/storage.png
Normal file
|
After Width: | Height: | Size: 501 B |
BIN
assets/icons/dark/trending_up.png
Normal file
|
After Width: | Height: | Size: 819 B |
BIN
assets/icons/dark/visibility.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/icons/dark/vpn_key.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/icons/light/add.png
Normal file
|
After Width: | Height: | Size: 392 B |
BIN
assets/icons/light/arrow_back.png
Normal file
|
After Width: | Height: | Size: 580 B |
BIN
assets/icons/light/arrow_upward.png
Normal file
|
After Width: | Height: | Size: 761 B |
BIN
assets/icons/light/backspace.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
assets/icons/light/check.png
Normal file
|
After Width: | Height: | Size: 554 B |
BIN
assets/icons/light/close.png
Normal file
|
After Width: | Height: | Size: 656 B |
BIN
assets/icons/light/code.png
Normal file
|
After Width: | Height: | Size: 700 B |
BIN
assets/icons/light/computer.png
Normal file
|
After Width: | Height: | Size: 634 B |
BIN
assets/icons/light/content_copy.png
Normal file
|
After Width: | Height: | Size: 617 B |
BIN
assets/icons/light/dashboard.png
Normal file
|
After Width: | Height: | Size: 486 B |
BIN
assets/icons/light/delete.png
Normal file
|
After Width: | Height: | Size: 533 B |
BIN
assets/icons/light/edit.png
Normal file
|
After Width: | Height: | Size: 591 B |
BIN
assets/icons/light/file_download.png
Normal file
|
After Width: | Height: | Size: 600 B |
BIN
assets/icons/light/file_upload.png
Normal file
|
After Width: | Height: | Size: 531 B |
BIN
assets/icons/light/folder.png
Normal file
|
After Width: | Height: | Size: 552 B |
BIN
assets/icons/light/folder_open.png
Normal file
|
After Width: | Height: | Size: 644 B |
BIN
assets/icons/light/info.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/icons/light/language.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
assets/icons/light/lock.png
Normal file
|
After Width: | Height: | Size: 959 B |
BIN
assets/icons/light/play_arrow.png
Normal file
|
After Width: | Height: | Size: 687 B |
BIN
assets/icons/light/refresh.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/icons/light/save.png
Normal file
|
After Width: | Height: | Size: 881 B |
BIN
assets/icons/light/search.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/icons/light/settings.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/icons/light/storage.png
Normal file
|
After Width: | Height: | Size: 452 B |
BIN
assets/icons/light/trending_up.png
Normal file
|
After Width: | Height: | Size: 765 B |
BIN
assets/icons/light/visibility.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/icons/light/vpn_key.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
166
build.py
@@ -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:
|
||||
|
||||
@@ -30,17 +30,39 @@ _BLOCK_START = "<!-- server-manager:start -->"
|
||||
_BLOCK_END = "<!-- server-manager:end -->"
|
||||
|
||||
GLOBAL_CLAUDE_MD_BLOCK = f"""{_BLOCK_START}
|
||||
## Server Manager — управление серверами
|
||||
## Серверы — ТОЛЬКО через /ssh
|
||||
|
||||
**ВСЕГДА** используй server manager для подключения к серверам. Никогда не используй `ssh`, `sshpass` или прямые подключения.
|
||||
**НИКОГДА не используй raw `ssh` команды.** НИКОГДА не читай `~/.ssh/config` для поиска серверов.
|
||||
Все операции с серверами — **ТОЛЬКО через скилл `/ssh`** или напрямую через `ssh.py`:
|
||||
|
||||
- Скилл: `/ssh ALIAS "command"` — выполнить команду на сервере
|
||||
- Список серверов: `python3 ~/.server-connections/ssh.py --list`
|
||||
- Документация: `~/.claude/commands/ssh.md`
|
||||
- Memory bank: проект `global-infrastructure` → `techContext.md`
|
||||
- Инфраструктура: https://git.sensey24.ru/aibot777/infrastructure-docs
|
||||
```bash
|
||||
python ~/.server-connections/ssh.py --list # список серверов (alias, тип, заметки)
|
||||
python ~/.server-connections/ssh.py --info ALIAS # инфо (без creds)
|
||||
python ~/.server-connections/ssh.py --status # online/offline
|
||||
```
|
||||
|
||||
**Запрещено:** использовать `ssh`, `sshpass`, читать `~/.server-connections/` напрямую, раскрывать IP/пароли/порты.
|
||||
При вопросе о сервере — **СНАЧАЛА `--list`**, найди нужный алиас по заметкам и **ПРОВЕРЬ ТИП**.
|
||||
Скрипт `ssh.py` сам читает credentials из зашифрованного хранилища. Claude НЕ видит IP, логины, пароли.
|
||||
|
||||
### КРИТИЧНО — команды зависят от типа сервера
|
||||
|
||||
**`ALIAS "command"` (shell) — ТОЛЬКО для типов `ssh` и `telnet`!**
|
||||
|
||||
| Тип | Команды |
|
||||
|-----|---------|
|
||||
| `ssh`/`telnet` | `ALIAS "cmd"`, `--upload ALIAS local remote`, `--download ALIAS remote local` |
|
||||
| `s3` (MinIO и др.) | `--s3-buckets ALIAS`, `--s3-ls ALIAS bucket/prefix`, `--s3-upload ALIAS local bucket/key`, `--s3-download ALIAS bucket/key local`, `--s3-delete ALIAS bucket/key`, `--s3-url ALIAS bucket/key [SEC]` |
|
||||
| `mariadb`/`mssql`/`postgresql` | `--sql ALIAS "SELECT ..."`, `--sql-databases ALIAS`, `--sql-tables ALIAS [db]` |
|
||||
| `redis` | `--redis ALIAS "GET key"`, `--redis-info ALIAS`, `--redis-keys ALIAS "pattern"` |
|
||||
| `grafana` | `--grafana-dashboards ALIAS`, `--grafana-alerts ALIAS` |
|
||||
| `prometheus` | `--prom-query ALIAS "up"`, `--prom-targets ALIAS`, `--prom-alerts ALIAS` |
|
||||
| `winrm` | `--ps ALIAS "Get-Process"`, `--cmd ALIAS "dir"` |
|
||||
|
||||
**Формат: `python ~/.server-connections/ssh.py КОМАНДА АЛИАС АРГУМЕНТЫ`** — алиас ВСЕГДА второй после команды.
|
||||
|
||||
**S3 правило:** перед `--s3-upload/download/delete` — СНАЧАЛА `--s3-buckets ALIAS` и `--s3-ls ALIAS bucket/` чтобы узнать реальные бакеты и пути. НЕ УГАДЫВАЙ имена бакетов!
|
||||
|
||||
**Запрещено:** использовать `ssh`/`sshpass`, читать `~/.server-connections/` напрямую, раскрывать IP/пароли/порты.
|
||||
{_BLOCK_END}
|
||||
"""
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
189
core/i18n.py
@@ -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 = {
|
||||
|
||||
121
core/icons.py
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
128
core/updater.py
@@ -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()
|
||||
|
||||
119
gui/app.py
@@ -2,6 +2,7 @@
|
||||
Main application window — sidebar + tabview layout.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tkinter
|
||||
import customtkinter as ctk
|
||||
from tkinter import messagebox
|
||||
@@ -11,7 +12,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 +29,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 +44,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 +61,7 @@ TAB_CLASSES = {
|
||||
"metrics": PrometheusTab,
|
||||
"powershell": PowershellTab,
|
||||
"launch": LaunchTab,
|
||||
"objects": S3Tab,
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +92,7 @@ class App(ctk.CTk):
|
||||
|
||||
# Restore saved window geometry or use default
|
||||
saved_geo = self.store._window_geometry
|
||||
if saved_geo:
|
||||
if saved_geo and self._is_valid_geometry(saved_geo):
|
||||
self.geometry(saved_geo)
|
||||
else:
|
||||
self.geometry("1100x700")
|
||||
@@ -115,20 +119,54 @@ class App(ctk.CTk):
|
||||
# Cleanup on close
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
# Win32: restore window when stuck minimized after Win+D
|
||||
self._restore_check_id = None
|
||||
if sys.platform == "win32":
|
||||
self.after(3000, self._start_restore_watchdog)
|
||||
|
||||
def _start_restore_watchdog(self):
|
||||
"""Start periodic check for stuck minimized state (Windows only)."""
|
||||
try:
|
||||
import ctypes
|
||||
self._user32 = ctypes.windll.user32
|
||||
self._hwnd = int(self.wm_frame(), 16)
|
||||
self._check_restore()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _check_restore(self):
|
||||
"""If window is iconic but user clicked taskbar, force restore."""
|
||||
try:
|
||||
if self._user32.IsIconic(self._hwnd):
|
||||
fg = self._user32.GetForegroundWindow()
|
||||
if fg == self._hwnd:
|
||||
self._user32.ShowWindow(self._hwnd, 9) # SW_RESTORE
|
||||
except Exception:
|
||||
pass
|
||||
self._restore_check_id = self.after(500, self._check_restore)
|
||||
|
||||
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 +174,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 +190,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 +311,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 +326,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)):
|
||||
@@ -633,10 +694,38 @@ class App(ctk.CTk):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_close(self):
|
||||
# Save window geometry (size + position)
|
||||
@staticmethod
|
||||
def _is_valid_geometry(geo: str) -> bool:
|
||||
"""Reject geometry with offscreen coordinates (e.g. minimized -32000)."""
|
||||
try:
|
||||
self.store._window_geometry = self.geometry()
|
||||
# format: WxH+X+Y or WxH-X-Y
|
||||
import re
|
||||
m = re.match(r"(\d+)x(\d+)([+-]\d+)([+-]\d+)", geo)
|
||||
if not m:
|
||||
return False
|
||||
x, y = int(m.group(3)), int(m.group(4))
|
||||
return -100 < x < 10000 and -100 < y < 10000
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _on_close(self):
|
||||
# Cancel restore watchdog
|
||||
try:
|
||||
if self._restore_check_id:
|
||||
self.after_cancel(self._restore_check_id)
|
||||
except Exception:
|
||||
pass
|
||||
# Save window geometry (size + position) and sidebar width
|
||||
try:
|
||||
geo = self.geometry()
|
||||
self.store._window_geometry = geo if self._is_valid_geometry(geo) else None
|
||||
# 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
@@ -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()
|
||||
@@ -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
|
||||
|
||||
284
gui/sidebar.py
@@ -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")),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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. Переключить язык — все строки переведены
|
||||
BIN
releases/ServerManager-v1.9.22-win-x64.exe
Normal 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
@@ -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
@@ -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 ""
|
||||
@@ -1,7 +1,7 @@
|
||||
# Скилл /ssh — управление удалёнными серверами
|
||||
|
||||
Ты управляешь удалёнными серверами через универсальную CLI-утилиту.
|
||||
Поддерживаются: SSH, SQL (MariaDB/MSSQL/PostgreSQL), Redis, Grafana, Prometheus, WinRM (PowerShell/CMD).
|
||||
Поддерживаются: SSH, SQL (MariaDB/MSSQL/PostgreSQL), Redis, S3/MinIO, Grafana, Prometheus, WinRM (PowerShell/CMD).
|
||||
|
||||
## ВАЖНО — Безопасность
|
||||
|
||||
@@ -19,6 +19,49 @@
|
||||
|
||||
Пользователь передаёт через `$ARGUMENTS`. Разбери и выполни.
|
||||
|
||||
## КРИТИЧНО — СНАЧАЛА ПРОВЕРЬ ТИП СЕРВЕРА
|
||||
|
||||
**ПЕРЕД ЛЮБОЙ операцией** с сервером — **ОБЯЗАТЕЛЬНО** выполни `--list` и посмотри колонку `Type`.
|
||||
**ЗАПРЕЩЕНО** угадывать тип сервера. MinIO/S3 — это НЕ SSH, Redis — это НЕ SSH, MariaDB — это НЕ SSH.
|
||||
|
||||
**Тип сервера определяет КАКИЕ команды использовать. Использование команд не того типа — СЛОМАЕТ операцию.**
|
||||
|
||||
| Тип | Команды | НЕ использовать |
|
||||
|-----|---------|-----------------|
|
||||
| `ssh` | `ALIAS "command"`, `--upload`, `--download`, `--ping`, `--install-key` | — |
|
||||
| `telnet` | `ALIAS "command"` (без SFTP/sudo/ключей) | `--upload`, `--download` |
|
||||
| `mariadb` / `mssql` / `postgresql` | `--sql`, `--sql-databases`, `--sql-tables` | `ALIAS "command"` |
|
||||
| `redis` | `--redis`, `--redis-info`, `--redis-keys` | `ALIAS "command"` |
|
||||
| `s3` (MinIO, AWS S3, и др.) | `--s3-buckets`, `--s3-ls`, `--s3-upload`, `--s3-download`, `--s3-delete`, `--s3-url` | `ALIAS "command"`, `--upload`, `--download` |
|
||||
| `grafana` | `--grafana-dashboards`, `--grafana-alerts` | `ALIAS "command"` |
|
||||
| `prometheus` | `--prom-query`, `--prom-targets`, `--prom-alerts` | `ALIAS "command"` |
|
||||
| `winrm` | `--ps`, `--cmd` | `ALIAS "command"` |
|
||||
| `rdp` / `vnc` | Только GUI | всё |
|
||||
|
||||
**`ALIAS "command"` (shell-команды типа ls, cat, mkdir) — ТОЛЬКО для типов `ssh` и `telnet`.**
|
||||
|
||||
```bash
|
||||
# ❌ НЕПРАВИЛЬНО — MinIO/S3 это НЕ SSH, нельзя выполнять shell-команды
|
||||
python ~/.server-connections/ssh.py "minio-alias" "ls /bucket"
|
||||
python ~/.server-connections/ssh.py "minio-alias" "mkdir /bucket/folder"
|
||||
|
||||
# ✅ ПРАВИЛЬНО — S3-команды для типа s3
|
||||
python ~/.server-connections/ssh.py --s3-ls "minio-alias" bucket
|
||||
python ~/.server-connections/ssh.py --s3-upload "minio-alias" "D:/file.txt" bucket/folder/file.txt
|
||||
|
||||
# ❌ НЕПРАВИЛЬНО — Redis это НЕ SSH
|
||||
python ~/.server-connections/ssh.py "redis-alias" "INFO"
|
||||
|
||||
# ✅ ПРАВИЛЬНО
|
||||
python ~/.server-connections/ssh.py --redis-info "redis-alias"
|
||||
|
||||
# ❌ НЕПРАВИЛЬНО — MariaDB это НЕ SSH
|
||||
python ~/.server-connections/ssh.py "mariadb-alias" "SHOW DATABASES"
|
||||
|
||||
# ✅ ПРАВИЛЬНО
|
||||
python ~/.server-connections/ssh.py --sql-databases "mariadb-alias"
|
||||
```
|
||||
|
||||
## Общие команды
|
||||
|
||||
### Список серверов (безопасный — alias, тип, ключ, заметки)
|
||||
@@ -50,7 +93,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 +173,58 @@ python ~/.server-connections/ssh.py --redis-info ALIAS
|
||||
python ~/.server-connections/ssh.py --redis-keys ALIAS "user:*"
|
||||
```
|
||||
|
||||
## S3-команды (тип: s3) — MinIO, AWS S3, любое S3-совместимое хранилище
|
||||
|
||||
**MinIO = тип `s3`.** Когда пользователь говорит "MinIO" или "S3" — используй ТОЛЬКО `--s3-*` команды.
|
||||
**НЕ пытайся** выполнять shell-команды (`ls`, `mkdir`, `cat`) на S3-серверах — это не SSH!
|
||||
|
||||
**Папки в S3 не существуют** — это префиксы. "Создать папку" = загрузить файл с префиксом в ключе (например `bucket/folder/file.txt`).
|
||||
|
||||
### Список бакетов
|
||||
```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
|
||||
```
|
||||
|
||||
### Получить ссылку на файл (presigned URL)
|
||||
```bash
|
||||
python ~/.server-connections/ssh.py --s3-url ALIAS bucket/key
|
||||
python ~/.server-connections/ssh.py --s3-url ALIAS bucket/key 86400
|
||||
```
|
||||
По умолчанию ссылка действует 1 час (3600 сек). Второй аргумент — время жизни в секундах (например 86400 = 24 часа).
|
||||
|
||||
### Типичный workflow: "создай папку и залей файл"
|
||||
```bash
|
||||
# 1. Посмотри бакеты
|
||||
python ~/.server-connections/ssh.py --s3-buckets ALIAS
|
||||
# 2. "Создать папку" = просто загрузить файл с нужным путём (prefix)
|
||||
python ~/.server-connections/ssh.py --s3-upload ALIAS "D:/file.txt" mybucket/newfolder/file.txt
|
||||
# 3. Проверить
|
||||
python ~/.server-connections/ssh.py --s3-ls ALIAS mybucket/newfolder/
|
||||
# 4. Получить ссылку
|
||||
python ~/.server-connections/ssh.py --s3-url ALIAS mybucket/newfolder/file.txt
|
||||
```
|
||||
|
||||
## Grafana-команды (тип: grafana)
|
||||
|
||||
### Список дашбордов
|
||||
|
||||