Compare commits

..

23 Commits

Author SHA1 Message Date
chrome-storm-c442
6c5ceead09 v1.9.29: show large OFF overlay on terminal when disconnected
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 05:42:19 -05:00
chrome-storm-c442
65c1f809b1 v1.9.28: terminal connect/disconnect toggle button with state indication
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 05:34:40 -05:00
chrome-storm-c442
064de8df8d v1.9.27: add disconnect button in terminal + disconnect in context menu
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 05:27:03 -05:00
chrome-storm-c442
7522908404 v1.9.26: disable terminal auto-connect on single click, require double-click
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 05:09:54 -05:00
chrome-storm-c442
c21b263b24 v1.9.25: show server group in --list and --info CLI output
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:55:32 -05:00
chrome-storm-c442
464b803b42 v1.9.24: add --s3-create-bucket to CLI and skill
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:21:27 -05:00
chrome-storm-c442
bbef9ad014 v1.9.23: S3 create/delete bucket GUI buttons
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:16:50 -05:00
chrome-storm-c442
9f7fbb759f v1.9.22: add S3 bucket check rule to global CLAUDE.md installer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 05:23:45 -05:00
chrome-storm-c442
16e69a2bd6 v1.9.21: update global CLAUDE.md installer — full command table for all server types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 05:21:21 -05:00
chrome-storm-c442
bc4cf2b7a3 v1.9.20: Win32 restore watchdog — fix stuck minimized after Win+D
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 04:37:32 -05:00
chrome-storm-c442
35bdefba59 v1.9.19: fix offscreen window — validate saved geometry, reject -32000 coords
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 04:13:11 -05:00
chrome-storm-c442
d33f573483 v1.9.18: revert GUI to v1.9.14 state — fix broken window display
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 04:09:47 -05:00
chrome-storm-c442
cf319c502e v1.9.16: add --s3-url presigned URL command to ssh.py
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 04:05:00 -05:00
chrome-storm-c442
01ab318e4b v1.9.15: fix minimize/restore — remove grab_set, add Win32 restore fallback
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 03:58:32 -05:00
chrome-storm-c442
f9a81a4825 v1.9.14: fix dialog minimize bug — restore modal dialogs on un-minimize
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 03:25:18 -05:00
chrome-storm-c442
3bafb0deb8 skill: enforce server type checking — MinIO/S3, presigned URL, workflow examples
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:25:41 -05:00
chrome-storm-c442
b37e696094 v1.9.13: remove cleanup_gitea_releases — keep all Gitea releases
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:35:17 -05:00
chrome-storm-c442
289ce65431 chore: remove 12 old exe from git + use git rm in cleanup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:32:01 -05:00
chrome-storm-c442
704ce3bef2 v1.9.12: cleanup orphan Gitea tags alongside old releases
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:29:53 -05:00
chrome-storm-c442
00f3b76d2a v1.9.11: always check updates on startup + cleanup Gitea releases
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:27:21 -05:00
chrome-storm-c442
efbbfa13ee v1.9.10: cleanup old Gitea releases — keep first + last 5
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:21:33 -05:00
chrome-storm-c442
3c4d02c5ec v1.9.9: fix stale server data after Edit — force reconnect with fresh credentials
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:11:36 -05:00
chrome-storm-c442
5b4672dfe3 v1.9.8: S3 resumable download — Range GET with .s3part resume on disconnect
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:33:48 -05:00
30 changed files with 841 additions and 61 deletions

View File

@@ -26,7 +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 | `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 | — (запуск внешнего клиента) |
## БЕЗОПАСНОСТЬ
@@ -139,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

View File

@@ -311,6 +311,7 @@ def _version_key(path: str):
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
@@ -327,9 +328,20 @@ def cleanup_old_releases():
keep = set([first] + last_5)
removed = []
_flags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
for f in all_exes:
if f not in keep:
os.remove(f)
# Use git rm so deletion is staged for commit
try:
subprocess.run(
["git", "rm", "-f", "--quiet", f],
cwd=PROJECT_DIR, creationflags=_flags,
capture_output=True,
)
except Exception:
# Fallback: just delete the file
if os.path.exists(f):
os.remove(f)
removed.append(os.path.basename(f))
if removed:

View File

@@ -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]`, `--s3-create-bucket ALIAS name` |
| `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}
"""

View File

@@ -111,6 +111,9 @@ _EN = {
"term_connecting": "Connecting to {alias}...",
"term_connected": "Connected to {alias}",
"term_disconnected": "Disconnected",
"ctx_disconnect": "Disconnect",
"term_click_to_connect": "Double-click to connect to {alias}",
"sftp_click_to_connect": "Double-click server to browse files",
"term_reconnecting": "Reconnecting ({n}/{max})...",
"term_connect_failed": "Connection failed: {error}",
"term_reconnect_fail": "Disconnected (reconnect failed)",
@@ -390,6 +393,12 @@ _EN = {
"s3_uploading_n": "Uploading {count} files...",
"s3_uploaded_n": "Uploaded {count} files",
"s3_upload_partial": "Uploaded {ok}/{total} files",
"s3_create_bucket": "Create Bucket",
"s3_bucket_name_prompt": "Bucket name:",
"s3_delete_bucket": "Delete Bucket",
"s3_delete_bucket_confirm": "Delete bucket \"{name}\"? It must be empty.",
"s3_bucket_created": "Bucket \"{name}\" created",
"s3_bucket_deleted": "Bucket \"{name}\" deleted",
"s3_new_folder": "New Folder",
"s3_folder_name_prompt": "Folder name:",
"s3_creating_folder": "Creating folder...",
@@ -628,6 +637,9 @@ _RU = {
"term_connecting": "Подключение к {alias}...",
"term_connected": "Подключено к {alias}",
"term_disconnected": "Отключено",
"ctx_disconnect": "Отключиться",
"term_click_to_connect": "Двойной клик для подключения к {alias}",
"sftp_click_to_connect": "Двойной клик для просмотра файлов",
"term_reconnecting": "Переподключение ({n}/{max})...",
"term_connect_failed": "Ошибка подключения: {error}",
"term_reconnect_fail": "Отключено (не удалось переподключиться)",
@@ -907,6 +919,12 @@ _RU = {
"s3_uploading_n": "Загрузка {count} файлов...",
"s3_uploaded_n": "Загружено {count} файлов",
"s3_upload_partial": "Загружено {ok}/{total} файлов",
"s3_create_bucket": "Создать бакет",
"s3_bucket_name_prompt": "Имя бакета:",
"s3_delete_bucket": "Удалить бакет",
"s3_delete_bucket_confirm": "Удалить бакет \"{name}\"? Он должен быть пустым.",
"s3_bucket_created": "Бакет \"{name}\" создан",
"s3_bucket_deleted": "Бакет \"{name}\" удалён",
"s3_new_folder": "Новая папка",
"s3_folder_name_prompt": "Имя папки:",
"s3_creating_folder": "Создание папки...",
@@ -1145,6 +1163,9 @@ _ZH = {
"term_connecting": "正在连接 {alias}...",
"term_connected": "已连接到 {alias}",
"term_disconnected": "已断开",
"ctx_disconnect": "断开连接",
"term_click_to_connect": "双击连接 {alias}",
"sftp_click_to_connect": "双击服务器浏览文件",
"term_reconnecting": "重新连接中 ({n}/{max})...",
"term_connect_failed": "连接失败:{error}",
"term_reconnect_fail": "已断开(重连失败)",
@@ -1424,6 +1445,12 @@ _ZH = {
"s3_uploading_n": "正在上传 {count} 个文件...",
"s3_uploaded_n": "已上传 {count} 个文件",
"s3_upload_partial": "已上传 {ok}/{total} 个文件",
"s3_create_bucket": "创建存储桶",
"s3_bucket_name_prompt": "存储桶名称:",
"s3_delete_bucket": "删除存储桶",
"s3_delete_bucket_confirm": "删除存储桶 \"{name}\"?必须为空。",
"s3_bucket_created": "存储桶 \"{name}\" 已创建",
"s3_bucket_deleted": "存储桶 \"{name}\" 已删除",
"s3_new_folder": "新建文件夹",
"s3_folder_name_prompt": "文件夹名称:",
"s3_creating_folder": "创建文件夹中...",

View File

@@ -210,6 +210,7 @@ CTX_ICONS = {
"ctx_open_browser": "browser",
"ctx_check_status": "status_check",
"ctx_copy_alias": "copy",
"ctx_disconnect": "close",
"edit": "edit",
"delete": "delete",
}

View File

@@ -240,16 +240,145 @@ class S3Client:
def download_file(self, bucket: str, key: str, local_path: str,
progress_cb=None, status_cb=None) -> bool:
"""Download an S3 object to a local file with retry.
"""Download with resume support using S3 Range GET.
progress_cb(bytes_transferred) — called periodically.
status_cb(message) — called with retry info.
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).
boto3 TransferConfig.num_download_attempts handles part-level retries.
This method adds full-transfer retries with reconnect.
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(
@@ -257,6 +386,7 @@ class S3Client:
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:
@@ -269,11 +399,18 @@ class S3Client:
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():
@@ -381,3 +518,29 @@ class S3Client:
return resp.get("ContentLength", 0)
except Exception:
return 0
def create_bucket(self, bucket_name: str) -> bool:
"""Create a new S3 bucket."""
if not self._ensure_connected():
return False
try:
self._client.create_bucket(Bucket=bucket_name)
self._last_ok = time.time()
log.info("S3 bucket created: %s", bucket_name)
return True
except Exception as exc:
log.error("S3 create_bucket failed: %s", exc)
return False
def delete_bucket(self, bucket_name: str) -> bool:
"""Delete an empty S3 bucket."""
if not self._ensure_connected():
return False
try:
self._client.delete_bucket(Bucket=bucket_name)
self._last_ok = time.time()
log.info("S3 bucket deleted: %s", bucket_name)
return True
except Exception as exc:
log.error("S3 delete_bucket failed: %s", exc)
return False

View File

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

View File

@@ -401,12 +401,15 @@ del /f /q "%~f0" >nul 2>&1
return
time.sleep(0.1)
first_run = True
while self._running:
# Check if enough time passed since last check
# On first run after startup, always check regardless of interval
last_check = self.store.get_last_update_check()
now = time.time()
if not last_check or (now - last_check) >= _CHECK_INTERVAL:
if first_run or not last_check or (now - last_check) >= _CHECK_INTERVAL:
first_run = False
info = self.check_now()
if info and self._gui_callback:
mode = self.store.get_update_mode()

View File

@@ -2,6 +2,7 @@
Main application window — sidebar + tabview layout.
"""
import sys
import tkinter
import customtkinter as ctk
from tkinter import messagebox
@@ -91,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")
@@ -118,6 +119,32 @@ 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(
@@ -127,7 +154,7 @@ class App(ctk.CTk):
self._paned.pack(fill="both", expand=True)
# Sidebar
self.sidebar = Sidebar(self._paned, self.store, on_select=self._on_server_select, session_pool=self.session_pool)
self.sidebar = Sidebar(self._paned, self.store, on_select=self._on_server_select, on_double_click=self._on_server_connect, 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
@@ -136,6 +163,7 @@ class App(ctk.CTk):
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
self.sidebar.disconnect_callback = self._on_server_disconnect
# Main area
self._main_frame = ctk.CTkFrame(self._paned, fg_color="transparent")
@@ -237,6 +265,11 @@ class App(ctk.CTk):
widget.pack(fill="both", expand=True)
self._tab_instances[key] = widget
# Wire disconnect callback for terminal toolbar button
terminal = self._tab_instances.get("terminal")
if terminal and hasattr(terminal, "_on_disconnect_callback"):
terminal._on_disconnect_callback = self._on_server_disconnect
# Restore previously active tab if still available
if restore_tab_key and restore_tab_key in self._tab_keys:
try:
@@ -280,6 +313,20 @@ class App(ctk.CTk):
# Update session indicators after a short delay (connection is async)
self.after(1500, self.sidebar.update_session_indicators)
def _on_server_connect(self, alias: str):
"""Double-click: connect interactive tabs (terminal, files, powershell)."""
for key, widget in self._tab_instances.items():
if hasattr(widget, "connect"):
widget.connect()
def _on_server_disconnect(self, alias: str):
"""Disconnect all sessions for a server."""
for key, widget in self._tab_instances.items():
if hasattr(widget, "disconnect"):
widget.disconnect()
self.session_pool.disconnect_session(alias)
self.after(500, self.sidebar.update_session_indicators)
def _add_server(self):
dialog = ServerDialog(self, self.store)
self.wait_window(dialog)
@@ -299,9 +346,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)):
@@ -319,6 +376,10 @@ class App(ctk.CTk):
self.tabview.set(_tab_label(tab_key))
except Exception:
pass
# Connect the target tab if it supports explicit connection
widget = self._tab_instances.get(tab_key)
if widget and hasattr(widget, "connect"):
widget.connect()
def _context_check_status(self, alias: str):
"""Context menu: check single server status in background."""
@@ -657,10 +718,31 @@ class App(ctk.CTk):
except Exception:
pass
@staticmethod
def _is_valid_geometry(geo: str) -> bool:
"""Reject geometry with offscreen coordinates (e.g. minimized -32000)."""
try:
# 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:
self.store._window_geometry = self.geometry()
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)

View File

@@ -34,10 +34,11 @@ _CONTEXT_ACTIONS = {
class Sidebar(ctk.CTkFrame):
def __init__(self, master, store, on_select=None, session_pool=None):
def __init__(self, master, store, on_select=None, on_double_click=None, session_pool=None):
super().__init__(master, width=250, corner_radius=0)
self.store = store
self.on_select = on_select
self.on_double_click = on_double_click
self.session_pool = session_pool
self._selected_alias: str | None = None
self._server_frames: dict[str, ctk.CTkFrame] = {}
@@ -96,6 +97,7 @@ class Sidebar(ctk.CTkFrame):
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
self.disconnect_callback = None # (alias) → disconnect all sessions
# Subscribe to store changes
self.store.subscribe(self._refresh_list)
@@ -272,6 +274,7 @@ class Sidebar(ctk.CTkFrame):
# Click handlers
for widget in [frame, info, name_label, detail_label, badge, type_badge, session_ind]:
widget.bind("<Button-1>", lambda e, a=alias: self._select(a))
widget.bind("<Double-Button-1>", lambda e, a=alias: self._on_double_click(a))
widget.bind("<Button-3>", lambda e, a=alias: self._show_context_menu(e, a))
self._server_frames[alias] = frame
@@ -371,6 +374,11 @@ class Sidebar(ctk.CTkFrame):
if self.on_select:
self.on_select(alias)
def _on_double_click(self, alias: str):
self._select(alias)
if self.on_double_click:
self.on_double_click(alias)
def _highlight_selected(self):
for alias, frame in self._server_frames.items():
if alias == self._selected_alias:
@@ -460,6 +468,18 @@ class Sidebar(ctk.CTkFrame):
if actions:
menu.add_separator()
# Dynamic disconnect if session is active
if self.session_pool and self.session_pool.has_active_session(alias):
dc_icon = icon(CTX_ICONS.get("ctx_disconnect", ""))
dc_label = f"{dc_icon} {t('ctx_disconnect')}" if dc_icon else t("ctx_disconnect")
menu.add_command(
label=dc_label,
command=lambda a=alias: (
self.disconnect_callback(a) if self.disconnect_callback else None
),
)
menu.add_separator()
# "Move to Group" submenu
groups = self.store.get_groups()
if groups:

View File

@@ -307,13 +307,21 @@ class FilesTab(ctk.CTkFrame):
stored_path, stored_sudo = self.session_pool.get_sftp_state(alias)
if stored_path != "/":
self._remote_path = stored_path
# The stored sudo mode will be applied when the connection is established
self._connect_sftp()
self._remote_status.configure(text=t("sftp_click_to_connect"))
else:
self._remote_list.populate([])
self._remote_status.configure(text=t("connect_to_browse"))
self._set_remote_buttons_state("disabled")
def connect(self):
"""Explicitly connect SFTP (double-click or context menu)."""
if self._current_alias and not self._sftp:
self._connect_sftp()
def disconnect(self):
"""Disconnect SFTP and update UI (called by app)."""
self._disconnect_sftp()
# ── SFTP connection ──
def _connect_sftp(self):

View File

@@ -97,7 +97,18 @@ class PowershellTab(ctk.CTkFrame):
self._set_status(t("ps_disconnected"), "#888888")
return
self._connect(alias)
self._set_status(t("term_click_to_connect").format(alias=alias), "#f59e0b")
def connect(self):
"""Explicitly connect WinRM (double-click or context menu)."""
if self._current_alias and not self._client:
self._connect(self._current_alias)
def disconnect(self):
"""Disconnect WinRM and update UI (called by app)."""
self._disconnect()
if self._current_alias:
self._set_status(t("term_click_to_connect").format(alias=self._current_alias), "#f59e0b")
# ── Connection ───────────────────────────────────────────────────

View File

@@ -153,7 +153,24 @@ class S3Tab(ctk.CTkFrame):
bucket_frame, variable=self._bucket_var, values=[""],
width=200, command=self._on_bucket_change,
)
self._bucket_menu.pack(side="left", padx=(0, 15))
self._bucket_menu.pack(side="left", padx=(0, 5))
# Create bucket [+]
self._create_bucket_btn = ctk.CTkButton(
bucket_frame, text="+", width=28, height=28,
corner_radius=6, font=ctk.CTkFont(size=14, weight="bold"),
command=self._create_bucket,
)
self._create_bucket_btn.pack(side="left", padx=(0, 3))
# Delete bucket [🗑]
self._delete_bucket_btn = ctk.CTkButton(
bucket_frame, text="\U0001f5d1", width=28, height=28,
corner_radius=6, fg_color="#dc2626", hover_color="#b91c1c",
font=ctk.CTkFont(size=13),
command=self._delete_bucket,
)
self._delete_bucket_btn.pack(side="left", padx=(0, 15))
# Path display
self._path_label = ctk.CTkLabel(
@@ -349,8 +366,9 @@ class S3Tab(ctk.CTkFrame):
def _on_transfer_status(self, message: str):
"""Called from transfer thread with retry/status info."""
# Reset progress on retry (boto3 restarts the transfer)
self._transfer_bytes = 0
# 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]):
@@ -625,6 +643,64 @@ class S3Tab(ctk.CTkFrame):
threading.Thread(target=_do, daemon=True).start()
def _create_bucket(self):
"""Prompt for bucket name and create it."""
if not self._client:
return
dialog = ctk.CTkInputDialog(
text=t("s3_bucket_name_prompt"),
title=t("s3_create_bucket"),
)
name = dialog.get_input()
if not name or not name.strip():
return
name = name.strip()
self._status_label.configure(text="...")
def _do():
ok = self._client.create_bucket(name)
self.after(0, lambda: self._on_bucket_created(ok, name))
threading.Thread(target=_do, daemon=True).start()
def _on_bucket_created(self, ok: bool, name: str):
if ok:
self._status_label.configure(
text=t("s3_bucket_created").format(name=name))
self._current_bucket = name
self._load_buckets()
else:
self._status_label.configure(text=t("s3_folder_failed"))
def _delete_bucket(self):
"""Delete the currently selected bucket (must be empty)."""
if not self._client or not self._current_bucket:
return
from tkinter import messagebox
ok = messagebox.askyesno(
t("s3_delete_bucket"),
t("s3_delete_bucket_confirm").format(name=self._current_bucket),
)
if not ok:
return
bucket_name = self._current_bucket
self._status_label.configure(text="...")
def _do():
ok = self._client.delete_bucket(bucket_name)
self.after(0, lambda: self._on_bucket_deleted(ok, bucket_name))
threading.Thread(target=_do, daemon=True).start()
def _on_bucket_deleted(self, ok: bool, name: str):
if ok:
self._status_label.configure(
text=t("s3_bucket_deleted").format(name=name))
self._current_bucket = ""
self._load_buckets()
else:
self._status_label.configure(text=t("s3_delete_failed"))
def _go_back(self):
if self._nav_stack:
self._current_prefix = self._nav_stack.pop()

View File

@@ -29,6 +29,18 @@ class TerminalTab(ctk.CTkFrame):
# Import here to avoid circular issues
from gui.widgets.terminal_widget import TerminalWidget
self._toolbar = ctk.CTkFrame(self, fg_color="transparent", height=32)
self._toolbar.pack(fill="x", padx=5, pady=(5, 0))
self._toolbar.pack_propagate(False)
self._conn_btn = ctk.CTkButton(
self._toolbar, text=t("ctx_connect"), width=120, height=28,
fg_color="#6b7280", hover_color="#4b5563",
font=ctk.CTkFont(size=12), state="disabled",
command=self._on_conn_btn_click,
)
self._conn_btn.pack(side="right", padx=2)
self._connected = False
self._terminal = TerminalWidget(
self,
send_callback=self._send_to_shell,
@@ -37,6 +49,15 @@ class TerminalTab(ctk.CTkFrame):
on_font_size_changed=self._on_font_size_changed,
)
self._terminal.pack(fill="both", expand=True, padx=5, pady=5)
# Overlay "OFF" label (shown when disconnected)
self._overlay = ctk.CTkLabel(
self._terminal, text="OFF",
font=ctk.CTkFont(size=72, weight="bold"),
text_color=("#cccccc", "#333333"),
fg_color="transparent",
)
self._overlay.place(relx=0.5, rely=0.45, anchor="center")
self._terminal.set_status(t("term_disconnected"), "#888888")
# Thread-safe data queue
@@ -45,6 +66,7 @@ class TerminalTab(ctk.CTkFrame):
# Sudo auto-password detection
self._sudo_buffer = b"" # Buffer for detecting sudo prompts
self._sudo_sent = False # Prevent sending password twice for same prompt
self._on_disconnect_callback = None
def set_server(self, alias: str | None):
if alias == self._current_alias:
@@ -61,11 +83,48 @@ class TerminalTab(ctk.CTkFrame):
self._current_alias = alias
if alias:
self._connect()
self._set_conn_btn_disconnected()
self._conn_btn.configure(state="normal")
self._terminal.set_status(t("term_click_to_connect").format(alias=alias), "#f59e0b")
else:
self._set_conn_btn_disconnected()
self._conn_btn.configure(state="disabled")
self._terminal.reset()
self._terminal.set_status(t("term_disconnected"), "#888888")
def connect(self):
"""Explicitly connect (double-click or context menu)."""
if self._current_alias and not self._session:
self._connect()
def _on_conn_btn_click(self):
if self._connected:
if self._on_disconnect_callback and self._current_alias:
self._on_disconnect_callback(self._current_alias)
else:
self.connect()
def _set_conn_btn_connected(self):
self._connected = True
self._conn_btn.configure(
text=t("ctx_disconnect"), fg_color="#dc2626", hover_color="#b91c1c", state="normal",
)
self._overlay.place_forget()
def _set_conn_btn_disconnected(self):
self._connected = False
self._conn_btn.configure(
text=t("ctx_connect"), fg_color="#6b7280", hover_color="#4b5563",
)
self._overlay.place(relx=0.5, rely=0.45, anchor="center")
def disconnect(self):
"""Disconnect and update UI (called by app)."""
self._disconnect()
self._set_conn_btn_disconnected()
if self._current_alias:
self._terminal.set_status(t("term_click_to_connect").format(alias=self._current_alias), "#f59e0b")
def _connect(self):
if not self._current_alias:
return
@@ -135,6 +194,7 @@ class TerminalTab(ctk.CTkFrame):
# Only grab focus if terminal tab is currently visible
if self._terminal.winfo_ismapped():
self._terminal.focus_terminal()
self._set_conn_btn_connected()
self.after(0, _set_session)
except Exception as e:
self.after(0, lambda: self._terminal.set_status(

View File

@@ -0,0 +1,160 @@
# Отключить автоподключение терминала при одинарном клике
## Контекст
При одинарном клике на сервер в sidebar все табы (terminal, files, powershell) сразу подключаются к серверу. Пользователь хочет просто переключаться между серверами без автоподключения. Подключение — только по двойному клику.
## Подход
- **Одинарный клик** — выбрать сервер, обновить табы (info, setup, keys и т.д.), но НЕ подключаться к terminal/files/powershell
- **Двойной клик** — выбрать сервер + подключить все "connecting" табы (terminal, files, powershell)
- **Контекстное меню** "Open Terminal" / "Browse Files" — тоже подключает
Tkinter при двойном клике генерирует оба события: `<Button-1>` (первый клик) → `<Double-Button-1>`. Это нам на руку: первый клик выберет сервер, двойной клик — подключит. Debounce не нужен.
## Изменения — 4 файла
### 1. `gui/sidebar.py` — добавить двойной клик
**Строка 37** — добавить `on_double_click` в конструктор:
```python
def __init__(self, master, store, on_select=None, on_double_click=None, session_pool=None):
...
self.on_select = on_select
self.on_double_click = on_double_click
```
**Строки 272-275** — добавить `<Double-Button-1>` binding:
```python
for widget in [frame, info, name_label, detail_label, badge, type_badge, session_ind]:
widget.bind("<Button-1>", lambda e, a=alias: self._select(a))
widget.bind("<Double-Button-1>", lambda e, a=alias: self._on_double_click(a))
widget.bind("<Button-3>", lambda e, a=alias: self._show_context_menu(e, a))
```
**После `_select()`** (строка 372) — новый метод:
```python
def _on_double_click(self, alias: str):
self._select(alias)
if self.on_double_click:
self.on_double_click(alias)
```
### 2. `gui/tabs/terminal_tab.py` — убрать автоподключение
**Строки 49-67**`set_server()`: заменить `self._connect()` на показ статуса:
```python
def set_server(self, alias: str | None):
if alias == self._current_alias:
return
if self._current_alias and self._session and self.session_pool:
buf = self._terminal.get_current_buffer()
self.session_pool.store_shell_state(self._current_alias, buf)
self._disconnect()
self._current_alias = alias
if alias:
self._terminal.set_status(t("term_click_to_connect").format(alias=alias), "#f59e0b")
else:
self._terminal.reset()
self._terminal.set_status(t("term_disconnected"), "#888888")
```
**Добавить публичный метод `connect()`** после `set_server()`:
```python
def connect(self):
"""Explicitly connect (double-click or context menu)."""
if self._current_alias and not self._session:
self._connect()
```
### 3. `gui/tabs/files_tab.py` — убрать автоподключение
**Строки 304-311**`set_server()`: заменить `self._connect_sftp()` на статус:
```python
if alias:
if self.session_pool:
stored_path, stored_sudo = self.session_pool.get_sftp_state(alias)
if stored_path != "/":
self._remote_path = stored_path
self._remote_status.configure(text=t("sftp_click_to_connect"))
else:
...
```
**Добавить публичный метод `connect()`**:
```python
def connect(self):
"""Explicitly connect SFTP (double-click or context menu)."""
if self._current_alias and not self._sftp:
self._connect_sftp()
```
### 4. `gui/tabs/powershell_tab.py` — убрать автоподключение
**Строка 100** — заменить `self._connect(alias)` на статус:
```python
if alias is None:
self._set_status(t("ps_disconnected"), "#888888")
return
self._set_status(t("term_click_to_connect").format(alias=alias), "#f59e0b")
```
**Добавить публичный метод `connect()`**:
```python
def connect(self):
"""Explicitly connect WinRM (double-click or context menu)."""
if self._current_alias and not self._client:
self._connect(self._current_alias)
```
### 5. `gui/app.py` — подключить двойной клик
**Строка 157** — передать `on_double_click`:
```python
self.sidebar = Sidebar(self._paned, self.store,
on_select=self._on_server_select,
on_double_click=self._on_server_connect,
session_pool=self.session_pool)
```
**Новый метод `_on_server_connect()`** (после `_on_server_select`):
```python
def _on_server_connect(self, alias: str):
"""Double-click: connect interactive tabs (terminal, files, powershell)."""
for key, widget in self._tab_instances.items():
if hasattr(widget, "connect"):
widget.connect()
```
**Строки 350-358**`_context_open_tab()`: добавить вызов `connect()`:
```python
def _context_open_tab(self, alias: str, tab_key: str):
self._on_server_select(alias)
self.sidebar._select(alias)
if tab_key in self._tab_keys:
try:
self.tabview.set(_tab_label(tab_key))
except Exception:
pass
# Connect the target tab if it supports explicit connection
widget = self._tab_instances.get(tab_key)
if widget and hasattr(widget, "connect"):
widget.connect()
```
### 6. `core/i18n.py` — 2 ключа перевода
Рядом с `term_disconnected`:
| Ключ | EN | RU | ZH |
|------|----|----|-----|
| `term_click_to_connect` | `Double-click to connect to {alias}` | `Двойной клик для подключения к {alias}` | `双击连接 {alias}` |
| `sftp_click_to_connect` | `Double-click server to browse files` | `Двойной клик для просмотра файлов` | `双击服务器浏览文件` |
## Верификация
1. `python build.py` — собрать exe
2. Запустить exe, одинарный клик на SSH-сервер → терминал показывает "Двойной клик для подключения", файлы показывают аналогичное сообщение, info таб работает как раньше
3. Двойной клик на сервер → терминал и файлы подключаются
4. Правый клик → "Open Terminal" → терминал подключается
5. Переключение между серверами одним кликом → нет автоподключений, быстрое переключение

View File

@@ -1,7 +1,7 @@
# Скилл /ssh — управление удалёнными серверами
Ты управляешь удалёнными серверами через универсальную CLI-утилиту.
Поддерживаются: SSH, SQL (MariaDB/MSSQL/PostgreSQL), Redis, S3, Grafana, Prometheus, WinRM (PowerShell/CMD).
Поддерживаются: SSH, SQL (MariaDB/MSSQL/PostgreSQL), Redis, S3/MinIO, Grafana, Prometheus, WinRM (PowerShell/CMD).
## ВАЖНО — Безопасность
@@ -19,38 +19,52 @@
Пользователь передаёт через `$ARGUMENTS`. Разбери и выполни.
## КРИТИЧНО — Команды зависят от типа сервера
## КРИТИЧНО — СНАЧАЛА ПРОВЕРЬ ТИП СЕРВЕРА
`--list` возвращает колонку `Type` для каждого сервера. **Тип определяет какие команды использовать:**
**ПЕРЕД ЛЮБОЙ операцией** с сервером — **ОБЯЗАТЕЛЬНО** выполни `--list` и посмотри колонку `Type`.
**ЗАПРЕЩЕНО** угадывать тип сервера. MinIO/S3 — это НЕ SSH, Redis — это НЕ SSH, MariaDB — это НЕ SSH.
| Тип | Команды |
|-----|---------|
| `ssh` | `ALIAS "command"`, `--upload`, `--download`, `--ping`, `--install-key` |
| `telnet` | `ALIAS "command"` (как ssh, но без SFTP/sudo/ключей) |
| `mariadb` / `mssql` / `postgresql` | `--sql`, `--sql-databases`, `--sql-tables` |
| `redis` | `--redis`, `--redis-info`, `--redis-keys` |
| `s3` | `--s3-buckets`, `--s3-ls`, `--s3-upload`, `--s3-download`, `--s3-delete` |
| `grafana` | `--grafana-dashboards`, `--grafana-alerts` |
| `prometheus` | `--prom-query`, `--prom-targets`, `--prom-alerts` |
| `winrm` | `--ps`, `--cmd` |
| `rdp` / `vnc` | Только GUI (запуск внешнего клиента), CLI-команд нет |
**Тип сервера определяет КАКИЕ команды использовать. Использование команд не того типа — СЛОМАЕТ операцию.**
**`ALIAS "command"` — ТОЛЬКО для типа `ssh`.** Для Redis — `--redis`, для SQL — `--sql`, для WinRM — `--ps`/`--cmd` и т.д.
| Тип | Команды | НЕ использовать |
|-----|---------|-----------------|
| `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`, `--s3-create-bucket` | `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
# Тип redis → --redis-info, НЕ ALIAS "INFO"
python ~/.server-connections/ssh.py --redis-info "Reddis main ovh"
# ❌ НЕПРАВИЛЬНО — MinIO/S3 это НЕ SSH, нельзя выполнять shell-команды
python ~/.server-connections/ssh.py "minio-alias" "ls /bucket"
python ~/.server-connections/ssh.py "minio-alias" "mkdir /bucket/folder"
# Тип mariadb → --sql-databases, НЕ ALIAS "SHOW DATABASES"
python ~/.server-connections/ssh.py --sql-databases "Maria Db Connection main ovh"
# ✅ ПРАВИЛЬНО — 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
# Тип ssh → ALIAS "command"
python ~/.server-connections/ssh.py investor "uptime"
# ❌ НЕПРАВИЛЬНО — 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, тип, ключ, заметки)
### Список серверов (безопасный — alias, тип, группа, ключ, заметки)
```bash
python ~/.server-connections/ssh.py --list
```
@@ -159,7 +173,12 @@ python ~/.server-connections/ssh.py --redis-info ALIAS
python ~/.server-connections/ssh.py --redis-keys ALIAS "user:*"
```
## S3-команды (тип: s3)
## S3-команды (тип: s3) — MinIO, AWS S3, любое S3-совместимое хранилище
**MinIO = тип `s3`.** Когда пользователь говорит "MinIO" или "S3" — используй ТОЛЬКО `--s3-*` команды.
**НЕ пытайся** выполнять shell-команды (`ls`, `mkdir`, `cat`) на S3-серверах — это не SSH!
**Папки в S3 не существуют** — это префиксы. "Создать папку" = загрузить файл с префиксом в ключе (например `bucket/folder/file.txt`).
### Список бакетов
```bash
@@ -187,6 +206,30 @@ python ~/.server-connections/ssh.py --s3-download ALIAS bucket/key "D:/local/fil
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 часа).
### Создать бакет
```bash
python ~/.server-connections/ssh.py --s3-create-bucket ALIAS bucket-name
```
### Типичный 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)
### Список дашбордов

View File

@@ -42,6 +42,8 @@ S3 (type: s3):
python ssh.py --s3-upload ALIAS local bucket/key # upload file
python ssh.py --s3-download ALIAS bucket/key local # download file
python ssh.py --s3-delete ALIAS bucket/key # delete object
python ssh.py --s3-url ALIAS bucket/key [SEC] # presigned URL (default 3600s)
python ssh.py --s3-create-bucket ALIAS name # create bucket
WinRM (type: winrm):
python ssh.py --ps ALIAS "Get-Process" # PowerShell via WinRM
@@ -100,6 +102,11 @@ def load_servers():
return data, {s["alias"]: s for s in data.get("servers", [])}
def _group_map(data: dict) -> dict:
"""Map group UUID → group name."""
return {g["id"]: g.get("name", "") for g in data.get("groups", [])}
def save_servers(data):
servers_file = _get_servers_file()
text = json.dumps(data, indent=2, ensure_ascii=False)
@@ -777,7 +784,8 @@ def ping_server(server: dict):
def list_servers(full=False):
_, servers = load_servers()
data, servers = load_servers()
groups = _group_map(data)
if full:
# WARNING: full mode shows sensitive data (IP, port, user)
# Only for local/manual use, NEVER through AI API
@@ -789,13 +797,14 @@ def list_servers(full=False):
print(f"{alias:<20} {s['ip']:<20} {s.get('port', 22):<8} {s.get('user', 'root'):<10} {has_key:<6}")
else:
# Safe mode: only aliases (no IPs, ports, users)
print(f"{'Alias':<20} {'Type':<10} {'Key':<6} {'Notes'}")
print("-" * 70)
print(f"{'Alias':<20} {'Type':<10} {'Group':<14} {'Key':<6} {'Notes'}")
print("-" * 80)
for alias, s in servers.items():
has_key = "yes" if os.path.exists(SSH_KEY_PATH) else "no"
stype = s.get("type", "ssh")
group_name = groups.get(s.get("group", ""), "-")
notes = s.get("notes", "")
print(f"{alias:<20} {stype:<10} {has_key:<6} {notes}")
print(f"{alias:<20} {stype:<10} {group_name:<14} {has_key:<6} {notes}")
def _resolve_alias(alias: str, servers: dict) -> str:
@@ -829,12 +838,16 @@ def _resolve_alias(alias: str, servers: dict) -> str:
def server_info(alias: str):
"""Show server info safe for AI context — NO ip, user, password, port, totp_secret."""
_, servers = load_servers()
data, servers = load_servers()
groups = _group_map(data)
alias = _resolve_alias(alias, servers)
s = servers[alias]
has_key = "yes" if os.path.exists(SSH_KEY_PATH) else "no"
print(f"Alias: {s['alias']}")
print(f"Type: {s.get('type', 'ssh')}")
group_name = groups.get(s.get("group", ""), "")
if group_name:
print(f"Group: {group_name}")
print(f"Key: {has_key}")
print(f"Auth: {s.get('auth', 'password')}")
print(f"2FA: {'yes' if s.get('totp_secret') else 'no'}")
@@ -1459,6 +1472,38 @@ def s3_delete(server: dict, remote_path: str):
sys.exit(1)
def s3_url(server: dict, remote_path: str, expires: int = 3600):
"""Generate a presigned URL for an S3 object."""
client = _get_s3_client(server)
parts = remote_path.split("/", 1)
bucket = parts[0] if parts else server.get("bucket", "")
key = parts[1] if len(parts) > 1 else ""
if not bucket or not key:
print("ERROR: Usage: --s3-url ALIAS bucket/key [seconds]", file=sys.stderr)
sys.exit(1)
try:
url = client.generate_presigned_url(
"get_object",
Params={"Bucket": bucket, "Key": key},
ExpiresIn=expires,
)
print(url)
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
def s3_create_bucket(server: dict, bucket_name: str):
"""Create a new S3 bucket."""
client = _get_s3_client(server)
try:
client.create_bucket(Bucket=bucket_name)
print(f"Bucket created: {bucket_name}")
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
# ── Grafana commands ──────────────────────────────────
def _grafana_request(server: dict, endpoint: str) -> dict:
@@ -1763,6 +1808,17 @@ def main():
alias = _resolve_alias(sys.argv[2], servers)
s3_delete(servers[alias], sys.argv[3])
sys.exit(0)
if cmd == "--s3-url" and len(sys.argv) >= 4:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
expires = int(sys.argv[4]) if len(sys.argv) >= 5 else 3600
s3_url(servers[alias], sys.argv[3], expires)
sys.exit(0)
if cmd == "--s3-create-bucket" and len(sys.argv) >= 4:
_, servers = load_servers()
alias = _resolve_alias(sys.argv[2], servers)
s3_create_bucket(servers[alias], sys.argv[3])
sys.exit(0)
# ── Grafana commands ──
if cmd == "--grafana-dashboards" and len(sys.argv) >= 3:

View File

@@ -1,6 +1,6 @@
"""Version info for ServerManager."""
__version__ = "1.9.7"
__version__ = "1.9.29"
__app_name__ = "ServerManager"
__author__ = "aibot777"
__description__ = "Desktop GUI for managing remote servers"