Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
464b803b42 | ||
|
|
bbef9ad014 | ||
|
|
9f7fbb759f | ||
|
|
16e69a2bd6 | ||
|
|
bc4cf2b7a3 | ||
|
|
35bdefba59 | ||
|
|
d33f573483 | ||
|
|
cf319c502e | ||
|
|
01ab318e4b | ||
|
|
f9a81a4825 | ||
|
|
3bafb0deb8 | ||
|
|
b37e696094 | ||
|
|
289ce65431 | ||
|
|
704ce3bef2 | ||
|
|
00f3b76d2a | ||
|
|
efbbfa13ee | ||
|
|
3c4d02c5ec |
@@ -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
|
||||
|
||||
14
build.py
14
build.py
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
"""
|
||||
|
||||
|
||||
18
core/i18n.py
18
core/i18n.py
@@ -390,6 +390,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...",
|
||||
@@ -907,6 +913,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": "Создание папки...",
|
||||
@@ -1424,6 +1436,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": "创建文件夹中...",
|
||||
|
||||
@@ -518,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
68
gui/app.py
68
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(
|
||||
@@ -299,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)):
|
||||
@@ -657,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)
|
||||
|
||||
@@ -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(
|
||||
@@ -626,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()
|
||||
|
||||
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`, `--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"
|
||||
```
|
||||
|
||||
## Общие команды
|
||||
@@ -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)
|
||||
|
||||
### Список дашбордов
|
||||
|
||||
45
tools/ssh.py
45
tools/ssh.py
@@ -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
|
||||
@@ -1459,6 +1461,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 +1797,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:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Version info for ServerManager."""
|
||||
|
||||
__version__ = "1.9.8"
|
||||
__version__ = "1.9.24"
|
||||
__app_name__ = "ServerManager"
|
||||
__author__ = "aibot777"
|
||||
__description__ = "Desktop GUI for managing remote servers"
|
||||
|
||||
Reference in New Issue
Block a user