From f624fee5e037c75c31ebaa242639bc40e12467c0 Mon Sep 17 00:00:00 2001 From: dinlo Date: Sat, 5 Sep 2026 19:46:45 +0800 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20Debian=20Manager=20?= =?UTF-8?q?=E2=80=94=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20Debian-=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSH/SFTP управление без агентов - Пакеты (apt), Docker, файлы, systemd, ufw, cron - Плейбуки, групповые операции, планировщик - Бэкапы, отчёты, Portainer, Proxmox VE, Gitea Deploy - Терминал, Telegram-уведомления, тёмная/светлая тема --- .gitignore | 30 +++ README.md | 342 ++++++++++++++++++++++++++++++++++ app/__init__.py | 0 app/core/agent.py | 217 +++++++++++++++++++++ app/core/backup.py | 98 ++++++++++ app/core/cron_svc.py | 126 +++++++++++++ app/core/diff.py | 43 +++++ app/core/docker_svc.py | 109 +++++++++++ app/core/files.py | 123 ++++++++++++ app/core/firewall.py | 98 ++++++++++ app/core/gitea.py | 110 +++++++++++ app/core/group_ops.py | 143 ++++++++++++++ app/core/monitor.py | 94 ++++++++++ app/core/notify.py | 91 +++++++++ app/core/packages.py | 101 ++++++++++ app/core/playbooks.py | 188 +++++++++++++++++++ app/core/proxmox.py | 132 +++++++++++++ app/core/reports.py | 58 ++++++ app/core/scheduler.py | 193 +++++++++++++++++++ app/core/servers.py | 47 +++++ app/core/ssh.py | 130 +++++++++++++ app/core/systemd_svc.py | 73 ++++++++ app/core/tasks.py | 81 ++++++++ app/core/terminal.py | 69 +++++++ app/core/webhook.py | 111 +++++++++++ app/db/database.py | 38 ++++ app/db/models.py | 96 ++++++++++ app/main.py | 177 ++++++++++++++++++ app/ui/__init__.py | 0 app/ui/components.py | 65 +++++++ app/ui/pages/__init__.py | 0 app/ui/pages/agent.py | 108 +++++++++++ app/ui/pages/backups.py | 87 +++++++++ app/ui/pages/dashboard.py | 128 +++++++++++++ app/ui/pages/diff.py | 84 +++++++++ app/ui/pages/gitea.py | 168 +++++++++++++++++ app/ui/pages/group_ops.py | 131 +++++++++++++ app/ui/pages/playbooks.py | 131 +++++++++++++ app/ui/pages/portainer.py | 106 +++++++++++ app/ui/pages/proxmox_page.py | 243 ++++++++++++++++++++++++ app/ui/pages/reports.py | 68 +++++++ app/ui/pages/scheduler.py | 161 ++++++++++++++++ app/ui/pages/server_detail.py | 64 +++++++ app/ui/pages/servers.py | 158 ++++++++++++++++ app/ui/pages/settings.py | 76 ++++++++ app/ui/pages/tasks.py | 47 +++++ app/ui/pages/wizard.py | 190 +++++++++++++++++++ app/ui/tabs/__init__.py | 0 app/ui/tabs/cron_tab.py | 172 +++++++++++++++++ app/ui/tabs/docker_tab.py | 296 +++++++++++++++++++++++++++++ app/ui/tabs/files_tab.py | 229 +++++++++++++++++++++++ app/ui/tabs/firewall_tab.py | 155 +++++++++++++++ app/ui/tabs/overview.py | 158 ++++++++++++++++ app/ui/tabs/packages.py | 216 +++++++++++++++++++++ app/ui/tabs/systemd_tab.py | 116 ++++++++++++ app/ui/tabs/terminal_tab.py | 272 +++++++++++++++++++++++++++ pyproject.toml | 26 +++ run.bat | 10 + scripts/check_docker.py | 12 ++ scripts/check_metrics.py | 22 +++ scripts/check_pkgs.py | 12 ++ scripts/check_ssh.py | 14 ++ scripts/install_docker.py | 38 ++++ scripts/test_metrics.py | 20 ++ tests/test_parsers.py | 80 ++++++++ 65 files changed, 6981 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/core/agent.py create mode 100644 app/core/backup.py create mode 100644 app/core/cron_svc.py create mode 100644 app/core/diff.py create mode 100644 app/core/docker_svc.py create mode 100644 app/core/files.py create mode 100644 app/core/firewall.py create mode 100644 app/core/gitea.py create mode 100644 app/core/group_ops.py create mode 100644 app/core/monitor.py create mode 100644 app/core/notify.py create mode 100644 app/core/packages.py create mode 100644 app/core/playbooks.py create mode 100644 app/core/proxmox.py create mode 100644 app/core/reports.py create mode 100644 app/core/scheduler.py create mode 100644 app/core/servers.py create mode 100644 app/core/ssh.py create mode 100644 app/core/systemd_svc.py create mode 100644 app/core/tasks.py create mode 100644 app/core/terminal.py create mode 100644 app/core/webhook.py create mode 100644 app/db/database.py create mode 100644 app/db/models.py create mode 100644 app/main.py create mode 100644 app/ui/__init__.py create mode 100644 app/ui/components.py create mode 100644 app/ui/pages/__init__.py create mode 100644 app/ui/pages/agent.py create mode 100644 app/ui/pages/backups.py create mode 100644 app/ui/pages/dashboard.py create mode 100644 app/ui/pages/diff.py create mode 100644 app/ui/pages/gitea.py create mode 100644 app/ui/pages/group_ops.py create mode 100644 app/ui/pages/playbooks.py create mode 100644 app/ui/pages/portainer.py create mode 100644 app/ui/pages/proxmox_page.py create mode 100644 app/ui/pages/reports.py create mode 100644 app/ui/pages/scheduler.py create mode 100644 app/ui/pages/server_detail.py create mode 100644 app/ui/pages/servers.py create mode 100644 app/ui/pages/settings.py create mode 100644 app/ui/pages/tasks.py create mode 100644 app/ui/pages/wizard.py create mode 100644 app/ui/tabs/__init__.py create mode 100644 app/ui/tabs/cron_tab.py create mode 100644 app/ui/tabs/docker_tab.py create mode 100644 app/ui/tabs/files_tab.py create mode 100644 app/ui/tabs/firewall_tab.py create mode 100644 app/ui/tabs/overview.py create mode 100644 app/ui/tabs/packages.py create mode 100644 app/ui/tabs/systemd_tab.py create mode 100644 app/ui/tabs/terminal_tab.py create mode 100644 pyproject.toml create mode 100644 run.bat create mode 100644 scripts/check_docker.py create mode 100644 scripts/check_metrics.py create mode 100644 scripts/check_pkgs.py create mode 100644 scripts/check_ssh.py create mode 100644 scripts/install_docker.py create mode 100644 scripts/test_metrics.py create mode 100644 tests/test_parsers.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fac8f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +*.egg + +# Virtualenv +.venv/ +venv/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp + +# Flet +flet-desktop/ + +# Runtime data (не в git — настройки, БД, плейбуки пользователя) +# Они хранятся в ~/.debian-manager/ — сюда не входят + +# OS +Thumbs.db +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..2db4a6e --- /dev/null +++ b/README.md @@ -0,0 +1,342 @@ +# Debian Manager + +Десктопное приложение на Python для централизованного управления несколькими Debian-серверами: обновления, мониторинг, пакеты, Docker, файлы, systemd, firewall, cron, плейбуки, групповые операции, деплой из Gitea. + +## Возможности + +| Модуль | Что умеет | +|--------|-----------| +| **Серверы** | Добавление по паролю или SSH-ключу, теги, проверка подключения, онлайн-статус | +| **Обзор** | CPU/RAM/диск/load/сеть, история метрик, `apt update`, `upgrade`, `dist-upgrade`, `reboot` с ожиданием возврата | +| **Пакеты** | Список с разбивкой на системные/пользовательские/зависимости, поиск, установка, удаление (remove/purge), autoremove, блокировка удаления системных | +| **Docker** | Контейнеры (start/stop/restart/rm, логи, stats), образы (pull/rmi), тома, сети, system df, system prune | +| **Файлы** | SFTP-браузер: навигация, upload/download с прогрессом, удаление, переименование, mkdir, встроенный редактор (sudo-режим для root-файлов) | +| **Сервисы** | Список systemd-юнитов, start/stop/restart/enable/disable, просмотр journalctl, защита критичных юнитов (sshd, docker) | +| **Firewall** | UFW: статус, правила (добавить/удалить), enable/disable с автоматическим разрешением SSH-порта, пресеты («Только SSH», «Веб-сервер», «Docker-хост»), список прослушиваемых портов | +| **Cron** | Редактор crontab: расписание из 5 полей с человекочитаемым описанием («следующий запуск»), включение/выключение строк, бэкапы с откатом | +| **Терминал** | Интерактивная PTY-сессия по SSH + библиотека из ~40 команд обслуживания (пользователи, система, процессы, сеть, логи, пакеты, безопасность) с селектором и подстановкой параметров | +| **Группы серверов** | Фильтрация дашборда по тегам («локальные», «удалённые», «prod») — чипы в верхней панели | +| **Планировщик** | Задачи по cron: update/upgrade/бэкап/плейбук/отчёт/команда, пресеты, запуск по кнопке, Telegram-уведомление о результате | +| **Мастер настройки** | Первичная настройка сервера чекбоксами: hostname, часовой пояс, NTP, пользователь, утилиты, Docker, Portainer, ufw, fail2ban, автообновления, агент | +| **Бэкапы** | tar.gz на сервере → SFTP → локальный архив, история с размерами, запуск из планировщика | +| **Отчёты** | CSV со всеми серверами (онлайн, ОС, метрики) + последние 100 задач, открытие в проводнике | +| **Portainer** | Статус контейнера portainer на каждом сервере, установка/перезапуск, открытие веб-интерфейса в браузере | +| **Агент-режим** | Установка dm-agent на сервер одной кнопкой — метрики каждые 5 сек по HTTP (порт 9102), real-time без SSH-поллинга | +| **Webhook-автодеплой** | Привязка «репозиторий@ветка → сервер», push в Gitea → автоматический pull + compose up (порт 9100) | +| **Proxmox VE** | Список нод с метриками, VM/LXC с CPU/RAM, start/reboot/shutdown/stop, добавление VM как управляемого сервера по SSH | +| **Темы** | Тёмная/светлая — иконка в нижней части навигации и кнопка в Настройках, выбор сохраняется | +| **Групповые операции** | Массовый update/upgrade/reboot/команда/установка пакета по выбранным серверам, параллельно или rolling (по одному), fail-fast, прогресс по каждому серверу | +| **Плейбуки** | YAML-плейбуки с шагами `shell`/`apt`/`ufw`/`systemd`/`docker`/`reboot`, Jinja2-переменные, `when`-условия, запуск на сервере или группе, встроенные шаблоны («Новый сервер», «Docker-хост», «Обновить и перезагрузить») | +| **Сравнение** | Diff установленных пакетов между двумя серверами: только на A / только на B / различаются версии | +| **Gitea Deploy** | Деплой docker-compose проектов из Gitea-репозитория: git clone/pull → `docker compose up -d`, `.env` вне git, история в журнале задач | +| **Telegram** | Уведомления об алертах мониторинга и завершении задач (настраивается в «Настройки») | +| **Журнал задач** | Все длинные операции с полным логом выполнения | + +## Требования + +- Windows 10/11, Linux или macOS +- Python 3.11+ +- Debian-сервер(ы) с SSH-доступом (пароль или ключ) +- Для Docker-модуля: Docker на сервере (устанавливается плейбуком «Docker-хост» одной кнопкой) + +## Установка + +```bash +git clone debian-manager +cd debian-manager + +# виртуальное окружение + зависимости +uv venv --python 3.12 .venv +uv pip install --python .venv/Scripts/python.exe \ + flet asyncssh sqlmodel pydantic httpx pyyaml jinja2 croniter websockets proxmoxer requests + +# запуск +.venv/Scripts/python.exe -m app.main +``` + +Без `uv` — классический вариант: +```bash +.venv\Scripts\pip install flet asyncssh sqlmodel pydantic httpx pyyaml jinja2 croniter websockets proxmoxer requests +.venv\Scripts\python -m app.main +``` + +## Первый запуск + +1. При старте автоматически создаётся тестовый сервер `Test VPS` (132.243.17.164:2292) — удалите или отредактируйте его. +2. Откройте **Серверы** → **Добавить сервер**: укажите хост, порт, пользователя, пароль или путь к SSH-ключу, нажмите **«Проверить подключение»**. +3. Кликните на сервер в списке или на дашборде — откроются вкладки управления. + +## Использование по разделам + +### Дашборд +Карточки всех серверов с живыми метриками (CPU/RAM/диск), статусом онлайн/оффлайн, ОС и аптаймом. Клик по карточке → страница сервера. + +### Обзор (вкладка сервера) +- **apt update** — обновить индекс пакетов +- **Upgrade / Dist-upgrade** — обновить систему, вывод в реальном времени в лог-панели +- **Reboot** — перезагрузка с автоматическим ожиданием возврата сервера (до 5 минут) +- Графики метрик, количество доступных обновлений + +### Пакеты +- Чипы фильтрации: **Пользовательские** / **Системные** / **Зависимости** / **Все** +- Поиск по имени +- **Установить пакет** — с поиском по apt-cache +- Кнопка удаления у каждого пакета (системные заблокированы), чекбокс **purge** +- **Autoremove** — очистка неиспользуемых зависимостей + +### Docker +- **Контейнеры**: start/stop/restart/rm, логи (последние 100 строк), live-статистика CPU/MEM +- **Образы**: pull с прогрессом, удаление +- **Тома/Сети**: список, удаление +- **Система**: `docker system df`, кнопка **System prune** с отчётом + +### Файлы +- Навигация по SFTP, иконки папок/файлов, права доступа +- **Upload/Download** с прогресс-баром (через нативный диалог выбора файла) +- **Редактировать** — текстовые файлы прямо в UI, чекбокс **sudo** для root-owned файлов (`/etc/...`) +- Удаление, переименование, создание папок + +### Сервисы +- Таблица юнитов: состояние (цвет), enabled, описание +- start/stop/restart одной кнопкой; для критичных (ssh, docker) — двойное подтверждение +- Поиск, фильтр «только failed» +- Просмотр логов (`journalctl -u `) справа + +### Firewall +- **Включить ufw** — порт текущего SSH-подключения разрешается автоматически (защита от отстрела себя) +- Таблица правил с номерами, удаление по номеру +- **Добавить правило**: порт/диапазон, allow/deny/limit, tcp/udp, from-адрес +- **Пресеты**: «Только SSH», «Веб-сервер» (80,443), «Docker-хост» +- Список прослушиваемых портов (`ss -tlnp`) для сверки с правилами + +### Cron +- Карточки задач с человекочитаемым описанием расписания и следующим запуском +- Чекбокс — включить/выключить строку (комментируется) +- **Добавить задачу** — валидация выражения в реальном времени +- **Откатить к бэкапу** — каждое сохранение пишется в БД, можно вернуть любую версию + +### Терминал +Интерактивная SSH-сессия прямо в приложении — не нужен отдельный PuTTY/Terminal. + +- **Подключиться** — открывает PTY (xterm-256color), работают все интерактивные команды (`top`, `nano`, `htop`) +- Строка ввода внизу — любая команда, Enter — отправить +- **Селектор команд** — библиотека обслуживания по категориям: + - **Пользователи**: список, добавить/удалить, смена пароля, sudo, группы, сессии + - **Система**: аптайм, часовой пояс, NTP, locale, память, диски, SMART + - **Процессы**: топ по CPU, дерево, поиск, kill, lsof + - **Сеть**: IP, маршруты, порты, соединения, DNS, ping, wget + - **Логи**: journalctl, ошибки за час, dmesg, авторизации, неудачные логины + - **Пакеты**: update/upgrade, поиск, установленные вручную, история apt + - **Безопасность**: fail2ban, ufw, открытые сокеты, SUID-файлы +- Команды, требующие параметра (имя пользователя, имя сервиса и т.п.) — появляется поле ввода +- Выбранная команда подсвечивается жёлтым в выводе, затем выполняется +- **Очистить вывод** — сброс буфера (максимум 500 строк) + +### Групповые операции +- Выбор серверов чекбоксами +- Операции: update, upgrade, dist-upgrade, reboot, произвольная команда, установка пакета +- **Rolling** — по одному серверу, ждать возврата, только потом следующий (для кластеров) +- **Fail-fast** — остановиться при первой ошибке +- Прогресс и лог по каждому серверу + +### Группы серверов +- У каждого сервера поле **Теги** (через запятую): `локальные`, `удалённые`, `prod`, `docker`… +- В верхней панели — чипы «Все» + каждый тег: фильтрация карточек дашборда +- Теги также используются как цель в планировщике и групповых операциях + +### Планировщик +Задачи по cron-расписанию, выполняются внутри приложения (даже при свёрнутом окне). + +- **Действия**: apt update, upgrade, dist-upgrade, команда, бэкап, плейбук, отчёт, очистка метрик +- **Цель**: `all` или тег серверов +- **Пресеты**: ежедневный apt update, еженедельный upgrade, ежедневный бэкап /etc, очистка метрик, ежедневный отчёт, docker prune, проверка диска +- Кнопка **запустить сейчас**, включение/выключение чекбоксом, последний статус +- Результат отправляется в Telegram (если настроено) + +### Мастер первичной настройки +Один экран для подготовки нового сервера — отметьте нужное чекбоксами: + +- **Изменить hostname** — поле для имени +- **Часовой пояс** — по умолчанию Asia/Ulaanbaatar +- **NTP** — синхронизация времени +- **Обновить систему** — apt update + dist-upgrade +- **Создать пользователя** — имя + пароль, сразу в группу sudo +- **Базовые утилиты** — curl, wget, htop, git, mc, net-tools +- **Docker** — docker.io + docker-compose + автозапуск +- **Portainer** — веб-интерфейс Docker на порту 9000 +- **Firewall (ufw)** — пресет «Только SSH» + включение +- **Fail2ban** — защита SSH от перебора +- **Автообновления безопасности** — unattended-upgrades +- **Агент мониторинга** — dm-agent для real-time метрик + +Рекомендуемый набор (update, утилиты, ufw, fail2ban, NTP) выбран по умолчанию — кнопка **«Выбрать рекомендуемое»**. + +### Бэкапы +- Укажите сервер и пути (`/etc`, `/home/user`, `/var/www`) +- На сервере создаётся tar.gz → скачивается по SFTP в `~/.debian-manager/backups/<сервер>/` +- Временный файл на сервере удаляется после скачивания +- История: дата, размер, статус, локальный путь +- Автоматические бэкапы — через планировщик (действие «бэкап») + +### Отчёты +- **Сгенерировать отчёт** — CSV: все серверы (онлайн, ОС, uptime, CPU/RAM/диск) + последние 100 задач +- Файлы в `~/.debian-manager/reports/`, открытие в проводнике из списка +- Автоматический отчёт — через планировщик (действие «отчёт») + +### Portainer +- Карточка по каждому серверу: установлен ли Portainer, статус контейнера +- **Открыть в браузере** — http://<сервер>:9000 +- **Установить** / **Перезапустить** одной кнопкой + +### Агент-режим +Для real-time метрик без SSH-поллинга: + +1. **Запустить приёмник** — приложение слушает порт 9102 +2. **Установить агента** на сервер — Python-скрипт (только stdlib, psutil опционально) + systemd unit `dm-agent` +3. Агент шлёт метрики каждые 5 сек по HTTP POST с токеном +4. Статус агента и последние метрики — на карточке сервера +5. **Удалить агента** — остановка и полное удаление с сервера + +Если сервер за NAT — агент сам инициирует соединение к приложению, проброс портов не нужен (нужен лишь доступ сервера к ПК с приложением). + +### Webhook-автодеплой из Gitea +1. Приложение слушает порт **9100** (запускается автоматически) +2. На странице **Gitea Deploy** → **Добавить привязку**: репозиторий, сервер, ветка, compose-файл, `.env` +3. В Gitea: репозиторий → Настройки → Webhooks → создать: + - URL: `http://:9100/webhooks/gitea` + - Метод: POST, событие: push +4. Push в указанную ветку → автоматический `git pull` + `docker compose up -d` на сервере +5. История автодеплоев — в **Журнале задач** (тип `webhook`) + +### Proxmox VE +Управление виртуальными машинами и LXC-контейнерами на гипервизоре Proxmox. + +**Настройка:** +1. В Proxmox: Datacenter → Permissions → API Tokens → создать токен (например `root@pam!dm-manager`) + - **Важно:** у токена должна быть роль **PVEAuditor** (или **PVEAdmin**) на пути `/`. Если включён «Privilege Separation» — добавьте права отдельно: Permissions → Add → API Token Permission +2. В приложении → **Proxmox**: + - **Хост** — только IP или FQDN, без `http://` (например `192.168.1.100`) + - **Пользователь** — `root@pam` + - **Token ID** — `dm-manager` + - **Token Secret** — значение токена + - **Проверять SSL** — выключите для локальной сети без сертификатов +3. **Сохранить и проверить** — покажет версию Proxmox + +**Ошибка 403 Forbidden:** у токена нет прав. Добавьте роль PVEAuditor/PVEAdmin на путь `/` в настройках токена. + +**Возможности:** +- Карточка каждой ноды: CPU/RAM ноды, uptime, список VM/LXC +- По каждой VM: VMID, имя, тип (VM/LXC), статус, CPU/RAM +- Действия: **start**, **reboot**, **shutdown** (graceful), **stop** (жёсткое) — с подтверждением +- **Добавить как сервер** — определяет IP через QEMU Guest Agent (или из конфига LXC) и создаёт сервер в приложении с тегом `proxmox` → дальше полное управление по SSH + +> Для определения IP в VM должен быть установлен `qemu-guest-agent` (apt install qemu-guest-agent). + +### Темы +- Иконка ☀️/🌙 в нижней части левой навигации — мгновенное переключение +- Также кнопка в **Настройки → Интерфейс** +- Выбор сохраняется между запусками + +### Плейбуки +YAML-файлы в `~/.debian-manager/playbooks/`. Пример: + +```yaml +name: "Настройка нового сервера" +vars: + ssh_port: "22" +steps: + - name: Обновить систему + apt: {update: true, upgrade: dist} + - name: Установить утилиты + apt: {install: [curl, htop, git, ufw]} + - name: Firewall + ufw: {preset: "Только SSH"} + - name: Включить ufw + shell: "yes | ufw enable" + - name: Установить Docker + apt: {install: [docker.io, docker-compose]} + - name: Запустить docker + systemd: {unit: docker, action: "enable --now"} +``` + +Типы шагов: `shell`, `apt` (update/upgrade/install/remove/autoremove), `ufw` (preset/enable), `systemd` (unit/action), `docker` (compose up/down), `reboot` (wait). Поддерживаются Jinja2-переменные `{{var}}` и `when: условие`. + +### Сравнение +Выберите два сервера → таблица расхождений: что установлено только на одном, где различаются версии. + +### Gitea Deploy +1. Укажите URL и API-токен Gitea (токен: Gitea → Настройки → Приложения → Создать токен) +2. **Загрузить репозитории** → выберите репо, сервер, ветку, путь к compose-файлу +3. Опционально заполните `.env` (запишется на сервер, не попадёт в git) +4. **Деплой** — на сервере выполнится `git clone/pull` в `/opt/stacks/` + `docker compose up -d` + +### Настройки → Telegram +1. Создайте бота через @BotFather, получите токен +2. Узнайте chat_id через @userinfobot +3. Включите нужные триггеры: алерты мониторинга, завершение задач +4. **Тест** — проверочное сообщение + +## Запуск + +**Windows — двойной клик по `run.bat`** или из терминала: +```bash +.venv\Scripts\python.exe -m app.main +``` + +## Сборка в .exe (PyInstaller) + +```bash +# установить PyInstaller +.venv\Scripts\pip install pyinstaller + +# сборка одного exe (окно без консоли) +.venv\Scripts\pyinstaller --noconfirm --onefile --windowed \ + --name "DebianManager" \ + --add-data "app;app" \ + app/main.py + +# результат: dist\DebianManager.exe +``` + +Примечания: +- `--windowed` убирает чёрную консоль; для отладки соберите без него — увидите traceback'и +- SQLite-база (`~/.debian-manager/data.db`) и плейбуки (`~/.debian-manager/playbooks/`) создаются в домашней папке пользователя, не рядом с exe — перенос exe на другую машину не переносит данные +- Flet при первом запуске exe скачивает свой runtime (~100 МБ) — нужен интернет +- Для уменьшения размера добавьте `--exclude-module pytest` и подобные + +## Структура проекта + +``` +app/ +├── main.py # точка входа Flet +├── core/ # бизнес-логика (SSH, apt, docker, sftp, ufw, cron, плейбуки...) +├── db/ # SQLModel: Server, TaskRecord, MetricSnapshot, CronBackup, Setting +└── ui/ + ├── pages/ # дашборд, серверы, групповые операции, плейбуки, diff, gitea, задачи, настройки + ├── tabs/ # вкладки сервера: обзор, пакеты, docker, файлы, сервисы, firewall, cron + └── components.py # диалоги, лог-панель, хелперы +tests/ # юнит-тесты парсеров (cron, packages) +scripts/ # утилиты для ручной проверки сервера +``` + +## Безопасность + +- Пароли хранятся в SQLite (`~/.debian-manager/data.db`) — для продакшена перенесите в ОС keyring +- Все деструктивные действия (reboot, purge, prune, удаление) — с диалогом подтверждения и именем сервера +- Удаление системных пакетов (priority required/important) заблокировано +- При включении ufw текущий SSH-порт разрешается автоматически +- Stop/restart критичных systemd-юнитов (sshd, docker) — с двойным подтверждением +- Журнал задач — полный аудит всех операций с логами + +## Тестирование + +```bash +.venv/Scripts/python.exe -m pytest tests/ -v +# 9 тестов парсеров cron и packages +``` + +## Известные ограничения + +- Графики метрик — текстовые (в Flet 0.86 удалён LineChart; замените на `flet-charts` или plotly при необходимости) +- Нет мультипользовательского режима и ролей +- Webhook-сервер слушает на 0.0.0.0:9100 без TLS — используйте в доверенной сети или за reverse-proxy diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/agent.py b/app/core/agent.py new file mode 100644 index 0000000..a0614cf --- /dev/null +++ b/app/core/agent.py @@ -0,0 +1,217 @@ +"""Агент-режим: установка агента на сервер, WS-сервер, приём метрик.""" +from __future__ import annotations + +import asyncio +import json +import secrets +import time +from typing import Callable, Optional + +from app.core.ssh import SSHClient +from app.db.database import get_session +from app.db.models import MetricSnapshot, Server, Setting + +# последние метрики по server_id: {"cpu": .., "mem": .., "disk": .., "ts": ..} +agent_metrics: dict[int, dict] = {} +_http_server = None + +AGENT_VERSION = "0.1" + +AGENT_SCRIPT = r'''#!/usr/bin/env python3 +"""Debian Manager agent: метрики по WebSocket. Только stdlib.""" +import json, socket, ssl, sys, time, urllib.request, os +try: + import psutil +except ImportError: + psutil = None + +SERVER_WS = os.environ.get("DM_SERVER", "ws://127.0.0.1:9101") +TOKEN = os.environ.get("DM_TOKEN", "") +SERVER_ID = int(os.environ.get("DM_SERVER_ID", "0")) +INTERVAL = float(os.environ.get("DM_INTERVAL", "5")) + +def metrics(): + cpu = 0.0 + if psutil: + cpu = psutil.cpu_percent(interval=None) + vm = psutil.virtual_memory() + mem_used, mem_total = vm.used // 1048576, vm.total // 1048576 + du = psutil.disk_usage("/") + disk = du.percent + else: + with open("/proc/stat") as f: + parts = f.readline().split() + idle = int(parts[4]) + int(parts[5]) + total = sum(int(x) for x in parts[1:8]) + cpu = round((1 - idle / total) * 100, 1) if total else 0 + with open("/proc/meminfo") as f: + mi = {} + for line in f: + k, v = line.split(":") + mi[k] = int(v.split()[0]) + mem_total = mi["MemTotal"] // 1024 + mem_used = (mi["MemTotal"] - mi.get("MemAvailable", mi["MemFree"])) // 1024 + st = os.statvfs("/") + disk = round((st.f_blocks - st.f_bavail) * 100 / st.f_blocks, 1) + with open("/proc/loadavg") as f: + load = float(f.read().split()[0]) + return {"cpu": cpu, "mem_used": mem_used, "mem_total": mem_total, + "mem_pct": round(mem_used * 100 / mem_total, 1) if mem_total else 0, + "disk": disk, "load": load, "ts": time.time()} + +def main(): + # минимальный WebSocket-клиент на stdlib (только текстовые фреймы, без маскировки сложная; + # для простоты используем HTTP POST каждые INTERVAL сек — надёжнее и без библиотек) + url = SERVER_WS.replace("ws://", "http://").replace("wss://", "https://") + "/metrics" + while True: + try: + data = json.dumps({"server_id": SERVER_ID, "token": TOKEN, + "metrics": metrics()}).encode() + req = urllib.request.Request(url, data=data, + headers={"Content-Type": "application/json"}) + urllib.request.urlopen(req, timeout=10) + except Exception as e: + print("send error:", e, file=sys.stderr) + time.sleep(INTERVAL) + +if __name__ == "__main__": + main() +''' + + +def _get(key: str) -> str: + with get_session() as s: + row = s.get(Setting, key) + return row.value if row else "" + + +def _set(key: str, value: str) -> None: + with get_session() as s: + row = s.get(Setting, key) or Setting(key=key) + row.value = value + s.add(row) + s.commit() + + +def get_agent_port() -> int: + return int(_get("agent_port") or "9102") + + +def get_or_create_token(server_id: int) -> str: + key = f"agent_token_{server_id}" + token = _get(key) + if not token: + token = secrets.token_hex(16) + _set(key, token) + return token + + +async def install_agent(srv: Server, app_host: str, log=None) -> bool: + """Установить агента на сервер через SSH.""" + def _log(m): + if log: + log(m) + token = get_or_create_token(srv.id) + port = get_agent_port() + env = (f"DM_SERVER=http://{app_host}:{port} DM_TOKEN={token} " + f"DM_SERVER_ID={srv.id} DM_INTERVAL=5") + script_b64 = __import__("base64").b64encode(AGENT_SCRIPT.encode()).decode() + + unit = f"""[Unit] +Description=Debian Manager Agent +After=network.target + +[Service] +Type=simple +Environment="{env.replace('"', chr(92)+'"')}" +ExecStart=/usr/bin/python3 /opt/dm-agent/agent.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +""" + unit_b64 = __import__("base64").b64encode(unit.encode()).decode() + + async with SSHClient(srv) as c: + _log("создаю /opt/dm-agent") + await c.sudo("mkdir -p /opt/dm-agent") + _log("записываю agent.py") + await c.sudo(f"echo {script_b64} | base64 -d > /opt/dm-agent/agent.py") + await c.sudo(f"echo {unit_b64} | base64 -d > /etc/systemd/system/dm-agent.service") + _log("запускаю systemd unit") + r = await c.sudo("systemctl daemon-reload && systemctl enable --now dm-agent") + if not r.ok: + _log(f"ошибка: {r.stderr}") + return False + _log("агент установлен и запущен") + return True + + +async def uninstall_agent(srv: Server, log=None) -> None: + def _log(m): + if log: + log(m) + async with SSHClient(srv) as c: + await c.sudo("systemctl disable --now dm-agent 2>/dev/null; " + "rm -f /etc/systemd/system/dm-agent.service; " + "rm -rf /opt/dm-agent; systemctl daemon-reload") + _set(f"agent_token_{srv.id}", "") + _log("агент удалён") + + +async def agent_status(srv: Server) -> str: + async with SSHClient(srv) as c: + r = await c.sudo("systemctl is-active dm-agent 2>/dev/null") + return r.stdout.strip() or "не установлен" + + +# ---------- HTTP-приёмник метрик ---------- + +async def _metrics_handler(reader, writer): + try: + req = await reader.readuntil(b"\r\n\r\n") + head = req.decode(errors="replace") + length = 0 + for line in head.splitlines(): + if line.lower().startswith("content-length:"): + length = int(line.split(":", 1)[1].strip()) + body = await reader.readexactly(length) if length else b"{}" + data = json.loads(body) + sid = int(data.get("server_id", 0)) + if data.get("token") != _get(f"agent_token_{sid}"): + raise ValueError("bad token") + m = data.get("metrics", {}) + agent_metrics[sid] = m + with get_session() as s: + s.add(MetricSnapshot( + server_id=sid, + cpu_percent=m.get("cpu", 0), + mem_percent=m.get("mem_pct", 0), + mem_used_mb=m.get("mem_used", 0), + mem_total_mb=m.get("mem_total", 0), + disk_percent=m.get("disk", 0), + load1=m.get("load", 0), + )) + s.commit() + resp = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok" + except Exception: + resp = b"HTTP/1.1 400 Bad\r\nContent-Length: 0\r\n\r\n" + writer.write(resp) + await writer.drain() + try: + writer.close() + except Exception: + pass + + +async def start(port: Optional[int] = None) -> None: + global _http_server + if _http_server: + return + port = port or get_agent_port() + _http_server = await asyncio.start_server(_metrics_handler, "0.0.0.0", port) + + +def is_running() -> bool: + return _http_server is not None diff --git a/app/core/backup.py b/app/core/backup.py new file mode 100644 index 0000000..fcc208d --- /dev/null +++ b/app/core/backup.py @@ -0,0 +1,98 @@ +"""Резервное копирование: tar на сервере → SFTP → локальный архив.""" +from __future__ import annotations + +import asyncio +import shlex +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from sqlmodel import select + +from app.core import files as fcore +from app.core.ssh import SSHClient +from app.db.database import get_session +from app.db.models import BackupRecord, Server + +BACKUP_DIR = Path.home() / ".debian-manager" / "backups" +BACKUP_DIR.mkdir(parents=True, exist_ok=True) + + +async def run_backup(srv: Server, paths: str, download: bool = True, + log=None) -> BackupRecord: + """Создать tar.gz на сервере и скачать. log — callable(str).""" + def _log(line: str): + if log: + log(line) + + with get_session() as s: + rec = BackupRecord(server_id=srv.id, paths=paths, status="pending") + s.add(rec) + s.commit() + s.refresh(rec) + rec_id = rec.id + + def _set(**fields): + with get_session() as s: + r = s.get(BackupRecord, rec_id) + for k, v in fields.items(): + setattr(r, k, v) + s.add(r) + s.commit() + + try: + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + name = f"dm-backup-{srv.name}-{ts}.tar.gz" + remote = f"/tmp/{name}" + path_args = " ".join(shlex.quote(p) for p in paths.split()) + + async with SSHClient(srv) as c: + _log(f"tar -czf {remote} {path_args}") + r = await c.sudo( + f"tar -czf {remote} --ignore-failed-read {path_args} 2>&1", timeout=1800 + ) + if not r.ok and "tar:" not in (r.stdout + r.stderr)[:20]: + # tar возвращает 1 при --ignore-failed-read если часть файлов пропущена — ок + pass + r = await c.sudo(f"stat -c %s {remote}", timeout=30) + size = int(r.stdout.strip() or 0) + _log(f"архив на сервере: {size // 1024} КБ") + # дать доступ пользователю для SFTP + await c.sudo(f"chmod 644 {remote} && chown {srv.username}:{srv.username} {remote}") + + local = "" + if download: + local_path = BACKUP_DIR / srv.name / name + local_path.parent.mkdir(parents=True, exist_ok=True) + _log(f"скачивание → {local_path}") + await fcore.download(c, remote, str(local_path), + lambda d, t: _log(f" {d//1024}/{t//1024} КБ")) + local = str(local_path) + await c.run(f"rm -f {remote}") + + _set(status="success", file=local, size=size, log="") + rec = get_record(rec_id) + return rec + except Exception as e: + _set(status="error", log=str(e)) + return get_record(rec_id) + + +def get_record(rec_id: int) -> BackupRecord: + with get_session() as s: + return s.get(BackupRecord, rec_id) + + +def list_backups(server_id: Optional[int] = None, limit: int = 50) -> List[BackupRecord]: + with get_session() as s: + q = select(BackupRecord).order_by(BackupRecord.id.desc()).limit(limit) + if server_id: + q = q.where(BackupRecord.server_id == server_id) + return list(s.exec(q).all()) + + +def stats() -> tuple[int, int]: + """(кол-во, суммарный размер байт)""" + recs = list_backups(limit=1000) + ok = [r for r in recs if r.status == "success"] + return len(ok), sum(r.size for r in ok) diff --git a/app/core/cron_svc.py b/app/core/cron_svc.py new file mode 100644 index 0000000..87cf1ea --- /dev/null +++ b/app/core/cron_svc.py @@ -0,0 +1,126 @@ +"""Cron: чтение/запись crontab, версии, описание расписаний.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional + +from croniter import croniter + +from app.core.ssh import SSHClient +from app.db.database import get_session +from app.db.models import CronBackup + + +@dataclass +class CronLine: + raw: str + enabled: bool + schedule: str + command: str + comment: str = "" + + @property + def is_entry(self) -> bool: + return bool(self.schedule) + + +def parse_crontab(content: str) -> List[CronLine]: + lines: List[CronLine] = [] + for raw in content.splitlines(): + stripped = raw.strip() + enabled = True + candidate = stripped + if not candidate: + lines.append(CronLine(raw, True, "", "")) + continue + if candidate.startswith("#"): + enabled = False + candidate = candidate.lstrip("#").strip() + parts = candidate.split(None, 5) + if candidate.startswith("@") and len(candidate.split(None, 1)) == 2: + sched, cmd = candidate.split(None, 1) + lines.append(CronLine(raw, enabled, sched, cmd)) + elif len(parts) == 6 and _looks_like_schedule(parts[:5]): + lines.append(CronLine(raw, enabled, " ".join(parts[:5]), parts[5])) + else: + lines.append(CronLine(raw, enabled, "", "", comment=stripped)) + return lines + + +def _looks_like_schedule(fields: List[str]) -> bool: + for f in fields: + for ch in f: + if ch not in "0123456789*,-/": + return False + return True + + +def humanize(schedule: str) -> str: + try: + if schedule.startswith("@"): + return {"@reboot": "при загрузке", "@daily": "ежедневно", + "@hourly": "ежечасно", "@weekly": "еженедельно", + "@monthly": "ежемесячно", "@yearly": "ежегодно"}.get(schedule, schedule) + it = croniter(schedule) + nxt = it.get_next(__import__("datetime").datetime) + return f"следующий запуск: {nxt:%Y-%m-%d %H:%M}" + except Exception: + return "неверное выражение" + + +def render(lines: List[CronLine]) -> str: + out = [] + for l in lines: + if not l.is_entry: + out.append(l.raw) + continue + entry = f"{l.schedule} {l.command}" + out.append(entry if l.enabled else f"# {entry}") + return "\n".join(out) + "\n" + + +async def get_crontab(c: SSHClient, user: Optional[str] = None) -> str: + cmd = f"crontab -u {user} -l" if user else "crontab -l" + r = await c.sudo(cmd, timeout=30) + if not r.ok and "no crontab" in (r.stderr + r.stdout): + return "" + return r.stdout + + +async def set_crontab(c: SSHClient, content: str, server_id: int, + user: Optional[str] = None) -> None: + # бэкап текущего + current = await get_crontab(c, user) + with get_session() as s: + s.add(CronBackup(server_id=server_id, user=user or "current", content=current)) + s.commit() + import base64 + b64 = base64.b64encode(content.encode()).decode() + target = f"crontab -u {user} -" if user else "crontab -" + r = await c.sudo(f"echo {b64} | base64 -d | {target}", timeout=30) + if not r.ok: + raise RuntimeError(r.stderr) + + +async def list_cron_files(c: SSHClient) -> dict: + """Системные cron-файлы: /etc/crontab + /etc/cron.d/*""" + out = {} + r = await c.sudo("cat /etc/crontab 2>/dev/null", timeout=30) + if r.ok: + out["/etc/crontab"] = r.stdout + r = await c.sudo("ls /etc/cron.d/ 2>/dev/null", timeout=30) + for name in r.stdout.split(): + rr = await c.sudo(f"cat '/etc/cron.d/{name}'", timeout=30) + if rr.ok: + out[f"/etc/cron.d/{name}"] = rr.stdout + return out + + +def backups(server_id: int, user: str, limit: int = 10) -> List[CronBackup]: + from sqlmodel import select + with get_session() as s: + return list(s.exec( + select(CronBackup) + .where(CronBackup.server_id == server_id, CronBackup.user == user) + .order_by(CronBackup.id.desc()).limit(limit) + ).all()) diff --git a/app/core/diff.py b/app/core/diff.py new file mode 100644 index 0000000..a05ff47 --- /dev/null +++ b/app/core/diff.py @@ -0,0 +1,43 @@ +"""Diff пакетов между серверами.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List + +from app.core.packages import Package, list_packages +from app.core.ssh import SSHClient + + +@dataclass +class VersionDiff: + name: str + version_a: str + version_b: str + + +@dataclass +class PackageDiff: + only_a: List[Package] + only_b: List[Package] + version_mismatch: List[VersionDiff] + + +async def diff_servers(ca: SSHClient, cb: SSHClient, + user_only: bool = True) -> PackageDiff: + pa = await list_packages(ca) + pb = await list_packages(cb) + if user_only: + pa = [p for p in pa if not p.is_system and p.manual] + pb = [p for p in pb if not p.is_system and p.manual] + + map_a: Dict[str, Package] = {p.name: p for p in pa} + map_b: Dict[str, Package] = {p.name: p for p in pb} + + only_a = [map_a[n] for n in sorted(map_a.keys() - map_b.keys())] + only_b = [map_b[n] for n in sorted(map_b.keys() - map_a.keys())] + mismatch = [ + VersionDiff(n, map_a[n].version, map_b[n].version) + for n in sorted(map_a.keys() & map_b.keys()) + if map_a[n].version != map_b[n].version + ] + return PackageDiff(only_a, only_b, mismatch) diff --git a/app/core/docker_svc.py b/app/core/docker_svc.py new file mode 100644 index 0000000..a7e3ce5 --- /dev/null +++ b/app/core/docker_svc.py @@ -0,0 +1,109 @@ +"""Docker по SSH: команды docker с JSON-выводом. Не требует docker CLI на клиенте.""" +from __future__ import annotations + +import json +from typing import List, Optional + +from app.core.ssh import SSHClient + + +async def _docker(c: SSHClient, args: str, timeout: int = 120) -> str: + r = await c.sudo(f"docker {args}", timeout=timeout) + return r.stdout + + +async def available(c: SSHClient) -> bool: + r = await c.sudo("docker version --format '{{.Server.Version}}'", timeout=30) + return r.ok and bool(r.stdout.strip()) + + +async def server_version(c: SSHClient) -> str: + r = await c.sudo("docker version --format '{{.Server.Version}}'", timeout=30) + return r.stdout.strip() + + +# ---------- контейнеры ---------- + +async def list_containers(c: SSHClient) -> List[dict]: + out = await _docker(c, "ps -a --format '{{json .}}'") + return [json.loads(l) for l in out.splitlines() if l.strip()] + + +async def container_action(c: SSHClient, cid: str, action: str) -> str: + assert action in ("start", "stop", "restart", "kill", "rm") + return await _docker(c, f"{action} {cid}") + + +async def container_logs(c: SSHClient, cid: str, tail: int = 200) -> str: + return await _docker(c, f"logs --tail {tail} {cid}") + + +async def container_inspect(c: SSHClient, cid: str) -> dict: + out = await _docker(c, f"inspect {cid}") + data = json.loads(out) + return data[0] if data else {} + + +async def container_stats(c: SSHClient) -> List[dict]: + out = await _docker( + c, "stats --no-stream --format '{{json .}}'", timeout=60 + ) + return [json.loads(l) for l in out.splitlines() if l.strip()] + + +# ---------- образы ---------- + +async def list_images(c: SSHClient) -> List[dict]: + out = await _docker(c, "images --format '{{json .}}'") + return [json.loads(l) for l in out.splitlines() if l.strip()] + + +async def pull(c: SSHClient, image: str): + return c.run_stream(f"docker pull {image}", use_sudo=True) + + +async def remove_image(c: SSHClient, image: str) -> str: + return await _docker(c, f"rmi {image}") + + +# ---------- тома ---------- + +async def list_volumes(c: SSHClient) -> List[dict]: + out = await _docker(c, "volume ls --format '{{json .}}'") + return [json.loads(l) for l in out.splitlines() if l.strip()] + + +async def remove_volume(c: SSHClient, name: str) -> str: + return await _docker(c, f"volume rm {name}") + + +# ---------- сети ---------- + +async def list_networks(c: SSHClient) -> List[dict]: + out = await _docker(c, "network ls --format '{{json .}}'") + return [json.loads(l) for l in out.splitlines() if l.strip()] + + +# ---------- система ---------- + +async def system_df(c: SSHClient) -> str: + return await _docker(c, "system df") + + +async def system_prune(c: SSHClient) -> str: + return await _docker(c, "system prune -f", timeout=600) + + +# ---------- compose ---------- + +async def compose_projects(c: SSHClient) -> List[dict]: + out = await _docker(c, "compose ls --format json") + try: + return json.loads(out) + except json.JSONDecodeError: + return [] + + +async def compose_action(c: SSHClient, workdir: str, action: str, timeout: int = 600) -> str: + assert action in ("up -d", "down", "restart", "pull", "ps", "logs") + return await _docker(c, f"compose -f {workdir}/docker-compose.yml {action}", timeout) diff --git a/app/core/files.py b/app/core/files.py new file mode 100644 index 0000000..8d43680 --- /dev/null +++ b/app/core/files.py @@ -0,0 +1,123 @@ +"""SFTP файловый менеджер.""" +from __future__ import annotations + +import stat +from dataclasses import dataclass +from pathlib import PurePosixPath +from typing import Callable, List, Optional + +import asyncssh + +from app.core.ssh import SSHClient + + +@dataclass +class FileEntry: + name: str + path: str + is_dir: bool + size: int + mtime: float + permissions: str + + +def _fmt_perms(mode: int) -> str: + return stat.filemode(mode) if mode else "?" + + +async def list_dir(c: SSHClient, path: str) -> List[FileEntry]: + async with c.conn.start_sftp_client() as sftp: + entries = [] + try: + attrs = await sftp.readdir(path) + except asyncssh.SFTPFailure as e: + raise PermissionError(str(e)) + for a in attrs: + if a.filename in (".", ".."): + continue + full = str(PurePosixPath(path) / a.filename) + is_dir = stat.S_ISDIR(a.attrs.permissions or 0) + entries.append( + FileEntry( + name=a.filename, + path=full, + is_dir=is_dir, + size=a.attrs.size or 0, + mtime=a.attrs.mtime or 0, + permissions=_fmt_perms(a.attrs.permissions or 0), + ) + ) + entries.sort(key=lambda e: (not e.is_dir, e.name.lower())) + return entries + + +async def download(c: SSHClient, remote: str, local: str, + progress: Optional[Callable[[int, int], None]] = None) -> None: + async with c.conn.start_sftp_client() as sftp: + size = (await sftp.stat(remote)).size or 0 + done = 0 + async with sftp.open(remote, "rb") as rf, open(local, "wb") as lf: + while True: + chunk = await rf.read(65536) + if not chunk: + break + lf.write(chunk) + done += len(chunk) + if progress: + progress(done, size) + + +async def upload(c: SSHClient, local: str, remote: str, + progress: Optional[Callable[[int, int], None]] = None) -> None: + import os + size = os.path.getsize(local) + done = 0 + async with c.conn.start_sftp_client() as sftp: + async with sftp.open(remote, "wb") as rf, open(local, "rb") as lf: + while True: + chunk = lf.read(65536) + if not chunk: + break + await rf.write(chunk) + done += len(chunk) + if progress: + progress(done, size) + + +async def delete(c: SSHClient, path: str, is_dir: bool) -> None: + async with c.conn.start_sftp_client() as sftp: + if is_dir: + await c.run(f"rm -rf {path!r}") + else: + await sftp.remove(path) + + +async def mkdir(c: SSHClient, path: str) -> None: + async with c.conn.start_sftp_client() as sftp: + await sftp.makedirs(path, exist_ok=True) + + +async def rename(c: SSHClient, old: str, new: str) -> None: + async with c.conn.start_sftp_client() as sftp: + await sftp.rename(old, new) + + +async def read_text(c: SSHClient, path: str, limit: int = 500_000) -> str: + async with c.conn.start_sftp_client() as sftp: + async with sftp.open(path, "rb") as f: + data = await f.read(limit) + return data.decode("utf-8", errors="replace") + + +async def write_text(c: SSHClient, path: str, content: str, use_sudo: bool = False) -> None: + if use_sudo: + # запись в root-owned файл через sudo tee + import base64 + b64 = base64.b64encode(content.encode()).decode() + r = await c.sudo(f"echo {b64} | base64 -d > {path!r}") + if not r.ok: + raise PermissionError(r.stderr) + else: + async with c.conn.start_sftp_client() as sftp: + async with sftp.open(path, "wb") as f: + await f.write(content.encode()) diff --git a/app/core/firewall.py b/app/core/firewall.py new file mode 100644 index 0000000..186550d --- /dev/null +++ b/app/core/firewall.py @@ -0,0 +1,98 @@ +"""UFW файрвол.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional + +from app.core.ssh import SSHClient + + +@dataclass +class Rule: + number: int + to: str + action: str + from_: str + raw: str + + +async def is_installed(c: SSHClient) -> bool: + r = await c.run("which ufw") + return r.ok and bool(r.stdout.strip()) + + +async def status(c: SSHClient) -> tuple[bool, str]: + """(active, raw_status)""" + r = await c.sudo("ufw status verbose", timeout=30) + active = "Status: active" in r.stdout + return active, r.stdout + + +async def list_rules(c: SSHClient) -> List[Rule]: + r = await c.sudo("ufw status numbered", timeout=30) + rules: List[Rule] = [] + for line in r.stdout.splitlines(): + line = line.strip() + if line.startswith("[") and "]" in line: + num = int(line[1:line.index("]")].strip()) + rest = line[line.index("]") + 1:].strip() + parts = rest.split() + # "22/tcp ALLOW IN Anywhere" + to = parts[0] if parts else "" + action = " ".join(parts[1:3]) if len(parts) > 2 else (parts[1] if len(parts) > 1 else "") + from_ = " ".join(parts[3:]) if len(parts) > 3 else "" + rules.append(Rule(num, to, action, from_, line)) + return rules + + +async def ensure_ssh_allowed(c: SSHClient, ssh_port: int) -> None: + """Защита от отстрела себя: разрешить текущий SSH-порт.""" + rules = await list_rules(c) + for rule in rules: + if str(ssh_port) in rule.to and "ALLOW" in rule.action.upper(): + return + await c.sudo(f"ufw allow {ssh_port}/tcp", timeout=30) + + +async def enable(c: SSHClient, ssh_port: int) -> str: + await ensure_ssh_allowed(c, ssh_port) + r = await c.sudo("yes | ufw enable", timeout=30) + return r.stdout + r.stderr + + +async def disable(c: SSHClient) -> str: + r = await c.sudo("ufw disable", timeout=30) + return r.stdout + r.stderr + + +async def add_rule(c: SSHClient, port: str, action: str = "allow", + proto: str = "tcp", from_addr: str = "") -> str: + assert action in ("allow", "deny", "limit", "reject") + src = f"from {from_addr} " if from_addr else "" + r = await c.sudo(f"ufw {action} {src}to any port {port} proto {proto}", timeout=30) + return r.stdout + r.stderr + + +async def delete_rule(c: SSHClient, number: int) -> str: + r = await c.sudo(f"yes | ufw delete {number}", timeout=30) + return r.stdout + r.stderr + + +async def listening_ports(c: SSHClient) -> List[str]: + r = await c.sudo("ss -tlnp | awk 'NR>1{print $4, $6}'", timeout=30) + return [l for l in r.stdout.splitlines() if l.strip()] + + +PRESETS = { + "Только SSH": [("{ssh_port}", "allow", "tcp")], + "Веб-сервер": [("{ssh_port}", "allow", "tcp"), ("80", "allow", "tcp"), ("443", "allow", "tcp")], + "Docker-хост": [("{ssh_port}", "allow", "tcp"), ("2375", "deny", "tcp"), ("2376", "deny", "tcp")], +} + + +async def apply_preset(c: SSHClient, preset_name: str, ssh_port: int) -> str: + log = [] + for port, action, proto in PRESETS[preset_name]: + p = port.format(ssh_port=ssh_port) + log.append(await add_rule(c, p, action, proto)) + return "\n".join(log) diff --git a/app/core/gitea.py b/app/core/gitea.py new file mode 100644 index 0000000..066934a --- /dev/null +++ b/app/core/gitea.py @@ -0,0 +1,110 @@ +"""Gitea: репозитории, raw-файлы, деплой compose на сервер.""" +from __future__ import annotations + +from typing import List, Optional + +import httpx + +from app.core.ssh import SSHClient +from app.db.database import get_session +from app.db.models import Setting + +DEFAULT_URL = "http://192.168.1.171:3000" + + +def _get(key: str) -> str: + with get_session() as s: + row = s.get(Setting, key) + return row.value if row else "" + + +def _set(key: str, value: str) -> None: + with get_session() as s: + row = s.get(Setting, key) or Setting(key=key) + row.value = value + s.add(row) + s.commit() + + +def get_config() -> dict: + return {"url": _get("gitea_url") or DEFAULT_URL, "token": _get("gitea_token")} + + +def save_config(url: str, token: str) -> None: + _set("gitea_url", url.rstrip("/")) + _set("gitea_token", token) + + +async def _client() -> httpx.AsyncClient: + cfg = get_config() + headers = {"Authorization": f"token {cfg['token']}"} if cfg["token"] else {} + return httpx.AsyncClient(base_url=cfg["url"], headers=headers, timeout=20) + + +async def list_repos() -> List[dict]: + async with await _client() as cl: + r = await cl.get("/api/v1/user/repos", params={"limit": 50}) + r.raise_for_status() + return [{"name": x["name"], "full_name": x["full_name"], + "clone_url": x["clone_url"], "default_branch": x["default_branch"]} + for x in r.json()] + + +async def get_file(repo: str, path: str, ref: str = "main") -> str: + async with await _client() as cl: + r = await cl.get(f"/api/v1/repos/{repo}/raw/{path}", params={"ref": ref}) + r.raise_for_status() + return r.text + + +async def test_connection() -> tuple[bool, str]: + try: + async with await _client() as cl: + r = await cl.get("/api/v1/version") + if r.status_code == 200: + return True, f"Gitea {r.json().get('version')}" + return False, f"HTTP {r.status_code}" + except Exception as e: + return False, str(e) + + +# ---------- деплой compose ---------- + +async def deploy(c: SSHClient, clone_url: str, repo_name: str, branch: str, + compose_path: str, env_content: Optional[str], log) -> None: + """git clone/pull в /opt/stacks/ + docker compose up -d.""" + base = "/opt/stacks" + workdir = f"{base}/{repo_name}" + await c.sudo(f"mkdir -p {base}") + + r = await c.sudo(f"test -d {workdir}/.git && echo exists || echo missing") + if "exists" in r.stdout: + log(f"git pull в {workdir}") + r = await c.sudo(f"git -C {workdir} fetch --all && git -C {workdir} checkout {branch} " + f"&& git -C {workdir} pull origin {branch}", timeout=300) + else: + log(f"git clone {clone_url} → {workdir}") + url = clone_url + r = await c.sudo(f"git clone -b {branch} {url} {workdir}", timeout=300) + log(r.stdout[-1000:] + r.stderr[-500:]) + if not r.ok: + raise RuntimeError("git операция не удалась") + + if env_content is not None: + import base64 + b64 = base64.b64encode(env_content.encode()).decode() + await c.sudo(f"echo {b64} | base64 -d > {workdir}/.env") + log(".env записан") + + log("docker compose up -d") + r = await c.sudo(f"docker compose -f {workdir}/{compose_path} up -d", timeout=900) + log(r.stdout + r.stderr) + if not r.ok: + raise RuntimeError("compose up не удался") + + +async def stack_action(c: SSHClient, repo_name: str, compose_path: str, action: str) -> str: + assert action in ("up -d", "down", "restart", "pull", "logs --tail 100", "ps") + r = await c.sudo(f"docker compose -f /opt/stacks/{repo_name}/{compose_path} {action}", + timeout=600) + return r.stdout + r.stderr diff --git a/app/core/group_ops.py b/app/core/group_ops.py new file mode 100644 index 0000000..395b225 --- /dev/null +++ b/app/core/group_ops.py @@ -0,0 +1,143 @@ +"""Групповые операции по нескольким серверам с rolling-режимом.""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from typing import Callable, Coroutine, Dict, List, Optional + +from app.core.ssh import SSHClient, check_server, wait_online +from app.db.models import Server + +LogCallback = Callable[[str], None] + + +@dataclass +class ServerResult: + server_id: int + name: str + status: str = "pending" # pending/running/success/error + log: str = "" + + +@dataclass +class GroupResult: + results: Dict[int, ServerResult] = field(default_factory=dict) + + @property + def ok_count(self) -> int: + return sum(1 for r in self.results.values() if r.status == "success") + + @property + def err_count(self) -> int: + return sum(1 for r in self.results.values() if r.status == "error") + + +async def run_group( + servers: List[Server], + operation: Callable[[SSHClient, Server, LogCallback], Coroutine], + concurrency: int = 5, + fail_fast: bool = False, + rolling: bool = False, + on_status: Optional[Callable[[GroupResult], None]] = None, +) -> GroupResult: + """Выполнить operation(c, server, log) на группе серверов.""" + result = GroupResult(results={ + s.id: ServerResult(server_id=s.id, name=s.name) for s in servers + }) + sem = asyncio.Semaphore(1 if rolling else concurrency) + stop_flag = asyncio.Event() + + async def worker(srv: Server): + if stop_flag.is_set(): + result.results[srv.id].status = "error" + result.results[srv.id].log = "пропущено (fail-fast)" + return + async with sem: + r = result.results[srv.id] + r.status = "running" + if on_status: + on_status(result) + + def log(line: str) -> None: + r.log += line + "\n" + if on_status: + on_status(result) + + try: + async with SSHClient(srv) as c: + await operation(c, srv, log) + r.status = "success" + except Exception as e: + r.status = "error" + r.log += f"[ОШИБКА] {e}\n" + if fail_fast: + stop_flag.set() + if on_status: + on_status(result) + + if rolling: + for srv in servers: + await worker(srv) + if stop_flag.is_set(): + break + else: + await asyncio.gather(*(worker(s) for s in servers)) + return result + + +# ---------- готовые операции ---------- + +def op_apt_update(): + async def op(c: SSHClient, srv: Server, log: LogCallback): + log("$ apt-get update") + r = await c.sudo("DEBIAN_FRONTEND=noninteractive apt-get update", timeout=600) + log(r.stdout[-2000:] or "(пустой вывод)") + if not r.ok: + raise RuntimeError(r.stderr[-500:]) + return op + + +def op_upgrade(dist: bool = False): + async def op(c: SSHClient, srv: Server, log: LogCallback): + kind = "dist-upgrade" if dist else "upgrade" + log(f"$ apt-get {kind} -y") + async for line in c.run_stream( + f"DEBIAN_FRONTEND=noninteractive apt-get {kind} -y", use_sudo=True + ): + log(line) + return op + + +def op_reboot(wait: bool = True): + async def op(c: SSHClient, srv: Server, log: LogCallback): + log("$ reboot") + await c.sudo("(sleep 1; reboot) &", timeout=10) + if wait: + log("ожидание возврата сервера...") + await asyncio.sleep(10) + ok = await wait_online(srv, timeout_s=300) + if not ok: + raise RuntimeError("сервер не вернулся за 5 минут") + log("сервер снова онлайн") + return op + + +def op_install_package(names: List[str]): + async def op(c: SSHClient, srv: Server, log: LogCallback): + log(f"$ apt-get install -y {' '.join(names)}") + async for line in c.run_stream( + "DEBIAN_FRONTEND=noninteractive apt-get install -y " + " ".join(names), + use_sudo=True, + ): + log(line) + return op + + +def op_shell(cmd: str): + async def op(c: SSHClient, srv: Server, log: LogCallback): + log(f"$ {cmd}") + r = await c.sudo(cmd, timeout=600) + log(r.stdout[-3000:]) + if not r.ok: + raise RuntimeError(r.stderr[-500:] or f"rc={r.returncode}") + return op diff --git a/app/core/monitor.py b/app/core/monitor.py new file mode 100644 index 0000000..b3531d3 --- /dev/null +++ b/app/core/monitor.py @@ -0,0 +1,94 @@ +"""Мониторинг: сбор метрик по SSH, снапшоты в БД.""" +from __future__ import annotations + +from typing import Optional + +from sqlmodel import select + +from app.core.ssh import SSHClient +from app.db.database import get_session +from app.db.models import MetricSnapshot + +# Каждая команда выводит одну строку "KEY value..."; парсер ниже. +# CPU считаем по дельте двух замеров /proc/stat за 1 секунду. +_METRICS_CMD = ( + # CPU: первая строка cpu в /proc/stat (user nice system idle iowait irq softirq steal) + "read cpu u n s idle iow irq sirq st rest < /proc/stat; " + "t1=$((u+n+s+idle+iow+irq+sirq+st)); i1=$((idle+iow)); " + "sleep 1; " + "read cpu u n s idle iow irq sirq st rest < /proc/stat; " + "t2=$((u+n+s+idle+iow+irq+sirq+st)); i2=$((idle+iow)); " + "dt=$((t2-t1)); di=$((i2-i1)); " + "if [ $dt -gt 0 ]; then awk -v a=$dt -v b=$di 'BEGIN{printf \"CPU %.1f\\n\", (a-b)*100/a}'; else echo 'CPU 0.0'; fi; " + # RAM: used/total в МБ + "free -m | awk 'NR==2{printf \"MEM %d %d\\n\", $3, $2}'; " + # Диск: процент занятого на / + "df -m / | awk 'NR==2{printf \"DISK %.1f\\n\", $3*100/$2}'; " + # Load average 1min + "awk '{printf \"LOAD %s\\n\", $1}' /proc/loadavg; " + # Сеть: суммарные байты rx/tx всех интерфейсов + "awk '/: /{rx+=$2; tx+=$10} END{printf \"NET %d %d\\n\", rx, tx}' /proc/net/dev" +) + + +async def collect(c: SSHClient, server_id: int, prev_net: Optional[tuple] = None) -> MetricSnapshot: + r = await c.run(_METRICS_CMD, timeout=30) + cpu = mem_used = mem_total = disk = load = 0.0 + rx = tx = 0 + for line in r.stdout.splitlines(): + p = line.split() + if len(p) < 2: + continue + if p[0] == "CPU": + cpu = float(p[1]) + elif p[0] == "MEM": + mem_used, mem_total = int(p[1]), int(p[2]) + elif p[0] == "DISK": + disk = float(p[1]) + elif p[0] == "LOAD": + load = float(p[1]) + elif p[0] == "NET": + rx, tx = int(p[1]), int(p[2]) + + rx_kbs = tx_kbs = 0.0 + if prev_net: + dt = 60.0 # интервал опроса + rx_kbs = max(0.0, (rx - prev_net[0]) / 1024 / dt) + tx_kbs = max(0.0, (tx - prev_net[1]) / 1024 / dt) + + snap = MetricSnapshot( + server_id=server_id, + cpu_percent=round(cpu, 1), + mem_percent=round(mem_used * 100 / mem_total, 1) if mem_total else 0, + mem_used_mb=mem_used, + mem_total_mb=mem_total, + disk_percent=disk, + load1=load, + net_rx_kbs=round(rx_kbs, 1), + net_tx_kbs=round(tx_kbs, 1), + ) + with get_session() as s: + s.add(snap) + s.commit() + return snap + + +def latest(server_id: int) -> Optional[MetricSnapshot]: + with get_session() as s: + return s.exec( + select(MetricSnapshot) + .where(MetricSnapshot.server_id == server_id) + .order_by(MetricSnapshot.id.desc()) + .limit(1) + ).first() + + +def history(server_id: int, limit: int = 60) -> list[MetricSnapshot]: + with get_session() as s: + rows = s.exec( + select(MetricSnapshot) + .where(MetricSnapshot.server_id == server_id) + .order_by(MetricSnapshot.id.desc()) + .limit(limit) + ).all() + return list(reversed(rows)) diff --git a/app/core/notify.py b/app/core/notify.py new file mode 100644 index 0000000..6f51c5a --- /dev/null +++ b/app/core/notify.py @@ -0,0 +1,91 @@ +"""Telegram-уведомления с антиспамом.""" +from __future__ import annotations + +import time +from typing import Optional + +import httpx + +from app.db.database import get_session +from app.db.models import Setting + +_antispam: dict[str, float] = {} +THROTTLE_S = 600 # не чаще 1 раза в 10 мин на ключ + + +def _get(key: str) -> str: + with get_session() as s: + row = s.get(Setting, key) + return row.value if row else "" + + +def _set(key: str, value: str) -> None: + with get_session() as s: + row = s.get(Setting, key) or Setting(key=key) + row.value = value + s.add(row) + s.commit() + + +def get_config() -> dict: + return { + "token": _get("tg_token"), + "chat_id": _get("tg_chat_id"), + "enabled": _get("tg_enabled") == "1", + "on_alert": _get("tg_on_alert") == "1", + "on_task_done": _get("tg_on_task_done") == "1", + } + + +def save_config(token: str, chat_id: str, enabled: bool, + on_alert: bool, on_task_done: bool) -> None: + _set("tg_token", token) + _set("tg_chat_id", chat_id) + _set("tg_enabled", "1" if enabled else "0") + _set("tg_on_alert", "1" if on_alert else "0") + _set("tg_on_task_done", "1" if on_task_done else "0") + + +async def send(text: str, key: Optional[str] = None, force: bool = False) -> bool: + """Отправить сообщение. key — для антиспама (например 'disk:server1').""" + cfg = get_config() + if not cfg["enabled"] or not cfg["token"] or not cfg["chat_id"]: + return False + if key and not force: + last = _antispam.get(key, 0) + if time.time() - last < THROTTLE_S: + return False + _antispam[key] = time.time() + url = f"https://api.telegram.org/bot{cfg['token']}/sendMessage" + try: + async with httpx.AsyncClient(timeout=15) as cl: + r = await cl.post(url, json={ + "chat_id": cfg["chat_id"], + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": True, + }) + return r.status_code == 200 + except Exception: + return False + + +async def test() -> tuple[bool, str]: + cfg = get_config() + if not cfg["token"] or not cfg["chat_id"]: + return False, "заполните token и chat_id" + ok = await send("✅ Debian Manager: тестовое уведомление", force=True) + return ok, "отправлено" if ok else "ошибка отправки (проверьте token/chat_id)" + + +async def alert(server_name: str, text: str) -> None: + cfg = get_config() + if cfg["enabled"] and cfg["on_alert"]: + await send(f"⚠️ {server_name}: {text}", key=f"alert:{server_name}:{text[:30]}") + + +async def task_done(server_name: str, title: str, status: str) -> None: + cfg = get_config() + if cfg["enabled"] and cfg["on_task_done"]: + icon = "✅" if status == "success" else "❌" + await send(f"{icon} {server_name}: {title} — {status}", force=True) diff --git a/app/core/packages.py b/app/core/packages.py new file mode 100644 index 0000000..7c5b471 --- /dev/null +++ b/app/core/packages.py @@ -0,0 +1,101 @@ +"""APT: update/upgrade, списки пакетов, установка/удаление.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List + +from app.core.ssh import SSHClient + +SYSTEM_PRIORITIES = {"required", "important"} + + +@dataclass +class Package: + name: str + version: str + priority: str + manual: bool + upgradable: bool = False + candidate: str = "" + + @property + def is_system(self) -> bool: + return self.priority in SYSTEM_PRIORITIES + + +async def apt_update(c: SSHClient) -> str: + r = await c.sudo("DEBIAN_FRONTEND=noninteractive apt-get update", timeout=600) + return r.stdout + r.stderr + + +async def list_upgradable(c: SSHClient) -> List[dict]: + r = await c.run("apt list --upgradable 2>/dev/null | tail -n +2") + out = [] + for line in r.stdout.splitlines(): + if not line.strip(): + continue + # nginx/stable 1.22.1-9+deb12u1 amd64 [upgradable from: 1.22.1-9] + parts = line.split() + if len(parts) >= 2: + name = parts[0].split("/")[0] + out.append({"name": name, "candidate": parts[1], "raw": line}) + return out + + +async def list_packages(c: SSHClient) -> List[Package]: + """Все установленные пакеты с приоритетом и признаком manual.""" + r = await c.run( + "dpkg-query -W -f='${Package}\\t${Version}\\t${Priority}\\n'", timeout=180 + ) + manual_r = await c.run("apt-mark showmanual", timeout=120) + manual = set(manual_r.stdout.split()) + pkgs: List[Package] = [] + for line in r.stdout.splitlines(): + parts = line.split("\t") + if len(parts) == 3: + name, version, prio = parts + pkgs.append(Package(name, version, prio, name in manual)) + return pkgs + + +def split_packages(pkgs: List[Package]) -> Dict[str, List[Package]]: + """Разбивка на системные / пользовательские / зависимости.""" + system = [p for p in pkgs if p.is_system] + user = [p for p in pkgs if not p.is_system and p.manual] + deps = [p for p in pkgs if not p.is_system and not p.manual] + return {"system": system, "user": user, "deps": deps} + + +async def apt_upgrade(c: SSHClient, dist: bool = False): + """Стриминг upgrade. Возвращает async-итератор строк.""" + cmd = "DEBIAN_FRONTEND=noninteractive apt-get {} -y".format( + "dist-upgrade" if dist else "upgrade" + ) + return c.run_stream(cmd, use_sudo=True) + + +async def install(c: SSHClient, names: List[str]): + cmd = "DEBIAN_FRONTEND=noninteractive apt-get install -y " + " ".join(names) + return c.run_stream(cmd, use_sudo=True) + + +async def remove(c: SSHClient, names: List[str], purge: bool = False): + action = "purge" if purge else "remove" + cmd = f"DEBIAN_FRONTEND=noninteractive apt-get {action} -y " + " ".join(names) + return c.run_stream(cmd, use_sudo=True) + + +async def autoremove(c: SSHClient): + return c.run_stream( + "DEBIAN_FRONTEND=noninteractive apt-get autoremove -y", use_sudo=True + ) + + +async def apt_show(c: SSHClient, name: str) -> str: + r = await c.run(f"apt-cache show {name} 2>/dev/null | head -40") + return r.stdout + + +async def apt_search(c: SSHClient, query: str) -> List[str]: + r = await c.run(f"apt-cache search {query} 2>/dev/null | head -50") + return [l for l in r.stdout.splitlines() if l.strip()] diff --git a/app/core/playbooks.py b/app/core/playbooks.py new file mode 100644 index 0000000..1e6bfe2 --- /dev/null +++ b/app/core/playbooks.py @@ -0,0 +1,188 @@ +"""Плейбуки: YAML + Jinja2, типы шагов, выполнение.""" +from __future__ import annotations + +from pathlib import Path +from typing import Callable, Coroutine, List, Optional + +import yaml +from jinja2 import Template + +from app.core.ssh import SSHClient +from app.db.models import Server + +LogCallback = Callable[[str], None] + +PLAYBOOKS_DIR = Path.home() / ".debian-manager" / "playbooks" +PLAYBOOKS_DIR.mkdir(parents=True, exist_ok=True) + + +BUILTIN = { + "Новый сервер": """\ +name: "Настройка нового сервера" +vars: + ssh_port: "22" +steps: + - name: Обновить систему + apt: {update: true, upgrade: dist} + - name: Установить базовые утилиты + apt: {install: [curl, htop, git, ufw, fail2ban]} + - name: Firewall (SSH + исходящие) + ufw: {preset: "Только SSH"} + - name: Включить ufw + shell: "yes | ufw enable" +""", + "Docker-хост": """\ +name: "Docker-хост" +steps: + - name: Обновить индекс пакетов + apt: {update: true} + - name: Установить Docker + apt: {install: [docker.io, docker-compose-v2]} + - name: Запустить docker + systemd: {unit: docker, action: "enable --now"} +""", + "Обновить и перезагрузить": """\ +name: "Обновить всё и перезагрузить" +steps: + - name: Update + apt: {update: true} + - name: Upgrade + apt: {upgrade: dist} + - name: Autoremove + apt: {autoremove: true} + - name: Reboot + reboot: {wait: true} +""", +} + + +def list_playbooks() -> List[str]: + names = sorted(p.stem for p in PLAYBOOKS_DIR.glob("*.yml")) + return list(BUILTIN.keys()) + names + + +def load(name: str) -> dict: + if name in BUILTIN: + return yaml.safe_load(BUILTIN[name]) + path = PLAYBOOKS_DIR / f"{name}.yml" + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def save(name: str, content: str) -> None: + yaml.safe_load(content) # валидация + (PLAYBOOKS_DIR / f"{name}.yml").write_text(content, encoding="utf-8") + + +def delete(name: str) -> None: + p = PLAYBOOKS_DIR / f"{name}.yml" + if p.exists(): + p.unlink() + + +def _render(value, ctx: dict): + if isinstance(value, str): + return Template(value).render(**ctx) + if isinstance(value, list): + return [_render(v, ctx) for v in value] + if isinstance(value, dict): + return {k: _render(v, ctx) for k, v in value.items()} + return value + + +async def run_playbook(c: SSHClient, srv: Server, pb: dict, log: LogCallback, + extra_vars: Optional[dict] = None) -> None: + ctx = dict(pb.get("vars") or {}) + ctx.update(extra_vars or {}) + ctx.setdefault("ssh_port", str(srv.port)) + log(f"=== Плейбук: {pb.get('name', '?')} | сервер: {srv.name} ===") + for i, step in enumerate(pb.get("steps", []), 1): + name = _render(step.get("name", f"шаг {i}"), ctx) + log(f"\n--- [{i}] {name} ---") + + when = step.get("when") + if when: + cond = str(_render(when, ctx)).strip() + if cond.lower() in ("false", "0", "", "no"): + log("пропущен (when)") + continue + + try: + if "shell" in step: + cmd = _render(step["shell"], ctx) + log(f"$ {cmd}") + async for line in c.run_stream(cmd, use_sudo=True): + log(line) + elif "apt" in step: + a = _render(step["apt"], ctx) + if a.get("update"): + log("$ apt-get update") + r = await c.sudo("DEBIAN_FRONTEND=noninteractive apt-get update", 900) + log(r.stdout[-1500:]) + if a.get("upgrade"): + kind = "dist-upgrade" if a["upgrade"] == "dist" else "upgrade" + log(f"$ apt-get {kind} -y") + async for line in c.run_stream( + f"DEBIAN_FRONTEND=noninteractive apt-get {kind} -y", use_sudo=True + ): + log(line) + if a.get("install"): + pkgs = " ".join(a["install"]) + log(f"$ apt-get install -y {pkgs}") + async for line in c.run_stream( + f"DEBIAN_FRONTEND=noninteractive apt-get install -y {pkgs}", + use_sudo=True, + ): + log(line) + if a.get("remove"): + pkgs = " ".join(a["remove"]) + log(f"$ apt-get remove -y {pkgs}") + async for line in c.run_stream( + f"DEBIAN_FRONTEND=noninteractive apt-get remove -y {pkgs}", + use_sudo=True, + ): + log(line) + if a.get("autoremove"): + async for line in c.run_stream( + "DEBIAN_FRONTEND=noninteractive apt-get autoremove -y", use_sudo=True + ): + log(line) + elif "ufw" in step: + from app.core import firewall + u = _render(step["ufw"], ctx) + if "preset" in u: + log(await firewall.apply_preset(c, u["preset"], int(ctx["ssh_port"]))) + if u.get("enable"): + log(await firewall.enable(c, int(ctx["ssh_port"]))) + elif "systemd" in step: + from app.core import systemd_svc + sd = _render(step["systemd"], ctx) + log(await systemd_svc.action(c, sd["unit"], sd["action"])) + elif "docker" in step: + d = _render(step["docker"], ctx) + workdir = d.get("workdir", "/opt/stacks/app") + log(await _docker_compose(c, workdir, d.get("action", "up -d"))) + elif "reboot" in step: + from app.core.ssh import wait_online + import asyncio + log("перезагрузка...") + await c.sudo("(sleep 1; reboot) &", timeout=10) + if step["reboot"].get("wait", True): + await asyncio.sleep(10) + if not await wait_online(srv, 300): + raise RuntimeError("сервер не вернулся после reboot") + log("сервер снова онлайн — переподключение") + c.close() + await c.connect() + else: + log(f"неизвестный тип шага: {list(step.keys())}") + except Exception as e: + if step.get("ignore_errors"): + log(f"[ошибка проигнорирована] {e}") + else: + raise RuntimeError(f"шаг «{name}» упал: {e}") + log("\n=== Плейбук завершён ===") + + +async def _docker_compose(c: SSHClient, workdir: str, action: str) -> str: + r = await c.sudo(f"docker compose -f {workdir}/docker-compose.yml {action}", 900) + return r.stdout + r.stderr diff --git a/app/core/proxmox.py b/app/core/proxmox.py new file mode 100644 index 0000000..e2b825e --- /dev/null +++ b/app/core/proxmox.py @@ -0,0 +1,132 @@ +"""Proxmox VE: API через proxmoxer, ноды, VM/LXC, управление питанием.""" +from __future__ import annotations + +from typing import List, Optional + +from app.db.database import get_session +from app.db.models import Setting + + +def _get(key: str) -> str: + with get_session() as s: + row = s.get(Setting, key) + return row.value if row else "" + + +def _set(key: str, value: str) -> None: + with get_session() as s: + row = s.get(Setting, key) or Setting(key=key) + row.value = value + s.add(row) + s.commit() + + +def get_config() -> dict: + return { + "host": _get("pve_host"), + "user": _get("pve_user") or "root@pam", + "token_name": _get("pve_token_name"), + "token_value": _get("pve_token_value"), + "verify_ssl": _get("pve_verify_ssl") == "1", + } + + +def save_config(host: str, user: str, token_name: str, token_value: str, + verify_ssl: bool) -> None: + _set("pve_host", host.rstrip("/")) + _set("pve_user", user) + _set("pve_token_name", token_name) + _set("pve_token_value", token_value) + _set("pve_verify_ssl", "1" if verify_ssl else "0") + + +def _client(): + """Создать proxmoxer-клиент. Бросает если не настроено.""" + from proxmoxer import ProxmoxAPI + cfg = get_config() + if not cfg["host"] or not cfg["token_name"] or not cfg["token_value"]: + raise RuntimeError("Proxmox не настроен — заполните хост и API-токен") + # убрать http:// или https:// если пользователь ввёл + host = cfg["host"].replace("http://", "").replace("https://", "").rstrip("/") + return ProxmoxAPI( + host, user=cfg["user"], + token_name=cfg["token_name"], token_value=cfg["token_value"], + verify_ssl=cfg["verify_ssl"], timeout=15, + ) + + +def test_connection() -> tuple[bool, str]: + try: + pve = _client() + version = pve.version.get() + return True, f"Proxmox VE {version.get('version', '?')}" + except Exception as e: + return False, str(e) + + +def nodes() -> List[dict]: + return _client().nodes.get() + + +def node_status(node: str) -> dict: + return _client().nodes(node).status.get() + + +def vms(node: str) -> List[dict]: + """Все VM и LXC на ноде.""" + pve = _client() + qemu = [{"type": "qemu", **x} for x in pve.nodes(node).qemu.get()] + lxc = [{"type": "lxc", **x} for x in pve.nodes(node).lxc.get()] + return qemu + lxc + + +def vm_status(node: str, vmid: int, vm_type: str) -> dict: + pve = _client() + if vm_type == "qemu": + return pve.nodes(node).qemu(vmid).status.current.get() + return pve.nodes(node).lxc(vmid).status.current.get() + + +def vm_action(node: str, vmid: int, vm_type: str, action: str) -> str: + """start/stop/shutdown/reboot/reset. Возвращает UPID задачи.""" + assert action in ("start", "stop", "shutdown", "reboot", "reset") + pve = _client() + api = pve.nodes(node).qemu(vmid) if vm_type == "qemu" else pve.nodes(node).lxc(vmid) + return getattr(api.status, action).post() + + +def vm_config(node: str, vmid: int, vm_type: str) -> dict: + pve = _client() + if vm_type == "qemu": + return pve.nodes(node).qemu(vmid).config.get() + return pve.nodes(node).lxc(vmid).config.get() + + +def vm_rrd(node: str, vmid: int, vm_type: str, timeframe: str = "hour") -> list: + """Метрики из RRD (cpu, mem, netin, netout...).""" + pve = _client() + api = pve.nodes(node).qemu(vmid) if vm_type == "qemu" else pve.nodes(node).lxc(vmid) + return api.rrd.get(timeframe=timeframe, cf="AVERAGE") + + +def get_vm_ip(node: str, vmid: int, vm_type: str) -> Optional[str]: + """IP из QEMU Guest Agent или из конфига LXC.""" + try: + pve = _client() + if vm_type == "qemu": + data = pve.nodes(node).qemu(vmid).agent("network-get-interfaces").get() + for iface in data.get("result", []): + for addr in iface.get("ip-addresses", []): + ip = addr.get("ip-address", "") + if addr.get("ip-address-type") == "ipv4" and not ip.startswith("127."): + return ip + else: + cfg = pve.nodes(node).lxc(vmid).config.get() + net0 = cfg.get("net0", "") + # "name=eth0,bridge=vmbr0,ip=192.168.1.10/24,gw=..." + for part in net0.split(","): + if part.startswith("ip=") and part != "ip=dhcp": + return part[3:].split("/")[0] + except Exception: + pass + return None diff --git a/app/core/reports.py b/app/core/reports.py new file mode 100644 index 0000000..3164cb4 --- /dev/null +++ b/app/core/reports.py @@ -0,0 +1,58 @@ +"""Экспорт отчётов: CSV по серверам, метрикам, задачам.""" +from __future__ import annotations + +import csv +from datetime import datetime +from pathlib import Path +from typing import Optional + +from sqlmodel import select + +from app.core.ssh import check_server +from app.db.database import get_session +from app.db.models import MetricSnapshot, Server, TaskRecord + +REPORTS_DIR = Path.home() / ".debian-manager" / "reports" +REPORTS_DIR.mkdir(parents=True, exist_ok=True) + + +async def generate() -> str: + """Сводный CSV-отчёт. Возвращает путь к файлу.""" + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + path = REPORTS_DIR / f"report_{ts}.csv" + with open(path, "w", newline="", encoding="utf-8-sig") as f: + w = csv.writer(f) + w.writerow(["=== ОТЧЁТ ПО СЕРВЕРАМ", datetime.now().isoformat(), "==="]) + w.writerow([]) + w.writerow(["Сервер", "Хост", "Порт", "Пользователь", "Теги", + "Онлайн", "ОС", "Uptime", "CPU%", "RAM%", "Диск%"]) + with get_session() as s: + servers = list(s.exec(select(Server)).all()) + for srv in servers: + info = await check_server(srv) + cpu = ram = disk = "" + if info.get("online"): + with get_session() as s: + m = s.exec(select(MetricSnapshot) + .where(MetricSnapshot.server_id == srv.id) + .order_by(MetricSnapshot.id.desc()).limit(1)).first() + if m: + cpu, ram, disk = m.cpu_percent, m.mem_percent, m.disk_percent + w.writerow([srv.name, srv.host, srv.port, srv.username, srv.tags, + "да" if info.get("online") else "нет", + info.get("os", ""), info.get("uptime", ""), cpu, ram, disk]) + w.writerow([]) + w.writerow(["=== ПОСЛЕДНИЕ ЗАДАЧИ ==="]) + w.writerow(["Время", "Сервер", "Тип", "Название", "Статус"]) + with get_session() as s: + tasks = list(s.exec(select(TaskRecord) + .order_by(TaskRecord.id.desc()).limit(100)).all()) + srv_names = {x.id: x.name for x in servers} + for t in tasks: + w.writerow([t.created_at, srv_names.get(t.server_id, "?"), + t.kind, t.title, t.status]) + return str(path) + + +def list_reports() -> list[Path]: + return sorted(REPORTS_DIR.glob("report_*.csv"), reverse=True) diff --git a/app/core/scheduler.py b/app/core/scheduler.py new file mode 100644 index 0000000..a4cd433 --- /dev/null +++ b/app/core/scheduler.py @@ -0,0 +1,193 @@ +"""Планировщик: запуск задач по cron-расписанию внутри приложения.""" +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import Optional + +from croniter import croniter +from sqlmodel import select + +from app.db.database import get_session +from app.db.models import ScheduledTask, Server + +CHECK_INTERVAL = 30 # секунд между проверками + +_next_run: dict[int, datetime] = {} +_loop_task: Optional[asyncio.Task] = None + + +# ---------- CRUD ---------- + +def list_tasks() -> list[ScheduledTask]: + with get_session() as s: + return list(s.exec(select(ScheduledTask).order_by(ScheduledTask.id)).all()) + + +def add_task(**fields) -> ScheduledTask: + with get_session() as s: + t = ScheduledTask(**fields) + s.add(t) + s.commit() + s.refresh(t) + return t + + +def update_task(task_id: int, **fields) -> None: + with get_session() as s: + t = s.get(ScheduledTask, task_id) + if t: + for k, v in fields.items(): + setattr(t, k, v) + s.add(t) + s.commit() + _next_run.pop(task_id, None) # пересчитать расписание + + +def delete_task(task_id: int) -> None: + with get_session() as s: + t = s.get(ScheduledTask, task_id) + if t: + s.delete(t) + s.commit() + _next_run.pop(task_id, None) + + +# ---------- выполнение ---------- + +def _target_servers(target: str) -> list[Server]: + with get_session() as s: + servers = list(s.exec(select(Server)).all()) + if target == "all": + return servers + return [srv for srv in servers if target in (srv.tags or "").split(",") + or target.strip() in [t.strip() for t in (srv.tags or "").split(",")]] + + +async def execute_task(task_id: int) -> str: + """Выполнить задачу немедленно. Возвращает статус.""" + from app.core import servers as servers_core + from app.core.group_ops import op_apt_update, op_upgrade, op_shell, run_group + from app.core.tasks import tasks as tm + + with get_session() as s: + t = s.get(ScheduledTask, task_id) + if not t: + return "error: задача не найдена" + action, target, params, name = t.action, t.target, t.params, t.name + t.last_run = datetime.now(timezone.utc) + t.last_status = "running" + s.add(t) + s.commit() + + status = "success" + log_lines: list[str] = [] + try: + if action == "cleanup_metrics": + from app.db.models import MetricSnapshot + from datetime import timedelta + cutoff = datetime.now(timezone.utc) - timedelta(days=7) + with get_session() as s: + old = s.exec(select(MetricSnapshot).where(MetricSnapshot.ts < cutoff)).all() + for m in old: + s.delete(m) + s.commit() + log_lines.append(f"удалено {len(old)} снапшотов") + elif action == "report": + from app.core import reports + path = await reports.generate() + log_lines.append(f"отчёт: {path}") + else: + servers = _target_servers(target) + if not servers: + raise RuntimeError(f"нет серверов для target='{target}'") + if action == "apt_update": + op = op_apt_update() + elif action == "upgrade": + op = op_upgrade(False) + elif action == "dist_upgrade": + op = op_upgrade(True) + elif action == "shell": + op = op_shell(params) + elif action == "backup": + from app.core.backup import run_backup + for srv in servers: + rec = await run_backup(srv, params or "/etc") + log_lines.append(f"{srv.name}: {rec.status}") + op = None + elif action == "playbook": + from app.core import playbooks as pb + from app.core.ssh import SSHClient + data = pb.load(params) + for srv in servers: + async with SSHClient(srv) as c: + await pb.run_playbook(c, srv, data, log_lines.append) + op = None + else: + raise RuntimeError(f"неизвестное действие: {action}") + + if op is not None: + res = await run_group(servers, op) + log_lines.append(f"ok={res.ok_count} err={res.err_count}") + for r in res.results.values(): + if r.status == "error": + status = "error" + log_lines.append(f"{r.name}: {r.log[-300:]}") + except Exception as e: + status = "error" + log_lines.append(f"[ОШИБКА] {e}") + + with get_session() as s: + t = s.get(ScheduledTask, task_id) + if t: + t.last_status = status + s.add(t) + s.commit() + # Telegram-уведомление + try: + from app.core import notify + await notify.task_done(f"планировщик", name, status) + except Exception: + pass + return status + + +# ---------- цикл ---------- + +async def _loop(): + while True: + try: + now = datetime.now(timezone.utc) + for t in list_tasks(): + if not t.enabled: + continue + nxt = _next_run.get(t.id) + if nxt is None: + nxt = croniter(t.cron, now).get_next(datetime) + _next_run[t.id] = nxt + if now >= nxt: + _next_run[t.id] = croniter(t.cron, now).get_next(datetime) + asyncio.ensure_future(execute_task(t.id)) + except Exception: + pass + await asyncio.sleep(CHECK_INTERVAL) + + +def start() -> None: + global _loop_task + if _loop_task is None or _loop_task.done(): + _loop_task = asyncio.ensure_future(_loop()) + + +# ---------- пресеты ---------- + +PRESETS = [ + ("Ежедневный apt update (03:00)", "0 3 * * *", "apt_update", "all", ""), + ("Еженедельный upgrade (вс 04:00)", "0 4 * * 0", "upgrade", "all", ""), + ("Ежедневный бэкап /etc (02:00)", "0 2 * * *", "backup", "all", "/etc"), + ("Бэкап /etc и /home (02:30)", "30 2 * * *", "backup", "all", "/etc /home"), + ("Очистка старых метрик (ежечасно)", "7 * * * *", "cleanup_metrics", "all", ""), + ("Ежедневный отчёт (08:00)", "0 8 * * *", "report", "all", ""), + ("Docker prune (вс 05:00)", "0 5 * * 0", "shell", "all", "docker system prune -f"), + ("Проверка диска (каждые 6 ч)", "13 */6 * * *", "shell", "all", "df -h"), +] diff --git a/app/core/servers.py b/app/core/servers.py new file mode 100644 index 0000000..990222a --- /dev/null +++ b/app/core/servers.py @@ -0,0 +1,47 @@ +"""CRUD серверов.""" +from __future__ import annotations + +from typing import List, Optional + +from sqlmodel import select + +from app.db.database import get_session +from app.db.models import Server + + +def list_servers() -> List[Server]: + with get_session() as s: + return list(s.exec(select(Server).order_by(Server.name)).all()) + + +def get_server(server_id: int) -> Optional[Server]: + with get_session() as s: + return s.get(Server, server_id) + + +def add_server(**fields) -> Server: + with get_session() as s: + srv = Server(**fields) + s.add(srv) + s.commit() + s.refresh(srv) + return srv + + +def update_server(server_id: int, **fields) -> None: + with get_session() as s: + srv = s.get(Server, server_id) + if not srv: + return + for k, v in fields.items(): + setattr(srv, k, v) + s.add(srv) + s.commit() + + +def delete_server(server_id: int) -> None: + with get_session() as s: + srv = s.get(Server, server_id) + if srv: + s.delete(srv) + s.commit() diff --git a/app/core/ssh.py b/app/core/ssh.py new file mode 100644 index 0000000..d39926d --- /dev/null +++ b/app/core/ssh.py @@ -0,0 +1,130 @@ +"""SSH-слой: подключения, выполнение команд, sudo, потоковый вывод.""" +from __future__ import annotations + +import asyncio +import shlex +from dataclasses import dataclass +from typing import AsyncIterator, Optional + +import asyncssh + +from app.db.models import Server + +CONNECT_TIMEOUT = 15 + + +@dataclass +class CmdResult: + stdout: str + stderr: str + returncode: int + + @property + def ok(self) -> bool: + return self.returncode == 0 + + +class SSHClient: + """Одно SSH-подключение к серверу с помощниками sudo.""" + + def __init__(self, server: Server): + self.server = server + self._conn: Optional[asyncssh.SSHClientConnection] = None + + async def __aenter__(self) -> "SSHClient": + await self.connect() + return self + + async def __aexit__(self, *exc) -> None: + self.close() + + async def connect(self) -> None: + kwargs: dict = dict( + host=self.server.host, + port=self.server.port, + username=self.server.username, + known_hosts=None, + connect_timeout=CONNECT_TIMEOUT, + ) + if self.server.auth_type == "key" and self.server.key_path: + kwargs["client_keys"] = [self.server.key_path] + if self.server.password: + kwargs["passphrase"] = self.server.password + else: + kwargs["password"] = self.server.password + self._conn = await asyncssh.connect(**kwargs) + + def close(self) -> None: + if self._conn: + self._conn.close() + self._conn = None + + @property + def conn(self) -> asyncssh.SSHClientConnection: + if not self._conn: + raise RuntimeError("SSH not connected") + return self._conn + + # ---------- выполнение ---------- + + async def run(self, cmd: str, timeout: int = 120) -> CmdResult: + r = await self.conn.run(cmd, timeout=timeout) + return CmdResult(r.stdout or "", r.stderr or "", r.returncode) + + def sudo_wrap(self, cmd: str) -> str: + """Обернуть команду в sudo -S с передачей пароля по stdin.""" + pw = self.server.sudo_password or self.server.password or "" + if self.server.username == "root": + return cmd + return f"echo {shlex.quote(pw)} | sudo -S -p '' bash -c {shlex.quote(cmd)}" + + async def sudo(self, cmd: str, timeout: int = 600) -> CmdResult: + return await self.run(self.sudo_wrap(cmd), timeout=timeout) + + async def run_stream(self, cmd: str, use_sudo: bool = False) -> AsyncIterator[str]: + """Строки вывода по мере поступления (для лог-панели).""" + full = self.sudo_wrap(cmd) if use_sudo else cmd + async with self.conn.create_process(full, term_type="dumb") as proc: + async for line in proc.stdout: + yield line.rstrip("\n") + await proc.wait() + + async def is_alive(self) -> bool: + try: + r = await self.run("echo ok", timeout=10) + return r.ok + except Exception: + return False + + +async def check_server(server: Server) -> dict: + """Быстрый health-check: онлайн, ОС, uptime.""" + try: + async with SSHClient(server) as c: + r = await c.run( + "hostname; . /etc/os-release && echo $PRETTY_NAME; " + "uptime -p; nproc; free -m | awk 'NR==2{print $2}'", + timeout=20, + ) + lines = (r.stdout or "").splitlines() + return { + "online": True, + "hostname": lines[0] if len(lines) > 0 else "", + "os": lines[1] if len(lines) > 1 else "", + "uptime": lines[2] if len(lines) > 2 else "", + "cpu_cores": lines[3] if len(lines) > 3 else "?", + "mem_mb": lines[4] if len(lines) > 4 else "?", + } + except Exception as e: + return {"online": False, "error": str(e)} + + +async def wait_online(server: Server, timeout_s: int = 300, interval: int = 5) -> bool: + """Ждать возврата сервера после reboot.""" + deadline = asyncio.get_event_loop().time() + timeout_s + while asyncio.get_event_loop().time() < deadline: + info = await check_server(server) + if info.get("online"): + return True + await asyncio.sleep(interval) + return False diff --git a/app/core/systemd_svc.py b/app/core/systemd_svc.py new file mode 100644 index 0000000..fd1ea88 --- /dev/null +++ b/app/core/systemd_svc.py @@ -0,0 +1,73 @@ +"""systemd: юниты, действия, journalctl.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import List + +from app.core.ssh import SSHClient + +CRITICAL_UNITS = {"ssh.service", "sshd.service", "ssh.socket", "docker.service", + "networking.service", "systemd-networkd.service", "dbus.service"} + + +@dataclass +class Unit: + name: str + load: str + active: str + sub: str + description: str + enabled: str = "" + + +async def list_units(c: SSHClient) -> List[Unit]: + r = await c.sudo( + "systemctl list-units --type=service --all --no-legend --no-pager " + "--plain | awk '{$1=$1};1'", timeout=60 + ) + enabled_r = await c.sudo( + "systemctl list-unit-files --type=service --no-legend --no-pager", timeout=60 + ) + enabled_map = {} + for line in enabled_r.stdout.splitlines(): + p = line.split() + if len(p) >= 2: + enabled_map[p[0]] = p[1] + + units: List[Unit] = [] + for line in r.stdout.splitlines(): + p = line.split(None, 4) + if len(p) >= 4: + units.append(Unit( + name=p[0], load=p[1], active=p[2], sub=p[3], + description=p[4] if len(p) > 4 else "", + enabled=enabled_map.get(p[0], ""), + )) + return units + + +async def failed_units(c: SSHClient) -> List[str]: + r = await c.sudo("systemctl --failed --no-legend --no-pager --plain", timeout=30) + return [l.split()[0] for l in r.stdout.splitlines() if l.strip() and not l.startswith("0 ")] + + +async def action(c: SSHClient, unit: str, act: str) -> str: + assert act in ("start", "stop", "restart", "reload", "enable", "disable", "mask", "unmask") + r = await c.sudo(f"systemctl {act} {unit}", timeout=120) + if not r.ok: + raise RuntimeError(r.stderr.strip() or f"rc={r.returncode}") + return r.stdout + + +async def logs(c: SSHClient, unit: str, lines: int = 200) -> str: + r = await c.sudo(f"journalctl -u {unit} -n {lines} --no-pager", timeout=60) + return r.stdout + + +async def cat_unit(c: SSHClient, unit: str) -> str: + r = await c.sudo(f"systemctl cat {unit}", timeout=30) + return r.stdout + + +async def daemon_reload(c: SSHClient) -> None: + await c.sudo("systemctl daemon-reload") diff --git a/app/core/tasks.py b/app/core/tasks.py new file mode 100644 index 0000000..3396ead --- /dev/null +++ b/app/core/tasks.py @@ -0,0 +1,81 @@ +"""Очередь длинных задач с журналом.""" +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import Awaitable, Callable, Coroutine, List, Optional + +from app.db.database import get_session +from app.db.models import TaskRecord + +LogCallback = Callable[[str], None] + + +class TaskManager: + def __init__(self): + self._running: dict[int, asyncio.Task] = {} + + def start(self, kind: str, title: str, server_id: Optional[int], + coro_factory: Callable[[LogCallback], Coroutine], + on_log: Optional[LogCallback] = None, + on_done: Optional[Callable[[TaskRecord], None]] = None) -> int: + """Запустить задачу. coro_factory(log) — корутина, пишущая строки в log.""" + with get_session() as s: + rec = TaskRecord(kind=kind, title=title, server_id=server_id, status="running") + s.add(rec) + s.commit() + s.refresh(rec) + task_id = rec.id + + def log(line: str) -> None: + with get_session() as s: + r = s.get(TaskRecord, task_id) + if r: + r.log += line + "\n" + s.add(r) + s.commit() + if on_log: + on_log(line) + + async def runner(): + status = "success" + try: + await coro_factory(log) + except asyncio.CancelledError: + status = "cancelled" + log("[отменено пользователем]") + except Exception as e: + status = "error" + log(f"[ОШИБКА] {e}") + with get_session() as s: + r = s.get(TaskRecord, task_id) + if r: + r.status = status + r.finished_at = datetime.now(timezone.utc) + s.add(r) + s.commit() + s.refresh(r) + if on_done: + on_done(r) + + self._running[task_id] = asyncio.ensure_future(runner()) + return task_id + + def cancel(self, task_id: int) -> None: + t = self._running.get(task_id) + if t and not t.done(): + t.cancel() + + def get(self, task_id: int) -> Optional[TaskRecord]: + with get_session() as s: + return s.get(TaskRecord, task_id) + + def list_recent(self, limit: int = 50) -> List[TaskRecord]: + from sqlmodel import select + with get_session() as s: + return list(s.exec( + select(TaskRecord).order_by(TaskRecord.id.desc()).limit(limit) + ).all()) + + +tasks = TaskManager() diff --git a/app/core/terminal.py b/app/core/terminal.py new file mode 100644 index 0000000..919c517 --- /dev/null +++ b/app/core/terminal.py @@ -0,0 +1,69 @@ +"""Интерактивный терминал: PTY-сессия по SSH с потоковым вводом/выводом.""" +from __future__ import annotations + +import asyncio +from typing import Callable, Optional + +import asyncssh + +from app.db.models import Server + + +class TerminalSession: + """PTY-терминал к серверу. output_cb(str) вызывается для каждого куска вывода.""" + + def __init__(self, server: Server, output_cb: Callable[[str], None], + cols: int = 120, rows: int = 30): + self.server = server + self.output_cb = output_cb + self.cols = cols + self.rows = rows + self._conn: Optional[asyncssh.SSHClientConnection] = None + self._proc: Optional[asyncssh.SSHClientProcess] = None + self._closed = False + + async def open(self) -> None: + kwargs: dict = dict( + host=self.server.host, port=self.server.port, + username=self.server.username, known_hosts=None, connect_timeout=15, + ) + if self.server.auth_type == "key" and self.server.key_path: + kwargs["client_keys"] = [self.server.key_path] + if self.server.password: + kwargs["passphrase"] = self.server.password + else: + kwargs["password"] = self.server.password + self._conn = await asyncssh.connect(**kwargs) + self._proc = await self._conn.create_process( + term_type="xterm-256color", + term_size=(self.cols, self.rows), + ) + asyncio.ensure_future(self._reader()) + + async def _reader(self) -> None: + try: + while not self._closed and self._proc: + data = await self._proc.stdout.read(4096) + if not data: + break + self.output_cb(data) + except Exception: + pass + + def write(self, data: str) -> None: + if self._proc: + self._proc.stdin.write(data) + + def resize(self, cols: int, rows: int) -> None: + if self._proc: + self._proc.change_terminal_size(cols, rows) + + def close(self) -> None: + self._closed = True + try: + if self._proc: + self._proc.terminate() + if self._conn: + self._conn.close() + except Exception: + pass diff --git a/app/core/webhook.py b/app/core/webhook.py new file mode 100644 index 0000000..e6724a5 --- /dev/null +++ b/app/core/webhook.py @@ -0,0 +1,111 @@ +"""Webhook-сервер для автодеплоя из Gitea (push → redeploy).""" +from __future__ import annotations + +import asyncio +import json +from typing import Optional + +from sqlmodel import select + +from app.core import servers as servers_core +from app.db.database import get_session +from app.db.models import WebhookBinding + +_server = None + + +def list_bindings() -> list[WebhookBinding]: + with get_session() as s: + return list(s.exec(select(WebhookBinding)).all()) + + +def add_binding(**fields) -> WebhookBinding: + with get_session() as s: + b = WebhookBinding(**fields) + s.add(b) + s.commit() + s.refresh(b) + return b + + +def delete_binding(bid: int) -> None: + with get_session() as s: + b = s.get(WebhookBinding, bid) + if b: + s.delete(b) + s.commit() + + +async def _handler(reader, writer): + """Минимальный HTTP-сервер для /webhooks/gitea.""" + try: + req = await reader.readuntil(b"\r\n\r\n") + head = req.decode(errors="replace") + first = head.splitlines()[0] if head else "" + # тело + length = 0 + for line in head.splitlines(): + if line.lower().startswith("content-length:"): + length = int(line.split(":", 1)[1].strip()) + body = await reader.readexactly(length) if length else b"" + + status, text = 200, "ok" + if first.startswith("POST /webhooks/gitea"): + try: + payload = json.loads(body or b"{}") + repo = payload.get("repository", {}).get("full_name", "") + ref = payload.get("ref", "") # refs/heads/main + branch = ref.rsplit("/", 1)[-1] + asyncio.ensure_future(_deploy(repo, branch)) + text = f"deploy scheduled: {repo}@{branch}" + except Exception as e: + status, text = 400, f"bad payload: {e}" + else: + status, text = 404, "not found" + + writer.write(f"HTTP/1.1 {status} OK\r\nContent-Length: {len(text)}\r\n" + f"Connection: close\r\n\r\n{text}".encode()) + await writer.drain() + except Exception: + pass + finally: + try: + writer.close() + except Exception: + pass + + +async def _deploy(repo: str, branch: str) -> None: + with get_session() as s: + bindings = list(s.exec(select(WebhookBinding) + .where(WebhookBinding.repo == repo, + WebhookBinding.branch == branch, + WebhookBinding.enabled == True)).all()) # noqa: E712 + for b in bindings: + srv = servers_core.get_server(b.server_id) + if not srv: + continue + try: + from app.core import gitea as g + from app.core.ssh import SSHClient + from app.core.tasks import tasks + async def job(log, srv=srv, b=b): + async with SSHClient(srv) as c: + cfg = g.get_config() + clone_url = f"{cfg['url']}/{repo}.git" + await g.deploy(c, clone_url, repo.split("/")[-1], + b.branch, b.compose_path, b.env or None, log) + tasks.start("webhook", f"auto-deploy {repo}@{branch}", srv.id, job) + except Exception: + pass + + +async def start(port: int = 9100) -> None: + global _server + if _server: + return + _server = await asyncio.start_server(_handler, "0.0.0.0", port) + + +def is_running() -> bool: + return _server is not None diff --git a/app/db/database.py b/app/db/database.py new file mode 100644 index 0000000..7bcdb61 --- /dev/null +++ b/app/db/database.py @@ -0,0 +1,38 @@ +from pathlib import Path + +from sqlmodel import SQLModel, Session, create_engine + +DB_PATH = Path.home() / ".debian-manager" / "data.db" +DB_PATH.parent.mkdir(parents=True, exist_ok=True) + +engine = create_engine( + f"sqlite:///{DB_PATH}", + echo=False, + connect_args={"check_same_thread": False}, +) +# expire_on_commit=False — объекты остаются читаемыми после закрытия сессии +from sqlalchemy.orm import sessionmaker +from sqlmodel import Session as _Session + +_SessionFactory = sessionmaker(bind=engine, class_=_Session, expire_on_commit=False) + + +def init_db() -> None: + from app.db import models # noqa: F401 — регистрация таблиц + SQLModel.metadata.create_all(engine) + _migrate() + + +def _migrate() -> None: + """ALTER TABLE для колонок, добавленных после первого запуска.""" + import sqlite3 + conn = sqlite3.connect(DB_PATH) + cols = [r[1] for r in conn.execute("PRAGMA table_info(server)").fetchall()] + if "note" not in cols: + conn.execute("ALTER TABLE server ADD COLUMN note TEXT DEFAULT ''") + conn.commit() + conn.close() + + +def get_session() -> Session: + return _SessionFactory() diff --git a/app/db/models.py b/app/db/models.py new file mode 100644 index 0000000..b9ee4d0 --- /dev/null +++ b/app/db/models.py @@ -0,0 +1,96 @@ +from datetime import datetime, timezone +from typing import Optional + +from sqlmodel import Field, SQLModel + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class Server(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + host: str + port: int = 22 + username: str = "root" + auth_type: str = "password" # password | key + password: Optional[str] = None # см. README: для продакшена — keyring + key_path: Optional[str] = None + sudo_password: Optional[str] = None # если None — берётся password + tags: str = "" # через запятую + note: str = "" # пользовательская заметка + color: str = "blue" + read_only: bool = False + created_at: datetime = Field(default_factory=utcnow) + + +class TaskRecord(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + server_id: Optional[int] = None + kind: str # update/upgrade/reboot/install/docker/... + title: str + status: str = "pending" # pending/running/success/error/cancelled + log: str = "" + created_at: datetime = Field(default_factory=utcnow) + finished_at: Optional[datetime] = None + + +class MetricSnapshot(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + server_id: int + cpu_percent: float = 0.0 + mem_percent: float = 0.0 + mem_used_mb: int = 0 + mem_total_mb: int = 0 + disk_percent: float = 0.0 + load1: float = 0.0 + net_rx_kbs: float = 0.0 + net_tx_kbs: float = 0.0 + ts: datetime = Field(default_factory=utcnow) + + +class CronBackup(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + server_id: int + user: str + content: str + created_at: datetime = Field(default_factory=utcnow) + + +class Setting(SQLModel, table=True): + key: str = Field(primary_key=True) + value: str = "" + + +class ScheduledTask(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + cron: str # 5-полевое выражение + action: str # apt_update/upgrade/dist_upgrade/shell/backup/playbook/report/cleanup_metrics + target: str = "all" # "all" или тег + params: str = "" # команда / пути бэкапа / имя плейбука + enabled: bool = True + last_run: Optional[datetime] = None + last_status: str = "" + + +class BackupRecord(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + server_id: int + paths: str + file: str = "" # локальный путь скачанного архива + size: int = 0 + status: str = "pending" # pending/success/error + log: str = "" + created_at: datetime = Field(default_factory=utcnow) + + +class WebhookBinding(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + repo: str # full_name, напр. dimon/myapp + server_id: int + branch: str = "main" + compose_path: str = "docker-compose.yml" + env: str = "" + enabled: bool = True diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..374c932 --- /dev/null +++ b/app/main.py @@ -0,0 +1,177 @@ +"""Debian Manager — управление Debian-серверами.""" +import asyncio + +import flet as ft + +from app.db.database import init_db +from app.core import servers as servers_core +from app.core import scheduler +from app.db.models import Setting +from app.db.database import get_session +from app.ui.pages.dashboard import DashboardPage +from app.ui.pages.servers import ServersPage +from app.ui.pages.server_detail import ServerDetailPage +from app.ui.pages.group_ops import GroupOpsPage +from app.ui.pages.playbooks import PlaybooksPage +from app.ui.pages.diff import DiffPage +from app.ui.pages.gitea import GiteaPage +from app.ui.pages.scheduler import SchedulerPage +from app.ui.pages.backups import BackupsPage +from app.ui.pages.reports import ReportsPage +from app.ui.pages.portainer import PortainerPage +from app.ui.pages.agent import AgentPage +from app.ui.pages.proxmox_page import ProxmoxPage +from app.ui.pages.wizard import WizardPage +from app.ui.pages.settings import SettingsPage +from app.ui.pages.tasks import TasksPage + + +def _get_setting(key: str) -> str: + with get_session() as s: + row = s.get(Setting, key) + return row.value if row else "" + + +def _set_setting(key: str, value: str) -> None: + with get_session() as s: + row = s.get(Setting, key) or Setting(key=key) + row.value = value + s.add(row) + s.commit() + + +def main(page: ft.Page): + page.title = "Debian Manager" + page.window.width = 1400 + page.window.height = 900 + page.padding = 0 + + init_db() + + # тема из настроек + theme = _get_setting("theme") + page.theme_mode = (ft.ThemeMode.DARK if theme != "light" else ft.ThemeMode.LIGHT) + + # seed тестового сервера при первом запуске + if not servers_core.list_servers(): + servers_core.add_server( + name="Test VPS", host="132.243.17.164", port=2292, + username="dimon", password="123456789987654321", tags="удалённые", + ) + + # запуск планировщика и фоновых сервисов + async def _start_bg(): + scheduler.start() + try: + from app.core import webhook, agent + await webhook.start() + await agent.start() + except Exception: + pass + page.run_task(_start_bg) + + content = ft.Container(expand=True, padding=20) + + PAGES = { + "dashboard": ("Дашборд", ft.Icons.DASHBOARD, DashboardPage), + "servers": ("Серверы", ft.Icons.DNS, ServersPage), + "wizard": ("Мастер настройки", ft.Icons.AUTO_FIX_HIGH, WizardPage), + "group": ("Групповые операции", ft.Icons.GROUPS, GroupOpsPage), + "scheduler": ("Планировщик", ft.Icons.SCHEDULE, SchedulerPage), + "playbooks": ("Плейбуки", ft.Icons.PLAYLIST_PLAY, PlaybooksPage), + "backups": ("Бэкапы", ft.Icons.BACKUP, BackupsPage), + "reports": ("Отчёты", ft.Icons.SUMMARIZE, ReportsPage), + "portainer": ("Portainer", ft.Icons.WIDGETS, PortainerPage), + "agent": ("Агент", ft.Icons.SENSORS, AgentPage), + "proxmox": ("Proxmox", ft.Icons.CLOUD_QUEUE, ProxmoxPage), + "diff": ("Сравнение", ft.Icons.COMPARE_ARROWS, DiffPage), + "gitea": ("Gitea Deploy", ft.Icons.ROCKET_LAUNCH, GiteaPage), + "tasks": ("Журнал задач", ft.Icons.TASK_ALT, TasksPage), + "settings": ("Настройки", ft.Icons.SETTINGS, SettingsPage), + } + + state = {"route": "dashboard", "detail_server_id": None, + "group_filter": "Все"} + + # top_bar объявлен до render, чтобы render мог его обновлять + top_bar = ft.Container(padding=ft.Padding.only(left=20, top=10)) + + def render(): + top_bar.content = group_chips() + route = state["route"] + if route == "server_detail": + content.content = ServerDetailPage(page, state["detail_server_id"], go) + elif route == "dashboard": + content.content = DashboardPage(page, go, group_filter=state["group_filter"]) + else: + cls = PAGES[route][2] + content.content = cls(page, go) + nav.selected_index = list(PAGES.keys()).index(route) if route in PAGES else 0 + page.update() + + def go(route: str, server_id=None, group_filter=None): + state["route"] = route + state["detail_server_id"] = server_id + if group_filter is not None: + state["group_filter"] = group_filter + render() + + def toggle_theme(e): + new = "light" if page.theme_mode == ft.ThemeMode.DARK else "dark" + page.theme_mode = ft.ThemeMode.LIGHT if new == "light" else ft.ThemeMode.DARK + _set_setting("theme", new) + theme_btn.icon = ft.Icons.LIGHT_MODE if new == "dark" else ft.Icons.DARK_MODE + page.update() + + # группы серверов для дашборда + def group_chips(): + servers = servers_core.list_servers() + tags = sorted({t.strip() for s in servers for t in (s.tags or "").split(",") if t.strip()}) + groups = ["Все"] + tags + return ft.Row([ + ft.Chip( + label=ft.Text(g), + selected=state["group_filter"] == g, + on_select=lambda e, g=g: go("dashboard", group_filter=g), + ) for g in groups + ], spacing=6) + + theme_btn = ft.IconButton( + ft.Icons.LIGHT_MODE if page.theme_mode == ft.ThemeMode.DARK else ft.Icons.DARK_MODE, + tooltip="Сменить тему", on_click=toggle_theme, + ) + + # NavigationRail — компактный режим (только иконки), tooltip через label + nav = ft.NavigationRail( + selected_index=0, + label_type=ft.NavigationRailLabelType.NONE, + min_width=60, + destinations=[ + ft.NavigationRailDestination( + icon=ft.Icon(v[1], tooltip=v[0]), # tooltip на самой иконке + label=v[0], + ) + for v in PAGES.values() + ], + on_change=lambda e: go(list(PAGES.keys())[e.control.selected_index]), + trailing=ft.Column([theme_btn], alignment=ft.MainAxisAlignment.END), + ) + + # верхняя панель с группами (только на дашборде) + top_bar.content = group_chips() + + page.add( + ft.Column([ + top_bar, + ft.Row( + [nav, ft.VerticalDivider(width=1), content], + expand=True, + spacing=0, + ), + ], expand=True, spacing=0), + ) + render() + + +if __name__ == "__main__": + ft.app(target=main) diff --git a/app/ui/__init__.py b/app/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ui/components.py b/app/ui/components.py new file mode 100644 index 0000000..9e92a6b --- /dev/null +++ b/app/ui/components.py @@ -0,0 +1,65 @@ +"""Общие UI-хелперы (Flet ≥ 0.80: show_dialog/pop_dialog, SnackBar через show_dialog).""" +import asyncio + +import flet as ft + + +def confirm_dialog(page: ft.Page, title: str, text: str, on_confirm, danger: bool = True): + """Модальный диалог подтверждения.""" + def close(e=None): + page.pop_dialog() + + def yes(e): + page.pop_dialog() + on_confirm() + + dlg = ft.AlertDialog( + modal=True, + title=ft.Text(title), + content=ft.Text(text), + actions=[ + ft.TextButton("Отмена", on_click=close), + ft.FilledButton( + "Подтвердить", on_click=yes, + style=ft.ButtonStyle(bgcolor=ft.Colors.RED_700 if danger else None), + ), + ], + ) + page.show_dialog(dlg) + + +def snack(page: ft.Page, text: str, error: bool = False): + page.show_dialog(ft.SnackBar( + ft.Text(text), + bgcolor=ft.Colors.RED_700 if error else ft.Colors.GREEN_700, + open=True, + )) + + +def log_view(initial: str = "") -> ft.ListView: + """Скроллящаяся панель лога.""" + return ft.ListView(expand=True, spacing=2, auto_scroll=True) + + +def append_log(lv: ft.ListView, line: str): + lv.controls.append( + ft.Text(line, size=12, font_family="Consolas", selectable=True) + ) + try: + lv.update() + except Exception: + pass + + +def open_dialog(page: ft.Page, dlg: ft.AlertDialog): + page.show_dialog(dlg) + + +def close_dialog(page: ft.Page): + page.pop_dialog() + + +def fmt_mb(v: float) -> str: + if v >= 1024: + return f"{v/1024:.1f} ГБ" + return f"{v:.0f} МБ" diff --git a/app/ui/pages/__init__.py b/app/ui/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ui/pages/agent.py b/app/ui/pages/agent.py new file mode 100644 index 0000000..83a7893 --- /dev/null +++ b/app/ui/pages/agent.py @@ -0,0 +1,108 @@ +"""Агент-режим: установка/удаление агента, приём метрик.""" +import flet as ft + +from app.core import agent as ag +from app.core import servers as servers_core +from app.ui.components import confirm_dialog, snack + + +class AgentPage(ft.Column): + def __init__(self, page: ft.Page, go): + super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12) + self._page = page + self.cards_col = ft.Column(spacing=10) + self.server_status = ft.Text("…", size=13) + + self.controls = [ + ft.Text("Агент-режим", size=28, weight=ft.FontWeight.BOLD), + ft.Text("Агент на сервере шлёт метрики каждые 5 сек по HTTP — " + "real-time графики без SSH-поллинга. Приложение слушает порт " + f"{ag.get_agent_port()}.", + size=13, color=ft.Colors.GREY_500), + ft.Row([ + ft.FilledButton("Запустить приёмник", icon=ft.Icons.PLAY_ARROW, + on_click=self._start_server), + self.server_status, + ]), + ft.Divider(), + self.cards_col, + ] + self._reload() + self._update_server_status() + + def _update_server_status(self): + self.server_status.value = ( + f"✅ приёмник запущен на порту {ag.get_agent_port()}" + if ag.is_running() else "⚪ приёмник не запущен" + ) + self._page.update() + + def _start_server(self, e): + async def _s(): + await ag.start() + self._update_server_status() + self._page.run_task(_s) + + def _reload(self): + self.cards_col.controls = [] + for srv in servers_core.list_servers(): + self.cards_col.controls.append(self._card(srv)) + self._page.update() + for srv, card in zip(servers_core.list_servers(), self.cards_col.controls): + self._page.run_task(self._fill, srv, card) + + def _card(self, srv): + return ft.Card(content=ft.Container( + padding=14, width=560, + content=ft.Column([ + ft.Text(srv.name, weight=ft.FontWeight.BOLD, size=16), + ft.ProgressBar(height=3), + ]), + )) + + async def _fill(self, srv, card): + status = await ag.agent_status(srv) + metrics = ag.agent_metrics.get(srv.id) + running = status == "active" + + rows = [ft.Text(f"статус: {status}", size=12, + color=ft.Colors.GREEN_300 if running else ft.Colors.GREY_500)] + if metrics: + import time + age = time.time() - metrics.get("ts", 0) + rows.append(ft.Text( + f"CPU {metrics.get('cpu')}% • RAM {metrics.get('mem_pct')}% • " + f"Диск {metrics.get('disk')}% • load {metrics.get('load')} • " + f"обновлено {age:.0f} сек назад", + size=11, color=ft.Colors.BLUE_300)) + btns = [] + if not running: + btns.append(ft.FilledButton("Установить агента", icon=ft.Icons.DOWNLOAD, + on_click=lambda e, s=srv: self._install(s))) + else: + btns.append(ft.OutlinedButton("Удалить агента", + on_click=lambda e, s=srv: self._uninstall(s))) + rows.append(ft.Row(btns)) + card.content.content.controls[1] = ft.Column(rows, spacing=4) + self._page.update() + + def _install(self, srv): + from app.core.tasks import tasks + import socket + app_host = socket.gethostbyname(socket.gethostname()) + async def job(log): + log(f"адрес приложения для агента: {app_host}:{ag.get_agent_port()}") + ok = await ag.install_agent(srv, app_host, log) + log("готово" if ok else "ошибка установки") + tasks.start("agent", "установка агента", srv.id, job, + on_done=lambda r: self._reload()) + snack(self._page, f"Установка агента на {srv.name}…") + + def _uninstall(self, srv): + def do(): + async def _d(): + await ag.uninstall_agent(srv) + self._reload() + self._page.run_task(_d) + confirm_dialog(self._page, "Удалить агента?", + f"dm-agent будет остановлен и удалён с «{srv.name}».", do) diff --git a/app/ui/pages/backups.py b/app/ui/pages/backups.py new file mode 100644 index 0000000..87c4e3c --- /dev/null +++ b/app/ui/pages/backups.py @@ -0,0 +1,87 @@ +"""Резервное копирование: запуск, история, скачивание.""" +import flet as ft + +from app.core import backup as bk +from app.core import servers as servers_core +from app.core.tasks import tasks +from app.ui.components import append_log, log_view, snack + + +class BackupsPage(ft.Column): + def __init__(self, page: ft.Page, go): + super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12) + self._page = page + + self.srv_dd = ft.Dropdown(label="Сервер", width=250, options=[ + ft.dropdown.Option(str(s.id), s.name) for s in servers_core.list_servers() + ]) + self.paths_tf = ft.TextField( + label="Пути (через пробел)", value="/etc", width=400, + hint_text="/etc /home/user /var/www", + ) + self.download_cb = ft.Checkbox(label="Скачать на этот ПК", value=True) + self.log = log_view() + self.log_container = ft.Container( + height=180, border=ft.Border.all(1, ft.Colors.GREY_800), + border_radius=8, padding=8, content=self.log, visible=False) + self.table = ft.DataTable(columns=[ + ft.DataColumn(ft.Text("Дата")), + ft.DataColumn(ft.Text("Сервер")), + ft.DataColumn(ft.Text("Пути")), + ft.DataColumn(ft.Text("Размер")), + ft.DataColumn(ft.Text("Статус")), + ft.DataColumn(ft.Text("Файл")), + ], rows=[]) + + count, size = bk.stats() + self.stats_text = ft.Text(f"Всего бэкапов: {count}, суммарно {size/1048576:.1f} МБ", + size=13, color=ft.Colors.GREY_500) + + self.controls = [ + ft.Text("Резервное копирование", size=28, weight=ft.FontWeight.BOLD), + ft.Row([self.srv_dd, self.paths_tf, self.download_cb, + ft.FilledButton("Создать бэкап", icon=ft.Icons.BACKUP, + on_click=self._run)]), + self.stats_text, + self.log_container, + ft.Text("История", weight=ft.FontWeight.BOLD, size=16), + ft.Card(content=ft.Container(padding=10, content=self.table)), + ] + self._reload() + + def _reload(self): + servers = {s.id: s.name for s in servers_core.list_servers()} + self.table.rows = [] + for r in bk.list_backups(): + color = ft.Colors.GREEN_300 if r.status == "success" else \ + ft.Colors.RED_300 if r.status == "error" else ft.Colors.GREY_500 + self.table.rows.append(ft.DataRow(cells=[ + ft.DataCell(ft.Text(f"{r.created_at:%d.%m %H:%M}", size=11)), + ft.DataCell(ft.Text(servers.get(r.server_id, "?"), size=12)), + ft.DataCell(ft.Text(r.paths, size=11)), + ft.DataCell(ft.Text(f"{r.size/1048576:.1f} МБ" if r.size else "—", size=11)), + ft.DataCell(ft.Text(r.status, size=11, color=color)), + ft.DataCell(ft.Text(r.file or "—", size=10, color=ft.Colors.GREY_500)), + ])) + self._page.update() + + def _run(self, e): + if not self.srv_dd.value: + snack(self._page, "Выберите сервер", error=True) + return + srv = servers_core.get_server(int(self.srv_dd.value)) + paths = self.paths_tf.value.strip() + if not paths: + snack(self._page, "Укажите пути", error=True) + return + self.log_container.visible = True + self._page.update() + + async def job(log): + rec = await bk.run_backup(srv, paths, self.download_cb.value, log) + log(f"=== {rec.status} ===" if rec else "=== error ===") + + tasks.start("backup", f"бэкап {paths}", srv.id, job, + on_log=lambda l: (append_log(self.log, l), self._page.update()), + on_done=lambda r: self._reload()) + snack(self._page, "Бэкап запущен") diff --git a/app/ui/pages/dashboard.py b/app/ui/pages/dashboard.py new file mode 100644 index 0000000..0173627 --- /dev/null +++ b/app/ui/pages/dashboard.py @@ -0,0 +1,128 @@ +"""Дашборд: карточки серверов с health-check и метриками.""" +import asyncio + +import flet as ft + +from app.core import servers as servers_core +from app.core import monitor +from app.core.ssh import SSHClient, check_server +from app.ui.components import fmt_mb, snack + + +class DashboardPage(ft.Column): + def __init__(self, page: ft.Page, go, group_filter: str = "Все"): + super().__init__(expand=True, scroll=ft.ScrollMode.AUTO) + self._page = page + self.go = go + self.group_filter = group_filter + self.cards = ft.Row(wrap=True, spacing=16, run_spacing=16) + self.controls = [ + ft.Row([ + ft.Text("Дашборд", size=28, weight=ft.FontWeight.BOLD), + ft.IconButton(ft.Icons.REFRESH, tooltip="Обновить", + on_click=lambda e: self.refresh()), + ]), + self.cards, + ] + self.refresh() + + def refresh(self): + servers = servers_core.list_servers() + if self.group_filter != "Все": + servers = [s for s in servers + if self.group_filter in [t.strip() for t in (s.tags or "").split(",")]] + self.cards.controls = [self._placeholder(s) for s in servers] + self._page.update() + for srv, card in zip(servers, self.cards.controls): + self._page.run_task(self._fill, srv, card) + + def _placeholder(self, srv): + note_preview = (srv.note[:60] + "…") if len(srv.note) > 60 else srv.note + return ft.Card( + content=ft.Container( + width=340, padding=16, + content=ft.Column([ + ft.Row([ + ft.Icon(ft.Icons.CIRCLE, color=ft.Colors.GREY, size=14), + ft.Text(srv.name, size=18, weight=ft.FontWeight.BOLD), + ft.Text(f"{srv.host}:{srv.port}", size=12, + color=ft.Colors.GREY_500), + ft.Container(expand=True), + ft.IconButton(ft.Icons.NOTE_ALT_OUTLINED, icon_size=18, + tooltip="Заметка", + on_click=lambda e, s=srv: self._note_dialog(s)), + ]), + ft.Text(note_preview, size=11, color=ft.Colors.GREY_500, + visible=bool(srv.note)), + ft.ProgressBar(height=3), + ]), + on_click=lambda e: self.go("server_detail", srv.id), + ) + ) + + def _note_dialog(self, srv): + tf = ft.TextField(label="Заметка", value=srv.note, multiline=True, + min_lines=3, max_lines=5, width=400, autofocus=True) + def save(e): + servers_core.update_server(srv.id, note=tf.value) + self._page.pop_dialog() + self.refresh() + snack(self._page, "Заметка сохранена") + dlg = ft.AlertDialog( + modal=True, title=ft.Text(f"Заметка: {srv.name}"), content=tf, + actions=[ + ft.TextButton("Отмена", on_click=lambda e: self._page.pop_dialog()), + ft.FilledButton("Сохранить", on_click=save), + ], + ) + self._page.show_dialog(dlg) + + async def _fill(self, srv, card): + info = await check_server(srv) + if not info.get("online"): + card.content.content.controls[1] = ft.Text( + f"❌ {info.get('error', 'недоступен')}", color=ft.Colors.RED_400, size=13) + card.content.content.controls[0].controls[0] = ft.Icon( + ft.Icons.CIRCLE, color=ft.Colors.RED_400, size=14) + self._page.update() + return + + # метрики + snap = monitor.latest(srv.id) + try: + async with SSHClient(srv) as c: + snap = await monitor.collect(c, srv.id) + except Exception: + pass + + def bar(label, pct, color): + return ft.Column([ + ft.Row([ + ft.Text(label, size=12, width=45), + ft.ProgressBar(value=pct / 100, height=6, expand=True, + color=color, bgcolor=ft.Colors.GREY_800), + ft.Text(f"{pct:.0f}%", size=12, width=40, text_align=ft.TextAlign.RIGHT), + ]), + ], spacing=2) + + rows = [ft.Text(f"{info['os']} • {info['uptime']}", size=12, + color=ft.Colors.GREY_400)] + if snap: + rows += [ + ft.Container(height=4), + bar("CPU", snap.cpu_percent, ft.Colors.BLUE_400), + bar("RAM", snap.mem_percent, ft.Colors.PURPLE_300), + bar("Диск", snap.disk_percent, + ft.Colors.RED_400 if snap.disk_percent > 85 else ft.Colors.GREEN_400), + ft.Text( + f"RAM {fmt_mb(snap.mem_used_mb)} / {fmt_mb(snap.mem_total_mb)}" + f" • load {snap.load1:.2f}", + size=11, color=ft.Colors.GREY_500), + ] + card.content.content.controls[0].controls[0] = ft.Icon( + ft.Icons.CIRCLE, color=ft.Colors.GREEN_400, size=14) + # сохраняем заметку (controls[1]) — она идёт после заголовка + note_ctrl = card.content.content.controls[1] + card.content.content.controls[2:] = rows + card.content.content.controls[1] = note_ctrl + self._page.update() diff --git a/app/ui/pages/diff.py b/app/ui/pages/diff.py new file mode 100644 index 0000000..311ecc1 --- /dev/null +++ b/app/ui/pages/diff.py @@ -0,0 +1,84 @@ +"""Сравнение пакетов между серверами.""" +import flet as ft + +from app.core import servers as servers_core +from app.core.diff import diff_servers +from app.core.ssh import SSHClient +from app.ui.components import snack + + +class DiffPage(ft.Column): + def __init__(self, page: ft.Page, go): + super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12) + self._page = page + servers = servers_core.list_servers() + opts = [ft.dropdown.Option(str(s.id), s.name) for s in servers] + + self.dd_a = ft.Dropdown(label="Сервер A", options=opts, width=250) + self.dd_b = ft.Dropdown(label="Сервер B", options=opts, width=250) + self.user_only = ft.Checkbox(label="только пользовательские пакеты", value=True) + self.result = ft.Column(spacing=8) + + self.controls = [ + ft.Text("Сравнение серверов", size=28, weight=ft.FontWeight.BOLD), + ft.Row([ + self.dd_a, self.dd_b, self.user_only, + ft.FilledButton("Сравнить", icon=ft.Icons.COMPARE_ARROWS, + on_click=self._compare), + ]), + self.result, + ] + + def _compare(self, e): + if not self.dd_a.value or not self.dd_b.value or self.dd_a.value == self.dd_b.value: + snack(self._page, "Выберите два разных сервера", error=True) + return + self.result.controls = [ft.ProgressBar()] + self._page.update() + + async def _do(): + a = servers_core.get_server(int(self.dd_a.value)) + b = servers_core.get_server(int(self.dd_b.value)) + async with SSHClient(a) as ca, SSHClient(b) as cb: + diff = await diff_servers(ca, cb, self.user_only.value) + + self.result.controls = [ + ft.Row([ + self._section(f"Только на {a.name} ({len(diff.only_a)})", + [f"{p.name} {p.version}" for p in diff.only_a], + ft.Colors.BLUE_300), + self._section(f"Только на {b.name} ({len(diff.only_b)})", + [f"{p.name} {p.version}" for p in diff.only_b], + ft.Colors.GREEN_300), + ], vertical_alignment=ft.CrossAxisAlignment.START), + ft.Text(f"Различаются версии ({len(diff.version_mismatch)})", + weight=ft.FontWeight.BOLD, size=16), + ft.DataTable(columns=[ + ft.DataColumn(ft.Text("Пакет")), + ft.DataColumn(ft.Text(a.name)), + ft.DataColumn(ft.Text(b.name)), + ], rows=[ + ft.DataRow(cells=[ + ft.DataCell(ft.Text(m.name)), + ft.DataCell(ft.Text(m.version_a, size=12)), + ft.DataCell(ft.Text(m.version_b, size=12, + color=ft.Colors.ORANGE_300)), + ]) for m in diff.version_mismatch[:100] + ]) if diff.version_mismatch else ft.Text("версии совпадают", + color=ft.Colors.GREY_500), + ] + self._page.update() + self._page.run_task(_do) + + def _section(self, title, items, color): + return ft.Container(expand=True, content=ft.Column([ + ft.Text(title, weight=ft.FontWeight.BOLD, color=color), + ft.Container( + height=300, border=ft.Border.all(1, ft.Colors.GREY_800), + border_radius=8, padding=8, + content=ft.Column([ + ft.Text(i, size=11, font_family="Consolas") for i in items + ] or [ft.Text("—", color=ft.Colors.GREY_600)], + scroll=ft.ScrollMode.AUTO, spacing=1), + ), + ])) diff --git a/app/ui/pages/gitea.py b/app/ui/pages/gitea.py new file mode 100644 index 0000000..12fbfb7 --- /dev/null +++ b/app/ui/pages/gitea.py @@ -0,0 +1,168 @@ +"""Gitea: деплой compose-проектов.""" +import flet as ft + +from app.core import gitea +from app.core import servers as servers_core +from app.core.ssh import SSHClient +from app.core.tasks import tasks +from app.ui.components import append_log, log_view, snack + + +class GiteaPage(ft.Column): + def __init__(self, page: ft.Page, go): + super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12) + self._page = page + + cfg = gitea.get_config() + self.url_tf = ft.TextField(label="URL Gitea", value=cfg["url"], width=350) + self.token_tf = ft.TextField(label="API-токен", value=cfg["token"], + password=True, width=350) + self.status = ft.Text("", size=12) + + self.repos_dd = ft.Dropdown(label="Репозиторий", width=350, options=[]) + self.srv_dd = ft.Dropdown(label="Сервер", width=250, options=[ + ft.dropdown.Option(str(s.id), s.name) for s in servers_core.list_servers() + ]) + self.branch_tf = ft.TextField(label="Ветка", value="main", width=150) + self.compose_tf = ft.TextField(label="Путь к compose", value="docker-compose.yml", + width=250) + self.env_tf = ft.TextField(label=".env (опционально)", multiline=True, + min_lines=3, max_lines=5, width=450, + hint_text="KEY=value") + self.log = log_view() + self.log_container = ft.Container( + height=200, border=ft.Border.all(1, ft.Colors.GREY_800), + border_radius=8, padding=8, content=self.log, visible=False) + self.bindings_col = ft.Column(spacing=4) + + self.controls = [ + ft.Text("Gitea Deploy", size=28, weight=ft.FontWeight.BOLD), + ft.Row([self.url_tf, self.token_tf, + ft.FilledButton("Сохранить и проверить", on_click=self._save_cfg)]), + self.status, + ft.Divider(), + ft.Row([self.repos_dd, + ft.IconButton(ft.Icons.REFRESH, tooltip="Загрузить репозитории", + on_click=self._load_repos)]), + ft.Row([self.srv_dd, self.branch_tf, self.compose_tf]), + self.env_tf, + ft.FilledButton("Деплой", icon=ft.Icons.ROCKET_LAUNCH, + on_click=self._deploy), + self.log_container, + ft.Divider(), + ft.Text("Webhook-автодеплой (push → redeploy)", weight=ft.FontWeight.BOLD, size=16), + ft.Text("Приложение слушает порт 9100. В Gitea: репозиторий → Настройки → Webhooks → " + "создать: http://<этот-ПК>:9100/webhooks/gitea, событие push.", + size=12, color=ft.Colors.GREY_500), + ft.Row([ + ft.FilledButton("Добавить привязку", icon=ft.Icons.ADD, + on_click=self._binding_dialog), + ft.IconButton(ft.Icons.REFRESH, on_click=lambda e: self._load_bindings()), + ]), + self.bindings_col, + ] + self._load_bindings() + + def _load_bindings(self): + from app.core import webhook as wh + from app.core import servers as servers_core + servers = {s.id: s.name for s in servers_core.list_servers()} + self.bindings_col.controls = [] + for b in wh.list_bindings(): + self.bindings_col.controls.append(ft.Card(content=ft.Container( + padding=10, + content=ft.Row([ + ft.Icon(ft.Icons.LINK, color=ft.Colors.BLUE_300), + ft.Text(f"{b.repo}@{b.branch} → {servers.get(b.server_id, '?')}", + size=13, expand=True), + ft.Text(b.compose_path, size=11, color=ft.Colors.GREY_500), + ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=16, + on_click=lambda e, bid=b.id: self._del_binding(bid)), + ]), + ))) + if not self.bindings_col.controls: + self.bindings_col.controls.append( + ft.Text("привязок нет — push в репозиторий не будет триггерить деплой", + size=12, color=ft.Colors.GREY_500)) + self._page.update() + + def _del_binding(self, bid): + from app.core import webhook as wh + wh.delete_binding(bid) + self._load_bindings() + + def _binding_dialog(self, e): + from app.core import webhook as wh + from app.core import servers as servers_core + repo_tf = ft.TextField(label="Репозиторий (owner/name)", width=300, + hint_text="dimon/myapp") + srv_dd = ft.Dropdown(label="Сервер", width=250, options=[ + ft.dropdown.Option(str(s.id), s.name) for s in servers_core.list_servers() + ]) + branch_tf = ft.TextField(label="Ветка", value="main", width=150) + compose_tf = ft.TextField(label="compose-файл", value="docker-compose.yml", width=250) + env_tf = ft.TextField(label=".env (опционально)", multiline=True, + min_lines=2, max_lines=4, width=350) + + def save(e): + if not repo_tf.value or not srv_dd.value: + return + wh.add_binding(repo=repo_tf.value.strip(), + server_id=int(srv_dd.value), + branch=branch_tf.value, compose_path=compose_tf.value, + env=env_tf.value or "") + self._page.pop_dialog() + self._load_bindings() + snack(self._page, "Привязка добавлена") + + dlg = ft.AlertDialog( + modal=True, title=ft.Text("Привязка webhook"), + content=ft.Column([repo_tf, srv_dd, branch_tf, compose_tf, env_tf], tight=True), + actions=[ + ft.TextButton("Отмена", on_click=lambda e: self._page.pop_dialog()), + ft.FilledButton("Сохранить", on_click=save), + ], + ) + self._page.show_dialog(dlg) + + def _save_cfg(self, e): + gitea.save_config(self.url_tf.value, self.token_tf.value) + async def _t(): + ok, msg = await gitea.test_connection() + self.status.value = ("✅ " if ok else "❌ ") + msg + self.status.color = ft.Colors.GREEN_300 if ok else ft.Colors.RED_300 + self._page.update() + self._page.run_task(_t) + + def _load_repos(self, e): + async def _l(): + try: + repos = await gitea.list_repos() + self.repos_dd.options = [ + ft.dropdown.Option(r["full_name"], + f"{r['full_name']} ({r['default_branch']})") + for r in repos + ] + self._repos = {r["full_name"]: r for r in repos} + self.status.value = f"загружено {len(repos)} репозиториев" + except Exception as ex: + self.status.value = f"ошибка: {ex}" + self._page.update() + self._page.run_task(_l) + + def _deploy(self, e): + if not self.repos_dd.value or not self.srv_dd.value: + snack(self._page, "Выберите репозиторий и сервер", error=True) + return + repo = self._repos[self.repos_dd.value] + srv = servers_core.get_server(int(self.srv_dd.value)) + self.log_container.visible = True + + async def job(log): + async with SSHClient(srv) as c: + await gitea.deploy(c, repo["clone_url"], repo["name"], + self.branch_tf.value, self.compose_tf.value, + self.env_tf.value or None, log) + tasks.start("gitea", f"deploy {repo['name']}", srv.id, job, + on_log=lambda l: (append_log(self.log, l), self._page.update())) + snack(self._page, f"Деплой {repo['name']} запущен") diff --git a/app/ui/pages/group_ops.py b/app/ui/pages/group_ops.py new file mode 100644 index 0000000..c7c25db --- /dev/null +++ b/app/ui/pages/group_ops.py @@ -0,0 +1,131 @@ +"""Групповые операции по серверам.""" +import flet as ft + +from app.core import servers as servers_core +from app.core.group_ops import (GroupResult, op_apt_update, op_install_package, + op_reboot, op_shell, op_upgrade, run_group) +from app.ui.components import snack + + +class GroupOpsPage(ft.Column): + def __init__(self, page: ft.Page, go): + super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12) + self._page = page + self.go = go + + servers = servers_core.list_servers() + self.checks = {s.id: ft.Checkbox(label=f"{s.name} ({s.host})", value=False, + data=s.id) for s in servers} + + self.op_dd = ft.Dropdown( + label="Операция", value="update", width=300, + options=[ + ft.dropdown.Option("update", "apt update"), + ft.dropdown.Option("upgrade", "upgrade"), + ft.dropdown.Option("dist", "dist-upgrade"), + ft.dropdown.Option("reboot", "reboot (с ожиданием)"), + ft.dropdown.Option("shell", "произвольная команда"), + ft.dropdown.Option("install", "установить пакет"), + ], + on_select=lambda e: self._toggle_param(), + ) + self.param_tf = ft.TextField(label="Команда / пакет", visible=False, width=400) + self.rolling_cb = ft.Checkbox(label="Rolling (по одному, ждать возврата)", value=False) + self.failfast_cb = ft.Checkbox(label="Остановиться при первой ошибке", value=False) + self.results_col = ft.Column(spacing=6) + + self.controls = [ + ft.Text("Групповые операции", size=28, weight=ft.FontWeight.BOLD), + ft.Row([ + ft.Card(content=ft.Container(padding=14, width=380, content=ft.Column([ + ft.Text("Серверы", weight=ft.FontWeight.BOLD), + *self.checks.values(), + ft.TextButton("выбрать все", on_click=lambda e: self._select_all(True)), + ft.TextButton("снять все", on_click=lambda e: self._select_all(False)), + ]))), + ft.Card(content=ft.Container(padding=14, expand=True, content=ft.Column([ + ft.Text("Операция", weight=ft.FontWeight.BOLD), + self.op_dd, + self.param_tf, + self.rolling_cb, + self.failfast_cb, + ft.FilledButton("Запустить", icon=ft.Icons.PLAY_ARROW, + on_click=self._run), + ], spacing=10))), + ], vertical_alignment=ft.CrossAxisAlignment.START), + ft.Text("Результаты", size=18, weight=ft.FontWeight.BOLD), + self.results_col, + ] + + def _select_all(self, v): + for cb in self.checks.values(): + cb.value = v + self._page.update() + + def _toggle_param(self): + self.param_tf.visible = self.op_dd.value in ("shell", "install") + self._page.update() + + def _run(self, e): + selected = [s for s in servers_core.list_servers() + if self.checks.get(s.id) and self.checks[s.id].value] + if not selected: + snack(self._page, "Выберите хотя бы один сервер", error=True) + return + + op_name = self.op_dd.value + if op_name == "update": + op = op_apt_update() + elif op_name == "upgrade": + op = op_upgrade(False) + elif op_name == "dist": + op = op_upgrade(True) + elif op_name == "reboot": + op = op_reboot(wait=True) + elif op_name == "shell": + if not self.param_tf.value: + snack(self._page, "Введите команду", error=True) + return + op = op_shell(self.param_tf.value) + else: + if not self.param_tf.value: + snack(self._page, "Введите имя пакета", error=True) + return + op = op_install_package(self.param_tf.value.split()) + + self.results_col.controls = [ + ft.Text(f"запущено на {len(selected)} серверах…", color=ft.Colors.GREY_500)] + self._page.update() + + async def job(): + def on_status(res: GroupResult): + self._render_results(res) + res = await run_group( + selected, op, + rolling=self.rolling_cb.value, + fail_fast=self.failfast_cb.value, + on_status=on_status, + ) + self._render_results(res) + snack(self._page, f"готово: ✅{res.ok_count} ❌{res.err_count}") + self._page.run_task(job) + + def _render_results(self, res: GroupResult): + self.results_col.controls = [] + for r in res.results.values(): + icon = {"pending": ft.Icons.HOURGLASS_EMPTY, "running": ft.Icons.SYNC, + "success": ft.Icons.CHECK_CIRCLE, "error": ft.Icons.ERROR}.get(r.status) + color = {"success": ft.Colors.GREEN_400, "error": ft.Colors.RED_400, + "running": ft.Colors.BLUE_300}.get(r.status, ft.Colors.GREY_500) + log_preview = r.log.strip().splitlines()[-3:] if r.log.strip() else [] + self.results_col.controls.append(ft.Card(content=ft.Container( + padding=10, + content=ft.Column([ + ft.Row([ft.Icon(icon, color=color, size=18), + ft.Text(r.name, weight=ft.FontWeight.BOLD), + ft.Text(r.status, size=12, color=color)]), + *[ft.Text(l, size=10, font_family="Consolas", + color=ft.Colors.GREY_500) for l in log_preview], + ], spacing=2), + ))) + self._page.update() diff --git a/app/ui/pages/playbooks.py b/app/ui/pages/playbooks.py new file mode 100644 index 0000000..5055668 --- /dev/null +++ b/app/ui/pages/playbooks.py @@ -0,0 +1,131 @@ +"""Плейбуки: список, редактор, запуск.""" +import flet as ft + +from app.core import playbooks as pb +from app.core import servers as servers_core +from app.core.ssh import SSHClient +from app.core.tasks import tasks +from app.ui.components import append_log, log_view, snack + + +class PlaybooksPage(ft.Column): + def __init__(self, page: ft.Page, go): + super().__init__(expand=True, spacing=12) + self._page = page + self.go = go + + self.list_col = ft.Column(spacing=6, scroll=ft.ScrollMode.AUTO) + self.editor = ft.TextField(multiline=True, min_lines=18, max_lines=18, + text_size=12, + text_style=ft.TextStyle(font_family="Consolas"), + expand=True) + self.editor_name = ft.Text("", weight=ft.FontWeight.BOLD) + self.log = log_view() + self.log_container = ft.Container( + height=180, border=ft.Border.all(1, ft.Colors.GREY_800), + border_radius=8, padding=8, content=self.log, visible=False) + + self.controls = [ + ft.Text("Плейбуки", size=28, weight=ft.FontWeight.BOLD), + ft.Row([ + ft.Container(width=300, content=ft.Column([ + ft.FilledButton("Новый плейбук", icon=ft.Icons.ADD, + on_click=self._new), + ft.Divider(), + self.list_col, + ])), + ft.VerticalDivider(width=1), + ft.Container(expand=True, content=ft.Column([ + ft.Row([ + self.editor_name, + ft.Container(expand=True), + ft.OutlinedButton("Сохранить", icon=ft.Icons.SAVE, + on_click=self._save), + ft.FilledButton("Запустить", icon=ft.Icons.PLAY_ARROW, + on_click=self._run_dialog), + ]), + self.editor, + self.log_container, + ], expand=True)), + ], expand=True), + ] + self._current = None + self._reload_list() + + def _reload_list(self): + self.list_col.controls = [ + ft.ListTile( + title=ft.Text(name, size=13), + subtitle=ft.Text("встроенный" if name in pb.BUILTIN else "пользовательский", + size=10), + dense=True, + on_click=lambda e, n=name: self._open(n), + ) for name in pb.list_playbooks() + ] + self._page.update() + + def _open(self, name): + import yaml + self._current = name + data = pb.load(name) + self.editor.value = pb.BUILTIN.get(name) or ( + pb.PLAYBOOKS_DIR / f"{name}.yml").read_text(encoding="utf-8") + self.editor_name.value = name + self._page.update() + + def _new(self, e): + name_tf = ft.TextField(label="Имя", autofocus=True) + def create(e): + self._current = name_tf.value + self.editor_name.value = name_tf.value + self.editor.value = 'name: "Новый плейбук"\nsteps:\n - name: Проверка\n shell: "uptime"\n' + self._page.pop_dialog() + dlg = ft.AlertDialog(modal=True, title=ft.Text("Имя плейбука"), content=name_tf, + actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)), + ft.FilledButton("Создать", on_click=create)]) + self._page.show_dialog(dlg) + + def _save(self, e): + if not self._current or self._current in pb.BUILTIN: + snack(self._page, "Встроенный плейбук нельзя перезаписать — создайте копию", + error=True) + return + try: + pb.save(self._current, self.editor.value) + snack(self._page, "Сохранено") + self._reload_list() + except Exception as ex: + snack(self._page, f"Ошибка YAML: {ex}", error=True) + + def _run_dialog(self, e): + if not self._current: + return + servers = servers_core.list_servers() + checks = {s.id: ft.Checkbox(label=s.name, value=False) for s in servers} + def run(e): + selected = [s for s in servers if checks[s.id].value] + if not selected: + snack(self._page, "Выберите сервер", error=True) + return + self._page.pop_dialog() + self._run_on(selected) + dlg = ft.AlertDialog( + modal=True, title=ft.Text(f"Запустить «{self._current}»"), + content=ft.Column(list(checks.values()), tight=True), + actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)), + ft.FilledButton("Запустить", on_click=run)], + ) + self._page.show_dialog(dlg) + + def _run_on(self, servers): + self.log_container.visible = True + data = pb.load(self._current) + for srv in servers: + async def job(log, srv=srv): + async with SSHClient(srv) as c: + await pb.run_playbook(c, srv, data, log) + tasks.start("playbook", f"плейбук {self._current}", srv.id, job, + on_log=lambda l: (append_log(self.log, l), self._page.update())) + + def _close(self, dlg): + self._page.pop_dialog() diff --git a/app/ui/pages/portainer.py b/app/ui/pages/portainer.py new file mode 100644 index 0000000..fd765fb --- /dev/null +++ b/app/ui/pages/portainer.py @@ -0,0 +1,106 @@ +"""Portainer: статус, открытие в браузере, установка/перезапуск.""" +import flet as ft + +from app.core.ssh import SSHClient +from app.core import docker_svc as d +from app.db.models import Server +from app.ui.components import snack + +PORTAINER_PORT = 9000 + + +class PortainerPage(ft.Column): + def __init__(self, page: ft.Page, go): + super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12) + self._page = page + self.go = go + self.cards_col = ft.Column(spacing=10) + self.controls = [ + ft.Text("Portainer", size=28, weight=ft.FontWeight.BOLD), + ft.Row([ + ft.OutlinedButton("Обновить", icon=ft.Icons.REFRESH, + on_click=lambda e: self._reload()), + ]), + self.cards_col, + ] + self._reload() + + def _reload(self): + from app.core import servers as servers_core + self.cards_col.controls = [] + for srv in servers_core.list_servers(): + self.cards_col.controls.append(self._card_placeholder(srv)) + self._page.update() + from app.core import servers as servers_core + for srv, card in zip(servers_core.list_servers(), self.cards_col.controls): + self._page.run_task(self._fill, srv, card) + + def _card_placeholder(self, srv): + return ft.Card(content=ft.Container( + padding=14, width=500, + content=ft.Column([ + ft.Text(srv.name, weight=ft.FontWeight.BOLD, size=16), + ft.ProgressBar(height=3), + ]), + )) + + async def _fill(self, srv, card): + url = f"http://{srv.host}:{PORTAINER_PORT}" + try: + async with SSHClient(srv) as c: + containers = await d.list_containers(c) + portainer = next((x for x in containers if "portainer" in x["Names"].lower()), None) + if not portainer: + card.content.content.controls[1] = ft.Column([ + ft.Text("Portainer не установлен", color=ft.Colors.GREY_500), + ft.FilledButton("Установить", icon=ft.Icons.DOWNLOAD, + on_click=lambda e, s=srv: self._install(s)), + ]) + else: + running = "Up" in portainer["Status"] + cid = portainer["ID"] + card.content.content.controls[1] = ft.Column([ + ft.Row([ + ft.Icon(ft.Icons.CIRCLE, size=12, + color=ft.Colors.GREEN_400 if running else ft.Colors.RED_400), + ft.Text(portainer["Status"], size=12), + ]), + ft.Row([ + ft.FilledButton("Открыть в браузере", icon=ft.Icons.OPEN_IN_BROWSER, + on_click=lambda e, u=url: self._page.launch_url(u)), + ft.OutlinedButton( + "Перезапустить" if running else "Запустить", + on_click=lambda e, s=srv, c=cid, a="restart" if running else "start": + self._action(s, c, a)), + ]), + ft.Text(url, size=11, color=ft.Colors.BLUE_300, selectable=True), + ]) + except Exception as e: + card.content.content.controls[1] = ft.Text(f"ошибка: {e}", size=12, + color=ft.Colors.RED_300) + self._page.update() + + def _install(self, srv): + from app.core.tasks import tasks + from app.ui.components import append_log, log_view + async def job(log): + async with SSHClient(srv) as c: + log("$ docker volume create portainer_data") + await c.sudo("docker volume create portainer_data 2>/dev/null || true") + log("$ docker run portainer") + r = await c.sudo( + "docker rm -f portainer 2>/dev/null; " + "docker run -d -p 9000:9000 --name portainer --restart=always " + "-v /var/run/docker.sock:/var/run/docker.sock " + "-v portainer_data:/data portainer/portainer-ce:lts", timeout=600) + log(r.stdout + r.stderr) + tasks.start("portainer", "установка Portainer", srv.id, job, + on_done=lambda r: self._reload()) + snack(self._page, f"Установка Portainer на {srv.name} запущена") + + def _action(self, srv, cid, action): + async def _do(): + async with SSHClient(srv) as c: + await d.container_action(c, cid, action) + self._reload() + self._page.run_task(_do) diff --git a/app/ui/pages/proxmox_page.py b/app/ui/pages/proxmox_page.py new file mode 100644 index 0000000..7746c66 --- /dev/null +++ b/app/ui/pages/proxmox_page.py @@ -0,0 +1,243 @@ +"""Proxmox VE: ноды, VM/LXC, управление питанием, добавление в серверы.""" +import flet as ft + +from app.core import proxmox as pve_core +from app.core import servers as servers_core +from app.ui.components import confirm_dialog, snack + + +class ProxmoxPage(ft.Column): + def __init__(self, page: ft.Page, go): + super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12) + self._page = page + self.go = go + + cfg = pve_core.get_config() + self.host_tf = ft.TextField( + label="Хост (IP или FQDN)", value=cfg["host"], + width=280, hint_text="192.168.1.100 — без http://", + ) + self.user_tf = ft.TextField(label="Пользователь", value=cfg["user"], width=200) + self.token_name_tf = ft.TextField(label="Token ID", value=cfg["token_name"], + width=200, hint_text="dm-manager") + self.token_val_tf = ft.TextField(label="Token Secret", value=cfg["token_value"], + password=True, width=350) + self.ssl_cb = ft.Checkbox(label="Проверять SSL", value=cfg["verify_ssl"]) + self.conn_status = ft.Text("", size=12) + + # подсказка про права токена + self.perm_hint = ft.Container( + visible=False, + content=ft.Column([ + ft.Text("403 Forbidden — у API-токена нет прав.", weight=ft.FontWeight.BOLD, + color=ft.Colors.ORANGE_300, size=13), + ft.Text( + "В Proxmox: Datacenter → Permissions → API Tokens → ваш токен → " + "убедитесь, что у токена есть роль PVEAuditor (или PVEAdmin) на пути /.\n" + "Если используете «Privilege Separation» — добавьте права отдельно: " + "Permissions → Add → API Token Permission.", + size=12, color=ft.Colors.GREY_400), + ], spacing=4), + padding=10, border=ft.Border.all(1, ft.Colors.ORANGE_800), border_radius=6, + ) + + self.nodes_col = ft.Column(spacing=12) + + self.controls = [ + ft.Text("Proxmox VE", size=28, weight=ft.FontWeight.BOLD), + ft.Card(content=ft.Container(padding=14, content=ft.Column([ + ft.Text("Подключение", weight=ft.FontWeight.BOLD), + ft.Row([self.host_tf, self.user_tf]), + ft.Row([self.token_name_tf, self.token_val_tf]), + ft.Row([ + self.ssl_cb, + ft.FilledButton("Сохранить и проверить", icon=ft.Icons.CHECK, + on_click=self._save_cfg), + ft.OutlinedButton("Обновить список", icon=ft.Icons.REFRESH, + on_click=lambda e: self._reload()), + ]), + self.conn_status, + self.perm_hint, + ], spacing=8))), + self.nodes_col, + ] + + def _save_cfg(self, e): + pve_core.save_config(self.host_tf.value, self.user_tf.value, + self.token_name_tf.value, self.token_val_tf.value, + self.ssl_cb.value) + async def _t(): + ok, msg = pve_core.test_connection() + self.conn_status.value = ("✅ " if ok else "❌ ") + msg + self.conn_status.color = ft.Colors.GREEN_300 if ok else ft.Colors.RED_300 + self._page.update() + if ok: + self._reload() + self._page.run_task(_t) + + def _reload(self): + self.nodes_col.controls = [ft.ProgressBar()] + self._page.update() + self._page.run_task(self._load_nodes) + + async def _load_nodes(self): + try: + nodes = pve_core.nodes() + except Exception as e: + err = str(e) + self.nodes_col.controls = [ft.Text(f"❌ {err}", color=ft.Colors.RED_300)] + # показать подсказку при 403 + if "403" in err or "Permission check failed" in err: + self.perm_hint.visible = True + self._page.update() + return + + self.nodes_col.controls = [] + for node in nodes: + name = node["node"] + self.nodes_col.controls.append(self._node_card(name, node)) + self._page.update() + # загрузить VM каждой ноды + for i, node in enumerate(nodes): + self._page.run_task(self._fill_node, node["node"], i) + + def _node_card(self, name, node): + return ft.Card(content=ft.Container( + padding=14, + content=ft.Column([ + ft.Row([ + ft.Icon(ft.Icons.COMPUTER, size=20), + ft.Text(name, size=18, weight=ft.FontWeight.BOLD), + ft.Text(f"uptime: {node.get('uptime', 0)//3600}ч", size=11, + color=ft.Colors.GREY_500), + ]), + ft.ProgressBar(height=3), + ]), + )) + + async def _fill_node(self, node_name, card_idx): + try: + st = pve_core.node_status(node_name) + vms = pve_core.vms(node_name) + except Exception as e: + self.nodes_col.controls[card_idx].content.content.controls[1] = \ + ft.Text(f"ошибка: {e}", size=12, color=ft.Colors.RED_300) + self._page.update() + return + + cpu_pct = (st.get("cpu", 0) or 0) * 100 + mem_used = st.get("memory", {}).get("used", 0) / 1073741824 + mem_total = st.get("memory", {}).get("total", 1) / 1073741824 + + def bar(label, pct, color): + return ft.Row([ + ft.Text(label, size=11, width=40), + ft.ProgressBar(value=min(pct / 100, 1.0), height=6, expand=True, + color=color, bgcolor=ft.Colors.GREY_800), + ft.Text(f"{pct:.0f}%", size=11, width=38, text_align=ft.TextAlign.RIGHT), + ]) + + rows = [ + bar("CPU", cpu_pct, ft.Colors.BLUE_400), + bar("RAM", mem_used * 100 / mem_total, ft.Colors.PURPLE_300), + ft.Text(f"RAM {mem_used:.1f}/{mem_total:.1f} ГБ", size=11, + color=ft.Colors.GREY_500), + ft.Divider(), + ft.Text(f"Виртуальные машины ({len(vms)})", weight=ft.FontWeight.BOLD), + ] + + for vm in vms: + rows.append(self._vm_row(node_name, vm)) + + self.nodes_col.controls[card_idx].content.content.controls[1] = \ + ft.Column(rows, spacing=4) + self._page.update() + + def _vm_row(self, node, vm): + vmid = vm["vmid"] + vm_type = vm["type"] + name = vm.get("name", f"vm-{vmid}") + running = vm.get("status") == "running" + cpu = (vm.get("cpu", 0) or 0) * 100 + mem = (vm.get("mem", 0) or 0) / 1073741824 + maxmem = (vm.get("maxmem", 1) or 1) / 1073741824 + + icon = ft.Icons.MONITOR if vm_type == "qemu" else ft.Icons.INVENTORY + status_color = ft.Colors.GREEN_400 if running else ft.Colors.GREY_500 + + return ft.Container( + border=ft.Border.all(1, ft.Colors.GREY_800), border_radius=6, padding=8, + content=ft.Column([ + ft.Row([ + ft.Icon(icon, size=16, color=status_color), + ft.Text(f"{vmid} {name}", weight=ft.FontWeight.BOLD, size=13), + ft.Text(f"{'VM' if vm_type == 'qemu' else 'LXC'} • " + f"{vm.get('status', '?')}", size=11, color=status_color), + ft.Container(expand=True), + ft.IconButton(ft.Icons.PLAY_ARROW, icon_size=16, tooltip="start", + visible=not running, + on_click=lambda e: self._vm_act(node, vmid, vm_type, "start")), + ft.IconButton(ft.Icons.RESTART_ALT, icon_size=16, tooltip="reboot", + visible=running, + on_click=lambda e, n=name: self._vm_confirm(node, vmid, vm_type, "reboot", n)), + ft.IconButton(ft.Icons.STOP, icon_size=16, tooltip="shutdown", + visible=running, + on_click=lambda e, n=name: self._vm_confirm(node, vmid, vm_type, "shutdown", n)), + ft.IconButton(ft.Icons.POWER_SETTINGS_NEW, icon_size=16, tooltip="stop (жёстко)", + visible=running, + on_click=lambda e, n=name: self._vm_confirm(node, vmid, vm_type, "stop", n)), + ft.IconButton(ft.Icons.ADD_CIRCLE_OUTLINE, icon_size=16, + tooltip="Добавить как сервер", + on_click=lambda e: self._add_as_server(node, vmid, vm_type, name)), + ]), + ft.Text(f"CPU {cpu:.1f}% • RAM {mem:.1f}/{maxmem:.1f} ГБ", + size=10, color=ft.Colors.GREY_500), + ], spacing=2), + ) + + def _vm_act(self, node, vmid, vm_type, action): + async def _do(): + try: + pve_core.vm_action(node, vmid, vm_type, action) + snack(self._page, f"{action} отправлен") + self._reload() + except Exception as e: + snack(self._page, f"ошибка: {e}", error=True) + self._page.run_task(_do) + + def _vm_confirm(self, node, vmid, vm_type, action, name): + def do(): + self._vm_act(node, vmid, vm_type, action) + confirm_dialog(self._page, f"{action} {name}?", + f"VMID {vmid} на ноде {node}", do) + + def _add_as_server(self, node, vmid, vm_type, name): + async def _do(): + ip = pve_core.get_vm_ip(node, vmid, vm_type) + if not ip: + snack(self._page, + "Не удалось определить IP (установите qemu-guest-agent в VM)", + error=True) + return + # диалог с предзаполненным IP + user_tf = ft.TextField(label="Пользователь SSH", value="root", width=250) + pass_tf = ft.TextField(label="Пароль", password=True, width=250) + port_tf = ft.TextField(label="Порт SSH", value="22", width=100) + def save(e): + servers_core.add_server( + name=f"{name} (pve:{node})", host=ip, + port=int(port_tf.value or 22), username=user_tf.value, + password=pass_tf.value or None, tags="proxmox", + ) + self._page.pop_dialog() + snack(self._page, f"Сервер {name} ({ip}) добавлен") + dlg = ft.AlertDialog( + modal=True, title=ft.Text(f"Добавить {name} ({ip})"), + content=ft.Column([user_tf, pass_tf, port_tf], tight=True), + actions=[ + ft.TextButton("Отмена", on_click=lambda e: self._page.pop_dialog()), + ft.FilledButton("Добавить", on_click=save), + ], + ) + self._page.show_dialog(dlg) + self._page.run_task(_do) diff --git a/app/ui/pages/reports.py b/app/ui/pages/reports.py new file mode 100644 index 0000000..f03eae2 --- /dev/null +++ b/app/ui/pages/reports.py @@ -0,0 +1,68 @@ +"""Экспорт отчётов: генерация CSV, история.""" +import subprocess + +import flet as ft + +from app.core import reports as rep +from app.ui.components import snack + + +class ReportsPage(ft.Column): + def __init__(self, page: ft.Page, go): + super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12) + self._page = page + + self.status = ft.Text("", size=13, color=ft.Colors.GREY_500) + self.list_col = ft.Column(spacing=4) + + self.controls = [ + ft.Text("Отчёты", size=28, weight=ft.FontWeight.BOLD), + ft.Text("CSV со списком серверов, статусом онлайн, метриками и последними задачами.", + size=13, color=ft.Colors.GREY_500), + ft.Row([ + ft.FilledButton("Сгенерировать отчёт", icon=ft.Icons.SUMMARIZE, + on_click=self._generate), + ft.IconButton(ft.Icons.REFRESH, on_click=lambda e: self._reload()), + ]), + self.status, + ft.Divider(), + ft.Text("История отчётов", weight=ft.FontWeight.BOLD, size=16), + self.list_col, + ] + self._reload() + + def _generate(self, e): + self.status.value = "генерация…" + self._page.update() + + async def _do(): + path = await rep.generate() + self.status.value = f"сохранён: {path}" + self._reload() + self._page.run_task(_do) + + def _reload(self): + self.list_col.controls = [] + for p in rep.list_reports(): + size_kb = p.stat().st_size // 1024 + self.list_col.controls.append( + ft.ListTile( + leading=ft.Icon(ft.Icons.DESCRIPTION), + title=ft.Text(p.name, size=13), + subtitle=ft.Text(f"{size_kb} КБ", size=11), + dense=True, + trailing=ft.IconButton( + ft.Icons.OPEN_IN_NEW, tooltip="Открыть", + on_click=lambda e, p=p: self._open(p)), + ) + ) + if not self.list_col.controls: + self.list_col.controls.append( + ft.Text("отчётов пока нет", color=ft.Colors.GREY_500)) + self._page.update() + + def _open(self, path): + try: + subprocess.Popen(["explorer", "/select,", str(path)]) + except Exception as ex: + snack(self._page, f"не удалось открыть: {ex}", error=True) diff --git a/app/ui/pages/scheduler.py b/app/ui/pages/scheduler.py new file mode 100644 index 0000000..0eaaaa7 --- /dev/null +++ b/app/ui/pages/scheduler.py @@ -0,0 +1,161 @@ +"""Планировщик: задачи по cron, пресеты, запуск.""" +import flet as ft +from croniter import croniter + +from app.core import scheduler as sch +from app.ui.components import confirm_dialog, snack + + +class SchedulerPage(ft.Column): + def __init__(self, page: ft.Page, go): + super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12) + self._page = page + self.table = ft.DataTable(columns=[ + ft.DataColumn(ft.Text("Вкл")), + ft.DataColumn(ft.Text("Название")), + ft.DataColumn(ft.Text("Расписание")), + ft.DataColumn(ft.Text("Действие")), + ft.DataColumn(ft.Text("Цель")), + ft.DataColumn(ft.Text("Последний запуск")), + ft.DataColumn(ft.Text("Статус")), + ft.DataColumn(ft.Text("")), + ], rows=[]) + self.controls = [ + ft.Row([ + ft.Text("Планировщик", size=28, weight=ft.FontWeight.BOLD), + ft.FilledButton("Добавить задачу", icon=ft.Icons.ADD, + on_click=self._add_dialog), + ft.OutlinedButton("Из пресета", icon=ft.Icons.LIST_ALT, + on_click=self._preset_dialog), + ft.IconButton(ft.Icons.REFRESH, on_click=lambda e: self._reload()), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + ft.Card(content=ft.Container(padding=10, content=self.table)), + ] + self._reload() + + def _reload(self): + self.table.rows = [] + for t in sch.list_tasks(): + try: + nxt = croniter(t.cron).get_next(__import__("datetime").datetime) + sched_txt = f"{t.cron} → {nxt:%d.%m %H:%M}" + except Exception: + sched_txt = f"{t.cron} (неверно)" + self.table.rows.append(ft.DataRow(cells=[ + ft.DataCell(ft.Checkbox(value=t.enabled, + on_change=lambda e, t=t: self._toggle(t))), + ft.DataCell(ft.Text(t.name, weight=ft.FontWeight.BOLD)), + ft.DataCell(ft.Text(sched_txt, size=11)), + ft.DataCell(ft.Text(t.action, size=11)), + ft.DataCell(ft.Text(t.target, size=11)), + ft.DataCell(ft.Text(f"{t.last_run:%d.%m %H:%M}" if t.last_run else "—", + size=11)), + ft.DataCell(ft.Text(t.last_status or "—", size=11, + color=ft.Colors.GREEN_300 if t.last_status == "success" + else ft.Colors.RED_300 if t.last_status == "error" + else ft.Colors.GREY_500)), + ft.DataCell(ft.Row([ + ft.IconButton(ft.Icons.PLAY_ARROW, icon_size=16, tooltip="Запустить сейчас", + on_click=lambda e, t=t: self._run_now(t)), + ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=16, + on_click=lambda e, t=t: self._delete(t)), + ], spacing=0)), + ])) + self._page.update() + + def _toggle(self, t): + sch.update_task(t.id, enabled=not t.enabled) + self._reload() + + def _run_now(self, t): + async def _r(): + status = await sch.execute_task(t.id) + snack(self._page, f"{t.name}: {status}", + error=status != "success") + self._reload() + self._page.run_task(_r) + + def _delete(self, t): + def do(): + sch.delete_task(t.id) + self._reload() + confirm_dialog(self._page, f"Удалить задачу «{t.name}»?", "", do) + + def _add_dialog(self, e): + self._form("Новая задача", None) + + def _form(self, title, t): + name_tf = ft.TextField(label="Название", value=t.name if t else "", width=350) + cron_tf = ft.TextField(label="Cron (мин час день мес день_нед)", + value=t.cron if t else "0 3 * * *", width=350) + hint = ft.Text("", size=11, color=ft.Colors.GREY_500) + + def upd(e): + try: + nxt = croniter(cron_tf.value).get_next(__import__("datetime").datetime) + hint.value = f"следующий запуск: {nxt:%Y-%m-%d %H:%M}" + hint.color = ft.Colors.GREY_500 + except Exception: + hint.value = "неверное выражение" + hint.color = ft.Colors.RED_300 + dlg.content.update() + cron_tf.on_change = upd + + action_dd = ft.Dropdown(label="Действие", value=t.action if t else "apt_update", + width=350, options=[ + ft.dropdown.Option("apt_update", "apt update"), + ft.dropdown.Option("upgrade", "upgrade"), + ft.dropdown.Option("dist_upgrade", "dist-upgrade"), + ft.dropdown.Option("shell", "команда"), + ft.dropdown.Option("backup", "бэкап"), + ft.dropdown.Option("playbook", "плейбук"), + ft.dropdown.Option("report", "отчёт"), + ft.dropdown.Option("cleanup_metrics", "очистка метрик"), + ]) + target_tf = ft.TextField(label="Цель (all или тег)", + value=t.target if t else "all", width=350) + params_tf = ft.TextField(label="Параметры (команда / пути / плейбук)", + value=t.params if t else "", width=350) + + def save(e): + if "неверное" in (hint.value or ""): + snack(self._page, "Исправьте cron-выражение", error=True) + return + fields = dict(name=name_tf.value, cron=cron_tf.value, + action=action_dd.value, target=target_tf.value, + params=params_tf.value) + if t: + sch.update_task(t.id, **fields) + else: + sch.add_task(**fields) + self._page.pop_dialog() + self._reload() + snack(self._page, "Сохранено") + + dlg = ft.AlertDialog( + modal=True, title=ft.Text(title), + content=ft.Column([name_tf, cron_tf, hint, action_dd, target_tf, params_tf], + tight=True), + actions=[ + ft.TextButton("Отмена", on_click=lambda e: self._page.pop_dialog()), + ft.FilledButton("Сохранить", on_click=save), + ], + ) + self._page.show_dialog(dlg) + + def _preset_dialog(self, e): + def add(idx): + name, cron, action, target, params = sch.PRESETS[idx] + sch.add_task(name=name, cron=cron, action=action, target=target, params=params) + self._page.pop_dialog() + self._reload() + snack(self._page, f"Добавлено: {name}") + dlg = ft.AlertDialog( + modal=True, title=ft.Text("Пресеты"), + content=ft.Column([ + ft.TextButton(f"{name} ({cron})", on_click=lambda e, i=i: add(i)) + for i, (name, cron, *_ ) in enumerate(sch.PRESETS) + ], tight=True), + actions=[ft.TextButton("Отмена", on_click=lambda e: self._page.pop_dialog())], + ) + self._page.show_dialog(dlg) diff --git a/app/ui/pages/server_detail.py b/app/ui/pages/server_detail.py new file mode 100644 index 0000000..ce15f7f --- /dev/null +++ b/app/ui/pages/server_detail.py @@ -0,0 +1,64 @@ +"""Страница сервера: вкладки Обзор / Пакеты / Docker / Файлы / Сервисы / Firewall / Cron.""" +import flet as ft + +from app.core import servers as servers_core +from app.ui.tabs.overview import OverviewTab +from app.ui.tabs.packages import PackagesTab +from app.ui.tabs.docker_tab import DockerTab +from app.ui.tabs.files_tab import FilesTab +from app.ui.tabs.systemd_tab import SystemdTab +from app.ui.tabs.firewall_tab import FirewallTab +from app.ui.tabs.cron_tab import CronTab +from app.ui.tabs.terminal_tab import TerminalTab + + +class ServerDetailPage(ft.Column): + def __init__(self, page: ft.Page, server_id: int, go): + super().__init__(expand=True) + self._page = page + self.go = go + srv = servers_core.get_server(server_id) + + tabs = ft.Tabs( + selected_index=0, + length=8, + expand=True, + content=ft.Column( + expand=True, + controls=[ + ft.TabBar(tabs=[ + ft.Tab(label="Обзор", icon=ft.Icons.INFO), + ft.Tab(label="Пакеты", icon=ft.Icons.INVENTORY_2), + ft.Tab(label="Docker", icon=ft.Icons.AC_UNIT), + ft.Tab(label="Файлы", icon=ft.Icons.FOLDER), + ft.Tab(label="Сервисы", icon=ft.Icons.SETTINGS_APPLICATIONS), + ft.Tab(label="Firewall", icon=ft.Icons.SHIELD), + ft.Tab(label="Cron", icon=ft.Icons.SCHEDULE), + ft.Tab(label="Терминал", icon=ft.Icons.TERMINAL), + ]), + ft.TabBarView( + expand=True, + controls=[ + ft.Container(padding=10, content=OverviewTab(page, srv)), + ft.Container(padding=10, content=PackagesTab(page, srv)), + ft.Container(padding=10, content=DockerTab(page, srv)), + ft.Container(padding=10, content=FilesTab(page, srv)), + ft.Container(padding=10, content=SystemdTab(page, srv)), + ft.Container(padding=10, content=FirewallTab(page, srv)), + ft.Container(padding=10, content=CronTab(page, srv)), + ft.Container(padding=0, content=TerminalTab(page, srv)), + ], + ), + ], + ), + ) + + self.controls = [ + ft.Row([ + ft.IconButton(ft.Icons.ARROW_BACK, on_click=lambda e: go("dashboard")), + ft.Text(srv.name, size=24, weight=ft.FontWeight.BOLD), + ft.Text(f"{srv.username}@{srv.host}:{srv.port}", + size=13, color=ft.Colors.GREY_500), + ]), + tabs, + ] diff --git a/app/ui/pages/servers.py b/app/ui/pages/servers.py new file mode 100644 index 0000000..f37f05e --- /dev/null +++ b/app/ui/pages/servers.py @@ -0,0 +1,158 @@ +"""Серверы: список, добавление, редактирование, удаление.""" +import flet as ft + +from app.core import servers as servers_core +from app.core.ssh import check_server +from app.ui.components import confirm_dialog, snack + + +class ServersPage(ft.Column): + def __init__(self, page: ft.Page, go): + super().__init__(expand=True, scroll=ft.ScrollMode.AUTO) + self._page = page + self.go = go + self.table = ft.DataTable( + columns=[ + ft.DataColumn(ft.Text("Имя")), + ft.DataColumn(ft.Text("Хост")), + ft.DataColumn(ft.Text("Пользователь")), + ft.DataColumn(ft.Text("Теги")), + ft.DataColumn(ft.Text("Статус")), + ft.DataColumn(ft.Text("Действия")), + ], + rows=[], + ) + self.controls = [ + ft.Row([ + ft.Text("Серверы", size=28, weight=ft.FontWeight.BOLD), + ft.FilledButton("Добавить сервер", icon=ft.Icons.ADD, + on_click=self._add_dialog), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + ft.Card(content=ft.Container(padding=10, content=self.table)), + ] + self.reload() + + def reload(self): + self.table.rows = [] + for s in servers_core.list_servers(): + self.table.rows.append(ft.DataRow(cells=[ + ft.DataCell(ft.Text(s.name, weight=ft.FontWeight.BOLD)), + ft.DataCell(ft.Text(f"{s.host}:{s.port}")), + ft.DataCell(ft.Text(s.username)), + ft.DataCell(ft.Text(s.tags or "—")), + ft.DataCell(ft.Text("…", key=f"status_{s.id}")), + ft.DataCell(ft.Row([ + ft.IconButton(ft.Icons.OPEN_IN_NEW, tooltip="Открыть", + on_click=lambda e, sid=s.id: self.go("server_detail", sid)), + ft.IconButton(ft.Icons.EDIT, tooltip="Изменить", + on_click=lambda e, s=s: self._edit_dialog(s)), + ft.IconButton(ft.Icons.DELETE, tooltip="Удалить", + on_click=lambda e, s=s: self._delete(s)), + ])), + ])) + self._page.update() + for s in servers_core.list_servers(): + self._page.run_task(self._check, s) + + async def _check(self, s): + info = await check_server(s) + txt = "🟢 онлайн" if info.get("online") else "🔴 оффлайн" + self._statuses = getattr(self, "_statuses", {}) + self._statuses[s.id] = txt + for i, srv in enumerate(servers_core.list_servers()): + if i < len(self.table.rows): + self.table.rows[i].cells[4].content = ft.Text( + self._statuses.get(srv.id, "…")) + self._page.update() + + # ---------- диалоги ---------- + + def _form(self, s=None): + return { + "name": ft.TextField(label="Имя", value=s.name if s else "", width=300), + "host": ft.TextField(label="Хост", value=s.host if s else "", width=300), + "port": ft.TextField(label="Порт", value=str(s.port) if s else "22", width=100), + "username": ft.TextField(label="Пользователь", value=s.username if s else "root", width=300), + "auth_type": ft.Dropdown( + label="Авторизация", width=300, + value=s.auth_type if s else "password", + options=[ + ft.dropdown.Option("password", "Пароль"), + ft.dropdown.Option("key", "SSH-ключ"), + ], + ), + "password": ft.TextField(label="Пароль / passphrase", password=True, + value=s.password if s else "", width=300), + "key_path": ft.TextField(label="Путь к ключу", value=s.key_path or "" if s else "", + width=300), + "sudo_password": ft.TextField(label="Sudo-пароль (если отличается)", password=True, + value=s.sudo_password or "" if s else "", width=300), + "tags": ft.TextField(label="Теги (через запятую)", value=s.tags if s else "", width=300), + } + + def _add_dialog(self, e): + self._open_form("Новый сервер", self._form(), None) + + def _edit_dialog(self, s): + self._open_form(f"Изменить: {s.name}", self._form(s), s.id) + + def _open_form(self, title, f, server_id): + def save(e): + fields = dict( + name=f["name"].value, host=f["host"].value, + port=int(f["port"].value or 22), username=f["username"].value, + auth_type=f["auth_type"].value, + password=f["password"].value or None, + key_path=f["key_path"].value or None, + sudo_password=f["sudo_password"].value or None, + tags=f["tags"].value, + ) + if server_id: + servers_core.update_server(server_id, **fields) + else: + servers_core.add_server(**fields) + self._page.pop_dialog() + self.reload() + snack(self._page, "Сохранено") + + def test(e): + async def _t(): + fields = dict( + id=0, name=f["name"].value or "test", host=f["host"].value, + port=int(f["port"].value or 22), username=f["username"].value, + auth_type=f["auth_type"].value, + password=f["password"].value or None, + key_path=f["key_path"].value or None, + sudo_password=f["sudo_password"].value or None, + tags="", color="blue", read_only=False, + ) + from app.db.models import Server + info = await check_server(Server(**fields)) + if info.get("online"): + snack(self._page, f"✅ {info['os']} | {info['uptime']}") + else: + snack(self._page, f"❌ {info.get('error')}", error=True) + self._page.run_task(_t) + + dlg = ft.AlertDialog( + modal=True, + title=ft.Text(title), + content=ft.Column(list(f.values()), tight=True, + scroll=ft.ScrollMode.AUTO, height=480), + actions=[ + ft.TextButton("Проверить подключение", on_click=test), + ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)), + ft.FilledButton("Сохранить", on_click=save), + ], + ) + self._page.show_dialog(dlg) + + def _close(self, dlg): + self._page.pop_dialog() + + def _delete(self, s): + confirm_dialog( + self._page, "Удалить сервер?", + f"Сервер «{s.name}» ({s.host}) будет удалён из приложения. На самом сервере ничего не изменится.", + lambda: (servers_core.delete_server(s.id), self.reload()), + ) diff --git a/app/ui/pages/settings.py b/app/ui/pages/settings.py new file mode 100644 index 0000000..6825100 --- /dev/null +++ b/app/ui/pages/settings.py @@ -0,0 +1,76 @@ +"""Настройки: Telegram, Gitea.""" +import flet as ft + +from app.core import notify +from app.ui.components import snack + + +class SettingsPage(ft.Column): + def __init__(self, page: ft.Page, go): + super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12) + self._page = page + + cfg = notify.get_config() + self.tg_token = ft.TextField(label="Bot token", value=cfg["token"], + password=True, width=400) + self.tg_chat = ft.TextField(label="Chat ID", value=cfg["chat_id"], width=400) + self.tg_enabled = ft.Checkbox(label="Уведомления включены", value=cfg["enabled"]) + self.tg_alert = ft.Checkbox(label="Алерты мониторинга", value=cfg["on_alert"]) + self.tg_task = ft.Checkbox(label="Завершение задач", value=cfg["on_task_done"]) + self.tg_status = ft.Text("", size=12) + + self.controls = [ + ft.Text("Настройки", size=28, weight=ft.FontWeight.BOLD), + ft.Card(content=ft.Container(padding=16, content=ft.Column([ + ft.Text("Интерфейс", weight=ft.FontWeight.BOLD, size=16), + ft.Row([ + ft.Text("Тема:", size=13), + ft.FilledButton( + "Сменить тему (тёмная/светлая)", + icon=ft.Icons.BRIGHTNESS_6, + on_click=self._toggle_theme, + ), + ]), + ], spacing=10))), + ft.Card(content=ft.Container(padding=16, content=ft.Column([ + ft.Text("Telegram-уведомления", weight=ft.FontWeight.BOLD, size=16), + ft.Text("Создайте бота через @BotFather, узнайте chat_id через @userinfobot", + size=12, color=ft.Colors.GREY_500), + self.tg_token, + self.tg_chat, + self.tg_enabled, + self.tg_alert, + self.tg_task, + ft.Row([ + ft.FilledButton("Сохранить", icon=ft.Icons.SAVE, on_click=self._save), + ft.OutlinedButton("Тест", icon=ft.Icons.SEND, on_click=self._test), + ]), + self.tg_status, + ], spacing=10))), + ] + + def _toggle_theme(self, e): + from app.db.database import get_session + from app.db.models import Setting + new = "light" if self._page.theme_mode == ft.ThemeMode.DARK else "dark" + self._page.theme_mode = ft.ThemeMode.LIGHT if new == "light" else ft.ThemeMode.DARK + with get_session() as s: + row = s.get(Setting, "theme") or Setting(key="theme") + row.value = new + s.add(row) + s.commit() + self._page.update() + + def _save(self, e): + notify.save_config(self.tg_token.value, self.tg_chat.value, + self.tg_enabled.value, self.tg_alert.value, self.tg_task.value) + snack(self._page, "Настройки сохранены") + + def _test(self, e): + self._save(e) + async def _t(): + ok, msg = await notify.test() + self.tg_status.value = ("✅ " if ok else "❌ ") + msg + self.tg_status.color = ft.Colors.GREEN_300 if ok else ft.Colors.RED_300 + self._page.update() + self._page.run_task(_t) diff --git a/app/ui/pages/tasks.py b/app/ui/pages/tasks.py new file mode 100644 index 0000000..21a132c --- /dev/null +++ b/app/ui/pages/tasks.py @@ -0,0 +1,47 @@ +"""Журнал задач.""" +import flet as ft + +from app.core.tasks import tasks +from app.core import servers as servers_core + + +class TasksPage(ft.Column): + def __init__(self, page: ft.Page, go): + super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=8) + self._page = page + self.col = ft.Column(spacing=6) + self.controls = [ + ft.Row([ + ft.Text("Журнал задач", size=28, weight=ft.FontWeight.BOLD), + ft.IconButton(ft.Icons.REFRESH, on_click=lambda e: self._load()), + ]), + self.col, + ] + self._load() + + def _load(self): + self.col.controls = [] + servers = {s.id: s.name for s in servers_core.list_servers()} + for t in tasks.list_recent(50): + color = {"success": ft.Colors.GREEN_400, "error": ft.Colors.RED_400, + "running": ft.Colors.BLUE_300, "cancelled": ft.Colors.ORANGE_300, + }.get(t.status, ft.Colors.GREY_500) + self.col.controls.append( + ft.ExpansionTile( + title=ft.Text(f"{t.title} — {servers.get(t.server_id, '?')}", size=14), + subtitle=ft.Text(f"{t.created_at:%Y-%m-%d %H:%M} • {t.kind}", + size=11), + leading=ft.Icon(ft.Icons.CIRCLE, color=color, size=14), + controls=[ + ft.Container( + padding=10, + content=ft.Text(t.log[-5000:] or "(нет лога)", + size=11, font_family="Consolas", + selectable=True), + ) + ], + ) + ) + if not self.col.controls: + self.col.controls.append(ft.Text("задач пока не было", color=ft.Colors.GREY_500)) + self._page.update() diff --git a/app/ui/pages/wizard.py b/app/ui/pages/wizard.py new file mode 100644 index 0000000..ec76af1 --- /dev/null +++ b/app/ui/pages/wizard.py @@ -0,0 +1,190 @@ +"""Мастер первичной настройки сервера: чекбоксы → плейбук.""" +import flet as ft + +from app.core.ssh import SSHClient +from app.core.tasks import tasks +from app.core import playbooks as pb +from app.db.models import Server +from app.ui.components import append_log, log_view, snack + +# (id, название, описание, шаги) +WIZARD_STEPS = [ + ("hostname", "Изменить hostname", "Задать имя сервера", + lambda v: [{"name": "Hostname", "shell": f"hostnamectl set-hostname {v['hostname']}"}]), + ("timezone", "Часовой пояс", "timedatectl set-timezone (по умолчанию Asia/Ulaanbaatar)", + lambda v: [{"name": "Timezone", "shell": f"timedatectl set-timezone {v['timezone']}"}]), + ("ntp", "Синхронизация времени", "Включить NTP", + lambda v: [{"name": "NTP", "shell": "timedatectl set-ntp true"}]), + ("update", "Обновить систему", "apt update + dist-upgrade", + lambda v: [{"name": "Update", "apt": {"update": True, "upgrade": "dist"}}]), + ("user", "Создать пользователя", "Новый пользователь с sudo", + lambda v: [ + {"name": "Создать пользователя", + "shell": f"useradd -m -s /bin/bash {v['new_user']} 2>/dev/null || true"}, + {"name": "Пароль", + "shell": f"echo '{v['new_user']}:{v['new_pass']}' | chpasswd"}, + {"name": "Sudo", + "shell": f"usermod -aG sudo {v['new_user']}"}, + ]), + ("utils", "Базовые утилиты", "curl, wget, htop, git, mc, net-tools", + lambda v: [{"name": "Утилиты", + "apt": {"install": ["curl", "wget", "htop", "git", "mc", "net-tools"]}}]), + ("docker", "Docker", "docker.io + docker-compose + автозапуск", + lambda v: [ + {"name": "Установить Docker", + "apt": {"install": ["docker.io", "docker-compose"]}}, + {"name": "Запустить Docker", "systemd": {"unit": "docker", "action": "enable --now"}}, + ]), + ("portainer", "Portainer (порт 9000)", "Веб-интерфейс для Docker", + lambda v: [ + {"name": "Portainer volume", "shell": "docker volume create portainer_data || true"}, + {"name": "Portainer run", + "shell": "docker rm -f portainer 2>/dev/null; " + "docker run -d -p 9000:9000 --name portainer --restart=always " + "-v /var/run/docker.sock:/var/run/docker.sock " + "-v portainer_data:/data portainer/portainer-ce:lts"}, + ]), + ("ufw", "Firewall (ufw)", "Установить, разрешить SSH, включить", + lambda v: [ + {"name": "Установить ufw", "apt": {"install": ["ufw"]}}, + {"name": "Пресет", "ufw": {"preset": "Только SSH"}}, + {"name": "Включить ufw", "shell": "yes | ufw enable"}, + ]), + ("fail2ban", "Fail2ban", "Защита SSH от перебора", + lambda v: [ + {"name": "Установить fail2ban", "apt": {"install": ["fail2ban"]}}, + {"name": "Запустить", "systemd": {"unit": "fail2ban", "action": "enable --now"}}, + ]), + ("unattended", "Автообновления безопасности", "unattended-upgrades", + lambda v: [ + {"name": "Установить", "apt": {"install": ["unattended-upgrades"]}}, + {"name": "Включить", + "shell": "echo 'unattended-upgrades unattended-upgrades/enable_auto_updates boolean true' " + "| debconf-set-selections && dpkg-reconfigure -f noninteractive unattended-upgrades"}, + ]), + ("agent", "Агент мониторинга", "dm-agent для real-time метрик (HTTP)", + None), # обрабатывается отдельно +] + +RECOMMENDED = {"update", "utils", "ufw", "fail2ban", "ntp"} + + +class WizardPage(ft.Column): + def __init__(self, page: ft.Page, go): + super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12) + self._page = page + + from app.core import servers as servers_core + self.srv_dd = ft.Dropdown(label="Сервер", width=300, options=[ + ft.dropdown.Option(str(s.id), s.name) for s in servers_core.list_servers() + ]) + + self.fields = { + "hostname": ft.TextField(label="hostname", width=250, visible=False, dense=True), + "timezone": ft.TextField(label="часовой пояс", value="Asia/Ulaanbaatar", + width=250, visible=False, dense=True), + "new_user": ft.TextField(label="имя пользователя", width=200, visible=False, dense=True), + "new_pass": ft.TextField(label="пароль", password=True, width=200, + visible=False, dense=True), + } + + self.checks = {} + step_rows = [] + for sid, name, desc, _ in WIZARD_STEPS: + cb = ft.Checkbox(label=name, value=sid in RECOMMENDED, + on_change=lambda e, sid=sid: self._toggle_fields()) + self.checks[sid] = cb + extra = [] + if sid == "hostname": + extra = [self.fields["hostname"]] + elif sid == "timezone": + extra = [self.fields["timezone"]] + elif sid == "user": + extra = [ft.Row([self.fields["new_user"], self.fields["new_pass"]])] + step_rows.append(ft.Column([ + ft.Row([cb, ft.Text(desc, size=12, color=ft.Colors.GREY_500)]), + ft.Container(content=ft.Row(extra), padding=ft.Padding.only(left=40)) + if extra else ft.Container(), + ], spacing=2)) + + self.log = log_view() + self.log_container = ft.Container( + height=250, border=ft.Border.all(1, ft.Colors.GREY_800), + border_radius=8, padding=8, content=self.log, visible=False) + + self.controls = [ + ft.Text("Мастер первичной настройки", size=28, weight=ft.FontWeight.BOLD), + ft.Text("Отметьте что настроить, заполните поля, нажмите «Применить».", + size=13, color=ft.Colors.GREY_500), + self.srv_dd, + ft.Card(content=ft.Container(padding=14, content=ft.Column(step_rows, spacing=6))), + ft.Row([ + ft.FilledButton("Применить выбранное", icon=ft.Icons.PLAY_ARROW, + on_click=self._apply), + ft.TextButton("Выбрать рекомендуемое", on_click=lambda e: self._preset()), + ]), + self.log_container, + ] + + def _toggle_fields(self): + self.fields["hostname"].visible = self.checks["hostname"].value + self.fields["timezone"].visible = self.checks["timezone"].value + user_on = self.checks["user"].value + self.fields["new_user"].visible = user_on + self.fields["new_pass"].visible = user_on + self._page.update() + + def _preset(self): + for sid, cb in self.checks.items(): + cb.value = sid in RECOMMENDED + self._toggle_fields() + + def _apply(self, e): + if not self.srv_dd.value: + snack(self._page, "Выберите сервер", error=True) + return + from app.core import servers as servers_core + srv = servers_core.get_server(int(self.srv_dd.value)) + + values = { + "hostname": self.fields["hostname"].value or "server", + "timezone": self.fields["timezone"].value or "Asia/Ulaanbaatar", + "new_user": self.fields["new_user"].value or "deploy", + "new_pass": self.fields["new_pass"].value or "changeme", + "ssh_port": str(srv.port), + } + + steps = [] + agent_selected = False + for sid, _, _, builder in WIZARD_STEPS: + if not self.checks[sid].value: + continue + if sid == "agent": + agent_selected = True + continue + steps.extend(builder(values)) + + if not steps and not agent_selected: + snack(self._page, "Ничего не выбрано", error=True) + return + + self.log_container.visible = True + self._page.update() + + playbook = {"name": "Мастер настройки", "vars": values, "steps": steps} + + async def job(log): + async with SSHClient(srv) as c: + if steps: + await pb.run_playbook(c, srv, playbook, log) + if agent_selected: + from app.core import agent as agent_core + import socket + # определить IP приложения, доступный серверу + app_host = socket.gethostbyname(socket.gethostname()) + log(f"установка агента (app_host={app_host})...") + await agent_core.install_agent(srv, app_host, log) + + tasks.start("wizard", "мастер настройки", srv.id, job, + on_log=lambda l: (append_log(self.log, l), self._page.update())) + snack(self._page, "Мастер запущен — следите за логом") diff --git a/app/ui/tabs/__init__.py b/app/ui/tabs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ui/tabs/cron_tab.py b/app/ui/tabs/cron_tab.py new file mode 100644 index 0000000..f3929ae --- /dev/null +++ b/app/ui/tabs/cron_tab.py @@ -0,0 +1,172 @@ +"""Вкладка Cron: редактор crontab.""" +import flet as ft + +from app.core import cron_svc as cron +from app.core.ssh import SSHClient +from app.db.models import Server +from app.ui.components import confirm_dialog, snack + + +class CronTab(ft.Column): + def __init__(self, page: ft.Page, srv: Server): + super().__init__(expand=True, spacing=8, scroll=ft.ScrollMode.AUTO) + self._page = page + self.srv = srv + self._lines: list[cron.CronLine] = [] + self._user = "" # пусто = текущий sudo-пользовательский контекст + + self.entries_col = ft.Column(spacing=4) + self.controls = [ + ft.Container(height=8), + ft.Row([ + ft.FilledButton("Добавить задачу", icon=ft.Icons.ADD, + on_click=self._add_dialog), + ft.OutlinedButton("Обновить", icon=ft.Icons.REFRESH, + on_click=lambda e: self._page.run_task(self._load)), + ft.OutlinedButton("Откатить к бэкапу", icon=ft.Icons.HISTORY, + on_click=self._restore_dialog), + ]), + self.entries_col, + ] + self._page.run_task(self._load) + + async def _load(self): + try: + async with SSHClient(self.srv) as c: + content = await cron.get_crontab(c) + self._lines = cron.parse_crontab(content) + except Exception as e: + snack(self._page, f"cron: {e}", error=True) + self._render() + + def _render(self): + self.entries_col.controls = [] + for i, l in enumerate(self._lines): + if not l.is_entry: + if l.raw.strip() and not l.raw.strip().startswith(("#", "SHELL", "PATH", "MAILTO")): + self.entries_col.controls.append( + ft.Text(l.raw, size=11, color=ft.Colors.GREY_600, + font_family="Consolas")) + continue + self.entries_col.controls.append( + ft.Card(content=ft.Container(padding=8, content=ft.Row([ + ft.Checkbox(value=l.enabled, + on_change=lambda e, idx=i: self._toggle(idx)), + ft.Column([ + ft.Text(l.schedule, size=13, weight=ft.FontWeight.BOLD, + font_family="Consolas"), + ft.Text(l.command, size=12, font_family="Consolas"), + ft.Text(cron.humanize(l.schedule), size=10, + color=ft.Colors.GREY_500), + ], spacing=1, expand=True), + ft.IconButton(ft.Icons.EDIT, icon_size=16, + on_click=lambda e, idx=i: self._edit_dialog(idx)), + ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=16, + on_click=lambda e, idx=i: self._delete(idx)), + ]))) + ) + if not any(l.is_entry for l in self._lines): + self.entries_col.controls.append( + ft.Text("crontab пуст", color=ft.Colors.GREY_500)) + self._page.update() + + async def _save(self): + content = cron.render(self._lines) + async with SSHClient(self.srv) as c: + await cron.set_crontab(c, content, self.srv.id) + snack(self._page, "crontab сохранён (бэкап записан)") + + def _toggle(self, idx): + self._lines[idx].enabled = not self._lines[idx].enabled + self._page.run_task(self._save) + + def _delete(self, idx): + def do(): + del self._lines[idx] + self._page.run_task(self._save_and_reload) + confirm_dialog(self._page, "Удалить задачу?", self._lines[idx].command[:80], do) + + async def _save_and_reload(self): + await self._save() + await self._load() + + def _edit_dialog(self, idx): + l = self._lines[idx] + sched_tf = ft.TextField(label="Расписание (мин час день мес день_нед)", + value=l.schedule, width=400) + cmd_tf = ft.TextField(label="Команда", value=l.command, width=400) + hint = ft.Text(cron.humanize(l.schedule), size=11, color=ft.Colors.GREY_500) + + def upd_hint(e): + hint.value = cron.humanize(sched_tf.value) + dlg.content.update() + sched_tf.on_change = upd_hint + + def save(e): + if "неверное" in hint.value: + snack(self._page, "Неверное cron-выражение", error=True) + return + self._lines[idx] = cron.CronLine("", l.enabled, sched_tf.value.strip(), + cmd_tf.value.strip()) + self._page.pop_dialog() + self._page.run_task(self._save_and_reload) + + dlg = ft.AlertDialog( + modal=True, title=ft.Text("Редактировать задачу"), + content=ft.Column([sched_tf, cmd_tf, hint], tight=True), + actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)), + ft.FilledButton("Сохранить", on_click=save)], + ) + self._page.show_dialog(dlg) + + def _add_dialog(self, e): + sched_tf = ft.TextField(label="Расписание", hint_text="0 3 * * *", width=400) + cmd_tf = ft.TextField(label="Команда", width=400) + hint = ft.Text("", size=11, color=ft.Colors.GREY_500) + + def upd_hint(ev): + hint.value = cron.humanize(sched_tf.value) + dlg.content.update() + sched_tf.on_change = upd_hint + + def save(e): + if not sched_tf.value or not cmd_tf.value: + return + self._lines.append(cron.CronLine("", True, sched_tf.value.strip(), + cmd_tf.value.strip())) + self._page.pop_dialog() + self._page.run_task(self._save_and_reload) + + dlg = ft.AlertDialog( + modal=True, title=ft.Text("Новая cron-задача"), + content=ft.Column([sched_tf, cmd_tf, hint], tight=True), + actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)), + ft.FilledButton("Добавить", on_click=save)], + ) + self._page.show_dialog(dlg) + + def _restore_dialog(self, e): + backups = cron.backups(self.srv.id, "current") + if not backups: + snack(self._page, "Бэкапов пока нет", error=True) + return + def restore(b): + self._page.pop_dialog() + async def _r(): + async with SSHClient(self.srv) as c: + await cron.set_crontab(c, b.content, self.srv.id) + await self._load() + self._page.run_task(_r) + dlg = ft.AlertDialog( + modal=True, title=ft.Text("Откатить к бэкапу"), + content=ft.Column([ + ft.TextButton(f"{b.created_at:%Y-%m-%d %H:%M} ({len(b.content)} символов)", + on_click=lambda e, b=b: restore(b)) + for b in backups + ], tight=True), + actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg))], + ) + self._page.show_dialog(dlg) + + def _close(self, dlg): + self._page.pop_dialog() diff --git a/app/ui/tabs/docker_tab.py b/app/ui/tabs/docker_tab.py new file mode 100644 index 0000000..cd698c8 --- /dev/null +++ b/app/ui/tabs/docker_tab.py @@ -0,0 +1,296 @@ +"""Вкладка Docker: контейнеры, образы, тома, сети, compose.""" +import flet as ft + +from app.core import docker_svc as d +from app.core.ssh import SSHClient +from app.core.tasks import tasks +from app.db.models import Server +from app.ui.components import append_log, confirm_dialog, log_view, snack + + +class DockerTab(ft.Column): + def __init__(self, page: ft.Page, srv: Server): + super().__init__(expand=True, spacing=8) + self._page = page + self.srv = srv + self.inner_tabs = ft.Tabs( + selected_index=0, length=5, expand=True, + content=ft.Column( + expand=True, + controls=[ + ft.TabBar(tabs=[ + ft.Tab(label="Контейнеры"), + ft.Tab(label="Образы"), + ft.Tab(label="Тома"), + ft.Tab(label="Сети"), + ft.Tab(label="Система"), + ]), + ft.TabBarView( + expand=True, + controls=[ + self._containers_view(), + self._images_view(), + self._volumes_view(), + self._networks_view(), + self._system_view(), + ], + ), + ], + ), + ) + self.log = log_view() + self.log_container = ft.Container( + height=200, border=ft.Border.all(1, ft.Colors.GREY_800), + border_radius=8, padding=8, content=self.log, visible=False) + self.controls = [ft.Container(height=8), self.inner_tabs, self.log_container] + self._page.run_task(self._check) + + async def _check(self): + try: + async with SSHClient(self.srv) as c: + if not await d.available(c): + snack(self._page, "Docker не установлен на сервере", error=True) + return + await self._load_containers() + await self._load_images() + await self._load_volumes() + await self._load_networks() + await self._load_system() + except Exception as e: + snack(self._page, f"Docker: {e}", error=True) + + # ---------- контейнеры ---------- + + def _containers_view(self): + self.ct_table = ft.DataTable(columns=[ + ft.DataColumn(ft.Text("Имя")), ft.DataColumn(ft.Text("Образ")), + ft.DataColumn(ft.Text("Статус")), ft.DataColumn(ft.Text("Порты")), + ft.DataColumn(ft.Text("Действия")), + ], rows=[]) + self.stats_row = ft.Row(spacing=16, wrap=True) + return ft.Column([ + ft.Row([ + ft.OutlinedButton("Обновить", icon=ft.Icons.REFRESH, + on_click=lambda e: self._page.run_task(self._load_containers)), + ]), + ft.Container(expand=True, content=ft.Column( + [self.ct_table, ft.Divider(), ft.Text("Нагрузка:", weight=ft.FontWeight.BOLD), self.stats_row], + scroll=ft.ScrollMode.AUTO)), + ], expand=True) + + async def _load_containers(self): + async with SSHClient(self.srv) as c: + containers = await d.list_containers(c) + try: + stats = {s["Name"]: s for s in await d.container_stats(c)} + except Exception: + stats = {} + self.ct_table.rows = [] + for ct in containers: + running = "Up" in ct.get("Status", "") + cid = ct["ID"] + name = ct["Names"] + self.ct_table.rows.append(ft.DataRow(cells=[ + ft.DataCell(ft.Text(name, weight=ft.FontWeight.BOLD)), + ft.DataCell(ft.Text(ct["Image"], size=12)), + ft.DataCell(ft.Text(ct["Status"], size=12, + color=ft.Colors.GREEN_300 if running else ft.Colors.GREY_500)), + ft.DataCell(ft.Text(ct.get("Ports", "") or "—", size=11)), + ft.DataCell(ft.Row([ + ft.IconButton(ft.Icons.PLAY_ARROW if not running else ft.Icons.STOP, + icon_size=18, tooltip="start" if not running else "stop", + on_click=lambda e, c=cid, a="start" if not running else "stop": self._ct_action(c, a)), + ft.IconButton(ft.Icons.RESTART_ALT, icon_size=18, tooltip="restart", + on_click=lambda e, c=cid: self._ct_action(c, "restart")), + ft.IconButton(ft.Icons.ARTICLE, icon_size=18, tooltip="логи", + on_click=lambda e, c=cid, n=name: self._show_logs(c, n)), + ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=18, tooltip="удалить", + on_click=lambda e, c=cid, n=name: self._remove_ct(c, n)), + ], spacing=0)), + ])) + self.stats_row.controls = [ + ft.Chip(label=ft.Text(f"{n}: CPU {s.get('CPUPerc','?')} MEM {s.get('MemPerc','?')}")) + for n, s in stats.items() + ] or [ft.Text("нет запущенных", size=12, color=ft.Colors.GREY_500)] + self._page.update() + + def _ct_action(self, cid, action): + async def _do(): + async with SSHClient(self.srv) as c: + await d.container_action(c, cid, action) + await self._load_containers() + self._page.run_task(_do) + + def _show_logs(self, cid, name): + self.log_container.visible = True + append_log(self.log, f"=== логи {name} ===") + self._page.update() + async def _do(): + async with SSHClient(self.srv) as c: + out = await d.container_logs(c, cid) + for line in out.splitlines()[-100:]: + append_log(self.log, line) + self._page.update() + self._page.run_task(_do) + + def _remove_ct(self, cid, name): + def do(): + async def _d(): + async with SSHClient(self.srv) as c: + await d.container_action(c, cid, "rm") + await self._load_containers() + self._page.run_task(_d) + confirm_dialog(self._page, f"Удалить контейнер {name}?", "Действие необратимо.", do) + + # ---------- образы ---------- + + def _images_view(self): + self.img_table = ft.DataTable(columns=[ + ft.DataColumn(ft.Text("Репозиторий")), ft.DataColumn(ft.Text("Тег")), + ft.DataColumn(ft.Text("Размер")), ft.DataColumn(ft.Text("Создан")), + ft.DataColumn(ft.Text("")), + ], rows=[]) + pull_tf = ft.TextField(hint_text="образ для pull (напр. nginx:alpine)", width=300, dense=True) + return ft.Column([ + ft.Row([ + pull_tf, + ft.FilledButton("Pull", icon=ft.Icons.DOWNLOAD, + on_click=lambda e: self._pull(pull_tf.value)), + ft.OutlinedButton("Обновить", icon=ft.Icons.REFRESH, + on_click=lambda e: self._page.run_task(self._load_images)), + ]), + ft.Container(expand=True, content=ft.Column([self.img_table], scroll=ft.ScrollMode.AUTO)), + ], expand=True) + + async def _load_images(self): + async with SSHClient(self.srv) as c: + images = await d.list_images(c) + self.img_table.rows = [ + ft.DataRow(cells=[ + ft.DataCell(ft.Text(i["Repository"])), + ft.DataCell(ft.Text(i["Tag"])), + ft.DataCell(ft.Text(i["Size"])), + ft.DataCell(ft.Text(i.get("CreatedSince", ""), size=11)), + ft.DataCell(ft.IconButton( + ft.Icons.DELETE_OUTLINE, icon_size=18, + on_click=lambda e, ref=f"{i['Repository']}:{i['Tag']}": self._rmi(ref))), + ]) for i in images + ] + self._page.update() + + def _pull(self, image): + if not image: + return + self.log_container.visible = True + async def job(log): + async with SSHClient(self.srv) as c: + stream = await d.pull(c, image) + async for line in stream: + log(line) + tasks.start("docker", f"docker pull {image}", self.srv.id, job, + on_log=lambda l: (append_log(self.log, l), self._page.update()), + on_done=lambda r: self._page.run_task(self._load_images)) + + def _rmi(self, ref): + def do(): + async def _d(): + async with SSHClient(self.srv) as c: + await d.remove_image(c, ref) + await self._load_images() + self._page.run_task(_d) + confirm_dialog(self._page, f"Удалить образ {ref}?", "", do) + + # ---------- тома ---------- + + def _volumes_view(self): + self.vol_table = ft.DataTable(columns=[ + ft.DataColumn(ft.Text("Имя")), ft.DataColumn(ft.Text("Драйвер")), + ft.DataColumn(ft.Text("")), + ], rows=[]) + return ft.Column([ + ft.OutlinedButton("Обновить", icon=ft.Icons.REFRESH, + on_click=lambda e: self._page.run_task(self._load_volumes)), + ft.Container(expand=True, content=ft.Column([self.vol_table], scroll=ft.ScrollMode.AUTO)), + ], expand=True) + + async def _load_volumes(self): + async with SSHClient(self.srv) as c: + vols = await d.list_volumes(c) + self.vol_table.rows = [ + ft.DataRow(cells=[ + ft.DataCell(ft.Text(v["Name"])), + ft.DataCell(ft.Text(v.get("Driver", ""))), + ft.DataCell(ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=18, + on_click=lambda e, n=v["Name"]: self._rm_vol(n))), + ]) for v in vols + ] + self._page.update() + + def _rm_vol(self, name): + def do(): + async def _d(): + async with SSHClient(self.srv) as c: + await d.remove_volume(c, name) + await self._load_volumes() + self._page.run_task(_d) + confirm_dialog(self._page, f"Удалить том {name}?", "Данные будут потеряны.", do) + + # ---------- сети ---------- + + def _networks_view(self): + self.net_table = ft.DataTable(columns=[ + ft.DataColumn(ft.Text("Имя")), ft.DataColumn(ft.Text("Драйвер")), + ft.DataColumn(ft.Text("Scope")), + ], rows=[]) + return ft.Column([ + ft.OutlinedButton("Обновить", icon=ft.Icons.REFRESH, + on_click=lambda e: self._page.run_task(self._load_networks)), + ft.Container(expand=True, content=ft.Column([self.net_table], scroll=ft.ScrollMode.AUTO)), + ], expand=True) + + async def _load_networks(self): + async with SSHClient(self.srv) as c: + nets = await d.list_networks(c) + self.net_table.rows = [ + ft.DataRow(cells=[ + ft.DataCell(ft.Text(n["Name"])), + ft.DataCell(ft.Text(n.get("Driver", ""))), + ft.DataCell(ft.Text(n.get("Scope", ""))), + ]) for n in nets + ] + self._page.update() + + # ---------- система ---------- + + def _system_view(self): + self.sys_text = ft.Text("…", selectable=True, size=12, font_family="Consolas") + return ft.Column([ + ft.Row([ + ft.OutlinedButton("Обновить df", icon=ft.Icons.REFRESH, + on_click=lambda e: self._page.run_task(self._load_system)), + ft.FilledButton("System prune", icon=ft.Icons.CLEANING_SERVICES, + style=ft.ButtonStyle(bgcolor=ft.Colors.ORANGE_800), + on_click=self._prune), + ]), + ft.Container(padding=8, content=self.sys_text), + ]) + + async def _load_system(self): + async with SSHClient(self.srv) as c: + ver = await d.server_version(c) + df = await d.system_df(c) + self.sys_text.value = f"Docker {ver}\n\n{df}" + self._page.update() + + def _prune(self, e): + def do(): + self.log_container.visible = True + async def job(log): + async with SSHClient(self.srv) as c: + log(await d.system_prune(c)) + tasks.start("docker", "docker system prune", self.srv.id, job, + on_log=lambda l: (append_log(self.log, l), self._page.update()), + on_done=lambda r: self._page.run_task(self._load_system)) + confirm_dialog(self._page, "Docker system prune?", + "Будут удалены остановленные контейнеры, неиспользуемые сети, " + "висячие образы и кэш сборки.", do) diff --git a/app/ui/tabs/files_tab.py b/app/ui/tabs/files_tab.py new file mode 100644 index 0000000..3463239 --- /dev/null +++ b/app/ui/tabs/files_tab.py @@ -0,0 +1,229 @@ +"""Вкладка Файлы: SFTP-браузер с upload/download/редактором.""" +import os +from pathlib import PurePosixPath + +import flet as ft + +from app.core import files as fcore +from app.core.ssh import SSHClient +from app.db.models import Server +from app.ui.components import confirm_dialog, snack + + +class FilesTab(ft.Column): + def __init__(self, page: ft.Page, srv: Server): + super().__init__(expand=True, spacing=8) + self._page = page + self.srv = srv + self._path = f"/home/{srv.username}" if srv.username != "root" else "/root" + self._client = None + + self.path_text = ft.Text(self._path, size=13, weight=ft.FontWeight.BOLD) + self.list_col = ft.Column(scroll=ft.ScrollMode.AUTO, expand=True, spacing=0) + self.progress = ft.ProgressBar(visible=False, height=4) + self.status = ft.Text("", size=12, color=ft.Colors.GREY_500) + + self.controls = [ + ft.Container(height=8), + ft.Row([ + ft.IconButton(ft.Icons.ARROW_UPWARD, tooltip="Вверх", + on_click=self._up), + ft.IconButton(ft.Icons.HOME, tooltip="Домой", on_click=self._home), + ft.IconButton(ft.Icons.REFRESH, on_click=lambda e: self._reload()), + self.path_text, + ft.Container(expand=True), + ft.IconButton(ft.Icons.CREATE_NEW_FOLDER, tooltip="Новая папка", + on_click=self._mkdir_dialog), + ft.IconButton(ft.Icons.UPLOAD_FILE, tooltip="Загрузить файл", + on_click=self._upload_dialog), + ]), + self.progress, + ft.Container(expand=True, border=ft.Border.all(1, ft.Colors.GREY_800), + border_radius=8, padding=4, content=self.list_col), + self.status, + ] + # file picker для upload/download (async API flet ≥0.80) + self.picker = ft.FilePicker() + page.services.append(self.picker) + self._page.run_task(self._connect) + + async def _connect(self): + try: + self._client = SSHClient(self.srv) + await self._client.connect() + await self._load() + except Exception as e: + snack(self._page, f"SFTP: {e}", error=True) + + async def _load(self): + if not self._client: + return + try: + entries = await fcore.list_dir(self._client, self._path) + except PermissionError as e: + self.status.value = f"нет доступа: {e}" + self._page.update() + return + self.path_text.value = self._path + self.list_col.controls = [] + for en in entries: + icon = ft.Icons.FOLDER if en.is_dir else ft.Icons.INSERT_DRIVE_FILE + size = "" if en.is_dir else self._fmt_size(en.size) + self.list_col.controls.append( + ft.ListTile( + leading=ft.Icon(icon, size=20, + color=ft.Colors.AMBER_300 if en.is_dir else None), + title=ft.Text(en.name, size=13), + subtitle=ft.Text(en.permissions, size=10, + color=ft.Colors.GREY_600), + trailing=ft.Row([ + ft.Text(size, size=11, color=ft.Colors.GREY_500), + ft.IconButton(ft.Icons.DOWNLOAD, icon_size=16, tooltip="Скачать", + visible=not en.is_dir, + on_click=lambda e, p=en.path: self._download(p)), + ft.IconButton(ft.Icons.EDIT, icon_size=16, tooltip="Редактировать", + visible=not en.is_dir, + on_click=lambda e, p=en.path: self._edit(p)), + ft.IconButton(ft.Icons.DRIVE_FILE_RENAME_OUTLINE, icon_size=16, + tooltip="Переименовать", + on_click=lambda e, en=en: self._rename_dialog(en)), + ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=16, tooltip="Удалить", + on_click=lambda e, en=en: self._delete(en)), + ], spacing=0, tight=True), + dense=True, + on_click=lambda e, en=en: self._enter(en), + ) + ) + self.status.value = f"{len(entries)} объектов" + self._page.update() + + def _fmt_size(self, s): + for unit in ("Б", "КБ", "МБ", "ГБ"): + if s < 1024 or unit == "ГБ": + return f"{s:.0f} {unit}" if unit == "Б" else f"{s:.1f} {unit}" + s /= 1024 + + def _reload(self): + self._page.run_task(self._load) + + def _enter(self, en): + if en.is_dir: + self._path = en.path + self._reload() + else: + self._edit(en.path) + + def _up(self, e): + parent = str(PurePosixPath(self._path).parent) + self._path = parent or "/" + self._reload() + + def _home(self, e): + self._path = f"/home/{self.srv.username}" if self.srv.username != "root" else "/root" + self._reload() + + # ---------- действия ---------- + + def _delete(self, en): + def do(): + async def _d(): + await fcore.delete(self._client, en.path, en.is_dir) + await self._load() + self._page.run_task(_d) + confirm_dialog(self._page, f"Удалить {en.name}?", + f"{'Папка и всё содержимое' if en.is_dir else 'Файл'} будет удалён с сервера.", do) + + def _rename_dialog(self, en): + tf = ft.TextField(label="Новое имя", value=en.name, autofocus=True) + def do(e): + async def _r(): + new = str(PurePosixPath(self._path) / tf.value) + await fcore.rename(self._client, en.path, new) + self._page.pop_dialog() + await self._load() + self._page.run_task(_r) + dlg = ft.AlertDialog(modal=True, title=ft.Text("Переименовать"), content=tf, + actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)), + ft.FilledButton("Ок", on_click=do)]) + self._page.show_dialog(dlg) + + def _mkdir_dialog(self, e): + tf = ft.TextField(label="Имя папки", autofocus=True) + def do(e): + async def _m(): + await fcore.mkdir(self._client, str(PurePosixPath(self._path) / tf.value)) + self._page.pop_dialog() + await self._load() + self._page.run_task(_m) + dlg = ft.AlertDialog(modal=True, title=ft.Text("Новая папка"), content=tf, + actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)), + ft.FilledButton("Создать", on_click=do)]) + self._page.show_dialog(dlg) + + def _close(self, dlg): + self._page.pop_dialog() + + def _download(self, remote_path): + async def _do(): + local = await self.picker.save_file( + dialog_title="Сохранить как", + file_name=PurePosixPath(remote_path).name, + ) + if not local: + return + self.progress.visible = True + self._page.update() + def prog(done, total): + self.progress.value = done / total if total else None + self.status.value = f"скачивание… {done//1024}/{total//1024} КБ" + self._page.update() + await fcore.download(self._client, remote_path, local, prog) + self.progress.visible = False + self.status.value = f"скачано: {local}" + self._page.update() + self._page.run_task(_do) + + def _upload_dialog(self, e): + async def _do(): + files = await self.picker.pick_files(dialog_title="Выберите файл для загрузки") + if not files: + return + f = files[0] + remote = str(PurePosixPath(self._path) / f.name) + self.progress.visible = True + self._page.update() + def prog(done, total): + self.progress.value = done / total if total else None + self.status.value = f"загрузка… {done//1024}/{total//1024} КБ" + self._page.update() + await fcore.upload(self._client, f.path, remote, prog) + self.progress.visible = False + await self._load() + self._page.run_task(_do) + + def _edit(self, path): + tf = ft.TextField(multiline=True, min_lines=20, max_lines=20, + text_size=12, text_style=ft.TextStyle(font_family="Consolas"), + expand=True) + sudo_cb = ft.Checkbox(label="sudo (root-файл)", value=False) + async def _load_text(): + try: + tf.value = await fcore.read_text(self._client, path) + dlg.content.update() + except Exception as ex: + tf.value = f"// ошибка чтения: {ex}" + dlg.content.update() + def save(e): + async def _s(): + await fcore.write_text(self._client, path, tf.value, sudo_cb.value) + self._page.pop_dialog() + snack(self._page, "Сохранено") + self._page.run_task(_s) + dlg = ft.AlertDialog( + modal=True, title=ft.Text(path, size=13), + content=ft.Container(width=700, content=ft.Column([sudo_cb, tf], tight=True)), + actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)), + ft.FilledButton("Сохранить", on_click=save)], + ) + self._page.show_dialog(dlg) + self._page.run_task(_load_text) diff --git a/app/ui/tabs/firewall_tab.py b/app/ui/tabs/firewall_tab.py new file mode 100644 index 0000000..c7e8555 --- /dev/null +++ b/app/ui/tabs/firewall_tab.py @@ -0,0 +1,155 @@ +"""Вкладка Firewall: ufw.""" +import flet as ft + +from app.core import firewall as fw +from app.core.ssh import SSHClient +from app.db.models import Server +from app.ui.components import confirm_dialog, snack + + +class FirewallTab(ft.Column): + def __init__(self, page: ft.Page, srv: Server): + super().__init__(expand=True, spacing=8, scroll=ft.ScrollMode.AUTO) + self._page = page + self.srv = srv + + self.status_text = ft.Text("…", size=13) + self.table = ft.DataTable(columns=[ + ft.DataColumn(ft.Text("#")), + ft.DataColumn(ft.Text("Куда")), + ft.DataColumn(ft.Text("Действие")), + ft.DataColumn(ft.Text("Откуда")), + ft.DataColumn(ft.Text("")), + ], rows=[]) + self.listen_text = ft.Column(spacing=1) + + self.controls = [ + ft.Container(height=8), + ft.Row([ + ft.FilledButton("Включить ufw", icon=ft.Icons.SHIELD, + on_click=self._enable), + ft.OutlinedButton("Выключить", on_click=self._disable), + ft.FilledButton("Добавить правило", icon=ft.Icons.ADD, + on_click=self._add_dialog), + ft.OutlinedButton("Пресет", icon=ft.Icons.LIST_ALT, + on_click=self._preset_dialog), + ft.IconButton(ft.Icons.REFRESH, on_click=lambda e: self._page.run_task(self._load)), + ]), + self.status_text, + ft.Card(content=ft.Container(padding=10, content=self.table)), + ft.Text("Прослушиваемые порты (ss -tlnp):", weight=ft.FontWeight.BOLD, size=13), + self.listen_text, + ] + self._page.run_task(self._load) + + async def _load(self): + try: + async with SSHClient(self.srv) as c: + if not await fw.is_installed(c): + self.status_text.value = "ufw не установлен (apt install ufw)" + self._page.update() + return + active, raw = await fw.status(c) + rules = await fw.list_rules(c) + listening = await fw.listening_ports(c) + self.status_text.value = "🟢 ufw активен" if active else "⚪ ufw выключен" + self.table.rows = [ + ft.DataRow(cells=[ + ft.DataCell(ft.Text(str(r.number))), + ft.DataCell(ft.Text(r.to)), + ft.DataCell(ft.Text(r.action, + color=ft.Colors.GREEN_300 if "ALLOW" in r.action.upper() + else ft.Colors.RED_300)), + ft.DataCell(ft.Text(r.from_)), + ft.DataCell(ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=16, + on_click=lambda e, n=r.number: self._del_rule(n))), + ]) for r in rules + ] + self.listen_text.controls = [ + ft.Text(l, size=11, font_family="Consolas", color=ft.Colors.GREY_500) + for l in listening + ] + except Exception as e: + self.status_text.value = f"ошибка: {e}" + self._page.update() + + def _enable(self, e): + def do(): + async def _d(): + async with SSHClient(self.srv) as c: + out = await fw.enable(c, self.srv.port) + snack(self._page, out.strip().splitlines()[-1] if out.strip() else "ufw включён") + await self._load() + self._page.run_task(_d) + confirm_dialog(self._page, "Включить ufw?", + f"Порт {self.srv.port} (текущий SSH) будет автоматически разрешён.", do, + danger=False) + + def _disable(self, e): + def do(): + async def _d(): + async with SSHClient(self.srv) as c: + await fw.disable(c) + await self._load() + self._page.run_task(_d) + confirm_dialog(self._page, "Выключить ufw?", "Файрвол будет полностью отключён.", do) + + def _del_rule(self, number): + def do(): + async def _d(): + async with SSHClient(self.srv) as c: + await fw.delete_rule(c, number) + await self._load() + self._page.run_task(_d) + confirm_dialog(self._page, f"Удалить правило #{number}?", "", do) + + def _add_dialog(self, e): + port_tf = ft.TextField(label="Порт (22, 80, 8000:8010)", width=250) + action_dd = ft.Dropdown(label="Действие", value="allow", width=150, options=[ + ft.dropdown.Option("allow"), ft.dropdown.Option("deny"), + ft.dropdown.Option("limit"), ft.dropdown.Option("reject"), + ]) + proto_dd = ft.Dropdown(label="Протокол", value="tcp", width=120, options=[ + ft.dropdown.Option("tcp"), ft.dropdown.Option("udp"), ft.dropdown.Option("any"), + ]) + from_tf = ft.TextField(label="От адреса (пусто = любой)", width=250) + + def do(e): + async def _d(): + async with SSHClient(self.srv) as c: + out = await fw.add_rule(c, port_tf.value, action_dd.value, + proto_dd.value, from_tf.value.strip()) + self._page.pop_dialog() + snack(self._page, out.strip() or "правило добавлено") + await self._load() + self._page.run_task(_d) + + dlg = ft.AlertDialog( + modal=True, title=ft.Text("Новое правило"), + content=ft.Column([port_tf, action_dd, proto_dd, from_tf], tight=True), + actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)), + ft.FilledButton("Добавить", on_click=do)], + ) + self._page.show_dialog(dlg) + + def _preset_dialog(self, e): + def apply(name): + self._page.pop_dialog() + async def _d(): + async with SSHClient(self.srv) as c: + out = await fw.apply_preset(c, name, self.srv.port) + snack(self._page, "пресет применён") + await self._load() + self._page.run_task(_d) + dlg = ft.AlertDialog( + modal=True, title=ft.Text("Применить пресет"), + content=ft.Column([ + ft.FilledButton(n, on_click=lambda e, n=n: apply(n), width=250) + for n in fw.PRESETS + ], tight=True), + actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg))], + ) + self._page.show_dialog(dlg) + + def _close(self, dlg): + self._page.pop_dialog() diff --git a/app/ui/tabs/overview.py b/app/ui/tabs/overview.py new file mode 100644 index 0000000..e50d0c0 --- /dev/null +++ b/app/ui/tabs/overview.py @@ -0,0 +1,158 @@ +"""Вкладка Обзор: метрики, update/upgrade/reboot, история.""" +import flet as ft + +from app.core import monitor +from app.core.packages import apt_update, list_upgradable +from app.core.ssh import SSHClient, check_server +from app.core.tasks import tasks +from app.db.models import Server +from app.ui.components import append_log, confirm_dialog, fmt_mb, log_view, snack + + +class OverviewTab(ft.Column): + def __init__(self, page: ft.Page, srv: Server): + super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=12) + self._page = page + self.srv = srv + + self.info_text = ft.Text("загрузка…", size=13) + self.upg_text = ft.Text("", size=13, color=ft.Colors.ORANGE_300) + self.metrics_col = ft.Column(spacing=4) + self.log = log_view() + self.log_container = ft.Container( + height=220, border=ft.Border.all(1, ft.Colors.GREY_800), + border_radius=8, padding=8, content=self.log, visible=False, + ) + + self.controls = [ + ft.Container(height=8), + ft.Row([ + ft.FilledButton("apt update", icon=ft.Icons.REFRESH, + on_click=self._update), + ft.FilledButton("Upgrade", icon=ft.Icons.UPGRADE, + on_click=lambda e: self._upgrade(False)), + ft.OutlinedButton("Dist-upgrade", + on_click=lambda e: self._upgrade(True)), + ft.FilledButton("Reboot", icon=ft.Icons.RESTART_ALT, + style=ft.ButtonStyle(bgcolor=ft.Colors.RED_800), + on_click=self._reboot), + self.upg_text, + ], spacing=10), + ft.Card(content=ft.Container(padding=14, content=ft.Column([ + ft.Text("Система", weight=ft.FontWeight.BOLD), + self.info_text, + ft.Container(height=6), + self.metrics_col, + ]))), + self.log_container, + ft.Text("История метрик", weight=ft.FontWeight.BOLD), + self._chart(), + ] + self._page.run_task(self._load) + + # ---------- данные ---------- + + async def _load(self): + info = await check_server(self.srv) + if info.get("online"): + self.info_text.value = ( + f"🟢 {info['hostname']} • {info['os']}\n" + f"{info['uptime']} • CPU: {info['cpu_cores']} ядер • RAM: {info['mem_mb']} МБ" + ) + try: + async with SSHClient(self.srv) as c: + upg = await list_upgradable(c) + self.upg_text.value = ( + f"доступно обновлений: {len(upg)}" if upg else "система обновлена" + ) + snap = await monitor.collect(c, self.srv.id) + self._show_metrics(snap) + except Exception as e: + self.info_text.value += f"\n⚠ {e}" + else: + self.info_text.value = f"🔴 {info.get('error')}" + self._page.update() + + def _show_metrics(self, snap): + def bar(label, pct, color): + return ft.Row([ + ft.Text(label, size=12, width=45), + ft.ProgressBar(value=pct / 100, height=8, expand=True, color=color, + bgcolor=ft.Colors.GREY_800), + ft.Text(f"{pct:.0f}%", size=12, width=42, + text_align=ft.TextAlign.RIGHT), + ]) + self.metrics_col.controls = [ + bar("CPU", snap.cpu_percent, ft.Colors.BLUE_400), + bar("RAM", snap.mem_percent, ft.Colors.PURPLE_300), + bar("Диск", snap.disk_percent, + ft.Colors.RED_400 if snap.disk_percent > 85 else ft.Colors.GREEN_400), + ft.Text(f"RAM {fmt_mb(snap.mem_used_mb)} / {fmt_mb(snap.mem_total_mb)}" + f" • load {snap.load1:.2f}" + f" • net ↓{snap.net_rx_kbs:.0f} ↑{snap.net_tx_kbs:.0f} КБ/с", + size=11, color=ft.Colors.GREY_500), + ] + + def _chart(self): + hist = monitor.history(self.srv.id, 30) + if len(hist) < 2: + return ft.Text("данных пока мало — график появится после нескольких замеров", + size=12, color=ft.Colors.GREY_500) + # простое текстовое представление истории (LineChart недоступен в flet 0.86) + cpu_vals = [h.cpu_percent for h in hist] + ram_vals = [h.mem_percent for h in hist] + rows = [] + for i, h in enumerate(hist[-15:]): + bar_cpu = "█" * int(h.cpu_percent / 5) + "░" * (20 - int(h.cpu_percent / 5)) + bar_ram = "█" * int(h.mem_percent / 5) + "░" * (20 - int(h.mem_percent / 5)) + rows.append(ft.Text( + f"{h.ts:%H:%M} CPU {bar_cpu} {h.cpu_percent:5.1f}% RAM {bar_ram} {h.mem_percent:5.1f}%", + size=10, font_family="Consolas", color=ft.Colors.GREY_400, + )) + return ft.Column(rows, spacing=1, scroll=ft.ScrollMode.AUTO, height=180) + + # ---------- действия ---------- + + def _show_log(self, show=True): + self.log_container.visible = show + self._page.update() + + def _update(self, e): + self._show_log() + async def job(log): + async with SSHClient(self.srv) as c: + log("$ apt-get update") + out = await apt_update(c) + for line in out.splitlines()[-30:]: + log(line) + tasks.start("update", "apt update", self.srv.id, job, + on_log=lambda l: (append_log(self.log, l), self._page.update())) + snack(self._page, "apt update запущен") + + def _upgrade(self, dist: bool): + label = "dist-upgrade" if dist else "upgrade" + def do(): + self._show_log() + async def job(log): + async with SSHClient(self.srv) as c: + log(f"$ apt-get {label} -y") + stream = await __import__("app.core.packages", fromlist=["apt_upgrade"]).apt_upgrade(c, dist) + async for line in stream: + log(line) + tasks.start("upgrade", label, self.srv.id, job, + on_log=lambda l: (append_log(self.log, l), self._page.update())) + confirm_dialog(self._page, f"Запустить {label}?", + f"На сервере «{self.srv.name}» будут обновлены пакеты.", do) + + def _reboot(self, e): + def do(): + self._show_log() + from app.core.group_ops import op_reboot + async def job(log): + async with SSHClient(self.srv) as c: + await op_reboot(wait=True)(c, self.srv, log) + tasks.start("reboot", "reboot", self.srv.id, job, + on_log=lambda l: (append_log(self.log, l), self._page.update())) + confirm_dialog(self._page, "Перезагрузить сервер?", + f"Сервер «{self.srv.name}» будет перезагружен. Приложение дождётся его возврата.", + do) diff --git a/app/ui/tabs/packages.py b/app/ui/tabs/packages.py new file mode 100644 index 0000000..d9ba779 --- /dev/null +++ b/app/ui/tabs/packages.py @@ -0,0 +1,216 @@ +"""Вкладка Пакеты: список с разбивкой, установка/удаление.""" +import flet as ft + +from app.core.packages import (Package, apt_search, install, list_packages, + remove, split_packages) +from app.core.ssh import SSHClient +from app.core.tasks import tasks +from app.db.models import Server +from app.ui.components import append_log, confirm_dialog, log_view, snack + + +class PackagesTab(ft.Column): + def __init__(self, page: ft.Page, srv: Server): + super().__init__(expand=True, spacing=8) + self._page = page + self.srv = srv + self._all: list[Package] = [] + self._filter = "user" # all/system/user/deps + + self.search = ft.TextField( + hint_text="Поиск по имени…", prefix_icon=ft.Icons.SEARCH, + on_change=lambda e: self._render_table(), expand=True, dense=True, + ) + self.chips = ft.Row([ + ft.Chip(label=ft.Text("Пользовательские"), on_select=lambda e: self._set_filter("user"), selected=True), + ft.Chip(label=ft.Text("Системные"), on_select=lambda e: self._set_filter("system")), + ft.Chip(label=ft.Text("Зависимости"), on_select=lambda e: self._set_filter("deps")), + ft.Chip(label=ft.Text("Все"), on_select=lambda e: self._set_filter("all")), + ]) + self.count_text = ft.Text("", size=12, color=ft.Colors.GREY_500) + self.table = ft.DataTable( + columns=[ + ft.DataColumn(ft.Text("Пакет")), + ft.DataColumn(ft.Text("Версия")), + ft.DataColumn(ft.Text("Тип")), + ft.DataColumn(ft.Text("")), + ], + rows=[], expand=True, + ) + self.table_col = ft.Column([self.table], scroll=ft.ScrollMode.AUTO, expand=True) + self.log = log_view() + self.log_container = ft.Container( + height=200, border=ft.Border.all(1, ft.Colors.GREY_800), + border_radius=8, padding=8, content=self.log, visible=False) + + self.controls = [ + ft.Container(height=8), + ft.Row([ + ft.FilledButton("Установить пакет", icon=ft.Icons.ADD, + on_click=self._install_dialog), + ft.OutlinedButton("Обновить список", icon=ft.Icons.REFRESH, + on_click=lambda e: self._reload()), + ft.OutlinedButton("Autoremove", icon=ft.Icons.CLEANING_SERVICES, + on_click=self._autoremove), + self.count_text, + ]), + ft.Row([self.search], spacing=10), + self.chips, + ft.Container(expand=True, content=self.table_col), + self.log_container, + ] + self._page.run_task(self._load) + + def _set_filter(self, f): + self._filter = f + for c in self.chips.controls: + c.selected = False + idx = {"user": 0, "system": 1, "deps": 2, "all": 3}[f] + self.chips.controls[idx].selected = True + self._render_table() + + async def _load(self): + try: + async with SSHClient(self.srv) as c: + self._all = await list_packages(c) + except Exception as e: + snack(self._page, f"Ошибка загрузки: {e}", error=True) + self._render_table() + + def _reload(self): + self.count_text.value = "загрузка…" + self._page.update() + self._page.run_task(self._load) + + def _render_table(self): + groups = split_packages(self._all) + items = self._all if self._filter == "all" else groups.get(self._filter, []) + q = (self.search.value or "").lower() + if q: + items = [p for p in items if q in p.name.lower()] + self.count_text.value = f"показано {len(items)} из {len(self._all)}" + + kind_label = lambda p: ("системный" if p.is_system else + "пользовательский" if p.manual else "зависимость") + kind_color = lambda p: (ft.Colors.BLUE_300 if p.is_system else + ft.Colors.GREEN_300 if p.manual else ft.Colors.GREY_500) + + self.table.rows = [ + ft.DataRow(cells=[ + ft.DataCell(ft.Text(p.name, selectable=True)), + ft.DataCell(ft.Text(p.version, size=12)), + ft.DataCell(ft.Text(kind_label(p), size=12, color=kind_color(p))), + ft.DataCell(ft.IconButton( + ft.Icons.DELETE_OUTLINE, icon_size=18, tooltip="Удалить", + on_click=lambda e, p=p: self._remove(p), + )), + ]) for p in items[:300] + ] + if len(items) > 300: + self.count_text.value += " (первые 300)" + self._page.update() + + # ---------- действия ---------- + + def _show_log(self): + self.log_container.visible = True + self._page.update() + + def _install_dialog(self, e): + name_tf = ft.TextField(label="Имя пакета (или несколько через пробел)", + autofocus=True, width=400) + results = ft.Column(tight=True, spacing=2) + + def search(e): + async def _s(): + results.controls = [ft.Text("поиск…", size=12)] + dlg.content.update() + try: + async with SSHClient(self.srv) as c: + lines = await apt_search(c, name_tf.value) + results.controls = [ + ft.TextButton( + l[:90], + on_click=lambda e, n=l.split(" ")[0]: setattr( + name_tf, "value", n) or dlg.content.update(), + style=ft.ButtonStyle(padding=2), + ) for l in lines[:15] + ] or [ft.Text("ничего не найдено", size=12)] + except Exception as ex: + results.controls = [ft.Text(f"ошибка: {ex}", size=12)] + dlg.content.update() + self._page.run_task(_s) + + def do_install(e): + names = name_tf.value.split() + if not names: + return + self._page.pop_dialog() + self._show_log() + async def job(log): + async with SSHClient(self.srv) as c: + log(f"$ apt-get install -y {' '.join(names)}") + stream = await install(c, names) + async for line in stream: + log(line) + tasks.start("install", f"install {' '.join(names)}", self.srv.id, job, + on_log=lambda l: (append_log(self.log, l), self._page.update()), + on_done=lambda r: self._reload()) + + dlg = ft.AlertDialog( + modal=True, title=ft.Text("Установить пакет"), + content=ft.Column([name_tf, results], tight=True, height=350, width=450), + actions=[ + ft.TextButton("Найти", on_click=search), + ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)), + ft.FilledButton("Установить", on_click=do_install), + ], + ) + self._page.show_dialog(dlg) + + def _close(self, dlg): + self._page.pop_dialog() + + def _remove(self, p: Package): + if p.is_system: + snack(self._page, f"«{p.name}» — системный пакет, удаление заблокировано", + error=True) + return + purge_cb = ft.Checkbox(label="Очистить конфигурацию (purge)", value=False) + + def do(): + self._page.pop_dialog() + self._show_log() + async def job(log): + async with SSHClient(self.srv) as c: + stream = await remove(c, [p.name], purge_cb.value) + async for line in stream: + log(line) + tasks.start("remove", f"remove {p.name}", self.srv.id, job, + on_log=lambda l: (append_log(self.log, l), self._page.update()), + on_done=lambda r: self._reload()) + + dlg = ft.AlertDialog( + modal=True, title=ft.Text(f"Удалить {p.name}?"), + content=purge_cb, + actions=[ + ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)), + ft.FilledButton("Удалить", on_click=lambda e: do(), + style=ft.ButtonStyle(bgcolor=ft.Colors.RED_700)), + ], + ) + self._page.show_dialog(dlg) + + def _autoremove(self, e): + def do(): + self._show_log() + from app.core.packages import autoremove + async def job(log): + async with SSHClient(self.srv) as c: + stream = await autoremove(c) + async for line in stream: + log(line) + tasks.start("autoremove", "autoremove", self.srv.id, job, + on_log=lambda l: (append_log(self.log, l), self._page.update())) + confirm_dialog(self._page, "Autoremove?", + "Будут удалены неиспользуемые зависимости.", do) diff --git a/app/ui/tabs/systemd_tab.py b/app/ui/tabs/systemd_tab.py new file mode 100644 index 0000000..02749b4 --- /dev/null +++ b/app/ui/tabs/systemd_tab.py @@ -0,0 +1,116 @@ +"""Вкладка Сервисы: systemd-юниты, журнал.""" +import flet as ft + +from app.core import systemd_svc as sd +from app.core.ssh import SSHClient +from app.db.models import Server +from app.ui.components import confirm_dialog, snack + + +class SystemdTab(ft.Column): + def __init__(self, page: ft.Page, srv: Server): + super().__init__(expand=True, spacing=8) + self._page = page + self.srv = srv + self._units = [] + + self.search = ft.TextField(hint_text="Поиск юнита…", prefix_icon=ft.Icons.SEARCH, + on_change=lambda e: self._render(), expand=True, dense=True) + self.only_failed = ft.Checkbox(label="только failed", value=False, + on_change=lambda e: self._render()) + self.table = ft.DataTable(columns=[ + ft.DataColumn(ft.Text("Юнит")), + ft.DataColumn(ft.Text("Состояние")), + ft.DataColumn(ft.Text("Enabled")), + ft.DataColumn(ft.Text("Описание")), + ft.DataColumn(ft.Text("Действия")), + ], rows=[]) + self.log_text = ft.Column(scroll=ft.ScrollMode.AUTO, spacing=1) + + self.controls = [ + ft.Container(height=8), + ft.Row([ + ft.OutlinedButton("Обновить", icon=ft.Icons.REFRESH, + on_click=lambda e: self._page.run_task(self._load)), + self.search, self.only_failed, + ]), + ft.Row([ + ft.Container(expand=3, content=ft.Column([self.table], + scroll=ft.ScrollMode.AUTO)), + ft.VerticalDivider(width=1), + ft.Container(expand=2, content=self.log_text), + ], expand=True), + ] + self._page.run_task(self._load) + + async def _load(self): + try: + async with SSHClient(self.srv) as c: + self._units = await sd.list_units(c) + except Exception as e: + snack(self._page, f"systemd: {e}", error=True) + self._render() + + def _render(self): + q = (self.search.value or "").lower() + units = self._units + if self.only_failed.value: + units = [u for u in units if u.active == "failed"] + if q: + units = [u for u in units if q in u.name.lower() or q in u.description.lower()] + + def state_color(u): + return {"active": ft.Colors.GREEN_300, "failed": ft.Colors.RED_400, + "inactive": ft.Colors.GREY_500}.get(u.active, ft.Colors.GREY_500) + + self.table.rows = [ + ft.DataRow(cells=[ + ft.DataCell(ft.Text(u.name, size=12, selectable=True)), + ft.DataCell(ft.Text(f"{u.active}/{u.sub}", size=12, color=state_color(u))), + ft.DataCell(ft.Text(u.enabled or "—", size=11)), + ft.DataCell(ft.Text(u.description[:50], size=11)), + ft.DataCell(ft.Row([ + ft.IconButton(ft.Icons.PLAY_ARROW, icon_size=16, tooltip="start", + on_click=lambda e, n=u.name: self._act(n, "start")), + ft.IconButton(ft.Icons.STOP, icon_size=16, tooltip="stop", + on_click=lambda e, n=u.name: self._act(n, "stop")), + ft.IconButton(ft.Icons.RESTART_ALT, icon_size=16, tooltip="restart", + on_click=lambda e, n=u.name: self._act(n, "restart")), + ft.IconButton(ft.Icons.ARTICLE, icon_size=16, tooltip="логи", + on_click=lambda e, n=u.name: self._logs(n)), + ], spacing=0)), + ]) for u in units[:200] + ] + self._page.update() + + def _act(self, name, action): + def do(): + async def _d(): + try: + async with SSHClient(self.srv) as c: + await sd.action(c, name, action) + snack(self._page, f"{name}: {action} ✓") + await self._load() + except Exception as ex: + snack(self._page, f"{ex}", error=True) + self._page.run_task(_d) + if name in sd.CRITICAL_UNITS and action in ("stop", "restart"): + confirm_dialog(self._page, f"⚠ {action} {name}?", + "Это критичный системный сервис! Можно потерять связь с сервером.", do) + else: + do() + + def _logs(self, name): + async def _l(): + self.log_text.controls = [ft.Text(f"journalctl -u {name}", weight=ft.FontWeight.BOLD, size=12)] + self._page.update() + try: + async with SSHClient(self.srv) as c: + out = await sd.logs(c, name) + for line in out.splitlines()[-80:]: + self.log_text.controls.append( + ft.Text(line, size=10, font_family="Consolas", selectable=True)) + except Exception as ex: + self.log_text.controls.append(ft.Text(f"ошибка: {ex}", size=11)) + self._page.update() + self._page.run_task(_l) diff --git a/app/ui/tabs/terminal_tab.py b/app/ui/tabs/terminal_tab.py new file mode 100644 index 0000000..16bff98 --- /dev/null +++ b/app/ui/tabs/terminal_tab.py @@ -0,0 +1,272 @@ +"""Вкладка Терминал: PTY-сессия + библиотека команд обслуживания.""" +import flet as ft + +from app.core.terminal import TerminalSession +from app.db.models import Server + +# Категории → [(название, команда), ...] +# {server} — подстановка не нужна, команды выполняются на самом сервере +COMMANDS = { + "Пользователи": [ + ("Список пользователей", "cat /etc/passwd | awk -F: '$3>=1000{print $1}'"), + ("Кто залогинен", "who -a"), + ("Добавить пользователя", "sudo adduser"), + ("Удалить пользователя", "sudo deluser --remove-home"), + ("Сменить пароль пользователя", "sudo passwd"), + ("Дать sudo", "sudo usermod -aG sudo"), + ("Убрать sudo", "sudo deluser $(whoami) sudo"), + ("Список групп", "getent group | awk -F: '$3>=1000{print $1}'"), + ("Добавить в группу", "sudo usermod -aG"), + ("Сессии пользователя", "last -n 20"), + ], + "Система": [ + ("Аптайм и нагрузка", "uptime"), + ("Инфо о системе", "hostnamectl"), + ("Версия ядра", "uname -a"), + ("Время и часовой пояс", "timedatectl"), + ("Установить часовой пояс", "sudo timedatectl set-timezone Asia/Ulaanbaatar"), + ("Синхронизация времени", "sudo timedatectl set-ntp true"), + ("Обновить locale", "sudo dpkg-reconfigure locales"), + ("Память", "free -h"), + ("Диски", "lsblk"), + ("SMART дисков", "sudo smartctl -a /dev/sda 2>/dev/null || echo 'smartmontools не установлен'"), + ], + "Процессы": [ + ("Топ процессов", "ps aux --sort=-%cpu | head -20"), + ("Дерево процессов", "pstree -p"), + ("Найти процесс", "ps aux | grep"), + ("Убить по имени", "pkill -f"), + ("Открытые файлы процесса", "sudo lsof -p"), + ], + "Сеть": [ + ("IP-адреса", "ip a"), + ("Маршруты", "ip r"), + ("Открытые порты", "sudo ss -tlnp"), + ("Соединения", "sudo ss -tnp"), + ("DNS", "cat /etc/resolv.conf"), + ("Проверить связь", "ping -c 4 8.8.8.8"), + ("Скачать файл", "wget"), + ("Открыть порт в ufw", "sudo ufw allow"), + ], + "Логи": [ + ("Системный журнал", "sudo journalctl -n 100 --no-pager"), + ("Ошибки за час", "sudo journalctl -p err -S '1 hour ago' --no-pager"), + ("Логи сервиса", "sudo journalctl -u"), + ("dmesg", "sudo dmesg | tail -30"), + ("Авторизации", "sudo last -n 20"), + ("Неудачные логины", "sudo lastb -n 20 2>/dev/null || sudo journalctl _COMM=sshd | grep -i fail | tail -20"), + ], + "Пакеты": [ + ("Обновить индекс", "sudo apt update"), + ("Обновить систему", "sudo apt upgrade -y"), + ("Найти пакет", "apt-cache search"), + ("Инфо о пакете", "apt-cache show"), + ("Установленные вручную", "apt-mark showmanual"), + ("Очистка", "sudo apt autoremove -y && sudo apt autoclean"), + ("История apt", "grep -h ' install \\| upgrade ' /var/log/apt/history.log* | tail -20"), + ], + "Безопасность": [ + ("Статус fail2ban", "sudo fail2ban-client status 2>/dev/null || echo 'не установлен'"), + ("Правила ufw", "sudo ufw status numbered"), + ("Открытые сокеты", "sudo lsof -i -P -n | grep LISTEN"), + ("SUID-файлы", "sudo find / -perm -4000 -type f 2>/dev/null | head -20"), + ("Пользователи с shell", "grep -E '/bin/(bash|sh|zsh)' /etc/passwd"), + ], +} + + +class TerminalTab(ft.Column): + def __init__(self, page: ft.Page, srv: Server): + super().__init__(expand=True, spacing=8) + self._page = page + self.srv = srv + self._term: TerminalSession | None = None + self._connected = False + + # вывод терминала + self.output = ft.Column( + scroll=ft.ScrollMode.AUTO, spacing=0, expand=True, + auto_scroll=True, + ) + self.output_container = ft.Container( + expand=True, bgcolor=ft.Colors.BLACK, border_radius=6, padding=8, + content=self.output, + ) + + # строка ввода + self.input = ft.TextField( + hint_text="команда… (Enter — отправить)", + dense=True, expand=True, text_size=13, + text_style=ft.TextStyle(font_family="Consolas"), + on_submit=self._send, + disabled=True, + ) + + # статус подключения + self.status_icon = ft.Icon(ft.Icons.CIRCLE, color=ft.Colors.GREY, size=12) + self.status_text = ft.Text("не подключено", size=12, color=ft.Colors.GREY_500) + + # селектор команд + cat_names = list(COMMANDS.keys()) + self.cat_dd = ft.Dropdown( + label="Категория", value=cat_names[0], width=180, + options=[ft.dropdown.Option(c) for c in cat_names], + on_select=lambda e: self._fill_cmd_dd(), + ) + self.cmd_dd = ft.Dropdown( + label="Команда", width=350, + options=[ft.dropdown.Option(n) for n, _ in COMMANDS[cat_names[0]]], + on_select=lambda e: self._on_cmd_selected(), + ) + self.param_tf = ft.TextField( + hint_text="параметр (если нужен)", width=200, dense=True, visible=False, + ) + + self.controls = [ + ft.Container(height=8), + # панель подключения + ft.Row([ + ft.FilledButton("Подключиться", icon=ft.Icons.TERMINAL, + on_click=self._connect), + ft.OutlinedButton("Отключиться", icon=ft.Icons.CLOSE, + on_click=self._disconnect), + self.status_icon, self.status_text, + ft.Container(expand=True), + ft.TextButton("Очистить вывод", on_click=lambda e: self._clear()), + ]), + # селектор команд + ft.Row([ + self.cat_dd, self.cmd_dd, self.param_tf, + ft.FilledButton("Выполнить", icon=ft.Icons.PLAY_ARROW, + on_click=self._run_selected), + ]), + # терминал + ft.Container(expand=True, content=self.output_container), + # ввод + ft.Row([self.input]), + ] + + # ---------- подключение ---------- + + def _connect(self, e=None): + if self._connected: + return + self._set_status("подключение…", ft.Colors.ORANGE_300) + self._page.run_task(self._do_connect) + + async def _do_connect(self): + try: + self._term = TerminalSession(self.srv, self._on_output) + await self._term.open() + self._connected = True + self.input.disabled = False + self._set_status(f"подключено: {self.srv.username}@{self.srv.host}", + ft.Colors.GREEN_400) + # приглашение + self._term.write(f"echo '--- Debian Manager Terminal | {self.srv.name} ---'\n") + except Exception as ex: + self._set_status(f"ошибка: {ex}", ft.Colors.RED_400) + + def _disconnect(self, e=None): + if self._term: + self._term.close() + self._term = None + self._connected = False + self.input.disabled = True + self._set_status("не подключено", ft.Colors.GREY_500) + + def _set_status(self, text, color): + self.status_text.value = text + self.status_icon.color = color + self._page.update() + + def _on_output(self, data: str): + # разбиваем ANSI-строки на Text-строки (упрощённо — без цветов) + for line in data.splitlines(): + # убираем ANSI escape-коды + clean = self._strip_ansi(line) + if clean is not None: + self.output.controls.append( + ft.Text(clean, size=12, font_family="Consolas", + color=ft.Colors.GREEN_100, selectable=True) + ) + # ограничиваем буфер + if len(self.output.controls) > 500: + self.output.controls = self.output.controls[-400:] + self._page.update() + + @staticmethod + def _strip_ansi(s: str) -> str: + import re + return re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", s) + + def _clear(self): + self.output.controls = [] + self._page.update() + + # ---------- ввод ---------- + + def _send(self, e=None): + if self._term and self._connected and self.input.value: + self._term.write(self.input.value + "\n") + self.input.value = "" + self._page.update() + + # ---------- библиотека команд ---------- + + def _fill_cmd_dd(self): + cat = self.cat_dd.value + self.cmd_dd.options = [ + ft.dropdown.Option(n) for n, _ in COMMANDS[cat] + ] + self.cmd_dd.value = None + self.param_tf.visible = False + self._page.update() + + def _on_cmd_selected(self): + """Показать поле параметра, если команда его требует.""" + cat = self.cat_dd.value + name = self.cmd_dd.value + if not name: + return + cmd = next((c for n, c in COMMANDS[cat] if n == name), "") + # команды, заканчивающиеся на пробел/утилиту без аргумента — нужен параметр + needs_param = cmd.endswith((" ", "adduser", "passwd", "deluser", "usermod -aG", + "journalctl -u", "grep", "pkill -f", "lsof -p", + "allow", "search", "show", "wget")) + self.param_tf.visible = needs_param + self._page.update() + + def _run_selected(self, e=None): + if not self._connected: + self._connect() + # повторим после подключения + self._page.run_task(self._run_after_connect) + return + self._execute_selected() + + async def _run_after_connect(self): + import asyncio + for _ in range(30): + await asyncio.sleep(0.2) + if self._connected: + break + if self._connected: + self._execute_selected() + + def _execute_selected(self): + cat = self.cat_dd.value + name = self.cmd_dd.value + if not name: + return + cmd = next((c for n, c in COMMANDS[cat] if n == name), "") + param = (self.param_tf.value or "").strip() + full = f"{cmd} {param}".rstrip() if param else cmd + # подсветим выполняемую команду в выводе + self.output.controls.append( + ft.Text(f"$ {full}", size=12, font_family="Consolas", + color=ft.Colors.YELLOW_300, weight=ft.FontWeight.BOLD) + ) + self._term.write(full + "\n") + self.param_tf.value = "" + self._page.update() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..80040d0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "debian-manager" +version = "0.1.0" +description = "Управление Debian-серверами: пакеты, Docker, мониторинг, файлы" +requires-python = ">=3.11" +dependencies = [ + "flet>=0.25", + "asyncssh>=2.14", + "sqlmodel>=0.0.22", + "pydantic>=2.9", + "httpx>=0.27", + "pyyaml>=6.0", + "jinja2>=3.1", + "croniter>=2.0", + "websockets>=13.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "pytest-asyncio>=0.24"] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["app*"] diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..83de622 --- /dev/null +++ b/run.bat @@ -0,0 +1,10 @@ +@echo off +cd /d "%~dp0" +if not exist .venv\Scripts\python.exe ( + echo [ERROR] Virtual environment not found. + echo Run setup first: see README.md + pause + exit /b 1 +) +.venv\Scripts\python.exe -m app.main +if errorlevel 1 pause diff --git a/scripts/check_docker.py b/scripts/check_docker.py new file mode 100644 index 0000000..bfd04d4 --- /dev/null +++ b/scripts/check_docker.py @@ -0,0 +1,12 @@ +import asyncio +import asyncssh + +async def main(): + async with asyncssh.connect( + "132.243.17.164", port=2292, username="dimon", + password="123456789987654321", known_hosts=None, + ) as conn: + r = await conn.run("echo '123456789987654321' | sudo -S docker ps 2>&1; echo '==='; echo '123456789987654321' | sudo -S docker version --format '{{.Server.Version}}' 2>/dev/null; echo '==='; echo '123456789987654321' | sudo -S docker images 2>&1") + print(r.stdout) + +asyncio.run(main()) diff --git a/scripts/check_metrics.py b/scripts/check_metrics.py new file mode 100644 index 0000000..d09fa3c --- /dev/null +++ b/scripts/check_metrics.py @@ -0,0 +1,22 @@ +import asyncio +import asyncssh + +async def main(): + async with asyncssh.connect( + "132.243.17.164", port=2292, username="dimon", + password="123456789987654321", known_hosts=None, + ) as conn: + # CPU: два замера /proc/stat с паузой 1 сек + r = await conn.run("grep '^cpu ' /proc/stat; sleep 1; grep '^cpu ' /proc/stat") + print("CPU lines:") + print(r.stdout) + # RAM + r = await conn.run("free -m") + print("FREE:") + print(r.stdout) + # DISK + r = await conn.run("df -m /") + print("DF:") + print(r.stdout) + +asyncio.run(main()) diff --git a/scripts/check_pkgs.py b/scripts/check_pkgs.py new file mode 100644 index 0000000..40acbf1 --- /dev/null +++ b/scripts/check_pkgs.py @@ -0,0 +1,12 @@ +import asyncio +import asyncssh + +async def main(): + async with asyncssh.connect( + "132.243.17.164", port=2292, username="dimon", + password="123456789987654321", known_hosts=None, + ) as conn: + r = await conn.run("echo '123456789987654321' | sudo -S apt-get update -qq 2>&1; apt-cache search docker-compose 2>/dev/null | head -5; echo '==='; apt-cache policy docker.io 2>/dev/null | head -4") + print(r.stdout) + +asyncio.run(main()) diff --git a/scripts/check_ssh.py b/scripts/check_ssh.py new file mode 100644 index 0000000..cc3c3da --- /dev/null +++ b/scripts/check_ssh.py @@ -0,0 +1,14 @@ +import asyncio +import asyncssh + +async def main(): + async with asyncssh.connect( + "132.243.17.164", port=2292, username="dimon", + password="123456789987654321", known_hosts=None, + ) as conn: + r = await conn.run("echo '123456789987654321' | sudo -S id 2>/dev/null") + print("sudo id:", r.stdout.strip(), "rc:", r.returncode) + r = await conn.run("which docker; docker --version 2>/dev/null; echo '---'; nproc; free -m | head -2; df -h / | tail -1") + print(r.stdout) + +asyncio.run(main()) diff --git a/scripts/install_docker.py b/scripts/install_docker.py new file mode 100644 index 0000000..2905ba0 --- /dev/null +++ b/scripts/install_docker.py @@ -0,0 +1,38 @@ +import asyncio +import asyncssh + +SCRIPT = r""" +set -e +echo '=== apt update ===' +echo '123456789987654321' | sudo -S apt-get update -qq +echo '=== install docker.io ===' +echo '123456789987654321' | sudo -S DEBIAN_FRONTEND=noninteractive apt-get install -y -qq docker.io docker-compose >/dev/null +echo '=== enable docker ===' +echo '123456789987654321' | sudo -S systemctl enable --now docker +echo '123456789987654321' | sudo -S usermod -aG docker dimon +echo '=== docker version ===' +echo '123456789987654321' | sudo -S docker --version +echo '=== portainer volume ===' +echo '123456789987654321' | sudo -S docker volume create portainer_data +echo '=== portainer run ===' +echo '123456789987654321' | sudo -S docker run -d -p 9000:9000 --name portainer --restart=always \ + -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce:lts +echo '=== containers ===' +echo '123456789987654321' | sudo -S docker ps +echo '=== ufw allow 9000 ===' +(echo '123456789987654321' | sudo -S ufw allow 9000/tcp 2>/dev/null && echo ufw-ok) || echo ufw-not-installed +echo DONE +""" + +async def main(): + async with asyncssh.connect( + "132.243.17.164", port=2292, username="dimon", + password="123456789987654321", known_hosts=None, + ) as conn: + r = await conn.run(SCRIPT, timeout=900) + print(r.stdout) + if r.stderr: + print("STDERR:", r.stderr[-3000:]) + print("RC:", r.returncode) + +asyncio.run(main()) diff --git a/scripts/test_metrics.py b/scripts/test_metrics.py new file mode 100644 index 0000000..fc2d7e5 --- /dev/null +++ b/scripts/test_metrics.py @@ -0,0 +1,20 @@ +import asyncio +import asyncssh +import sys +sys.path.insert(0, r"C:/Users/dimir/proects/debian-manager") + +async def main(): + from app.core.ssh import SSHClient + from app.core.monitor import _METRICS_CMD + from app.db.models import Server + + srv = Server(id=1, name="t", host="132.243.17.164", port=2292, + username="dimon", password="123456789987654321") + async with SSHClient(srv) as c: + r = await c.run(_METRICS_CMD, timeout=30) + print("STDOUT:") + print(r.stdout) + if r.stderr: + print("STDERR:", r.stderr[:500]) + +asyncio.run(main()) diff --git a/tests/test_parsers.py b/tests/test_parsers.py new file mode 100644 index 0000000..9381ccb --- /dev/null +++ b/tests/test_parsers.py @@ -0,0 +1,80 @@ +"""Юнит-тесты парсеров на фикстурах (без сервера).""" +from app.core.cron_svc import parse_crontab, humanize, render, CronLine +from app.core.packages import Package, split_packages + + +# ---------- cron ---------- + +CRONTAB_FIXTURE = """\ +# комментарий +SHELL=/bin/bash +0 3 * * * /usr/bin/backup.sh --full +# 30 4 * * 0 /usr/bin/weekly.sh +@daily /home/dimon/cleanup.sh +*/15 * * * * curl -s https://example.com/ping +not a cron line +""" + + +def test_parse_enabled_entry(): + lines = parse_crontab(CRONTAB_FIXTURE) + entry = next(l for l in lines if "backup.sh" in l.raw) + assert entry.is_entry and entry.enabled + assert entry.schedule == "0 3 * * *" + assert entry.command == "/usr/bin/backup.sh --full" + + +def test_parse_disabled_entry(): + lines = parse_crontab(CRONTAB_FIXTURE) + entry = next(l for l in lines if "weekly.sh" in l.raw) + assert entry.is_entry and not entry.enabled + assert entry.schedule == "30 4 * * 0" + + +def test_parse_special_at(): + lines = parse_crontab(CRONTAB_FIXTURE) + entry = next(l for l in lines if "cleanup.sh" in l.raw) + assert entry.is_entry and entry.schedule == "@daily" + + +def test_parse_comment_not_entry(): + lines = parse_crontab(CRONTAB_FIXTURE) + assert lines[0].comment == "# комментарий" or not lines[0].is_entry + non_entry = [l for l in lines if not l.is_entry] + assert any("SHELL" in l.raw for l in non_entry) + + +def test_render_roundtrip(): + lines = parse_crontab(CRONTAB_FIXTURE) + out = render(lines) + lines2 = parse_crontab(out) + entries1 = [(l.schedule, l.command, l.enabled) for l in lines if l.is_entry] + entries2 = [(l.schedule, l.command, l.enabled) for l in lines2 if l.is_entry] + assert entries1 == entries2 + + +def test_humanize_daily(): + assert humanize("@daily") == "ежедневно" + + +def test_humanize_valid_cron(): + assert "следующий запуск" in humanize("0 3 * * *") + + +def test_humanize_invalid(): + assert humanize("99 99 * * *") in ("неверное выражение",) + + +# ---------- packages ---------- + +def test_split_packages(): + pkgs = [ + Package("bash", "5.2", "required", True), + Package("dpkg", "1.22", "important", True), + Package("nginx", "1.24", "optional", True), + Package("libnginx-mod-http", "1.24", "optional", False), + ] + res = split_packages(pkgs) + assert {p.name for p in res["system"]} == {"bash", "dpkg"} + assert {p.name for p in res["user"]} == {"nginx"} + assert {p.name for p in res["deps"]} == {"libnginx-mod-http"}