Initial commit: Debian Manager — управление Debian-серверами
- SSH/SFTP управление без агентов - Пакеты (apt), Docker, файлы, systemd, ufw, cron - Плейбуки, групповые операции, планировщик - Бэкапы, отчёты, Portainer, Proxmox VE, Gitea Deploy - Терминал, Telegram-уведомления, тёмная/светлая тема
This commit is contained in:
+30
@@ -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
|
||||||
@@ -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 <repo> 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 <unit>`) справа
|
||||||
|
|
||||||
|
### 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://<IP-этого-ПК>: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/<repo>` + `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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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/<repo> + 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
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
@@ -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"⚠️ <b>{server_name}</b>: {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} <b>{server_name}</b>: {title} — {status}", force=True)
|
||||||
@@ -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()]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
+130
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
+177
@@ -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)
|
||||||
@@ -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} МБ"
|
||||||
@@ -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)
|
||||||
@@ -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, "Бэкап запущен")
|
||||||
@@ -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()
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]))
|
||||||
@@ -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']} запущен")
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
]
|
||||||
@@ -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()),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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, "Мастер запущен — следите за логом")
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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*"]
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user