mirror of
https://github.com/andrey271192/ssh-manage.git
synced 2026-09-20 11:55:37 +00:00
Portable SSH client with auto-save sessions. - Session groups (drag-and-drop between groups) - 100+ quick commands for Linux and Keenetic routers - Built-in command suggestion agent - Dark theme (Catppuccin), PCA branding - Builds to .exe (Windows) or .app (macOS) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1470 lines
67 KiB
Python
1470 lines
67 KiB
Python
"""
|
||
KU SSH Manager — Portable SSH client with auto-save sessions.
|
||
Tkinter GUI + paramiko SSH + JSON session store.
|
||
"""
|
||
|
||
import tkinter as tk
|
||
from tkinter import ttk, messagebox, font as tkfont
|
||
import paramiko
|
||
import threading
|
||
import json
|
||
import os
|
||
import sys
|
||
import re
|
||
import base64
|
||
import socket
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
APP_NAME = "PCA SSH"
|
||
APP_SUBTITLE = "Private Control Administration"
|
||
VERSION = "1.1"
|
||
|
||
# ── Quick Commands ─────────────────────────────────────────────
|
||
|
||
COMMANDS = {
|
||
"Ubuntu / Linux": {
|
||
"Система": [
|
||
("uname -a", "Ядро и архитектура"),
|
||
("cat /etc/os-release", "Версия ОС"),
|
||
("uptime -p", "Аптайм"),
|
||
("hostnamectl", "Имя хоста и ОС"),
|
||
("timedatectl", "Время и часовой пояс"),
|
||
("last reboot | head -5", "Последние перезагрузки"),
|
||
("who", "Кто залогинен"),
|
||
("w", "Активные сессии и нагрузка"),
|
||
],
|
||
"Ресурсы": [
|
||
("htop", "Мониторинг процессов (интерактивный)"),
|
||
("top -bn1 | head -20", "Топ процессов (снимок)"),
|
||
("free -h", "Оперативная память"),
|
||
("df -hT", "Диски и файловые системы"),
|
||
("du -sh /* 2>/dev/null | sort -rh | head -10", "Топ-10 тяжёлых директорий"),
|
||
("lsblk", "Блочные устройства"),
|
||
("iostat -x 1 3", "Нагрузка на диски"),
|
||
("vmstat 1 5", "CPU/память/swap за 5 сек"),
|
||
("cat /proc/cpuinfo | grep 'model name' | head -1", "Модель процессора"),
|
||
("nproc", "Количество ядер CPU"),
|
||
],
|
||
"Сеть": [
|
||
("ip addr", "IP-адреса интерфейсов"),
|
||
("ip route", "Таблица маршрутов"),
|
||
("ss -tulpn", "Открытые порты и процессы"),
|
||
("netstat -tulpn", "Порты (классический)"),
|
||
("curl -s ifconfig.me", "Внешний IP"),
|
||
("ping -c 4 8.8.8.8", "Пинг Google DNS"),
|
||
("ping -c 4 1.1.1.1", "Пинг Cloudflare"),
|
||
("traceroute 8.8.8.8", "Трассировка до Google"),
|
||
("dig google.com +short", "DNS-запрос"),
|
||
("nslookup google.com", "DNS (nslookup)"),
|
||
("iptables -L -n --line-numbers", "Правила файрвола"),
|
||
("ufw status verbose", "Статус UFW"),
|
||
("cat /etc/resolv.conf", "DNS-серверы"),
|
||
("arp -a", "ARP-таблица"),
|
||
("ethtool eth0", "Состояние сетевого адаптера"),
|
||
],
|
||
"Сервисы": [
|
||
("systemctl list-units --type=service --state=running", "Запущенные сервисы"),
|
||
("systemctl status", "Общий статус systemd"),
|
||
("systemctl --failed", "Упавшие сервисы"),
|
||
("journalctl -xe --no-pager | tail -50", "Последние логи"),
|
||
("journalctl -u nginx --since '1 hour ago'", "Логи nginx за час"),
|
||
("crontab -l", "Задачи cron"),
|
||
("systemctl list-timers", "Systemd-таймеры"),
|
||
],
|
||
"Docker": [
|
||
("docker ps", "Запущенные контейнеры"),
|
||
("docker ps -a", "Все контейнеры"),
|
||
("docker images", "Образы"),
|
||
("docker stats --no-stream", "Ресурсы контейнеров"),
|
||
("docker logs --tail 50 $(docker ps -q | head -1)", "Логи последнего контейнера"),
|
||
("docker system df", "Занятое место Docker"),
|
||
("docker system prune -f", "Очистка мусора Docker"),
|
||
("docker-compose ps", "Compose-сервисы"),
|
||
],
|
||
"Безопасность": [
|
||
("last -10", "Последние 10 логинов"),
|
||
("lastb -10 2>/dev/null", "Неудачные логины"),
|
||
("cat /var/log/auth.log | tail -20", "Лог авторизации"),
|
||
("find / -perm -4000 -type f 2>/dev/null", "SUID-файлы"),
|
||
("cat /etc/passwd | grep -v nologin | grep -v false", "Пользователи с шеллом"),
|
||
("ss -tulpn | grep LISTEN", "Слушающие порты"),
|
||
("fail2ban-client status 2>/dev/null", "Статус Fail2Ban"),
|
||
("openssl x509 -in /etc/ssl/certs/*.pem -noout -dates 2>/dev/null | head -4", "Сроки SSL-сертов"),
|
||
],
|
||
"Пользователи и пароли": [
|
||
("cat /etc/passwd", "Все пользователи системы"),
|
||
("cat /etc/passwd | grep -v nologin | grep -v false", "Пользователи с шеллом"),
|
||
("getent passwd | awk -F: '$3 >= 1000 {print $1}'", "Обычные пользователи (UID >= 1000)"),
|
||
("groups", "Группы текущего пользователя"),
|
||
("id", "UID/GID текущего пользователя"),
|
||
("id username", "Инфо о пользователе username"),
|
||
("passwd", "Сменить свой пароль"),
|
||
("passwd username", "Сменить пароль пользователю"),
|
||
("chage -l username", "Срок действия пароля"),
|
||
("chage -M 90 username", "Пароль истекает через 90 дней"),
|
||
("useradd -m -s /bin/bash username", "Создать пользователя с домашней папкой"),
|
||
("useradd -m -s /bin/bash -G sudo username", "Создать пользователя с sudo"),
|
||
("useradd -r -s /usr/sbin/nologin svcname", "Создать сервисного пользователя"),
|
||
("userdel username", "Удалить пользователя (без домашней)"),
|
||
("userdel -r username", "Удалить пользователя + домашнюю папку"),
|
||
("usermod -aG sudo username", "Добавить в группу sudo"),
|
||
("usermod -aG docker username", "Добавить в группу docker"),
|
||
("usermod -L username", "Заблокировать пользователя"),
|
||
("usermod -U username", "Разблокировать пользователя"),
|
||
("usermod -s /usr/sbin/nologin username", "Запретить логин"),
|
||
("usermod -s /bin/bash username", "Разрешить логин"),
|
||
("groupadd groupname", "Создать группу"),
|
||
("groupdel groupname", "Удалить группу"),
|
||
("gpasswd -d username groupname", "Убрать из группы"),
|
||
("cat /etc/shadow | grep username", "Хеш пароля (root)"),
|
||
("passwd -S username", "Статус пароля"),
|
||
("echo 'username:newpassword' | chpasswd", "Задать пароль одной командой"),
|
||
],
|
||
"SSH-ключи": [
|
||
("ssh-keygen -t ed25519 -C 'comment'", "Генерировать SSH-ключ ed25519"),
|
||
("ssh-keygen -t rsa -b 4096", "Генерировать SSH-ключ RSA 4096"),
|
||
("cat ~/.ssh/id_ed25519.pub", "Показать публичный ключ"),
|
||
("cat ~/.ssh/authorized_keys", "Авторизованные ключи"),
|
||
("ssh-copy-id user@host", "Скопировать ключ на сервер"),
|
||
("chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys", "Права на SSH-директорию"),
|
||
],
|
||
"Файлы и пакеты": [
|
||
("ls -lahS / | head -20", "Большие файлы в корне"),
|
||
("find /var/log -name '*.log' -mtime -1", "Свежие логи"),
|
||
("apt list --upgradable 2>/dev/null", "Доступные обновления (apt)"),
|
||
("yum check-update 2>/dev/null", "Доступные обновления (yum)"),
|
||
("dpkg -l | wc -l", "Количество пакетов"),
|
||
("tail -f /var/log/syslog", "Сислог в реальном времени"),
|
||
],
|
||
},
|
||
"Keenetic (NDMS CLI)": {
|
||
"Информация": [
|
||
("show version", "Прошивка и модель"),
|
||
("show system", "Загрузка CPU/RAM"),
|
||
("show clock", "Время роутера"),
|
||
("show uptime", "Аптайм"),
|
||
("show log", "Системный лог"),
|
||
("show running-config", "Текущая конфигурация"),
|
||
("show startup-config", "Стартовая конфигурация"),
|
||
("show components", "Установленные компоненты"),
|
||
],
|
||
"Сеть": [
|
||
("show interface", "Все интерфейсы"),
|
||
("show interface WifiMaster0", "Wi-Fi 2.4 ГГц"),
|
||
("show interface WifiMaster1", "Wi-Fi 5 ГГц"),
|
||
("show interface PPPoE0", "PPPoE-подключение"),
|
||
("show ip route", "Таблица маршрутов"),
|
||
("show ip arp", "ARP-таблица"),
|
||
("show ip dhcp bindings", "DHCP-аренды"),
|
||
("show ip dhcp pool", "DHCP-пулы"),
|
||
("show ip hotspot", "Подключённые устройства"),
|
||
("show ip hotspot host", "Список хостов"),
|
||
("show ndns", "Статус KeenDNS"),
|
||
("show internet status", "Статус интернета"),
|
||
],
|
||
"VPN": [
|
||
("show crypto ipsec sa", "IPsec-туннели"),
|
||
("show vpn-server", "VPN-сервер"),
|
||
("show interface Wireguard0", "WireGuard статус"),
|
||
("show interface OpenVPN0", "OpenVPN статус"),
|
||
("show interface PPTP0", "PPTP статус"),
|
||
("show interface L2TP0", "L2TP статус"),
|
||
("show interface SSTP0", "SSTP статус"),
|
||
],
|
||
"Wi-Fi": [
|
||
("show associations", "Подключённые клиенты Wi-Fi"),
|
||
("show interface WifiMaster0 wps", "WPS статус 2.4"),
|
||
("show interface WifiMaster1 wps", "WPS статус 5"),
|
||
("interface WifiMaster0 down", "Выключить Wi-Fi 2.4"),
|
||
("interface WifiMaster0 up", "Включить Wi-Fi 2.4"),
|
||
("interface WifiMaster1 down", "Выключить Wi-Fi 5"),
|
||
("interface WifiMaster1 up", "Включить Wi-Fi 5"),
|
||
],
|
||
"USB и накопители": [
|
||
("show usb", "USB-устройства"),
|
||
("show media", "Накопители"),
|
||
("show printer", "USB-принтер"),
|
||
("show interface UsbModem0", "USB-модем"),
|
||
],
|
||
"Диагностика": [
|
||
("tools ping 8.8.8.8", "Пинг Google DNS"),
|
||
("tools ping 1.1.1.1", "Пинг Cloudflare"),
|
||
("tools traceroute 8.8.8.8", "Трассировка"),
|
||
("tools dns-lookup google.com", "DNS-запрос"),
|
||
("tools ntp-sync", "Синхронизация времени"),
|
||
("show speed-test", "Speedtest (если доступен)"),
|
||
],
|
||
"Пользователи и пароли": [
|
||
("show user", "Список пользователей роутера"),
|
||
("user admin", "Войти в настройки пользователя admin"),
|
||
("user admin password newpass", "Сменить пароль admin"),
|
||
("user add username", "Создать пользователя"),
|
||
("user username password newpass", "Задать пароль пользователю"),
|
||
("user username tag http", "Разрешить доступ к web-интерфейсу"),
|
||
("user username tag cli", "Разрешить доступ к CLI"),
|
||
("user username tag http,cli,ftp", "Доступ: web + CLI + FTP"),
|
||
("no user username", "Удалить пользователя"),
|
||
("user username permit http 192.168.1.0/24", "Доступ только из подсети"),
|
||
("ip http security-level private", "Web-интерфейс только из LAN"),
|
||
("ip http security-level public", "Web-интерфейс из WAN (осторожно!)"),
|
||
("ip telnet security-level private", "Telnet только из LAN"),
|
||
("ip ssh security-level private", "SSH только из LAN"),
|
||
],
|
||
"Управление": [
|
||
("system configuration save", "Сохранить конфигурацию"),
|
||
("system reboot", "Перезагрузка роутера"),
|
||
("system log clear", "Очистить лог"),
|
||
("tools firmware check", "Проверить обновления прошивки"),
|
||
("tools firmware upgrade", "Обновить прошивку"),
|
||
("copy running-config startup-config", "Применить конфигурацию"),
|
||
],
|
||
},
|
||
"Быстрые скрипты": {
|
||
"Мониторинг": [
|
||
("watch -n 2 'free -h && echo --- && df -h /'", "Память+диск каждые 2 сек"),
|
||
("while true; do date; ss -s; sleep 5; done", "Счётчик соединений каждые 5 сек"),
|
||
("tail -f /var/log/syslog | grep -i error", "Ошибки в сислоге (live)"),
|
||
("sar -u 1 10", "CPU-нагрузка 10 замеров"),
|
||
],
|
||
"Сеть (скрипты)": [
|
||
("for h in 8.8.8.8 1.1.1.1 ya.ru; do echo -n \"$h: \"; ping -c1 -W2 $h | grep time= || echo FAIL; done", "Пинг 3 хостов"),
|
||
("curl -so /dev/null -w '%{time_total}s' https://google.com && echo", "Время ответа Google"),
|
||
("ss -s", "Сводка по соединениям"),
|
||
],
|
||
"Очистка": [
|
||
("apt autoremove -y && apt clean", "Очистка apt (Debian/Ubuntu)"),
|
||
("journalctl --vacuum-size=100M", "Обрезать логи до 100MB"),
|
||
("find /tmp -mtime +7 -delete", "Удалить файлы /tmp старше 7 дней"),
|
||
("find /var/log -name '*.gz' -delete", "Удалить сжатые логи"),
|
||
],
|
||
},
|
||
}
|
||
|
||
# ── Command Agent Knowledge Base ───────────────────────────────
|
||
|
||
AGENT_KB = [
|
||
(["обновить", "update", "upgrade", "обновление", "апдейт"],
|
||
"linux", [
|
||
("sudo apt update && sudo apt upgrade -y", "Обновить все пакеты (Debian/Ubuntu)"),
|
||
("sudo apt dist-upgrade -y", "Полное обновление с зависимостями"),
|
||
("sudo yum update -y", "Обновить все пакеты (CentOS/RHEL)"),
|
||
("sudo dnf upgrade -y", "Обновить все пакеты (Fedora)"),
|
||
("sudo apt list --upgradable", "Показать доступные обновления"),
|
||
]),
|
||
(["перезагрузить", "reboot", "restart", "рестарт", "ребут"],
|
||
"linux", [
|
||
("sudo reboot", "Перезагрузить сервер"),
|
||
("sudo shutdown -r +1 'Reboot in 1 min'", "Перезагрузка через 1 минуту"),
|
||
("sudo systemctl restart nginx", "Перестартовать nginx"),
|
||
("sudo systemctl restart sshd", "Перестартовать SSH"),
|
||
]),
|
||
(["перезагрузить", "reboot", "restart"],
|
||
"keenetic", [
|
||
("system reboot", "Перезагрузить роутер"),
|
||
]),
|
||
(["место", "disk", "диск", "свободно", "занято", "очистить диск", "нет места"],
|
||
"linux", [
|
||
("df -hT", "Показать свободное место"),
|
||
("du -sh /* 2>/dev/null | sort -rh | head -10", "Топ-10 тяжёлых папок"),
|
||
("sudo apt autoremove -y && sudo apt clean", "Очистить кэш apt"),
|
||
("sudo journalctl --vacuum-size=100M", "Обрезать логи до 100MB"),
|
||
("find /var/log -name '*.gz' -delete", "Удалить сжатые логи"),
|
||
("docker system prune -af", "Очистить Docker (образы, контейнеры)"),
|
||
]),
|
||
(["память", "ram", "memory", "swap", "oom"],
|
||
"linux", [
|
||
("free -h", "Показать RAM и swap"),
|
||
("top -bn1 | head -20", "Топ процессов по CPU/RAM"),
|
||
("ps aux --sort=-%mem | head -15", "Процессы по потреблению RAM"),
|
||
("sudo swapon --show", "Активные swap-разделы"),
|
||
("sudo sync && echo 3 | sudo tee /proc/sys/vm/drop_caches", "Очистить кэш памяти"),
|
||
]),
|
||
(["пользователь", "user", "юзер", "создать пользователя", "добавить пользователя"],
|
||
"linux", [
|
||
("sudo useradd -m -s /bin/bash username", "Создать пользователя"),
|
||
("sudo useradd -m -s /bin/bash -G sudo username", "Создать с sudo"),
|
||
("sudo passwd username", "Задать пароль"),
|
||
("sudo userdel -r username", "Удалить пользователя"),
|
||
("cat /etc/passwd | grep -v nologin", "Пользователи с шеллом"),
|
||
]),
|
||
(["пользователь", "user", "юзер", "пароль"],
|
||
"keenetic", [
|
||
("show user", "Список пользователей роутера"),
|
||
("user add username", "Создать пользователя"),
|
||
("user admin password newpass", "Сменить пароль admin"),
|
||
("no user username", "Удалить пользователя"),
|
||
]),
|
||
(["пароль", "password", "сменить пароль"],
|
||
"linux", [
|
||
("passwd", "Сменить свой пароль"),
|
||
("sudo passwd username", "Сменить пароль другому"),
|
||
("sudo chage -l username", "Срок действия пароля"),
|
||
("echo 'user:pass' | sudo chpasswd", "Задать пароль одной командой"),
|
||
]),
|
||
(["порт", "port", "слушает", "listen", "открытые порты"],
|
||
"linux", [
|
||
("ss -tulpn", "Все слушающие порты"),
|
||
("ss -tulpn | grep :80", "Кто слушает порт 80"),
|
||
("sudo lsof -i :443", "Процесс на порту 443"),
|
||
("sudo ufw allow 8080/tcp", "Открыть порт 8080"),
|
||
("sudo iptables -A INPUT -p tcp --dport 3000 -j ACCEPT", "Открыть порт iptables"),
|
||
]),
|
||
(["файрвол", "firewall", "ufw", "iptables", "заблокировать"],
|
||
"linux", [
|
||
("sudo ufw status verbose", "Статус UFW"),
|
||
("sudo ufw enable", "Включить UFW"),
|
||
("sudo ufw allow ssh", "Разрешить SSH"),
|
||
("sudo ufw allow 80,443/tcp", "Разрешить HTTP/HTTPS"),
|
||
("sudo ufw deny from 1.2.3.4", "Заблокировать IP"),
|
||
("sudo iptables -L -n --line-numbers", "Правила iptables"),
|
||
]),
|
||
(["сервис", "service", "демон", "systemctl", "запустить", "остановить"],
|
||
"linux", [
|
||
("sudo systemctl status servicename", "Статус сервиса"),
|
||
("sudo systemctl start servicename", "Запустить"),
|
||
("sudo systemctl stop servicename", "Остановить"),
|
||
("sudo systemctl restart servicename", "Перезапустить"),
|
||
("sudo systemctl enable servicename", "Автозапуск"),
|
||
("sudo systemctl disable servicename", "Убрать из автозапуска"),
|
||
("journalctl -u servicename -f", "Логи сервиса (live)"),
|
||
]),
|
||
(["лог", "log", "логи", "журнал", "ошибки"],
|
||
"linux", [
|
||
("journalctl -xe --no-pager | tail -50", "Последние системные логи"),
|
||
("tail -f /var/log/syslog", "Сислог (live)"),
|
||
("tail -f /var/log/auth.log", "Логи авторизации (live)"),
|
||
("journalctl --since '1 hour ago'", "Логи за последний час"),
|
||
("dmesg | tail -30", "Сообщения ядра"),
|
||
("grep -i error /var/log/syslog | tail -20", "Ошибки в сислоге"),
|
||
]),
|
||
(["лог", "log"],
|
||
"keenetic", [
|
||
("show log", "Системный лог роутера"),
|
||
("system log clear", "Очистить лог"),
|
||
]),
|
||
(["ssh", "ключ", "key", "ssh-ключ", "авторизация"],
|
||
"linux", [
|
||
("ssh-keygen -t ed25519 -C 'my key'", "Генерировать ключ ed25519"),
|
||
("cat ~/.ssh/id_ed25519.pub", "Показать публичный ключ"),
|
||
("ssh-copy-id user@host", "Скопировать ключ на сервер"),
|
||
("chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys", "Права SSH"),
|
||
]),
|
||
(["docker", "контейнер", "container"],
|
||
"linux", [
|
||
("docker ps", "Запущенные контейнеры"),
|
||
("docker ps -a", "Все контейнеры"),
|
||
("docker logs --tail 50 container_name", "Логи контейнера"),
|
||
("docker restart container_name", "Перезапустить контейнер"),
|
||
("docker system prune -f", "Очистить мусор"),
|
||
("docker stats --no-stream", "Ресурсы контейнеров"),
|
||
]),
|
||
(["ip", "адрес", "сеть", "network", "интерфейс"],
|
||
"linux", [
|
||
("ip addr", "IP-адреса"),
|
||
("ip route", "Маршруты"),
|
||
("curl -s ifconfig.me", "Внешний IP"),
|
||
("cat /etc/resolv.conf", "DNS-серверы"),
|
||
("ping -c 4 8.8.8.8", "Пинг Google"),
|
||
]),
|
||
(["ip", "адрес", "сеть", "интерфейс"],
|
||
"keenetic", [
|
||
("show interface", "Все интерфейсы"),
|
||
("show ip route", "Маршруты"),
|
||
("show ip hotspot", "Подключённые устройства"),
|
||
("show internet status", "Статус интернета"),
|
||
]),
|
||
(["vpn", "wireguard", "openvpn", "туннель"],
|
||
"keenetic", [
|
||
("show interface Wireguard0", "WireGuard статус"),
|
||
("show interface OpenVPN0", "OpenVPN статус"),
|
||
("show vpn-server", "VPN-сервер"),
|
||
("show crypto ipsec sa", "IPsec-туннели"),
|
||
]),
|
||
(["wifi", "wi-fi", "вайфай", "беспроводн"],
|
||
"keenetic", [
|
||
("show associations", "Клиенты Wi-Fi"),
|
||
("show interface WifiMaster0", "Wi-Fi 2.4 ГГц"),
|
||
("show interface WifiMaster1", "Wi-Fi 5 ГГц"),
|
||
("interface WifiMaster0 down", "Выключить 2.4"),
|
||
("interface WifiMaster0 up", "Включить 2.4"),
|
||
]),
|
||
(["dhcp", "аренда", "lease"],
|
||
"keenetic", [
|
||
("show ip dhcp bindings", "DHCP-аренды"),
|
||
("show ip dhcp pool", "DHCP-пулы"),
|
||
]),
|
||
(["dns", "днс", "resolve"],
|
||
"linux", [
|
||
("dig google.com +short", "DNS-запрос"),
|
||
("nslookup google.com", "nslookup"),
|
||
("cat /etc/resolv.conf", "DNS-серверы"),
|
||
("systemd-resolve --status | head -20", "systemd-resolved статус"),
|
||
]),
|
||
(["прошивка", "firmware", "обновить роутер"],
|
||
"keenetic", [
|
||
("show version", "Текущая прошивка"),
|
||
("tools firmware check", "Проверить обновления"),
|
||
("tools firmware upgrade", "Обновить прошивку"),
|
||
("show components", "Компоненты"),
|
||
]),
|
||
(["сохранить", "save", "конфиг", "config"],
|
||
"keenetic", [
|
||
("system configuration save", "Сохранить конфигурацию"),
|
||
("show running-config", "Текущая конфигурация"),
|
||
("copy running-config startup-config", "Применить конфиг"),
|
||
]),
|
||
(["cron", "расписание", "schedule", "автоматически"],
|
||
"linux", [
|
||
("crontab -l", "Показать задачи cron"),
|
||
("crontab -e", "Редактировать cron"),
|
||
("systemctl list-timers", "Systemd-таймеры"),
|
||
("echo '0 3 * * * /path/script.sh' | crontab -", "Запуск каждый день в 3:00"),
|
||
]),
|
||
(["nginx", "веб-сервер", "apache", "сайт"],
|
||
"linux", [
|
||
("sudo systemctl status nginx", "Статус nginx"),
|
||
("sudo nginx -t", "Проверить конфиг nginx"),
|
||
("sudo systemctl reload nginx", "Перечитать конфиг"),
|
||
("tail -f /var/log/nginx/error.log", "Ошибки nginx (live)"),
|
||
("tail -f /var/log/nginx/access.log", "Доступ nginx (live)"),
|
||
]),
|
||
(["ssl", "сертификат", "certbot", "https", "let's encrypt"],
|
||
"linux", [
|
||
("sudo certbot --nginx -d domain.com", "Получить SSL (nginx)"),
|
||
("sudo certbot renew --dry-run", "Тест обновления сертов"),
|
||
("sudo certbot certificates", "Список сертификатов"),
|
||
("openssl x509 -in cert.pem -noout -dates", "Срок действия серта"),
|
||
]),
|
||
(["процесс", "process", "kill", "убить", "завис"],
|
||
"linux", [
|
||
("ps aux | grep process_name", "Найти процесс"),
|
||
("kill -9 PID", "Убить по PID"),
|
||
("pkill -f process_name", "Убить по имени"),
|
||
("top -bn1 | head -20", "Топ процессов"),
|
||
("htop", "Интерактивный мониторинг"),
|
||
]),
|
||
]
|
||
|
||
def agent_search(query):
|
||
query_lower = query.lower()
|
||
results = []
|
||
seen = set()
|
||
for keywords, platform, cmds in AGENT_KB:
|
||
score = sum(1 for kw in keywords if kw in query_lower)
|
||
if score > 0:
|
||
for cmd, desc in cmds:
|
||
if cmd not in seen:
|
||
results.append((score, platform, cmd, desc))
|
||
seen.add(cmd)
|
||
results.sort(key=lambda x: -x[0])
|
||
return results[:12]
|
||
|
||
|
||
def app_dir():
|
||
if getattr(sys, "frozen", False):
|
||
return Path(sys.executable).parent
|
||
return Path(__file__).parent
|
||
|
||
SESSIONS_FILE = app_dir() / "sessions.json"
|
||
|
||
# ── Session Store ──────────────────────────────────────────────
|
||
|
||
class SessionStore:
|
||
def __init__(self, path=SESSIONS_FILE):
|
||
self.path = Path(path)
|
||
self.data = {"groups": [], "sessions": []}
|
||
self.load()
|
||
|
||
@property
|
||
def sessions(self):
|
||
return self.data["sessions"]
|
||
|
||
@property
|
||
def groups(self):
|
||
return self.data["groups"]
|
||
|
||
def load(self):
|
||
if self.path.exists():
|
||
try:
|
||
raw = json.loads(self.path.read_text("utf-8"))
|
||
if isinstance(raw, list):
|
||
self.data = {"groups": ["Без группы"], "sessions": []}
|
||
for s in raw:
|
||
s.setdefault("group", "Без группы")
|
||
self.data["sessions"].append(s)
|
||
self.save()
|
||
else:
|
||
self.data = raw
|
||
self.data.setdefault("groups", [])
|
||
self.data.setdefault("sessions", [])
|
||
except Exception:
|
||
self.data = {"groups": [], "sessions": []}
|
||
|
||
def save(self):
|
||
self.path.write_text(json.dumps(self.data, indent=2, ensure_ascii=False), "utf-8")
|
||
|
||
def add_group(self, name):
|
||
if name and name not in self.groups:
|
||
self.groups.append(name)
|
||
self.save()
|
||
|
||
def rename_group(self, old_name, new_name):
|
||
if old_name in self.groups and new_name:
|
||
idx = self.groups.index(old_name)
|
||
self.groups[idx] = new_name
|
||
for s in self.sessions:
|
||
if s.get("group") == old_name:
|
||
s["group"] = new_name
|
||
self.save()
|
||
|
||
def remove_group(self, name):
|
||
if name in self.groups:
|
||
self.groups.remove(name)
|
||
for s in self.sessions:
|
||
if s.get("group") == name:
|
||
s["group"] = ""
|
||
self.save()
|
||
|
||
def add(self, host, port, user, password, name=None, group=""):
|
||
for s in self.sessions:
|
||
if s["host"] == host and s["port"] == port and s["user"] == user:
|
||
s["password"] = self._encode(password)
|
||
s["last_used"] = datetime.now().isoformat()
|
||
if name:
|
||
s["name"] = name
|
||
if group:
|
||
s["group"] = group
|
||
self.save()
|
||
return s
|
||
entry = {
|
||
"name": name or f"{user}@{host}",
|
||
"host": host,
|
||
"port": port,
|
||
"user": user,
|
||
"password": self._encode(password),
|
||
"group": group,
|
||
"created": datetime.now().isoformat(),
|
||
"last_used": datetime.now().isoformat(),
|
||
}
|
||
self.sessions.append(entry)
|
||
if group and group not in self.groups:
|
||
self.groups.append(group)
|
||
self.save()
|
||
return entry
|
||
|
||
def remove(self, index):
|
||
if 0 <= index < len(self.sessions):
|
||
self.sessions.pop(index)
|
||
self.save()
|
||
|
||
def update(self, index, **kwargs):
|
||
if 0 <= index < len(self.sessions):
|
||
if "password" in kwargs:
|
||
kwargs["password"] = self._encode(kwargs["password"])
|
||
self.sessions[index].update(kwargs)
|
||
self.save()
|
||
|
||
def move_to_group(self, index, group):
|
||
if 0 <= index < len(self.sessions):
|
||
self.sessions[index]["group"] = group
|
||
self.save()
|
||
|
||
def get_password(self, session):
|
||
return self._decode(session.get("password", ""))
|
||
|
||
@staticmethod
|
||
def _encode(pw):
|
||
if not pw:
|
||
return ""
|
||
return base64.b64encode(pw.encode()).decode()
|
||
|
||
@staticmethod
|
||
def _decode(encoded):
|
||
if not encoded:
|
||
return ""
|
||
try:
|
||
return base64.b64decode(encoded.encode()).decode()
|
||
except Exception:
|
||
return encoded
|
||
|
||
|
||
# ── ANSI Parser ────────────────────────────────────────────────
|
||
|
||
ANSI_COLORS = {
|
||
"30": "#1a1a1a", "31": "#cc4444", "32": "#44cc44", "33": "#cccc44",
|
||
"34": "#4488cc", "35": "#cc44cc", "36": "#44cccc", "37": "#cccccc",
|
||
"90": "#666666", "91": "#ff6666", "92": "#66ff66", "93": "#ffff66",
|
||
"94": "#6699ff", "95": "#ff66ff", "96": "#66ffff", "97": "#ffffff",
|
||
}
|
||
|
||
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b\[[\?]?[0-9;]*[hlmJKHfGr]")
|
||
|
||
|
||
def strip_ansi(text):
|
||
return ANSI_RE.sub("", text)
|
||
|
||
|
||
# ── SSH Terminal Widget ────────────────────────────────────────
|
||
|
||
class TerminalWidget(tk.Frame):
|
||
def __init__(self, master, on_close=None, **kw):
|
||
super().__init__(master, **kw)
|
||
self.on_close = on_close
|
||
self.channel = None
|
||
self.ssh = None
|
||
self.read_thread = None
|
||
self.running = False
|
||
|
||
self.text = tk.Text(
|
||
self,
|
||
bg="#1e1e2e",
|
||
fg="#cdd6f4",
|
||
insertbackground="#cdd6f4",
|
||
selectbackground="#45475a",
|
||
font=("Consolas", 11),
|
||
wrap=tk.CHAR,
|
||
padx=6,
|
||
pady=6,
|
||
borderwidth=0,
|
||
highlightthickness=0,
|
||
)
|
||
scrollbar = ttk.Scrollbar(self, command=self.text.yview)
|
||
self.text.configure(yscrollcommand=scrollbar.set)
|
||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||
self.text.pack(fill=tk.BOTH, expand=True)
|
||
|
||
self.text.bind("<Key>", self._on_key)
|
||
self.text.bind("<Return>", lambda e: self._send("\r"))
|
||
self.text.bind("<BackSpace>", lambda e: self._send("\x7f"))
|
||
self.text.bind("<Tab>", lambda e: self._send("\t"))
|
||
self.text.bind("<Up>", lambda e: self._send("\x1b[A"))
|
||
self.text.bind("<Down>", lambda e: self._send("\x1b[B"))
|
||
self.text.bind("<Right>", lambda e: self._send("\x1b[C"))
|
||
self.text.bind("<Left>", lambda e: self._send("\x1b[D"))
|
||
self.text.bind("<Home>", lambda e: self._send("\x1b[H"))
|
||
self.text.bind("<End>", lambda e: self._send("\x1b[F"))
|
||
self.text.bind("<Delete>", lambda e: self._send("\x1b[3~"))
|
||
self.text.bind("<Control-c>", lambda e: self._send("\x03"))
|
||
self.text.bind("<Control-d>", lambda e: self._send("\x04"))
|
||
self.text.bind("<Control-l>", lambda e: self._send("\x0c"))
|
||
self.text.bind("<Control-z>", lambda e: self._send("\x1a"))
|
||
|
||
self.text.tag_configure("error", foreground="#f38ba8")
|
||
self.text.tag_configure("info", foreground="#89b4fa")
|
||
for code, color in ANSI_COLORS.items():
|
||
self.text.tag_configure(f"fg{code}", foreground=color)
|
||
|
||
def connect(self, host, port, user, password):
|
||
self.running = True
|
||
self._write(f"Подключение к {user}@{host}:{port}...\n", "info")
|
||
t = threading.Thread(target=self._connect_thread, args=(host, port, user, password), daemon=True)
|
||
t.start()
|
||
|
||
def _connect_thread(self, host, port, user, password):
|
||
try:
|
||
self.ssh = paramiko.SSHClient()
|
||
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||
self.ssh.connect(
|
||
host, port=port, username=user, password=password,
|
||
timeout=10, look_for_keys=False, allow_agent=False,
|
||
)
|
||
cols = max(80, self.text.winfo_width() // 8)
|
||
rows = max(24, self.text.winfo_height() // 16)
|
||
self.channel = self.ssh.invoke_shell(
|
||
term="xterm-256color", width=cols, height=rows
|
||
)
|
||
self.channel.settimeout(0.1)
|
||
self._write(f"Подключено!\n", "info")
|
||
self._read_loop()
|
||
except Exception as ex:
|
||
self._write(f"\nОшибка: {ex}\n", "error")
|
||
self.running = False
|
||
if self.on_close:
|
||
self.after(100, self.on_close)
|
||
|
||
def _read_loop(self):
|
||
buf = b""
|
||
while self.running and self.channel and not self.channel.closed:
|
||
try:
|
||
data = self.channel.recv(4096)
|
||
if not data:
|
||
break
|
||
text = data.decode("utf-8", errors="replace")
|
||
clean = strip_ansi(text)
|
||
self._write(clean)
|
||
except socket.timeout:
|
||
continue
|
||
except Exception:
|
||
break
|
||
self._write("\n--- Сессия завершена ---\n", "error")
|
||
self.running = False
|
||
if self.on_close:
|
||
self.after(100, self.on_close)
|
||
|
||
def _write(self, text, tag=None):
|
||
def _do():
|
||
self.text.configure(state=tk.NORMAL)
|
||
if tag:
|
||
self.text.insert(tk.END, text, tag)
|
||
else:
|
||
self.text.insert(tk.END, text)
|
||
self.text.see(tk.END)
|
||
self.after(0, _do)
|
||
|
||
def _send(self, data):
|
||
if self.channel and not self.channel.closed:
|
||
try:
|
||
self.channel.send(data.encode() if isinstance(data, str) else data)
|
||
except Exception:
|
||
pass
|
||
return "break"
|
||
|
||
def _on_key(self, event):
|
||
if event.state & 4: # Ctrl
|
||
return
|
||
if event.char and ord(event.char) >= 32:
|
||
self._send(event.char)
|
||
return "break"
|
||
return "break"
|
||
|
||
def disconnect(self):
|
||
self.running = False
|
||
if self.channel:
|
||
try:
|
||
self.channel.close()
|
||
except Exception:
|
||
pass
|
||
if self.ssh:
|
||
try:
|
||
self.ssh.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ── Session Edit Dialog ────────────────────────────────────────
|
||
|
||
class SessionDialog(tk.Toplevel):
|
||
def __init__(self, parent, title="Сессия", session=None, groups=None):
|
||
super().__init__(parent)
|
||
self.title(title)
|
||
self.result = None
|
||
self.resizable(False, False)
|
||
self.grab_set()
|
||
|
||
frame = ttk.Frame(self, padding=15)
|
||
frame.pack(fill=tk.BOTH, expand=True)
|
||
|
||
fields = [
|
||
("Название:", "name", session.get("name", "") if session else ""),
|
||
("Описание:", "description", session.get("description", "") if session else ""),
|
||
("Хост:", "host", session.get("host", "") if session else ""),
|
||
("Порт:", "port", str(session.get("port", 22)) if session else "22"),
|
||
("Пользователь:", "user", session.get("user", "") if session else "root"),
|
||
("Пароль:", "password", ""),
|
||
]
|
||
|
||
self.entries = {}
|
||
for i, (label, key, default) in enumerate(fields):
|
||
ttk.Label(frame, text=label).grid(row=i, column=0, sticky="w", pady=3)
|
||
entry = ttk.Entry(frame, width=35)
|
||
if key == "password":
|
||
entry.configure(show="*")
|
||
entry.insert(0, default)
|
||
entry.grid(row=i, column=1, sticky="ew", pady=3, padx=(8, 0))
|
||
self.entries[key] = entry
|
||
|
||
# Group selector
|
||
row_group = len(fields)
|
||
ttk.Label(frame, text="Группа:").grid(row=row_group, column=0, sticky="w", pady=3)
|
||
group_frame = ttk.Frame(frame)
|
||
group_frame.grid(row=row_group, column=1, sticky="ew", pady=3, padx=(8, 0))
|
||
|
||
group_values = groups or []
|
||
current_group = session.get("group", "") if session else ""
|
||
self.group_var = tk.StringVar(value=current_group)
|
||
self.group_combo = ttk.Combobox(group_frame, textvariable=self.group_var,
|
||
values=group_values, width=20)
|
||
self.group_combo.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||
|
||
btn_frame = ttk.Frame(frame)
|
||
btn_frame.grid(row=row_group + 1, column=0, columnspan=2, pady=(12, 0))
|
||
ttk.Button(btn_frame, text="OK", command=self._ok).pack(side=tk.LEFT, padx=4)
|
||
ttk.Button(btn_frame, text="Отмена", command=self.destroy).pack(side=tk.LEFT, padx=4)
|
||
|
||
self.entries["host"].focus_set()
|
||
self.bind("<Return>", lambda e: self._ok())
|
||
self.bind("<Escape>", lambda e: self.destroy())
|
||
|
||
self.wait_window()
|
||
|
||
def _ok(self):
|
||
host = self.entries["host"].get().strip()
|
||
if not host:
|
||
messagebox.showwarning("Ошибка", "Хост обязателен", parent=self)
|
||
return
|
||
try:
|
||
port = int(self.entries["port"].get().strip() or "22")
|
||
except ValueError:
|
||
messagebox.showwarning("Ошибка", "Порт — число", parent=self)
|
||
return
|
||
self.result = {
|
||
"name": self.entries["name"].get().strip() or f"{self.entries['user'].get()}@{host}",
|
||
"description": self.entries["description"].get().strip(),
|
||
"host": host,
|
||
"port": port,
|
||
"user": self.entries["user"].get().strip() or "root",
|
||
"password": self.entries["password"].get(),
|
||
"group": self.group_var.get().strip(),
|
||
}
|
||
self.destroy()
|
||
|
||
|
||
# ── Main Application ───────────────────────────────────────────
|
||
|
||
class App(tk.Tk):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.title(f"{APP_NAME} — {APP_SUBTITLE} v{VERSION}")
|
||
self.geometry("1100x700")
|
||
self.minsize(800, 500)
|
||
self.configure(bg="#1e1e2e")
|
||
|
||
self.store = SessionStore()
|
||
self.terminals = {} # tab_id -> TerminalWidget
|
||
|
||
self._apply_theme()
|
||
self._build_ui()
|
||
self._refresh_session_list()
|
||
|
||
def _apply_theme(self):
|
||
style = ttk.Style()
|
||
style.theme_use("clam")
|
||
bg = "#1e1e2e"
|
||
fg = "#cdd6f4"
|
||
sel = "#45475a"
|
||
|
||
style.configure(".", background=bg, foreground=fg, fieldbackground="#313244", borderwidth=0)
|
||
style.configure("TFrame", background=bg)
|
||
style.configure("TLabel", background=bg, foreground=fg)
|
||
style.configure("TButton", background="#45475a", foreground=fg, padding=(10, 5))
|
||
style.map("TButton", background=[("active", "#585b70")])
|
||
style.configure("Treeview", background="#313244", foreground=fg, fieldbackground="#313244",
|
||
rowheight=28, borderwidth=0)
|
||
style.configure("Treeview.Heading", background="#45475a", foreground=fg)
|
||
style.map("Treeview", background=[("selected", sel)])
|
||
style.configure("TEntry", fieldbackground="#313244", foreground=fg)
|
||
style.configure("TNotebook", background=bg, borderwidth=0)
|
||
style.configure("TNotebook.Tab", background="#45475a", foreground=fg, padding=(12, 6))
|
||
style.map("TNotebook.Tab", background=[("selected", "#585b70")])
|
||
|
||
def _open_url(self, url):
|
||
import webbrowser
|
||
webbrowser.open(url)
|
||
|
||
def _draw_pca_logo(self, canvas, cx, cy, size):
|
||
r = size // 2
|
||
canvas.create_oval(cx - r, cy - r, cx + r, cy + r, fill="#2a2a3d", outline="#3b3b5c", width=2)
|
||
label_font = ("Arial", size // 3, "bold")
|
||
canvas.create_text(cx, cy - 2, text="PCA", fill="#6c8fff", font=label_font)
|
||
dots = [(cx - 4, cy + r // 2), (cx, cy + r // 2 + 4), (cx + 4, cy + r // 2),
|
||
(cx - 6, cy + r // 2 + 6), (cx, cy + r // 2 + 10), (cx + 6, cy + r // 2 + 6)]
|
||
for dx, dy in dots:
|
||
canvas.create_oval(dx - 1, dy - 1, dx + 1, dy + 1, fill="#6c8fff", outline="")
|
||
|
||
def _build_ui(self):
|
||
# ── Top: PCA brand bar ──
|
||
top_bar = tk.Frame(self, bg="#181825", height=48)
|
||
top_bar.pack(fill=tk.X, side=tk.TOP)
|
||
top_bar.pack_propagate(False)
|
||
|
||
logo_canvas = tk.Canvas(top_bar, width=36, height=36, bg="#181825", highlightthickness=0)
|
||
logo_canvas.pack(side=tk.LEFT, padx=(10, 6), pady=6)
|
||
self._draw_pca_logo(logo_canvas, 18, 16, 30)
|
||
|
||
brand_frame = tk.Frame(top_bar, bg="#181825")
|
||
brand_frame.pack(side=tk.LEFT, pady=4)
|
||
tk.Label(
|
||
brand_frame, text=APP_NAME, bg="#181825", fg="#cdd6f4",
|
||
font=("Consolas", 13, "bold"),
|
||
).pack(anchor="w")
|
||
tk.Label(
|
||
brand_frame, text=APP_SUBTITLE, bg="#181825", fg="#6c7086",
|
||
font=("Consolas", 9),
|
||
).pack(anchor="w")
|
||
|
||
sep_v = tk.Frame(top_bar, bg="#45475a", width=1)
|
||
sep_v.pack(side=tk.LEFT, fill=tk.Y, padx=12, pady=8)
|
||
|
||
links = [
|
||
("GitHub", "https://github.com/nickolay-frolov"),
|
||
("Boosty", "https://boosty.to/lot_andrey"),
|
||
("Telegram", "https://t.me/lot_andrey"),
|
||
("Поддержать (СБП)", "https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798"),
|
||
]
|
||
for i, (text, url) in enumerate(links):
|
||
if i > 0:
|
||
tk.Label(top_bar, text="·", bg="#181825", fg="#6c7086", font=("Consolas", 9)).pack(side=tk.LEFT)
|
||
lnk = tk.Label(
|
||
top_bar, text=text, bg="#181825", fg="#89b4fa",
|
||
font=("Consolas", 9, "underline"), cursor="hand2",
|
||
)
|
||
lnk.pack(side=tk.LEFT, padx=6)
|
||
lnk.bind("<Button-1>", lambda e, u=url: self._open_url(u))
|
||
|
||
tk.Label(
|
||
top_bar, text="@lot_andrey", bg="#181825", fg="#6c7086",
|
||
font=("Consolas", 9),
|
||
).pack(side=tk.RIGHT, padx=10)
|
||
|
||
paned = ttk.PanedWindow(self, orient=tk.HORIZONTAL)
|
||
paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
|
||
|
||
# ── Left: session list with groups ──
|
||
left = ttk.Frame(paned, width=300)
|
||
paned.add(left, weight=0)
|
||
|
||
toolbar = ttk.Frame(left)
|
||
toolbar.pack(fill=tk.X, pady=(0, 2))
|
||
ttk.Button(toolbar, text="+ Сессия", command=self._new_session).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(toolbar, text="✎", command=self._edit_session, width=3).pack(side=tk.LEFT, padx=1)
|
||
ttk.Button(toolbar, text="✕", command=self._delete_session, width=3).pack(side=tk.LEFT, padx=1)
|
||
|
||
toolbar2 = ttk.Frame(left)
|
||
toolbar2.pack(fill=tk.X, pady=(0, 4))
|
||
ttk.Button(toolbar2, text="+ Группа", command=self._new_group).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(toolbar2, text="✎ Группа", command=self._rename_group).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(toolbar2, text="✕ Группа", command=self._delete_group).pack(side=tk.LEFT, padx=2)
|
||
|
||
self.tree = ttk.Treeview(left, columns=("desc", "addr"), show="tree headings", selectmode="browse")
|
||
self.tree.heading("#0", text="Сессия")
|
||
self.tree.heading("desc", text="Описание")
|
||
self.tree.heading("addr", text="Адрес")
|
||
self.tree.column("#0", width=130, minwidth=80)
|
||
self.tree.column("desc", width=100, minwidth=60)
|
||
self.tree.column("addr", width=120, minwidth=70)
|
||
tree_scroll = ttk.Scrollbar(left, command=self.tree.yview)
|
||
self.tree.configure(yscrollcommand=tree_scroll.set)
|
||
|
||
tree_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||
self.tree.pack(fill=tk.BOTH, expand=True)
|
||
self.tree.bind("<Double-1>", self._on_tree_double_click)
|
||
|
||
# Drag & drop state
|
||
self._drag_item = None
|
||
self._drag_after_id = None
|
||
self.tree.bind("<ButtonPress-1>", self._drag_start)
|
||
self.tree.bind("<B1-Motion>", self._drag_motion)
|
||
self.tree.bind("<ButtonRelease-1>", self._drag_end)
|
||
|
||
# Right-click context menu
|
||
self._ctx_menu = tk.Menu(self, tearoff=0)
|
||
self._ctx_menu.configure(bg="#313244", fg="#cdd6f4", activebackground="#45475a")
|
||
self.tree.bind("<Button-3>", self._show_context_menu)
|
||
if sys.platform == "darwin":
|
||
self.tree.bind("<Button-2>", self._show_context_menu)
|
||
self.tree.bind("<Control-Button-1>", self._show_context_menu)
|
||
|
||
# Quick connect bar
|
||
qf = ttk.LabelFrame(left, text="Быстрое подключение", padding=8)
|
||
qf.pack(fill=tk.X, pady=(6, 0))
|
||
|
||
row1 = ttk.Frame(qf)
|
||
row1.pack(fill=tk.X, pady=2)
|
||
ttk.Label(row1, text="Хост:").pack(side=tk.LEFT)
|
||
self.q_host = ttk.Entry(row1, width=16)
|
||
self.q_host.pack(side=tk.LEFT, padx=(4, 8))
|
||
ttk.Label(row1, text="Порт:").pack(side=tk.LEFT)
|
||
self.q_port = ttk.Entry(row1, width=6)
|
||
self.q_port.insert(0, "22")
|
||
self.q_port.pack(side=tk.LEFT, padx=4)
|
||
|
||
row2 = ttk.Frame(qf)
|
||
row2.pack(fill=tk.X, pady=2)
|
||
ttk.Label(row2, text="Юзер:").pack(side=tk.LEFT)
|
||
self.q_user = ttk.Entry(row2, width=12)
|
||
self.q_user.insert(0, "root")
|
||
self.q_user.pack(side=tk.LEFT, padx=(4, 8))
|
||
ttk.Label(row2, text="Пароль:").pack(side=tk.LEFT)
|
||
self.q_pass = ttk.Entry(row2, width=12, show="*")
|
||
self.q_pass.pack(side=tk.LEFT, padx=4)
|
||
|
||
ttk.Button(qf, text="Подключиться", command=self._quick_connect).pack(fill=tk.X, pady=(6, 0))
|
||
self.q_host.bind("<Return>", lambda e: self._quick_connect())
|
||
self.q_pass.bind("<Return>", lambda e: self._quick_connect())
|
||
|
||
# ── Center: terminal tabs ──
|
||
right_paned = ttk.PanedWindow(paned, orient=tk.HORIZONTAL)
|
||
paned.add(right_paned, weight=1)
|
||
|
||
self.notebook = ttk.Notebook(right_paned)
|
||
right_paned.add(self.notebook, weight=1)
|
||
|
||
# ── Right: commands panel ──
|
||
cmd_frame = ttk.Frame(right_paned, width=320)
|
||
right_paned.add(cmd_frame, weight=0)
|
||
|
||
cmd_header = tk.Label(
|
||
cmd_frame, text="Команды", bg="#181825", fg="#cdd6f4",
|
||
font=("Consolas", 11, "bold"), pady=6,
|
||
)
|
||
cmd_header.pack(fill=tk.X)
|
||
|
||
# Search bar
|
||
search_frame = ttk.Frame(cmd_frame)
|
||
search_frame.pack(fill=tk.X, padx=4, pady=4)
|
||
ttk.Label(search_frame, text="Поиск:").pack(side=tk.LEFT)
|
||
self.cmd_search = ttk.Entry(search_frame, width=20)
|
||
self.cmd_search.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(4, 0))
|
||
self.cmd_search.bind("<KeyRelease>", self._filter_commands)
|
||
|
||
# Commands tree
|
||
cmd_tree_frame = ttk.Frame(cmd_frame)
|
||
cmd_tree_frame.pack(fill=tk.BOTH, expand=True)
|
||
|
||
self.cmd_tree = ttk.Treeview(
|
||
cmd_tree_frame, columns=("desc",), show="tree headings",
|
||
selectmode="browse",
|
||
)
|
||
self.cmd_tree.heading("#0", text="Команда")
|
||
self.cmd_tree.heading("desc", text="Описание")
|
||
self.cmd_tree.column("#0", width=200, minwidth=120)
|
||
self.cmd_tree.column("desc", width=180, minwidth=100)
|
||
|
||
cmd_scroll = ttk.Scrollbar(cmd_tree_frame, command=self.cmd_tree.yview)
|
||
self.cmd_tree.configure(yscrollcommand=cmd_scroll.set)
|
||
cmd_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||
self.cmd_tree.pack(fill=tk.BOTH, expand=True)
|
||
|
||
self.cmd_tree.bind("<Double-1>", self._send_command)
|
||
|
||
# Hint label
|
||
hint = tk.Label(
|
||
cmd_frame, text="Двойной клик = отправить в терминал",
|
||
bg="#1e1e2e", fg="#6c7086", font=("Consolas", 8),
|
||
)
|
||
hint.pack(fill=tk.X, pady=2)
|
||
|
||
self._populate_commands()
|
||
|
||
# ── Agent tab (under commands) ──
|
||
agent_frame = ttk.LabelFrame(cmd_frame, text="Агент-подсказчик", padding=6)
|
||
agent_frame.pack(fill=tk.X, padx=4, pady=(4, 2))
|
||
|
||
agent_input_frame = ttk.Frame(agent_frame)
|
||
agent_input_frame.pack(fill=tk.X)
|
||
self.agent_entry = ttk.Entry(agent_input_frame, width=22)
|
||
self.agent_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 4))
|
||
self.agent_entry.insert(0, "как обновить ubuntu?")
|
||
ttk.Button(agent_input_frame, text="?", command=self._agent_ask, width=3).pack(side=tk.RIGHT)
|
||
self.agent_entry.bind("<Return>", lambda e: self._agent_ask())
|
||
|
||
self.agent_result = tk.Text(
|
||
agent_frame, height=8, bg="#181825", fg="#cdd6f4",
|
||
font=("Consolas", 9), wrap=tk.WORD, borderwidth=0,
|
||
insertbackground="#cdd6f4", highlightthickness=0,
|
||
)
|
||
self.agent_result.pack(fill=tk.X, pady=(4, 0))
|
||
self.agent_result.tag_configure("cmd", foreground="#a6e3a1", font=("Consolas", 9, "bold"))
|
||
self.agent_result.tag_configure("desc", foreground="#6c7086")
|
||
self.agent_result.tag_configure("platform", foreground="#89b4fa")
|
||
self.agent_result.tag_configure("hint", foreground="#f9e2af")
|
||
self.agent_result.configure(state=tk.DISABLED)
|
||
self.agent_result.bind("<Double-1>", self._agent_send_cmd)
|
||
|
||
# Welcome tab
|
||
welcome = ttk.Frame(self.notebook)
|
||
self.notebook.add(welcome, text="Добро пожаловать")
|
||
msg = ttk.Label(
|
||
welcome,
|
||
text=f"{APP_NAME} v{VERSION}\n\n"
|
||
"• Двойной клик по сессии = подключение\n"
|
||
"• Быстрое подключение внизу слева\n"
|
||
"• Все сессии сохраняются автоматически\n"
|
||
"• Ctrl+C/D/L работают в терминале\n"
|
||
"• Панель команд справа — клик = отправка\n"
|
||
"• Агент внизу справа — спроси что угодно",
|
||
font=("Consolas", 12),
|
||
justify=tk.CENTER,
|
||
)
|
||
msg.pack(expand=True)
|
||
|
||
# ── Drag & Drop ──
|
||
|
||
def _drag_start(self, event):
|
||
item = self.tree.identify_row(event.y)
|
||
if item and self.tree.parent(item):
|
||
self._drag_item = item
|
||
else:
|
||
self._drag_item = None
|
||
|
||
def _drag_motion(self, event):
|
||
if not self._drag_item:
|
||
return
|
||
target = self.tree.identify_row(event.y)
|
||
if target:
|
||
self.tree.selection_set(target)
|
||
|
||
def _drag_end(self, event):
|
||
if not self._drag_item:
|
||
return
|
||
target = self.tree.identify_row(event.y)
|
||
if not target or target == self._drag_item:
|
||
self._drag_item = None
|
||
return
|
||
|
||
src_iid = self._drag_item
|
||
src_tags = self.tree.item(src_iid, "tags")
|
||
if not src_tags or not src_tags[0].startswith("s_"):
|
||
self._drag_item = None
|
||
return
|
||
src_idx = int(src_tags[0].split("_")[1])
|
||
|
||
# Determine target group
|
||
target_tags = self.tree.item(target, "tags")
|
||
if target_tags and target_tags[0].startswith("g_"):
|
||
new_group = target_tags[0][2:]
|
||
elif target_tags and target_tags[0].startswith("s_"):
|
||
parent = self.tree.parent(target)
|
||
if parent:
|
||
ptags = self.tree.item(parent, "tags")
|
||
new_group = ptags[0][2:] if ptags and ptags[0].startswith("g_") else ""
|
||
else:
|
||
new_group = ""
|
||
else:
|
||
new_group = ""
|
||
|
||
if new_group == "__ungrouped__":
|
||
new_group = ""
|
||
|
||
self.store.move_to_group(src_idx, new_group)
|
||
self._refresh_session_list()
|
||
self._drag_item = None
|
||
|
||
# ── Context Menu ──
|
||
|
||
def _show_context_menu(self, event):
|
||
item = self.tree.identify_row(event.y)
|
||
if not item:
|
||
return
|
||
self.tree.selection_set(item)
|
||
self._ctx_menu.delete(0, tk.END)
|
||
|
||
tags = self.tree.item(item, "tags")
|
||
if tags and tags[0].startswith("s_"):
|
||
idx = int(tags[0].split("_")[1])
|
||
self._ctx_menu.add_command(label="Подключиться", command=lambda: self._connect_by_idx(idx))
|
||
self._ctx_menu.add_separator()
|
||
|
||
# Move to group submenu
|
||
move_menu = tk.Menu(self._ctx_menu, tearoff=0, bg="#313244", fg="#cdd6f4")
|
||
move_menu.add_command(label="Без группы", command=lambda: self._move_session(idx, ""))
|
||
for g in self.store.groups:
|
||
move_menu.add_command(label=g, command=lambda g=g: self._move_session(idx, g))
|
||
self._ctx_menu.add_cascade(label="Переместить в...", menu=move_menu)
|
||
self._ctx_menu.add_separator()
|
||
self._ctx_menu.add_command(label="Изменить", command=self._edit_session)
|
||
self._ctx_menu.add_command(label="Удалить", command=self._delete_session)
|
||
|
||
elif tags and tags[0].startswith("g_"):
|
||
gname = tags[0][2:]
|
||
if gname != "__ungrouped__":
|
||
self._ctx_menu.add_command(label="Переименовать", command=self._rename_group)
|
||
self._ctx_menu.add_command(label="Удалить группу", command=self._delete_group)
|
||
|
||
self._ctx_menu.tk_popup(event.x_root, event.y_root)
|
||
|
||
def _move_session(self, idx, group):
|
||
self.store.move_to_group(idx, group)
|
||
self._refresh_session_list()
|
||
|
||
def _connect_by_idx(self, idx):
|
||
s = self.store.sessions[idx]
|
||
pw = self.store.get_password(s)
|
||
self._open_terminal(s["host"], s["port"], s["user"], pw, s["name"])
|
||
|
||
# ── Agent ──
|
||
|
||
def _agent_ask(self):
|
||
query = self.agent_entry.get().strip()
|
||
if not query:
|
||
return
|
||
results = agent_search(query)
|
||
self.agent_result.configure(state=tk.NORMAL)
|
||
self.agent_result.delete("1.0", tk.END)
|
||
if not results:
|
||
self.agent_result.insert(tk.END, "Не нашёл команд. Попробуй другие слова:\n", "desc")
|
||
self.agent_result.insert(tk.END, "обновить, диск, память, порт, пароль,\n", "hint")
|
||
self.agent_result.insert(tk.END, "docker, nginx, ssl, vpn, wifi, dhcp", "hint")
|
||
else:
|
||
self.agent_result.insert(tk.END, "Двойной клик по команде = отправить\n\n", "desc")
|
||
for _, platform, cmd, desc in results:
|
||
tag = "linux" if platform == "linux" else "keenetic"
|
||
self.agent_result.insert(tk.END, f"[{platform}] ", "platform")
|
||
self.agent_result.insert(tk.END, f"{cmd}\n", "cmd")
|
||
self.agent_result.insert(tk.END, f" {desc}\n", "desc")
|
||
self.agent_result.configure(state=tk.DISABLED)
|
||
|
||
def _agent_send_cmd(self, event):
|
||
idx = self.agent_result.index(f"@{event.x},{event.y}")
|
||
line = self.agent_result.get(f"{idx} linestart", f"{idx} lineend").strip()
|
||
if line.startswith("["):
|
||
line = line.split("] ", 1)[-1] if "] " in line else line
|
||
if not line or line.startswith("Двойной") or line.startswith("Не нашёл"):
|
||
return
|
||
current_tab = self.notebook.select()
|
||
if not current_tab:
|
||
return
|
||
widget = self.nametowidget(current_tab)
|
||
if isinstance(widget, TerminalWidget) and widget.channel and not widget.channel.closed:
|
||
widget._send(line + "\r")
|
||
else:
|
||
messagebox.showinfo("Нет терминала", "Сначала подключитесь к серверу")
|
||
|
||
def _populate_commands(self, filter_text=""):
|
||
self.cmd_tree.delete(*self.cmd_tree.get_children())
|
||
ft = filter_text.lower()
|
||
for platform, categories in COMMANDS.items():
|
||
platform_id = self.cmd_tree.insert("", tk.END, text=platform, values=("",), open=not ft)
|
||
has_children = False
|
||
for category, cmds in categories.items():
|
||
cat_id = self.cmd_tree.insert(platform_id, tk.END, text=category, values=("",), open=bool(ft))
|
||
cat_has = False
|
||
for cmd, desc in cmds:
|
||
if ft and ft not in cmd.lower() and ft not in desc.lower() and ft not in category.lower():
|
||
continue
|
||
self.cmd_tree.insert(cat_id, tk.END, text=cmd, values=(desc,))
|
||
cat_has = True
|
||
has_children = True
|
||
if not cat_has:
|
||
self.cmd_tree.delete(cat_id)
|
||
if not has_children:
|
||
self.cmd_tree.delete(platform_id)
|
||
|
||
def _filter_commands(self, event=None):
|
||
text = self.cmd_search.get().strip()
|
||
self._populate_commands(text)
|
||
|
||
def _send_command(self, event=None):
|
||
sel = self.cmd_tree.selection()
|
||
if not sel:
|
||
return
|
||
item = sel[0]
|
||
cmd = self.cmd_tree.item(item, "text")
|
||
children = self.cmd_tree.get_children(item)
|
||
if children:
|
||
return
|
||
current_tab = self.notebook.select()
|
||
if not current_tab:
|
||
return
|
||
widget = self.nametowidget(current_tab)
|
||
if isinstance(widget, TerminalWidget) and widget.channel and not widget.channel.closed:
|
||
widget._send(cmd + "\r")
|
||
else:
|
||
messagebox.showinfo("Нет терминала", "Сначала подключитесь к серверу")
|
||
|
||
def _refresh_session_list(self):
|
||
self.tree.delete(*self.tree.get_children())
|
||
|
||
# Group sessions
|
||
grouped = {}
|
||
ungrouped = []
|
||
for i, s in enumerate(self.store.sessions):
|
||
g = s.get("group", "")
|
||
if g:
|
||
grouped.setdefault(g, []).append((i, s))
|
||
else:
|
||
ungrouped.append((i, s))
|
||
|
||
# Show groups from store order
|
||
for g in self.store.groups:
|
||
gid = self.tree.insert("", tk.END, text=f" {g}", values=("", ""),
|
||
open=True, tags=(f"g_{g}",))
|
||
for i, s in grouped.get(g, []):
|
||
desc = s.get("description", "")
|
||
self.tree.insert(gid, tk.END, text=s["name"],
|
||
values=(desc, f"{s['host']}:{s['port']}"),
|
||
tags=(f"s_{i}",))
|
||
|
||
# Groups that exist in sessions but not in store.groups
|
||
for g, items in grouped.items():
|
||
if g not in self.store.groups:
|
||
self.store.add_group(g)
|
||
gid = self.tree.insert("", tk.END, text=f" {g}", values=("", ""),
|
||
open=True, tags=(f"g_{g}",))
|
||
for i, s in items:
|
||
desc = s.get("description", "")
|
||
self.tree.insert(gid, tk.END, text=s["name"],
|
||
values=(desc, f"{s['host']}:{s['port']}"),
|
||
tags=(f"s_{i}",))
|
||
|
||
# Ungrouped sessions
|
||
if ungrouped:
|
||
uid = self.tree.insert("", tk.END, text=" Без группы", values=("", ""),
|
||
open=True, tags=("g___ungrouped__",))
|
||
for i, s in ungrouped:
|
||
desc = s.get("description", "")
|
||
self.tree.insert(uid, tk.END, text=s["name"],
|
||
values=(desc, f"{s['host']}:{s['port']}"),
|
||
tags=(f"s_{i}",))
|
||
|
||
def _get_selected_session_index(self):
|
||
sel = self.tree.selection()
|
||
if not sel:
|
||
return None
|
||
tags = self.tree.item(sel[0], "tags")
|
||
if tags and tags[0].startswith("s_"):
|
||
return int(tags[0].split("_")[1])
|
||
return None
|
||
|
||
def _get_selected_group_name(self):
|
||
sel = self.tree.selection()
|
||
if not sel:
|
||
return None
|
||
tags = self.tree.item(sel[0], "tags")
|
||
if tags and tags[0].startswith("g_"):
|
||
name = tags[0][2:]
|
||
return name if name != "__ungrouped__" else None
|
||
return None
|
||
|
||
def _on_tree_double_click(self, event):
|
||
idx = self._get_selected_session_index()
|
||
if idx is not None:
|
||
self._connect_by_idx(idx)
|
||
|
||
# ── Group CRUD ──
|
||
|
||
def _new_group(self):
|
||
from tkinter import simpledialog
|
||
name = simpledialog.askstring("Новая группа", "Название группы:", parent=self)
|
||
if name and name.strip():
|
||
self.store.add_group(name.strip())
|
||
self._refresh_session_list()
|
||
|
||
def _rename_group(self):
|
||
old = self._get_selected_group_name()
|
||
if not old:
|
||
sel = self.tree.selection()
|
||
if sel:
|
||
tags = self.tree.item(sel[0], "tags")
|
||
if tags and tags[0].startswith("s_"):
|
||
parent = self.tree.parent(sel[0])
|
||
if parent:
|
||
ptags = self.tree.item(parent, "tags")
|
||
if ptags and ptags[0].startswith("g_") and ptags[0][2:] != "__ungrouped__":
|
||
old = ptags[0][2:]
|
||
if not old:
|
||
return
|
||
from tkinter import simpledialog
|
||
new = simpledialog.askstring("Переименовать", f"Новое имя для «{old}»:", parent=self,
|
||
initialvalue=old)
|
||
if new and new.strip() and new.strip() != old:
|
||
self.store.rename_group(old, new.strip())
|
||
self._refresh_session_list()
|
||
|
||
def _delete_group(self):
|
||
name = self._get_selected_group_name()
|
||
if not name:
|
||
return
|
||
if messagebox.askyesno("Удалить группу?",
|
||
f"Удалить группу «{name}»?\nСессии переместятся в «Без группы»."):
|
||
self.store.remove_group(name)
|
||
self._refresh_session_list()
|
||
|
||
# ── Session CRUD ──
|
||
|
||
def _new_session(self):
|
||
# Pre-select group from tree selection
|
||
pregroup = self._get_selected_group_name() or ""
|
||
if not pregroup:
|
||
idx = self._get_selected_session_index()
|
||
if idx is not None:
|
||
pregroup = self.store.sessions[idx].get("group", "")
|
||
|
||
session_defaults = {"group": pregroup} if pregroup else None
|
||
dlg = SessionDialog(self, "Новая сессия", session=session_defaults,
|
||
groups=self.store.groups)
|
||
if dlg.result:
|
||
r = dlg.result
|
||
self.store.add(r["host"], r["port"], r["user"], r["password"],
|
||
r["name"], r.get("group", ""))
|
||
self._refresh_session_list()
|
||
|
||
def _edit_session(self):
|
||
idx = self._get_selected_session_index()
|
||
if idx is None:
|
||
return
|
||
s = self.store.sessions[idx].copy()
|
||
s["password"] = ""
|
||
dlg = SessionDialog(self, "Изменить сессию", s, groups=self.store.groups)
|
||
if dlg.result:
|
||
r = dlg.result
|
||
update = {"name": r["name"], "description": r.get("description", ""),
|
||
"host": r["host"], "port": r["port"],
|
||
"user": r["user"], "group": r.get("group", "")}
|
||
if r["password"]:
|
||
update["password"] = r["password"]
|
||
self.store.update(idx, **update)
|
||
self._refresh_session_list()
|
||
|
||
def _delete_session(self):
|
||
idx = self._get_selected_session_index()
|
||
if idx is None:
|
||
return
|
||
s = self.store.sessions[idx]
|
||
if messagebox.askyesno("Удалить?", f"Удалить сессию «{s['name']}»?"):
|
||
self.store.remove(idx)
|
||
self._refresh_session_list()
|
||
|
||
def _quick_connect(self):
|
||
host = self.q_host.get().strip()
|
||
if not host:
|
||
return
|
||
try:
|
||
port = int(self.q_port.get().strip() or "22")
|
||
except ValueError:
|
||
port = 22
|
||
user = self.q_user.get().strip() or "root"
|
||
pw = self.q_pass.get()
|
||
group = self._get_selected_group_name() or ""
|
||
|
||
self.store.add(host, port, user, pw, group=group)
|
||
self._refresh_session_list()
|
||
self._open_terminal(host, port, user, pw)
|
||
self.q_pass.delete(0, tk.END)
|
||
|
||
def _open_terminal(self, host, port, user, password, name=None):
|
||
tab_name = name or f"{user}@{host}"
|
||
|
||
def on_close():
|
||
pass
|
||
|
||
term = TerminalWidget(self.notebook, on_close=on_close)
|
||
self.notebook.add(term, text=f"● {tab_name}")
|
||
self.notebook.select(term)
|
||
term.connect(host, port, user, password)
|
||
|
||
close_btn_frame = ttk.Frame(term)
|
||
close_btn = ttk.Button(
|
||
close_btn_frame, text="✕ Закрыть вкладку",
|
||
command=lambda: self._close_tab(term),
|
||
)
|
||
close_btn.pack(side=tk.RIGHT, padx=4, pady=2)
|
||
close_btn_frame.pack(fill=tk.X, side=tk.BOTTOM)
|
||
|
||
def _close_tab(self, term):
|
||
term.disconnect()
|
||
self.notebook.forget(term)
|
||
term.destroy()
|
||
|
||
def destroy(self):
|
||
for child in self.notebook.winfo_children():
|
||
if isinstance(child, TerminalWidget):
|
||
child.disconnect()
|
||
super().destroy()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app = App()
|
||
app.mainloop()
|