Initial commit: Debian Manager — управление Debian-серверами

- SSH/SFTP управление без агентов
- Пакеты (apt), Docker, файлы, systemd, ufw, cron
- Плейбуки, групповые операции, планировщик
- Бэкапы, отчёты, Portainer, Proxmox VE, Gitea Deploy
- Терминал, Telegram-уведомления, тёмная/светлая тема
This commit is contained in:
dinlo
2026-09-05 19:46:45 +08:00
commit f624fee5e0
65 changed files with 6981 additions and 0 deletions
+30
View File
@@ -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
+342
View File
@@ -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
View File
+217
View File
@@ -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
+98
View File
@@ -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)
+126
View File
@@ -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())
+43
View File
@@ -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)
+109
View File
@@ -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)
+123
View File
@@ -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())
+98
View File
@@ -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)
+110
View File
@@ -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
+143
View File
@@ -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
+94
View File
@@ -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))
+91
View File
@@ -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)
+101
View File
@@ -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()]
+188
View File
@@ -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
+132
View File
@@ -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
+58
View File
@@ -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)
+193
View File
@@ -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"),
]
+47
View File
@@ -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
View File
@@ -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
+73
View File
@@ -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")
+81
View File
@@ -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()
+69
View File
@@ -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
+111
View File
@@ -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
+38
View File
@@ -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()
+96
View File
@@ -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
View File
@@ -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)
View File
+65
View File
@@ -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} МБ"
View File
+108
View File
@@ -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)
+87
View File
@@ -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, "Бэкап запущен")
+128
View File
@@ -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()
+84
View File
@@ -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),
),
]))
+168
View File
@@ -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']} запущен")
+131
View File
@@ -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()
+131
View File
@@ -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()
+106
View File
@@ -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)
+243
View File
@@ -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)
+68
View File
@@ -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)
+161
View File
@@ -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)
+64
View File
@@ -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,
]
+158
View File
@@ -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()),
)
+76
View File
@@ -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)
+47
View File
@@ -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()
+190
View File
@@ -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, "Мастер запущен — следите за логом")
View File
+172
View File
@@ -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()
+296
View File
@@ -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)
+229
View File
@@ -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)
+155
View File
@@ -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()
+158
View File
@@ -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)
+216
View File
@@ -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)
+116
View File
@@ -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)
+272
View File
@@ -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()
+26
View File
@@ -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*"]
+10
View File
@@ -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
+12
View File
@@ -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())
+22
View File
@@ -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())
+12
View File
@@ -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())
+14
View File
@@ -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())
+38
View File
@@ -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())
+20
View File
@@ -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())
+80
View File
@@ -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"}