Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc4cf2b7a3 | ||
|
|
35bdefba59 | ||
|
|
d33f573483 | ||
|
|
cf319c502e | ||
|
|
01ab318e4b | ||
|
|
f9a81a4825 | ||
|
|
3bafb0deb8 | ||
|
|
b37e696094 | ||
|
|
289ce65431 | ||
|
|
704ce3bef2 | ||
|
|
00f3b76d2a |
@@ -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
|
||||
|
||||
70
build.py
70
build.py
@@ -182,9 +182,8 @@ def build():
|
||||
# Auto-deploy: sync shared files so Claude Code always has the latest
|
||||
deploy_shared_files()
|
||||
|
||||
# Publish release to Gitea + cleanup old remote releases
|
||||
# Publish release to Gitea
|
||||
publish_gitea_release(dst)
|
||||
cleanup_gitea_releases()
|
||||
|
||||
|
||||
def _get_gitea_auth() -> dict:
|
||||
@@ -312,60 +311,6 @@ def _version_key(path: str):
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
def _tag_version_key(tag_name: str):
|
||||
"""Extract (major, minor, patch) from tag like 'v1.9.5'."""
|
||||
m = re.match(r'v(\d+)\.(\d+)\.(\d+)', tag_name)
|
||||
if m:
|
||||
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
def cleanup_gitea_releases():
|
||||
"""Keep the first release (v1.0.0) and the last 5 releases on Gitea, delete the rest."""
|
||||
auth = _get_gitea_auth()
|
||||
if not auth:
|
||||
return
|
||||
|
||||
# List all releases
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{_GITEA_API}/releases?limit=50",
|
||||
headers=auth,
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=30)
|
||||
releases = json.loads(resp.read())
|
||||
except Exception as e:
|
||||
print(f"Gitea release list failed: {e}")
|
||||
return
|
||||
|
||||
if len(releases) <= 6:
|
||||
return
|
||||
|
||||
# Sort by semver
|
||||
releases.sort(key=lambda r: _tag_version_key(r.get("tag_name", "")))
|
||||
|
||||
first = releases[0]
|
||||
last_5 = releases[-5:]
|
||||
keep_ids = {first["id"]} | {r["id"] for r in last_5}
|
||||
|
||||
removed = []
|
||||
for r in releases:
|
||||
if r["id"] in keep_ids:
|
||||
continue
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{_GITEA_API}/releases/{r['id']}",
|
||||
headers=auth,
|
||||
method="DELETE",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=15)
|
||||
removed.append(r.get("tag_name", "?"))
|
||||
except Exception as e:
|
||||
print(f"Failed to delete Gitea release {r.get('tag_name')}: {e}")
|
||||
|
||||
if removed:
|
||||
print(f"Cleaned {len(removed)} old Gitea releases: {', '.join(removed)}")
|
||||
|
||||
|
||||
def cleanup_old_releases():
|
||||
"""Keep the first release (v1.0.0) and the last 5 releases, delete the rest."""
|
||||
@@ -383,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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
52
gui/app.py
52
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
|
||||
@@ -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(
|
||||
@@ -667,10 +694,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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,33 +19,47 @@
|
||||
|
||||
Пользователь передаёт через `$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` | `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"
|
||||
```
|
||||
|
||||
## Общие команды
|
||||
@@ -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,25 @@ 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 часа).
|
||||
|
||||
### Типичный 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)
|
||||
|
||||
### Список дашбордов
|
||||
|
||||
28
tools/ssh.py
28
tools/ssh.py
@@ -42,6 +42,7 @@ 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)
|
||||
|
||||
WinRM (type: winrm):
|
||||
python ssh.py --ps ALIAS "Get-Process" # PowerShell via WinRM
|
||||
@@ -1459,6 +1460,27 @@ 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)
|
||||
|
||||
|
||||
# ── Grafana commands ──────────────────────────────────
|
||||
|
||||
def _grafana_request(server: dict, endpoint: str) -> dict:
|
||||
@@ -1763,6 +1785,12 @@ 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)
|
||||
|
||||
# ── Grafana commands ──
|
||||
if cmd == "--grafana-dashboards" and len(sys.argv) >= 3:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Version info for ServerManager."""
|
||||
|
||||
__version__ = "1.9.10"
|
||||
__version__ = "1.9.20"
|
||||
__app_name__ = "ServerManager"
|
||||
__author__ = "aibot777"
|
||||
__description__ = "Desktop GUI for managing remote servers"
|
||||
|
||||
Reference in New Issue
Block a user