Initial import: CephDeploy — PyQt6 GUI для развёртывания Ceph-кластера

Десктопное приложение на PyQt6 + SQLAlchemy для автоматизации установки и управления Ceph-кластерами через Ansible и cephadm. Страницы: - Кластеры — CRUD профилей, список серверов - Сканер сети — TCP+SSH поиск хостов по CIDR, добавление в кластер - Развёртывание — precheck, генерация inventory/playbook, запуск ansible-playbook через QProcess, кнопка очистки с автопредложением после неудачного развёртывания - Состояние — живой дашборд ceph -s / ceph df / ceph osd tree через cephadm shell по SSH - OSD — назначение дисков, диалог добавления с lsblk-опросом и фильтром по состоянию (чистый / с данными / смонтирован) - Журнал — история запусков, просмотр и скачивание лога - Отчёт — HTML-экспорт конфигурации через Jinja2 - Настройки — QFormLayout для AppConfig Стек: Python 3.13, PyQt6, SQLAlchemy 2.x, paramiko, Jinja2, ansible-core. Целевая платформа: ALT Linux (apt-rpm) и Debian/Ubuntu. Test-env: docker-compose стенд из 3 systemd-контейнеров с podman + cephadm + chrony для локального тестирования развёртывания.
parents
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.mypy_cache/
.pytest_cache/
.ruff_cache/
# Виртуальные окружения
.venv/
venv/
env/
# PyInstaller
build/
dist/
*.spec.bak
# Локальная БД CephDeploy — содержит пользовательские кластеры,
# и должна оставаться приватной
cephdeploy.db
cephdeploy.db-wal
cephdeploy.db-shm
cephdeploy.db-journal
# Локальные конфиги
.env
.env.local
# Временные каталоги развёртывания (если вдруг попали в проект)
cephdeploy_*/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
#!/usr/bin/env bash
# Скрипт сборки CephDeploy
# Использование: ./build.sh [--rpm]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
VERSION="0.1.0"
APP_NAME="cephdeploy"
DIST_DIR="$SCRIPT_DIR/dist"
BUILD_DIR="$SCRIPT_DIR/build"
echo "=== CephDeploy build v${VERSION} ==="
# ── 1. Сборка PyInstaller-бандла ──────────────────────────────────────────
echo "→ Запуск PyInstaller..."
pyinstaller --clean --noconfirm cephdeploy.spec
BUNDLE_DIR="$DIST_DIR/$APP_NAME"
echo "→ Бандл собран: $BUNDLE_DIR"
# ── 2. Создание портативного архива ───────────────────────────────────────
ARCHIVE="$DIST_DIR/${APP_NAME}-${VERSION}-linux-x86_64.tar.gz"
tar -czf "$ARCHIVE" -C "$DIST_DIR" "$APP_NAME"
echo "→ Архив: $ARCHIVE"
# ── 3. Обёртка-запускатор (launcher script) ───────────────────────────────
LAUNCHER="$DIST_DIR/${APP_NAME}-${VERSION}-linux-x86_64.run"
cat > "$LAUNCHER" <<'LAUNCHER_EOF'
#!/usr/bin/env bash
# Самораспаковывающийся запускатор CephDeploy
TMPDIR=$(mktemp -d /tmp/cephdeploy_XXXXXX)
trap 'rm -rf "$TMPDIR"' EXIT
SKIP=$(awk '/^__ARCHIVE__$/{print NR+1; exit}' "$0")
tail -n +${SKIP} "$0" | tar -xz -C "$TMPDIR"
DISPLAY=${DISPLAY:-:0} "$TMPDIR/cephdeploy/cephdeploy" "$@"
exit 0
__ARCHIVE__
LAUNCHER_EOF
cat "$ARCHIVE" >> "$LAUNCHER"
chmod +x "$LAUNCHER"
echo "→ Self-run: $LAUNCHER"
# ── 4. RPM-пакет (если передан флаг --rpm) ────────────────────────────────
if [[ "${1:-}" == "--rpm" ]]; then
echo "→ Сборка RPM..."
RPM_BUILD="$SCRIPT_DIR/rpmbuild"
mkdir -p "$RPM_BUILD"/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
cp "$ARCHIVE" "$RPM_BUILD/SOURCES/"
cat > "$RPM_BUILD/SPECS/${APP_NAME}.spec" <<SPEC_EOF
Name: ${APP_NAME}
Version: ${VERSION}
Release: 1%{?dist}
Summary: Приложение для развёртывания Ceph-кластера
License: MIT
Source0: ${APP_NAME}-${VERSION}-linux-x86_64.tar.gz
Requires: ansible-core >= 2.12, openssh-clients
%description
CephDeploy — десктопное приложение для автоматизированной
установки и мониторинга кластера Ceph Reef.
Разработано в ООО Этерсофт.
%prep
%setup -q -n ${APP_NAME}
%install
install -d %{buildroot}/opt/${APP_NAME}
cp -a . %{buildroot}/opt/${APP_NAME}/
install -d %{buildroot}%{_bindir}
cat > %{buildroot}%{_bindir}/${APP_NAME} <<'EOF'
#!/bin/sh
exec /opt/${APP_NAME}/${APP_NAME} "\$@"
EOF
chmod 755 %{buildroot}%{_bindir}/${APP_NAME}
install -d %{buildroot}%{_datadir}/applications
cat > %{buildroot}%{_datadir}/applications/${APP_NAME}.desktop <<'EOF'
[Desktop Entry]
Name=CephDeploy
Comment=Управление кластером Ceph
Exec=/opt/${APP_NAME}/${APP_NAME}
Icon=${APP_NAME}
Terminal=false
Type=Application
Categories=System;Network;
EOF
%files
/opt/${APP_NAME}/
%{_bindir}/${APP_NAME}
%{_datadir}/applications/${APP_NAME}.desktop
%changelog
* $(date '+%a %b %d %Y') CephDeploy Build <build@etersoft.ru> - ${VERSION}-1
- Первая сборка
SPEC_EOF
rpmbuild --define "_topdir $RPM_BUILD" -bb "$RPM_BUILD/SPECS/${APP_NAME}.spec"
RPM_FILE=$(find "$RPM_BUILD/RPMS" -name "*.rpm" | head -1)
cp "$RPM_FILE" "$DIST_DIR/"
echo "→ RPM: $DIST_DIR/$(basename "$RPM_FILE")"
fi
echo ""
echo "=== Готово ==="
echo "Портативный архив: $ARCHIVE"
echo "Self-run launcher: $LAUNCHER"
[[ "${1:-}" == "--rpm" ]] && echo "RPM-пакет: $DIST_DIR/${APP_NAME}-${VERSION}-*.rpm"
echo ""
echo "Запуск портативной версии:"
echo " tar -xzf ${APP_NAME}-${VERSION}-linux-x86_64.tar.gz && ./${APP_NAME}/${APP_NAME}"
# -*- mode: python ; coding: utf-8 -*-
"""
PyInstaller spec для CephDeploy.
Сборка: pyinstaller cephdeploy.spec
"""
import sys
from pathlib import Path
ROOT = Path(SPECPATH)
a = Analysis(
[str(ROOT / 'main.py')],
pathex=[str(ROOT)],
binaries=[],
datas=[
(str(ROOT / 'templates'), 'templates'),
],
hiddenimports=[
# SQLAlchemy диалект SQLite
'sqlalchemy.dialects.sqlite',
'sqlalchemy.dialects.sqlite.pysqlite',
# PyQt6
'PyQt6.QtCore',
'PyQt6.QtGui',
'PyQt6.QtWidgets',
'PyQt6.sip',
# Jinja2
'jinja2.ext',
# Paramiko
'paramiko',
'paramiko.transport',
'paramiko.auth_handler',
'paramiko.ecdsakey',
'paramiko.ed25519key',
# Cryptography (paramiko dep)
'cryptography',
'cryptography.hazmat.primitives.asymmetric.ed25519',
# Стандартная библиотека
'ipaddress',
'concurrent.futures',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[
'tkinter', 'unittest', 'xmlrpc', 'pydoc',
],
noarchive=False,
optimize=2,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='cephdeploy',
debug=False,
bootloader_ignore_signals=False,
strip=True,
upx=False,
console=False, # без терминала
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=True,
upx=False,
upx_exclude=[],
name='cephdeploy',
)
"""
Настройки приложения — хранение в JSON, доступ через AppConfig.
"""
from __future__ import annotations
import json
from pathlib import Path
_CONFIG_PATH = Path.home() / ".config" / "cephdeploy" / "settings.json"
_DEFAULTS: dict = {
"ssh_user": "amegami",
"ssh_key_path": "~/.ssh/id_ed25519",
"scan_tcp_timeout": 2,
"scan_ssh_timeout": 8,
"ansible_bin": "ansible-playbook",
"status_refresh_interval": 30,
}
class AppConfig:
_data: dict = {}
_loaded: bool = False
@classmethod
def _ensure(cls) -> None:
if not cls._loaded:
cls.load()
@classmethod
def load(cls) -> None:
raw: dict = {}
if _CONFIG_PATH.exists():
try:
raw = json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
except Exception:
raw = {}
cls._data = {**_DEFAULTS, **raw}
cls._loaded = True
@classmethod
def get(cls, key: str):
cls._ensure()
return cls._data.get(key, _DEFAULTS.get(key))
@classmethod
def set_value(cls, key: str, value) -> None:
cls._ensure()
cls._data[key] = value
@classmethod
def save(cls) -> None:
cls._ensure()
_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
_CONFIG_PATH.write_text(
json.dumps(cls._data, indent=2, ensure_ascii=False),
encoding="utf-8",
)
@classmethod
def all(cls) -> dict:
cls._ensure()
return dict(cls._data)
"""
Сканер сети для поиска потенциальных серверов Ceph.
Алгоритм:
1. Для каждого IP из подсети: TCP-подключение к порту 22 (2 сек таймаут)
2. Если порт открыт: резолв hostname через reverse DNS
3. Если включена проверка авторизации: SSH-подключение paramiko,
сбор информации об ОС, CPU, RAM и дисках
"""
from __future__ import annotations
import ipaddress
import socket
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from pathlib import Path
import paramiko
from PyQt6.QtCore import QThread, pyqtSignal
_TCP_TIMEOUT = 2.0 # таймаут TCP-проверки порта 22
_SSH_TIMEOUT = 8.0 # таймаут SSH-сессии
_MAX_WORKERS = 64 # параллельных проверок
# ---------------------------------------------------------------------------
# Результат проверки хоста
# ---------------------------------------------------------------------------
@dataclass
class HostInfo:
ip: str
hostname: str = ""
ssh_open: bool = False
ssh_auth_ok: bool = False
os_name: str = ""
cpu_count: int = 0
ram_mb: int = 0
disks: list[dict] = field(default_factory=list) # [{name, size, rota, type}]
error: str = ""
def display_hostname(self) -> str:
return self.hostname if self.hostname and self.hostname != self.ip else self.ip
def to_dict(self) -> dict:
return {
"ip": self.ip,
"hostname": self.hostname,
"ssh_open": self.ssh_open,
"ssh_auth_ok": self.ssh_auth_ok,
"os_name": self.os_name,
"cpu_count": self.cpu_count,
"ram_mb": self.ram_mb,
"disks": self.disks,
"error": self.error,
}
# ---------------------------------------------------------------------------
# Низкоуровневые проверки (запускаются в потоке пула)
# ---------------------------------------------------------------------------
def _tcp_check(ip: str, port: int = 22, timeout: float = _TCP_TIMEOUT) -> bool:
try:
with socket.create_connection((ip, port), timeout=timeout):
return True
except OSError:
return False
def _resolve_hostname(ip: str) -> str:
try:
name, _, _ = socket.gethostbyaddr(ip)
return name
except socket.herror:
return ip
def _ssh_gather(
info: HostInfo,
ssh_user: str,
ssh_key_path: str,
) -> None:
"""Подключается по SSH и собирает информацию о хосте. Изменяет info на месте."""
key_path = Path(ssh_key_path).expanduser()
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(
info.ip,
username=ssh_user,
key_filename=str(key_path),
timeout=_SSH_TIMEOUT,
banner_timeout=_SSH_TIMEOUT,
auth_timeout=_SSH_TIMEOUT,
look_for_keys=False,
allow_agent=False,
)
info.ssh_auth_ok = True
def run(cmd: str) -> str:
_, stdout, stderr = client.exec_command(cmd, timeout=10)
return stdout.read().decode(errors="replace").strip()
# Hostname — fallback, если обратный DNS не сработал
if not info.hostname or info.hostname == info.ip:
hn = run("hostname -f 2>/dev/null || hostname")
if hn:
info.hostname = hn
# ОС
os_raw = run(
"grep -oP '(?<=^PRETTY_NAME=\")[^\"]+' /etc/os-release 2>/dev/null"
" || cat /etc/issue 2>/dev/null | head -1"
)
info.os_name = os_raw or "Linux"
# CPU
cpu_raw = run("nproc --all 2>/dev/null || grep -c ^processor /proc/cpuinfo")
try:
info.cpu_count = int(cpu_raw)
except ValueError:
pass
# RAM (МБ)
mem_raw = run("awk '/MemTotal/{print $2}' /proc/meminfo")
try:
info.ram_mb = int(mem_raw) // 1024
except ValueError:
pass
# Диски (только физические, без разделов)
disk_raw = run(
"lsblk -d -n -o NAME,SIZE,ROTA,TYPE 2>/dev/null"
)
for line in disk_raw.splitlines():
parts = line.split()
if len(parts) >= 4 and parts[3] == "disk":
info.disks.append(
{
"name": f"/dev/{parts[0]}",
"size": parts[1],
"rota": parts[2] == "1", # True = HDD, False = SSD
"type": "hdd" if parts[2] == "1" else "ssd",
}
)
except paramiko.AuthenticationException:
info.error = "Ошибка авторизации SSH"
except paramiko.SSHException as exc:
info.error = f"SSH: {exc}"
except OSError as exc:
info.error = str(exc)
finally:
client.close()
def _scan_host(
ip: str,
ssh_user: str,
ssh_key_path: str,
check_auth: bool,
) -> HostInfo:
info = HostInfo(ip=ip)
if not _tcp_check(ip):
return info
info.ssh_open = True
info.hostname = _resolve_hostname(ip)
if check_auth:
_ssh_gather(info, ssh_user, ssh_key_path)
return info
# ---------------------------------------------------------------------------
# QThread-обёртка
# ---------------------------------------------------------------------------
class ScanWorker(QThread):
"""
Сканирует подсеть в фоновом потоке.
Сигналы:
host_found(HostInfo) — найден доступный хост (ssh_open=True)
progress(current, total) — прогресс сканирования
finished_scan(int) — сканирование завершено, кол-во найденных хостов
error(str) — критическая ошибка (неверный CIDR и т.п.)
"""
host_found = pyqtSignal(object) # HostInfo
progress = pyqtSignal(int, int) # (scanned, total)
finished_scan = pyqtSignal(int) # кол-во найденных
error = pyqtSignal(str)
def __init__(
self,
subnet: str,
ssh_user: str = "amegami",
ssh_key_path: str = "~/.ssh/id_ed25519",
check_auth: bool = True,
parent=None,
) -> None:
super().__init__(parent)
self.subnet = subnet
self.ssh_user = ssh_user
self.ssh_key_path = ssh_key_path
self.check_auth = check_auth
self._cancel = False
def cancel(self) -> None:
self._cancel = True
def run(self) -> None:
try:
network = ipaddress.ip_network(self.subnet, strict=False)
except ValueError as exc:
self.error.emit(f"Неверный CIDR: {exc}")
return
hosts = list(network.hosts())
total = len(hosts)
found = 0
scanned = 0
with ThreadPoolExecutor(max_workers=min(_MAX_WORKERS, total)) as pool:
futures = {
pool.submit(
_scan_host,
str(ip),
self.ssh_user,
self.ssh_key_path,
self.check_auth,
): str(ip)
for ip in hosts
}
for future in as_completed(futures):
if self._cancel:
pool.shutdown(wait=False, cancel_futures=True)
break
scanned += 1
self.progress.emit(scanned, total)
try:
info: HostInfo = future.result()
except Exception as exc:
continue
if info.ssh_open:
found += 1
self.host_found.emit(info)
self.finished_scan.emit(found)
"""
Разрешение путей к ресурсам — работает как из исходников, так и из PyInstaller-бандла.
"""
from __future__ import annotations
import sys
from pathlib import Path
def get_templates_dir() -> Path:
"""Возвращает путь к каталогу templates."""
if hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS) / "templates"
return Path(__file__).resolve().parent.parent / "templates"
def get_db_path() -> Path:
"""
Путь к SQLite-базе данных.
В бандле и при установке — ~/.local/share/cephdeploy/cephdeploy.db
В режиме разработки — рядом с проектом.
"""
if hasattr(sys, "_MEIPASS"):
data_dir = Path.home() / ".local" / "share" / "cephdeploy"
else:
# Режим разработки: корень проекта
data_dir = Path(__file__).resolve().parent.parent
data_dir.mkdir(parents=True, exist_ok=True)
return data_dir / "cephdeploy.db"
"""
Инициализация базы данных: движок SQLAlchemy и фабрика сессий.
"""
from __future__ import annotations
from pathlib import Path
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from core.resources import get_db_path
from db.models import Base
_DB_PATH = get_db_path()
engine = create_engine(
f"sqlite:///{_DB_PATH}",
connect_args={"check_same_thread": False},
echo=False,
)
# Включаем WAL-режим и foreign keys для SQLite
@event.listens_for(engine, "connect")
def _sqlite_pragmas(dbapi_conn, _):
cur = dbapi_conn.cursor()
cur.execute("PRAGMA journal_mode=WAL")
cur.execute("PRAGMA foreign_keys=ON")
cur.close()
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def init_db() -> None:
"""Создаёт все таблицы, если их ещё нет."""
Base.metadata.create_all(bind=engine)
"""
ORM-модели SQLAlchemy для CephDeploy.
"""
from __future__ import annotations
import enum
from datetime import datetime
from sqlalchemy import (
BigInteger,
DateTime,
Enum,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class ServerRole(str, enum.Enum):
MON = "mon"
MGR = "mgr"
OSD = "osd"
MDS = "mds"
RGW = "rgw"
ALL = "all" # универсальный узел
class DeviceType(str, enum.Enum):
HDD = "hdd"
SSD = "ssd"
NVME = "nvme"
class OSDRole(str, enum.Enum):
DATA = "data"
WAL = "wal"
DB = "db"
class DeployStatus(str, enum.Enum):
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
CANCELLED = "cancelled"
# ---------------------------------------------------------------------------
class Cluster(Base):
__tablename__ = "clusters"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
ceph_version: Mapped[str] = mapped_column(String(32), default="reef")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now, nullable=False
)
servers: Mapped[list[Server]] = relationship(
"Server", back_populates="cluster", cascade="all, delete-orphan"
)
deployment_runs: Mapped[list[DeploymentRun]] = relationship(
"DeploymentRun", back_populates="cluster", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Cluster id={self.id} name={self.name!r}>"
class Server(Base):
__tablename__ = "servers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
cluster_id: Mapped[int] = mapped_column(
Integer, ForeignKey("clusters.id", ondelete="CASCADE"), nullable=False
)
hostname: Mapped[str] = mapped_column(String(253), nullable=False)
ip_address: Mapped[str] = mapped_column(String(45), nullable=False)
role: Mapped[ServerRole] = mapped_column(
Enum(ServerRole), default=ServerRole.OSD, nullable=False
)
ssh_user: Mapped[str] = mapped_column(String(64), default="amegami")
ssh_key_path: Mapped[str] = mapped_column(
String(512), default="~/.ssh/id_ed25519"
)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now, nullable=False
)
cluster: Mapped[Cluster] = relationship("Cluster", back_populates="servers")
osd_devices: Mapped[list[OSDDevice]] = relationship(
"OSDDevice", back_populates="server", cascade="all, delete-orphan"
)
network_interfaces: Mapped[list[NetworkInterface]] = relationship(
"NetworkInterface", back_populates="server", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Server id={self.id} hostname={self.hostname!r} role={self.role}>"
class OSDDevice(Base):
__tablename__ = "osd_devices"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
server_id: Mapped[int] = mapped_column(
Integer, ForeignKey("servers.id", ondelete="CASCADE"), nullable=False
)
device_path: Mapped[str] = mapped_column(String(256), nullable=False)
device_type: Mapped[DeviceType] = mapped_column(
Enum(DeviceType), default=DeviceType.HDD, nullable=False
)
osd_role: Mapped[OSDRole] = mapped_column(
Enum(OSDRole), default=OSDRole.DATA, nullable=False
)
server: Mapped[Server] = relationship("Server", back_populates="osd_devices")
def __repr__(self) -> str:
return (
f"<OSDDevice id={self.id} path={self.device_path!r} "
f"type={self.device_type} role={self.osd_role}>"
)
class NetworkInterface(Base):
__tablename__ = "network_interfaces"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
server_id: Mapped[int] = mapped_column(
Integer, ForeignKey("servers.id", ondelete="CASCADE"), nullable=False
)
iface_name: Mapped[str] = mapped_column(String(64), nullable=False)
purpose: Mapped[str] = mapped_column(
String(32), default="cluster"
) # "cluster" | "public"
speed_gbit: Mapped[int | None] = mapped_column(Integer, nullable=True)
server: Mapped[Server] = relationship(
"Server", back_populates="network_interfaces"
)
def __repr__(self) -> str:
return f"<NetworkInterface id={self.id} iface={self.iface_name!r}>"
class DeploymentRun(Base):
__tablename__ = "deployment_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
cluster_id: Mapped[int] = mapped_column(
Integer, ForeignKey("clusters.id", ondelete="CASCADE"), nullable=False
)
started_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now, nullable=False
)
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
status: Mapped[DeployStatus] = mapped_column(
Enum(DeployStatus), default=DeployStatus.RUNNING, nullable=False
)
log_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
cluster: Mapped[Cluster] = relationship(
"Cluster", back_populates="deployment_runs"
)
def __repr__(self) -> str:
return (
f"<DeploymentRun id={self.id} cluster_id={self.cluster_id} "
f"status={self.status}>"
)
"""
CRUD-операции для всех ORM-моделей CephDeploy.
Все методы принимают сессию явно — создание сессии на стороне вызывающего кода.
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from db.models import (
Cluster,
DeploymentRun,
DeployStatus,
NetworkInterface,
OSDDevice,
Server,
)
# ---------------------------------------------------------------------------
# Cluster
# ---------------------------------------------------------------------------
def create_cluster(
session: Session,
name: str,
ceph_version: str = "reef",
description: str | None = None,
) -> Cluster:
cluster = Cluster(name=name, ceph_version=ceph_version, description=description)
session.add(cluster)
session.flush()
return cluster
def get_cluster(session: Session, cluster_id: int) -> Optional[Cluster]:
return session.get(Cluster, cluster_id)
def get_cluster_by_name(session: Session, name: str) -> Optional[Cluster]:
return session.scalar(select(Cluster).where(Cluster.name == name))
def list_clusters(session: Session) -> list[Cluster]:
return list(session.scalars(select(Cluster).order_by(Cluster.created_at.desc())))
def update_cluster(
session: Session,
cluster_id: int,
*,
name: str | None = None,
ceph_version: str | None = None,
description: str | None = None,
) -> Optional[Cluster]:
cluster = get_cluster(session, cluster_id)
if cluster is None:
return None
if name is not None:
cluster.name = name
if ceph_version is not None:
cluster.ceph_version = ceph_version
if description is not None:
cluster.description = description
session.flush()
return cluster
def delete_cluster(session: Session, cluster_id: int) -> bool:
cluster = get_cluster(session, cluster_id)
if cluster is None:
return False
session.delete(cluster)
session.flush()
return True
# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------
def create_server(
session: Session,
cluster_id: int,
hostname: str,
ip_address: str,
role: str = "osd",
ssh_user: str = "amegami",
ssh_key_path: str = "~/.ssh/id_ed25519",
) -> Server:
server = Server(
cluster_id=cluster_id,
hostname=hostname,
ip_address=ip_address,
role=role,
ssh_user=ssh_user,
ssh_key_path=ssh_key_path,
)
session.add(server)
session.flush()
return server
def get_server(session: Session, server_id: int) -> Optional[Server]:
return session.get(Server, server_id)
def list_servers(session: Session, cluster_id: int) -> list[Server]:
return list(
session.scalars(
select(Server)
.where(Server.cluster_id == cluster_id)
.options(
selectinload(Server.osd_devices),
selectinload(Server.network_interfaces),
)
.order_by(Server.id)
)
)
def delete_server(session: Session, server_id: int) -> bool:
server = get_server(session, server_id)
if server is None:
return False
session.delete(server)
session.flush()
return True
# ---------------------------------------------------------------------------
# OSDDevice
# ---------------------------------------------------------------------------
def add_osd_device(
session: Session,
server_id: int,
device_path: str,
device_type: str = "hdd",
osd_role: str = "data",
) -> OSDDevice:
device = OSDDevice(
server_id=server_id,
device_path=device_path,
device_type=device_type,
osd_role=osd_role,
)
session.add(device)
session.flush()
return device
def list_osd_devices(session: Session, server_id: int) -> list[OSDDevice]:
return list(
session.scalars(
select(OSDDevice)
.where(OSDDevice.server_id == server_id)
.order_by(OSDDevice.id)
)
)
def delete_osd_device(session: Session, device_id: int) -> bool:
device = session.get(OSDDevice, device_id)
if device is None:
return False
session.delete(device)
session.flush()
return True
# ---------------------------------------------------------------------------
# NetworkInterface
# ---------------------------------------------------------------------------
def add_network_interface(
session: Session,
server_id: int,
iface_name: str,
purpose: str = "cluster",
speed_gbit: int | None = None,
) -> NetworkInterface:
iface = NetworkInterface(
server_id=server_id,
iface_name=iface_name,
purpose=purpose,
speed_gbit=speed_gbit,
)
session.add(iface)
session.flush()
return iface
def list_network_interfaces(
session: Session, server_id: int
) -> list[NetworkInterface]:
return list(
session.scalars(
select(NetworkInterface)
.where(NetworkInterface.server_id == server_id)
.order_by(NetworkInterface.id)
)
)
# ---------------------------------------------------------------------------
# DeploymentRun
# ---------------------------------------------------------------------------
def create_deployment_run(
session: Session,
cluster_id: int,
log_path: str | None = None,
) -> DeploymentRun:
run = DeploymentRun(
cluster_id=cluster_id,
status=DeployStatus.RUNNING,
log_path=log_path,
)
session.add(run)
session.flush()
return run
def finish_deployment_run(
session: Session,
run_id: int,
status: DeployStatus,
log_path: str | None = None,
) -> Optional[DeploymentRun]:
run = session.get(DeploymentRun, run_id)
if run is None:
return None
run.finished_at = datetime.now()
run.status = status
if log_path is not None:
run.log_path = log_path
session.flush()
return run
def list_deployment_runs(
session: Session, cluster_id: int, limit: int = 50
) -> list[DeploymentRun]:
return list(
session.scalars(
select(DeploymentRun)
.where(DeploymentRun.cluster_id == cluster_id)
.order_by(DeploymentRun.started_at.desc())
.limit(limit)
)
)
def get_last_deployment_run(
session: Session, cluster_id: int
) -> Optional[DeploymentRun]:
return session.scalar(
select(DeploymentRun)
.where(DeploymentRun.cluster_id == cluster_id)
.order_by(DeploymentRun.started_at.desc())
.limit(1)
)
"""
CephDeploy — точка входа приложения.
"""
from __future__ import annotations
import sys
from pathlib import Path
# Добавляем корень проекта в sys.path, чтобы импорты работали
# независимо от текущего рабочего каталога
sys.path.insert(0, str(Path(__file__).resolve().parent))
from PyQt6.QtGui import QPalette, QColor
from PyQt6.QtWidgets import QApplication
from db import init_db
from ui.main_window import MainWindow
def _setup_dark_palette(app: QApplication) -> None:
"""Базовая тёмная палитра Qt (дополняется QSS в MainWindow)."""
palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor(26, 31, 41))
palette.setColor(QPalette.ColorRole.WindowText, QColor(192, 200, 216))
palette.setColor(QPalette.ColorRole.Base, QColor(22, 27, 34))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor(30, 33, 40))
palette.setColor(QPalette.ColorRole.Text, QColor(192, 200, 216))
palette.setColor(QPalette.ColorRole.Button, QColor(30, 33, 40))
palette.setColor(QPalette.ColorRole.ButtonText, QColor(192, 200, 216))
palette.setColor(QPalette.ColorRole.Highlight, QColor(46, 74, 122))
palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255))
app.setPalette(palette)
def main() -> None:
app = QApplication(sys.argv)
app.setApplicationName("CephDeploy")
app.setOrganizationName("Etersoft")
_setup_dark_palette(app)
# Инициализация БД (создаём таблицы при первом запуске)
try:
init_db()
except Exception as exc:
from PyQt6.QtWidgets import QMessageBox
QMessageBox.critical(
None,
"Ошибка базы данных",
f"Не удалось инициализировать SQLite:\n{exc}",
)
sys.exit(1)
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
---
# Ansible-плейбук для развёртывания Ceph {{ cluster.version }} (cephadm)
# Кластер: {{ cluster.name }}
# Сгенерировано CephDeploy
- name: Подготовка узлов
hosts: all
become: true
gather_facts: true
tasks:
# ── Установка cephadm и зависимостей ─────────────────────────────
- name: Установить cephadm (Debian/Ubuntu)
ansible.builtin.apt:
name:
- cephadm
- python3-pip
- lvm2
state: present
update_cache: true
when: ansible_os_family == "Debian"
- name: Установить cephadm (ALT Linux)
community.general.apt_rpm:
package:
- cephadm
- python3-pip
- lvm2
state: present
update_cache: true
when: ansible_os_family == "Altlinux"
- name: Установить cephadm (RHEL/CentOS/Rocky)
ansible.builtin.dnf:
name:
- cephadm
- python3-pip
- lvm2
state: present
when: ansible_os_family == "RedHat"
# ── Проверка наличия chronyc и firewalld без зависимости от pkg-facts
- name: Проверить наличие chronyc
ansible.builtin.command: which chronyc
register: chronyc_check
failed_when: false
changed_when: false
- name: Проверить наличие firewalld
ansible.builtin.command: which firewalld
register: firewalld_check
failed_when: false
changed_when: false
- name: Синхронизировать время (chronyc makestep)
ansible.builtin.command: chronyc makestep
when: chronyc_check.rc == 0
register: chrony_result
failed_when: false
changed_when: chrony_result.rc == 0
- name: Отключить firewalld
ansible.builtin.service:
name: firewalld
state: stopped
enabled: false
when: firewalld_check.rc == 0
failed_when: false
- name: Bootstrap первого MON-узла
hosts: {{ bootstrap_host.hostname }}
become: true
tasks:
- name: Запустить cephadm bootstrap
ansible.builtin.command: >
cephadm bootstrap
--mon-ip {{ bootstrap_host.ip_address }}
--initial-dashboard-user admin
--initial-dashboard-password admin
--skip-monitoring-stack
--allow-overwrite
args:
creates: /etc/ceph/ceph.conf
register: bootstrap_result
- name: Вывод результата bootstrap
ansible.builtin.debug:
var: bootstrap_result.stdout_lines
when: bootstrap_result.stdout_lines is defined
- name: Прочитать публичный ключ cephadm-оркестратора
ansible.builtin.slurp:
src: /etc/ceph/ceph.pub
register: ceph_pub
# Cephadm-оркестратор SSH-ится на хосты как root со своим ключом
# /etc/ceph/ceph.pub. Чтобы `ceph orch host add` и развёртывание
# демонов работали, этот ключ должен быть в ~root/.ssh/authorized_keys
# на каждом узле, включая bootstrap-узел.
- name: Распространение публичного ключа cephadm на все узлы
hosts: all
become: true
tasks:
- name: Убедиться, что /root/.ssh существует
ansible.builtin.file:
path: /root/.ssh
state: directory
owner: root
group: root
mode: '0700'
{% raw %}
- name: Установить ceph.pub в authorized_keys root
ansible.builtin.lineinfile:
path: /root/.ssh/authorized_keys
line: "{{ (hostvars[groups['mons'][0]]['ceph_pub']['content'] | b64decode).strip() }}"
create: true
owner: root
group: root
mode: '0600'
{% endraw %}
- name: Добавление остальных узлов в кластер
hosts: {{ bootstrap_host.hostname }}
become: true
tasks:
- name: Подождать готовности оркестратора
ansible.builtin.command: ceph orch status
register: orch_status
retries: 10
delay: 10
until: "'available' in orch_status.stdout"
failed_when: false
{% for s in servers if s.hostname != bootstrap_host.hostname %}
{#
В БД CephDeploy `hostname` может быть равен IP (если reverse DNS не отработал).
cephadm-оркестратор же ожидает реальное имя машины и откажется добавлять
хост с жалобой «hostname "foo" does not match expected hostname "ip"».
Поэтому подставляем `ansible_hostname`, собранный в предыдущем play.
ansible_host — это IP, который использует SSH (из inventory).
#}
{% set hv = "{{ hostvars['" ~ s.hostname ~ "'].ansible_hostname | default('" ~ s.hostname ~ "') }}" %}
- name: Добавить хост {{ s.hostname }}
ansible.builtin.command: >
ceph orch host add {{ hv }} {{ s.ip_address }}
register: add_host_result
failed_when: false
changed_when: add_host_result.rc == 0
- name: Результат добавления {{ s.hostname }}
ansible.builtin.debug:
var: add_host_result.stdout
{% endfor %}
- name: Развёртывание OSD-дисков
hosts: {{ bootstrap_host.hostname }}
become: true
tasks:
{% for s in servers %}
{% set osd_hv = "{{ hostvars['" ~ s.hostname ~ "'].ansible_hostname | default('" ~ s.hostname ~ "') }}" %}
{% for osd in s.osds %}
- name: Добавить OSD {{ osd.path }} на {{ s.hostname }} ({{ osd.type }}/{{ osd.role }})
ansible.builtin.command: >
ceph orch daemon add osd {{ osd_hv }}:{{ osd.path }}
register: osd_result
failed_when: false
changed_when: "'Created osd' in (osd_result.stdout | default(''))"
{% endfor %}
{% endfor %}
- name: Статус кластера
ansible.builtin.command: ceph -s
register: ceph_status
failed_when: false
- name: Вывод статуса
ansible.builtin.debug:
var: ceph_status.stdout_lines
when: ceph_status.stdout_lines is defined
---
# Очистка cephadm-кластеров и конфигов на всех узлах.
# Используется CephDeploy после неудачного развёртывания или по кнопке «Очистить».
# Кластер: {{ cluster.name }}
{% raw %}
- name: Очистка cephadm-кластера
hosts: all
become: true
gather_facts: false
tasks:
- name: Проверить наличие cephadm
ansible.builtin.command: which cephadm
register: cephadm_check
failed_when: false
changed_when: false
- name: Получить fsid всех локальных cephadm-кластеров
ansible.builtin.shell: |
set -o pipefail
cephadm ls 2>/dev/null | grep -oP '(?<="fsid": ")[a-f0-9-]+' | sort -u
args:
executable: /bin/bash
register: fsids
when: cephadm_check.rc == 0
failed_when: false
changed_when: false
- name: Удалить каждый найденный кластер (rm-cluster)
ansible.builtin.command: >
cephadm rm-cluster --force --fsid {{ item }} --zap-osds
loop: "{{ fsids.stdout_lines | default([]) }}"
when:
- cephadm_check.rc == 0
- fsids.stdout_lines is defined
- fsids.stdout_lines | length > 0
failed_when: false
changed_when: true
- name: Остановить любые оставшиеся ceph-*@ юниты
ansible.builtin.shell: |
set -o pipefail
for u in $(systemctl list-units --all --no-legend 'ceph-*@*' 2>/dev/null | awk '{print $1}'); do
systemctl stop "$u" 2>/dev/null || true
systemctl disable "$u" 2>/dev/null || true
done
args:
executable: /bin/bash
changed_when: true
failed_when: false
- name: Удалить ceph-конфиги и данные
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/ceph
- /var/lib/ceph
- /var/log/ceph
failed_when: false
- name: Убрать оставшиеся podman/docker-контейнеры ceph-*
ansible.builtin.shell: |
if command -v podman >/dev/null 2>&1; then
for c in $(podman ps -aq --filter name=ceph- 2>/dev/null); do
podman rm -f "$c" >/dev/null 2>&1 || true
done
fi
if command -v docker >/dev/null 2>&1; then
for c in $(docker ps -aq --filter name=ceph- 2>/dev/null); do
docker rm -f "$c" >/dev/null 2>&1 || true
done
fi
args:
executable: /bin/bash
changed_when: true
failed_when: false
{% endraw %}
[all:vars]
ansible_python_interpreter=/usr/bin/python3
[all]
{% for s in servers %}
{{ s.hostname }} ansible_host={{ s.ip_address }} ansible_user={{ s.ssh_user }} ansible_ssh_private_key_file={{ s.ssh_key_path }}
{% endfor %}
[mons]
{% for s in servers if s.role in ('mon', 'all') %}
{{ s.hostname }}
{% endfor %}
[mgrs]
{% for s in servers if s.role in ('mgr', 'all') %}
{{ s.hostname }}
{% endfor %}
[osds]
{% for s in servers if s.role in ('osd', 'all') %}
{{ s.hostname }}
{% endfor %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Отчёт кластера {{ cluster.name }}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 14px;
background: #f4f6f9; color: #1a2030; }
.page { max-width: 960px; margin: 40px auto; padding: 0 24px 60px; }
h1 { font-size: 26px; color: #1565c0; border-bottom: 3px solid #1565c0;
padding-bottom: 10px; margin-bottom: 8px; }
.meta { color: #607d8b; font-size: 12px; margin-bottom: 32px; }
h2 { font-size: 16px; color: #1565c0; margin: 28px 0 10px;
padding-left: 10px; border-left: 4px solid #1565c0; }
table { width: 100%; border-collapse: collapse; background: #fff;
border-radius: 6px; overflow: hidden;
box-shadow: 0 1px 4px rgba(0,0,0,.12); margin-bottom: 8px; }
th { background: #1565c0; color: #fff; padding: 9px 12px;
text-align: left; font-size: 12px; font-weight: 600; }
td { padding: 8px 12px; border-bottom: 1px solid #e8ecf0; font-size: 13px; }
tr:last-child td { border-bottom: none; }
tr:nth-child(even) td { background: #f9fafc; }
.badge { display: inline-block; border-radius: 3px; padding: 2px 8px;
font-size: 11px; font-weight: bold; }
.ok { background: #e8f5e9; color: #2e7d32; }
.err { background: #ffebee; color: #b71c1c; }
.warn{ background: #fff8e1; color: #e65100; }
.run { background: #e3f2fd; color: #1565c0; }
.hdd { background: #eceff1; color: #455a64; }
.ssd { background: #e8f5e9; color: #2e7d32; }
.nvme{ background: #e8eaf6; color: #3949ab; }
.footer { margin-top: 40px; font-size: 11px; color: #9e9e9e; text-align: center; }
</style>
</head>
<body>
<div class="page">
<h1>🐙 CephDeploy — Отчёт кластера</h1>
<p class="meta">
Кластер: <strong>{{ cluster.name }}</strong> &nbsp;|&nbsp;
Версия Ceph: <strong>{{ cluster.version }}</strong> &nbsp;|&nbsp;
Создан: {{ cluster.created_at }} &nbsp;|&nbsp;
Сформирован: {{ generated_at }}
</p>
<!-- Серверы -->
<h2>Серверы ({{ servers | length }})</h2>
{% if servers %}
<table>
<thead>
<tr><th>Hostname</th><th>IP-адрес</th><th>Роль</th>
<th>SSH-пользователь</th><th>OSD-дисков</th></tr>
</thead>
<tbody>
{% for s in servers %}
<tr>
<td>{{ s.hostname }}</td>
<td>{{ s.ip_address }}</td>
<td><span class="badge run">{{ s.role }}</span></td>
<td>{{ s.ssh_user }}</td>
<td>{{ s.osd_count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#9e9e9e">Серверов нет.</p>
{% endif %}
<!-- OSD -->
<h2>OSD-устройства</h2>
{% set total_osds = namespace(n=0) %}
{% for s in servers %}{% set total_osds.n = total_osds.n + s.osd_count %}{% endfor %}
{% if total_osds.n > 0 %}
<table>
<thead>
<tr><th>Сервер</th><th>Устройство</th><th>Тип</th><th>Роль OSD</th></tr>
</thead>
<tbody>
{% for s in servers %}
{% for osd in s.osds %}
<tr>
<td>{{ s.hostname }}</td>
<td><code>{{ osd.path }}</code></td>
<td>
<span class="badge {{ osd.type }}">{{ osd.type | upper }}</span>
</td>
<td>{{ osd.role | upper }}</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#9e9e9e">OSD-устройства не назначены.</p>
{% endif %}
<!-- История развёртывания -->
<h2>История развёртывания (последние {{ runs | length }})</h2>
{% if runs %}
<table>
<thead>
<tr><th>#</th><th>Начало</th><th>Завершение</th>
<th>Длительность</th><th>Статус</th></tr>
</thead>
<tbody>
{% for r in runs %}
<tr>
<td>{{ r.id }}</td>
<td>{{ r.started_at }}</td>
<td>{{ r.finished_at or '—' }}</td>
<td>{{ r.duration }}</td>
<td>
{% if r.status == 'success' %}<span class="badge ok">✔ Успех</span>
{% elif r.status == 'failed' %}<span class="badge err">✘ Ошибка</span>
{% elif r.status == 'cancelled' %}<span class="badge warn">⏹ Отменено</span>
{% else %}<span class="badge run">⟳ Выполняется</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#9e9e9e">Запусков развёртывания ещё не было.</p>
{% endif %}
<div class="footer">Сформировано CephDeploy v0.1.0 &nbsp;·&nbsp; ООО Этерсофт</div>
</div>
</body>
</html>
# Тестовый «сервер» для CephDeploy на systemd-образе.
# systemd работает как PID 1, что позволяет cephadm нормально
# поднимать юниты (chrony, ceph-*, podman и т.д.).
# syntax=docker/dockerfile:1
FROM jrei/systemd-debian:12
ENV DEBIAN_FRONTEND=noninteractive
# Удаляем юниты, которые бесполезны в контейнере и любят падать
RUN find /etc/systemd/system /lib/systemd/system -path '*.wants/*' \
\( -name '*systemd-network*' \
-o -name '*systemd-resolved*' \
-o -name '*systemd-udevd*' \
-o -name '*getty*' \) -delete 2>/dev/null || true
RUN apt-get update && apt-get install -y --no-install-recommends \
openssh-server \
sudo \
python3 \
python3-apt \
util-linux \
procps \
iproute2 \
ca-certificates \
curl \
chrony \
lvm2 \
podman \
catatonit \
cephadm \
&& rm -rf /var/lib/apt/lists/*
# Пользователь amegami с sudo без пароля
RUN useradd -m -s /bin/bash amegami \
&& echo "amegami ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/amegami \
&& chmod 440 /etc/sudoers.d/amegami
# SSH-ключ
RUN mkdir -p /home/amegami/.ssh && chmod 700 /home/amegami/.ssh
COPY authorized_keys /home/amegami/.ssh/authorized_keys
RUN chmod 600 /home/amegami/.ssh/authorized_keys \
&& chown -R amegami:amegami /home/amegami/.ssh \
&& ssh-keygen -A \
&& mkdir -p /run/sshd \
&& systemctl enable ssh.service
# Podman при запуске ceph-демонов пытается делать statfs /run/udev
# (даже если udev не запущен). Создаём директорию через tmpfiles.d,
# чтобы systemd восстанавливал её при каждом старте.
RUN echo 'd /run/udev 0755 root root -' > /etc/tmpfiles.d/cephdeploy.conf
# Loop-диски для имитации OSD — создаются через systemd-юнит
COPY setup-loop-disks.sh /usr/local/bin/setup-loop-disks.sh
RUN chmod +x /usr/local/bin/setup-loop-disks.sh
COPY setup-loop-disks.service /etc/systemd/system/setup-loop-disks.service
RUN systemctl enable setup-loop-disks.service
EXPOSE 22
# Запускаем systemd как PID 1
CMD ["/sbin/init"]
# -*- mode: ruby -*-
# Тестовый кластер Ceph: 1 MON + 2 OSD
# Требует: vagrant + vagrant-libvirt
# Установка плагина: vagrant plugin install vagrant-libvirt
#
# Запуск: vagrant up
# Стоп: vagrant halt
# Удаление: vagrant destroy -f
# SSH: vagrant ssh mon-node
NODES = [
{ name: "mon-node", ip: "192.168.122.11", ram: 2048, cpus: 2, osd_disks: 0 },
{ name: "osd-node1", ip: "192.168.122.12", ram: 3072, cpus: 2, osd_disks: 3 },
{ name: "osd-node2", ip: "192.168.122.13", ram: 3072, cpus: 2, osd_disks: 3 },
]
Vagrant.configure("2") do |config|
# ALT Linux 10 (или замените на доступный box)
config.vm.box = "generic/debian12" # заменить на altlinux-box если есть
config.vm.synced_folder ".", "/vagrant", disabled: true
NODES.each do |node|
config.vm.define node[:name] do |vm_config|
vm_config.vm.hostname = node[:name]
vm_config.vm.network "private_network", ip: node[:ip]
vm_config.vm.provider :libvirt do |libvirt|
libvirt.memory = node[:ram]
libvirt.cpus = node[:cpus]
libvirt.driver = "kvm"
# Дополнительные диски для OSD (по 5 ГБ каждый)
node[:osd_disks].times do |i|
libvirt.storage :file,
size: "5G",
type: "raw",
bus: "virtio",
device: "vd#{('b'.ord + i).chr}"
end
end
# Настройка SSH-доступа для amegami
vm_config.vm.provision "shell", inline: <<-SHELL
set -e
id amegami 2>/dev/null || useradd -m -s /bin/bash amegami
echo "amegami ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/amegami
mkdir -p /home/amegami/.ssh
echo "#{File.read(File.expand_path("~/.ssh/id_ed25519.pub")).strip}" \
>> /home/amegami/.ssh/authorized_keys
sort -u /home/amegami/.ssh/authorized_keys -o /home/amegami/.ssh/authorized_keys
chmod 700 /home/amegami/.ssh
chmod 600 /home/amegami/.ssh/authorized_keys
chown -R amegami:amegami /home/amegami/.ssh
SHELL
end
end
end
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN39+VqA2hajA6KuES4Jlka7vg6l67k+av+Og130ftYh amegami@lin-test.office.etersoft.ru
version: "3.9"
# Тестовый кластер из 3 узлов для CephDeploy (Debian 12 + systemd).
# Запуск: docker compose up -d --build
# Стоп: docker compose down
# Добавить в сканер CephDeploy: подсеть 172.20.0.0/24, пользователь amegami.
networks:
ceph-test:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/24
services:
mon-node:
build:
context: .
network: host
hostname: mon-node
container_name: ceph-mon
networks:
ceph-test:
ipv4_address: 172.20.0.11
privileged: true
cgroup: host
tmpfs:
- /run
- /run/lock
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
- mon_data:/var/lib/ceph
- mon_disks:/var/lib/ceph-disks
stop_signal: SIGRTMIN+3
ports:
- "2201:22"
restart: unless-stopped
osd-node1:
build:
context: .
network: host
hostname: osd-node1
container_name: ceph-osd1
networks:
ceph-test:
ipv4_address: 172.20.0.12
privileged: true
cgroup: host
tmpfs:
- /run
- /run/lock
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
- osd1_data:/var/lib/ceph
- osd1_disks:/var/lib/ceph-disks
stop_signal: SIGRTMIN+3
ports:
- "2202:22"
restart: unless-stopped
osd-node2:
build:
context: .
network: host
hostname: osd-node2
container_name: ceph-osd2
networks:
ceph-test:
ipv4_address: 172.20.0.13
privileged: true
cgroup: host
tmpfs:
- /run
- /run/lock
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
- osd2_data:/var/lib/ceph
- osd2_disks:/var/lib/ceph-disks
stop_signal: SIGRTMIN+3
ports:
- "2203:22"
restart: unless-stopped
volumes:
mon_data:
osd1_data:
osd2_data:
mon_disks:
osd1_disks:
osd2_disks:
#!/usr/bin/env bash
# Управление тестовым кластером CephDeploy
#
# Использование:
# ./manage.sh start — запустить 3 тестовых узла
# ./manage.sh stop — остановить
# ./manage.sh status — проверить SSH-доступность
# ./manage.sh destroy — удалить контейнеры и тома
# ./manage.sh clean-keys — убрать старые ключи из known_hosts
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
NODES=(172.20.0.11 172.20.0.12 172.20.0.13)
SSH_KEY="${HOME}/.ssh/id_ed25519"
case "${1:-help}" in
start)
echo "→ Запуск тестового кластера..."
DOCKER_BUILDKIT=1 docker compose up -d
echo ""
echo "Ожидание SSH..."
sleep 3
for ip in "${NODES[@]}"; do
if ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
-i "$SSH_KEY" amegami@"$ip" "echo ok" &>/dev/null; then
echo " ✔ $ip доступен"
else
echo " ✘ $ip недоступен"
fi
done
echo ""
echo "В CephDeploy задайте подсеть: 172.20.0.0/24"
;;
stop)
docker compose stop
echo "Кластер остановлен."
;;
status)
echo "Статус контейнеров:"
docker compose ps
echo ""
echo "SSH-доступность:"
for ip in "${NODES[@]}"; do
name=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=3 \
-i "$SSH_KEY" amegami@"$ip" "hostname" 2>/dev/null || echo "недоступен")
echo " $ip$name"
done
;;
destroy)
read -p "Удалить контейнеры и тома? [y/N] " ans
[[ "$ans" =~ ^[Yy]$ ]] || exit 0
docker compose down -v
echo "Тестовая среда удалена."
;;
clean-keys)
for ip in "${NODES[@]}"; do
ssh-keygen -R "$ip" 2>/dev/null && echo "Ключ $ip удалён из known_hosts"
done
;;
*)
echo "Использование: $0 {start|stop|status|destroy|clean-keys}"
;;
esac
[Unit]
Description=Create loop devices for Ceph OSD testing
After=local-fs.target
Before=ssh.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/setup-loop-disks.sh
# systemd в docker-контейнере иногда не запускает user-sessions
# сам, из-за чего /run/nologin остаётся и SSH-логин для amegami
# блокируется. Запускаем его принудительно.
ExecStartPost=-/bin/systemctl start systemd-user-sessions.service
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
#!/bin/bash
# Создаёт 3 loop-устройства из файлов в /var/lib/ceph-disks для имитации OSD.
# В docker-контейнере ядро не создаёт новые loop-nodes автоматически через
# LOOP_CTL_GET_FREE, поэтому нужные ноды заводятся mknod'ом вручную.
DISK_DIR="/var/lib/ceph-disks"
mkdir -p "$DISK_DIR"
# Заранее создаём loop20..loop22, если их ещё нет (major 7 — блочные loop-устройства)
for n in 20 21 22; do
[ -e "/dev/loop$n" ] || mknod "/dev/loop$n" b 7 "$n"
done
for i in 1 2 3; do
IMG="$DISK_DIR/disk${i}.img"
if [ ! -f "$IMG" ]; then
echo "Creating $IMG (512 MB)..."
dd if=/dev/zero of="$IMG" bs=1M count=512 status=none
fi
# Уже привязан?
if losetup -a | grep -q " ($IMG)"; then
echo "$IMG already attached: $(losetup -j "$IMG" | head -1)"
continue
fi
# Привязываем к нашему выделенному номеру loop$((19+i))
LOOP_DEV="/dev/loop$((19 + i))"
if losetup "$LOOP_DEV" "$IMG" 2>/dev/null; then
echo "Attached $IMG to $LOOP_DEV"
elif LOOP_DEV=$(losetup -f --show "$IMG" 2>/dev/null); then
echo "Attached $IMG to $LOOP_DEV (auto-assigned)"
else
echo "WARN: failed to attach $IMG" >&2
fi
done
exit 0
"""
Базовый класс страницы с заголовком и кнопкой обновления.
Все страницы приложения наследуются от BasePage:
- единый стиль шапки
- кнопка ⟳ вызывает метод refresh()
- подклассы кладут свой контент в self.content_layout
"""
from __future__ import annotations
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import (
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
class RefreshButton(QPushButton):
def __init__(self, parent=None):
super().__init__("⟳ Обновить", parent)
self.setFixedHeight(30)
self.setStyleSheet(
"""
QPushButton {
background: #2a3040;
color: #8fbcbb;
border: 1px solid #3a4050;
border-radius: 5px;
padding: 0 14px;
font-size: 13px;
}
QPushButton:hover {
background: #2e4a7a;
color: #ffffff;
}
QPushButton:pressed {
background: #1e3060;
}
QPushButton:disabled {
color: #444c5c;
border-color: #2a3040;
}
"""
)
class PageHeader(QWidget):
"""Полоса заголовка: иконка + название + необязательный subtitle + кнопка ⟳."""
refresh_clicked = pyqtSignal()
def __init__(
self,
title: str,
subtitle: str = "",
show_refresh: bool = True,
parent=None,
) -> None:
super().__init__(parent)
self.setFixedHeight(56)
self.setStyleSheet("background: #1e2330; border-bottom: 1px solid #2e3340;")
layout = QHBoxLayout(self)
layout.setContentsMargins(20, 0, 16, 0)
# Заголовок + подзаголовок
text_col = QVBoxLayout()
text_col.setSpacing(0)
lbl_title = QLabel(title)
font = QFont()
font.setPointSize(14)
font.setBold(True)
lbl_title.setFont(font)
lbl_title.setStyleSheet("color: #e0e8f8;")
text_col.addWidget(lbl_title)
if subtitle:
lbl_sub = QLabel(subtitle)
lbl_sub.setStyleSheet("color: #5a6478; font-size: 11px;")
text_col.addWidget(lbl_sub)
layout.addLayout(text_col)
layout.addStretch()
if show_refresh:
self._btn_refresh = RefreshButton()
self._btn_refresh.clicked.connect(self.refresh_clicked)
layout.addWidget(self._btn_refresh)
def set_refreshing(self, active: bool) -> None:
"""Блокирует / разблокирует кнопку во время загрузки."""
if hasattr(self, "_btn_refresh"):
self._btn_refresh.setEnabled(not active)
self._btn_refresh.setText("⟳ Загрузка…" if active else "⟳ Обновить")
class BasePage(QWidget):
"""
Базовая страница приложения.
Структура:
┌──────────────────────────────┐
│ PageHeader (title + ⟳) │
├──────────────────────────────┤
│ content_area (QWidget) │ ← подкласс заполняет self.content_layout
└──────────────────────────────┘
Использование в подклассе:
class MyPage(BasePage):
def __init__(self):
super().__init__("Моя страница", "подзаголовок")
# добавить виджеты:
self.content_layout.addWidget(...)
def refresh(self):
# логика перезагрузки данных
...
"""
def __init__(
self,
title: str,
subtitle: str = "",
show_refresh: bool = True,
parent=None,
) -> None:
super().__init__(parent)
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
self._header = PageHeader(title, subtitle, show_refresh)
self._header.refresh_clicked.connect(self.refresh)
root.addWidget(self._header)
# Область контента
self._content_area = QWidget()
self._content_area.setStyleSheet("background: #1a1f29;")
self.content_layout = QVBoxLayout(self._content_area)
self.content_layout.setContentsMargins(20, 16, 20, 16)
self.content_layout.setSpacing(12)
root.addWidget(self._content_area, stretch=1)
# ------------------------------------------------------------------
def refresh(self) -> None:
"""Переопределить в подклассе для перезагрузки данных."""
pass
def set_loading(self, active: bool) -> None:
"""Показывает состояние загрузки в кнопке обновления."""
self._header.set_refreshing(active)
"""
Страница «Кластеры» — список профилей и управление ими.
"""
from __future__ import annotations
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from db import SessionLocal
from db.repository import (
create_cluster,
delete_cluster,
delete_server,
list_clusters,
list_servers,
)
from ui.base_page import BasePage
def _plain_item(text: str) -> QTableWidgetItem:
item = QTableWidgetItem(text)
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
return item
_TABLE_STYLE = """
QTableWidget {
background: #1e2330; gridline-color: #2a3040;
color: #c0c8d8; border: 1px solid #2e3340;
border-radius: 6px; font-size: 12px;
}
QTableWidget::item:selected { background: #2e4a7a; }
QHeaderView::section {
background: #1a2030; color: #8fbcbb; padding: 6px;
border: none; border-bottom: 1px solid #2e3340; font-weight: bold;
}
QTableWidget::item:alternate { background: #1c2230; }
"""
_BOX_STYLE = (
"QGroupBox { color: #8fbcbb; font-weight: bold; "
"border: 1px solid #2e3340; border-radius: 6px; margin-top: 8px; }"
"QGroupBox::title { subcontrol-origin: margin; padding: 0 6px; }"
)
_CLUSTER_COLS = ["Имя", "Версия Ceph", "Серверов", "Создан"]
_SERVER_COLS = ["Hostname", "IP-адрес", "Роль", "SSH-пользователь", ""]
# ---------------------------------------------------------------------------
# Диалог создания кластера
# ---------------------------------------------------------------------------
class CreateClusterDialog(QDialog):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle("Создать кластер")
self.setMinimumWidth(400)
self._build_ui()
def _build_ui(self) -> None:
layout = QVBoxLayout(self)
layout.setSpacing(14)
form = QFormLayout()
form.setSpacing(8)
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self._name_edit = QLineEdit()
self._name_edit.setPlaceholderText("например: prod-cluster-1")
form.addRow("Имя кластера:", self._name_edit)
self._version_combo = QComboBox()
self._version_combo.addItems(["reef", "squid", "quincy"])
form.addRow("Версия Ceph:", self._version_combo)
self._desc_edit = QLineEdit()
self._desc_edit.setPlaceholderText("Краткое описание (необязательно)")
form.addRow("Описание:", self._desc_edit)
layout.addLayout(form)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Создать")
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _on_accept(self) -> None:
if not self._name_edit.text().strip():
QMessageBox.warning(self, "Ошибка", "Введите имя кластера.")
return
self.accept()
def get_data(self) -> tuple[str, str, str | None]:
return (
self._name_edit.text().strip(),
self._version_combo.currentText(),
self._desc_edit.text().strip() or None,
)
# ---------------------------------------------------------------------------
# Основной виджет
# ---------------------------------------------------------------------------
class ClustersWidget(BasePage):
def __init__(self, parent=None) -> None:
super().__init__("🖥️ Кластеры", "Список профилей кластеров")
self._clusters: list = []
self._selected_cluster_id: int | None = None
self._build_content()
self.refresh()
# ------------------------------------------------------------------
# UI
# ------------------------------------------------------------------
def _build_content(self) -> None:
# ── Таблица кластеров ─────────────────────────────────────────
top_box = QGroupBox("Кластеры")
top_box.setStyleSheet(_BOX_STYLE)
top_layout = QVBoxLayout(top_box)
btn_row = QHBoxLayout()
self._btn_create = QPushButton("+ Создать кластер")
self._btn_create.setFixedHeight(32)
self._btn_create.setStyleSheet(
"QPushButton { background: #1565c0; color: #fff; border-radius: 5px; "
"font-size: 13px; font-weight: bold; }"
"QPushButton:hover { background: #1976d2; }"
)
self._btn_create.clicked.connect(self._on_create)
self._btn_delete_cluster = QPushButton("✕ Удалить кластер")
self._btn_delete_cluster.setFixedHeight(32)
self._btn_delete_cluster.setEnabled(False)
self._btn_delete_cluster.setStyleSheet(
"QPushButton { background: #b71c1c; color: #fff; border-radius: 5px; "
"font-size: 13px; font-weight: bold; }"
"QPushButton:hover { background: #c62828; }"
"QPushButton:disabled { background: #333; color: #555; }"
)
self._btn_delete_cluster.clicked.connect(self._on_delete_cluster)
btn_row.addWidget(self._btn_create)
btn_row.addWidget(self._btn_delete_cluster)
btn_row.addStretch()
top_layout.addLayout(btn_row)
self._cluster_table = QTableWidget(0, len(_CLUSTER_COLS))
self._cluster_table.setHorizontalHeaderLabels(_CLUSTER_COLS)
self._cluster_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self._cluster_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self._cluster_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self._cluster_table.setAlternatingRowColors(True)
self._cluster_table.verticalHeader().setVisible(False)
self._cluster_table.horizontalHeader().setStretchLastSection(True)
self._cluster_table.setFixedHeight(180)
self._cluster_table.setStyleSheet(_TABLE_STYLE)
self._cluster_table.itemSelectionChanged.connect(self._on_cluster_selected)
top_layout.addWidget(self._cluster_table)
self.content_layout.addWidget(top_box)
# ── Таблица серверов выбранного кластера ──────────────────────
self._servers_box = QGroupBox("Серверы кластера")
self._servers_box.setStyleSheet(_BOX_STYLE)
srv_layout = QVBoxLayout(self._servers_box)
self._lbl_hint = QLabel("Выберите кластер для просмотра серверов.")
self._lbl_hint.setStyleSheet("color: #5a6478; font-size: 12px;")
self._lbl_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
srv_layout.addWidget(self._lbl_hint)
self._server_table = QTableWidget(0, len(_SERVER_COLS))
self._server_table.setHorizontalHeaderLabels(_SERVER_COLS)
self._server_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self._server_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self._server_table.setAlternatingRowColors(True)
self._server_table.verticalHeader().setVisible(False)
self._server_table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Interactive
)
self._server_table.setVisible(False)
self._server_table.setStyleSheet(_TABLE_STYLE)
for i, w in enumerate([220, 130, 80, 150, 50]):
self._server_table.setColumnWidth(i, w)
srv_layout.addWidget(self._server_table)
self.content_layout.addWidget(self._servers_box, stretch=1)
# ------------------------------------------------------------------
# Данные
# ------------------------------------------------------------------
def refresh(self) -> None:
with SessionLocal() as session:
self._clusters = list_clusters(session)
self._cluster_table.setRowCount(0)
for cluster in self._clusters:
with SessionLocal() as s:
srv_count = len(list_servers(s, cluster.id))
row = self._cluster_table.rowCount()
self._cluster_table.insertRow(row)
self._cluster_table.setRowHeight(row, 32)
self._cluster_table.setItem(row, 0, _plain_item(cluster.name))
self._cluster_table.setItem(row, 1, _plain_item(cluster.ceph_version))
self._cluster_table.setItem(row, 2, _plain_item(str(srv_count)))
self._cluster_table.setItem(
row, 3, _plain_item(cluster.created_at.strftime("%Y-%m-%d %H:%M"))
)
self._selected_cluster_id = None
self._btn_delete_cluster.setEnabled(False)
self._server_table.setVisible(False)
self._lbl_hint.setText("Выберите кластер для просмотра серверов.")
self._lbl_hint.setVisible(True)
def _on_cluster_selected(self) -> None:
row = self._cluster_table.currentRow()
if row < 0 or row >= len(self._clusters):
self._selected_cluster_id = None
self._btn_delete_cluster.setEnabled(False)
return
cluster = self._clusters[row]
self._selected_cluster_id = cluster.id
self._btn_delete_cluster.setEnabled(True)
self._load_servers(cluster.id)
def _load_servers(self, cluster_id: int) -> None:
with SessionLocal() as session:
servers = list_servers(session, cluster_id)
self._server_table.setRowCount(0)
if not servers:
self._lbl_hint.setText("Серверов нет. Добавьте через сканер сети.")
self._lbl_hint.setVisible(True)
self._server_table.setVisible(False)
return
self._lbl_hint.setVisible(False)
self._server_table.setVisible(True)
for srv in servers:
row = self._server_table.rowCount()
self._server_table.insertRow(row)
self._server_table.setRowHeight(row, 32)
self._server_table.setItem(row, 0, _plain_item(srv.hostname))
self._server_table.setItem(row, 1, _plain_item(srv.ip_address))
self._server_table.setItem(row, 2, _plain_item(srv.role.value))
self._server_table.setItem(row, 3, _plain_item(srv.ssh_user))
btn = QPushButton("✕")
btn.setFixedSize(28, 24)
btn.setToolTip("Удалить сервер")
btn.setStyleSheet(
"QPushButton { background: #b71c1c; color: #fff; border-radius: 3px; font-size: 12px; }"
"QPushButton:hover { background: #c62828; }"
)
btn.clicked.connect(lambda _, sid=srv.id: self._on_delete_server(sid))
cell = QWidget()
cl = QHBoxLayout(cell)
cl.setContentsMargins(3, 2, 3, 2)
cl.addWidget(btn)
self._server_table.setCellWidget(row, 4, cell)
# ------------------------------------------------------------------
# Действия
# ------------------------------------------------------------------
def _on_create(self) -> None:
dlg = CreateClusterDialog(self)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
name, version, desc = dlg.get_data()
try:
with SessionLocal() as session:
create_cluster(session, name=name, ceph_version=version, description=desc)
session.commit()
except Exception as exc:
QMessageBox.critical(self, "Ошибка", f"Не удалось создать кластер:\n{exc}")
return
self.refresh()
def _on_delete_cluster(self) -> None:
if self._selected_cluster_id is None:
return
reply = QMessageBox.question(
self, "Удалить кластер?",
"Все серверы и данные этого кластера будут удалены. Продолжить?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
with SessionLocal() as session:
delete_cluster(session, self._selected_cluster_id)
session.commit()
self.refresh()
def _on_delete_server(self, server_id: int) -> None:
reply = QMessageBox.question(
self, "Удалить сервер?",
"Удалить этот сервер из кластера?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
with SessionLocal() as session:
delete_server(session, server_id)
session.commit()
if self._selected_cluster_id:
self._load_servers(self._selected_cluster_id)
self.refresh()
"""
Страница «Развёртывание» — мастер установки Ceph-кластера через Ansible.
"""
from __future__ import annotations
import os
import subprocess
import tempfile
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from PyQt6.QtCore import Qt, QProcess
from PyQt6.QtGui import QColor, QFont, QTextCursor
from PyQt6.QtWidgets import (
QAbstractItemView,
QComboBox,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QMessageBox,
QPushButton,
QTableWidget,
QTableWidgetItem,
QTextEdit,
QVBoxLayout,
QWidget,
)
from core.resources import get_templates_dir
from db import SessionLocal
from db.models import DeployStatus
from db.repository import (
create_deployment_run,
finish_deployment_run,
list_clusters,
list_osd_devices,
list_servers,
)
from ui.base_page import BasePage
_TEMPLATES_DIR = get_templates_dir()
_BOX_STYLE = (
"QGroupBox { color: #8fbcbb; font-weight: bold; "
"border: 1px solid #2e3340; border-radius: 6px; margin-top: 8px; }"
"QGroupBox::title { subcontrol-origin: margin; padding: 0 6px; }"
)
_TABLE_STYLE = """
QTableWidget {
background: #1e2330; gridline-color: #2a3040;
color: #c0c8d8; border: 1px solid #2e3340;
border-radius: 6px; font-size: 12px;
}
QTableWidget::item:selected { background: #2e4a7a; }
QHeaderView::section {
background: #1a2030; color: #8fbcbb; padding: 6px;
border: none; border-bottom: 1px solid #2e3340; font-weight: bold;
}
QTableWidget::item:alternate { background: #1c2230; }
"""
_CHECK_COLS = ["Сервер", "IP-адрес", "Роль", "OSD-дисков", "Готовность"]
def _plain_item(text: str) -> QTableWidgetItem:
item = QTableWidgetItem(text)
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
return item
def _status_item(text: str, color: str) -> QTableWidgetItem:
item = QTableWidgetItem(text)
item.setForeground(QColor(color))
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
return item
class DeployWidget(BasePage):
def __init__(self, parent=None) -> None:
super().__init__("🚀 Развёртывание", "Мастер установки Ceph-кластера")
self._process: QProcess | None = None
self._run_id: int | None = None
self._deploy_dir: str | None = None
# Режим текущего процесса: "deploy" | "cleanup" | None.
# Влияет на сообщения в логе и поведение по завершении.
self._mode: str | None = None
self._build_content()
# ------------------------------------------------------------------
# UI
# ------------------------------------------------------------------
def _build_content(self) -> None:
# ── Выбор кластера ────────────────────────────────────────────
sel_box = QGroupBox("Кластер")
sel_box.setStyleSheet(_BOX_STYLE)
sel_layout = QHBoxLayout(sel_box)
sel_layout.addWidget(QLabel("Кластер:"))
self._cluster_combo = QComboBox()
self._cluster_combo.setMinimumWidth(240)
self._cluster_combo.currentIndexChanged.connect(self._on_cluster_changed)
sel_layout.addWidget(self._cluster_combo)
self._btn_check = QPushButton("🔎 Проверить готовность")
self._btn_check.setFixedHeight(30)
self._btn_check.setEnabled(False)
self._btn_check.setStyleSheet(
"QPushButton { background: #2a3040; color: #8fbcbb; "
"border: 1px solid #3a4050; border-radius: 5px; font-size: 13px; }"
"QPushButton:hover { background: #2e4a7a; color: #fff; }"
"QPushButton:disabled { color: #444; border-color: #2a3040; }"
)
self._btn_check.clicked.connect(self._run_precheck)
sel_layout.addWidget(self._btn_check)
sel_layout.addStretch()
self.content_layout.addWidget(sel_box)
# ── Таблица готовности ────────────────────────────────────────
check_box = QGroupBox("Состояние готовности")
check_box.setStyleSheet(_BOX_STYLE)
check_layout = QVBoxLayout(check_box)
self._check_table = QTableWidget(0, len(_CHECK_COLS))
self._check_table.setHorizontalHeaderLabels(_CHECK_COLS)
self._check_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self._check_table.setAlternatingRowColors(True)
self._check_table.verticalHeader().setVisible(False)
self._check_table.horizontalHeader().setStretchLastSection(True)
self._check_table.setFixedHeight(150)
self._check_table.setStyleSheet(_TABLE_STYLE)
for i, w in enumerate([200, 130, 80, 90]):
self._check_table.setColumnWidth(i, w)
check_layout.addWidget(self._check_table)
self.content_layout.addWidget(check_box)
# ── Журнал развёртывания ──────────────────────────────────────
log_box = QGroupBox("Журнал развёртывания")
log_box.setStyleSheet(_BOX_STYLE)
log_layout = QVBoxLayout(log_box)
self._log = QTextEdit()
self._log.setReadOnly(True)
mono = QFont("Monospace")
mono.setStyleHint(QFont.StyleHint.TypeWriter)
mono.setPointSize(10)
self._log.setFont(mono)
self._log.setStyleSheet(
"QTextEdit { background: #0d1117; color: #c0c8d8; "
"border: 1px solid #2e3340; border-radius: 4px; }"
)
log_layout.addWidget(self._log)
btn_row = QHBoxLayout()
self._btn_deploy = QPushButton("▶ Развернуть")
self._btn_deploy.setFixedHeight(34)
self._btn_deploy.setEnabled(False)
self._btn_deploy.setStyleSheet(
"QPushButton { background: #2e7d32; color: #fff; border-radius: 6px; "
"font-size: 13px; font-weight: bold; }"
"QPushButton:hover { background: #388e3c; }"
"QPushButton:disabled { background: #333; color: #555; }"
)
self._btn_deploy.clicked.connect(self._start_deploy)
self._btn_stop = QPushButton("⏹ Остановить")
self._btn_stop.setFixedHeight(34)
self._btn_stop.setEnabled(False)
self._btn_stop.setStyleSheet(
"QPushButton { background: #b71c1c; color: #fff; border-radius: 6px; "
"font-size: 13px; font-weight: bold; }"
"QPushButton:hover { background: #c62828; }"
"QPushButton:disabled { background: #333; color: #555; }"
)
self._btn_stop.clicked.connect(self._stop_deploy)
self._btn_cleanup = QPushButton("🗑 Очистить")
self._btn_cleanup.setFixedHeight(34)
self._btn_cleanup.setEnabled(False)
self._btn_cleanup.setStyleSheet(
"QPushButton { background: #5d4037; color: #fff; border-radius: 6px; "
"font-size: 13px; font-weight: bold; }"
"QPushButton:hover { background: #6d4c41; }"
"QPushButton:disabled { background: #333; color: #555; }"
)
self._btn_cleanup.clicked.connect(self._on_cleanup_clicked)
self._btn_open_dir = QPushButton("📁 Открыть каталог")
self._btn_open_dir.setFixedHeight(34)
self._btn_open_dir.setEnabled(False)
self._btn_open_dir.setStyleSheet(
"QPushButton { background: #2a3040; color: #8fbcbb; "
"border: 1px solid #3a4050; border-radius: 6px; font-size: 13px; }"
"QPushButton:hover { background: #2e4a7a; color: #fff; }"
"QPushButton:disabled { color: #444; border-color: #2a3040; }"
)
self._btn_open_dir.clicked.connect(self._open_deploy_dir)
btn_row.addWidget(self._btn_deploy)
btn_row.addWidget(self._btn_stop)
btn_row.addWidget(self._btn_cleanup)
btn_row.addWidget(self._btn_open_dir)
btn_row.addStretch()
log_layout.addLayout(btn_row)
self.content_layout.addWidget(log_box, stretch=1)
self._load_clusters()
# ------------------------------------------------------------------
# Данные
# ------------------------------------------------------------------
def _load_clusters(self) -> None:
self._cluster_combo.blockSignals(True)
self._cluster_combo.clear()
with SessionLocal() as session:
clusters = list_clusters(session)
if clusters:
for c in clusters:
self._cluster_combo.addItem(
f"{c.name} [{c.ceph_version}]", userData=c.id
)
self._btn_check.setEnabled(True)
else:
self._cluster_combo.addItem("— нет кластеров —", userData=None)
self._btn_check.setEnabled(False)
self._cluster_combo.blockSignals(False)
self._on_cluster_changed()
def refresh(self) -> None:
self._load_clusters()
def _on_cluster_changed(self) -> None:
cluster_id = self._cluster_combo.currentData()
self._btn_check.setEnabled(cluster_id is not None)
self._btn_deploy.setEnabled(False)
self._btn_cleanup.setEnabled(False)
self._check_table.setRowCount(0)
# ------------------------------------------------------------------
# Проверка готовности
# ------------------------------------------------------------------
def _run_precheck(self) -> None:
cluster_id = self._cluster_combo.currentData()
if cluster_id is None:
return
with SessionLocal() as session:
servers = list_servers(session, cluster_id)
if not servers:
self._log_line("⚠ В кластере нет серверов. Добавьте серверы через сканер сети.")
return
self._check_table.setRowCount(0)
all_ready = True
for srv in servers:
with SessionLocal() as session:
osd_count = len(list_osd_devices(session, srv.id))
ssh_ok = self._tcp_check(srv.ip_address)
needs_osd = srv.role.value in ("osd", "all")
ready = ssh_ok and (not needs_osd or osd_count > 0)
if not ready:
all_ready = False
row = self._check_table.rowCount()
self._check_table.insertRow(row)
self._check_table.setRowHeight(row, 32)
hn = srv.hostname if srv.hostname and srv.hostname != srv.ip_address else "—"
self._check_table.setItem(row, 0, _plain_item(hn))
self._check_table.setItem(row, 1, _plain_item(srv.ip_address))
self._check_table.setItem(row, 2, _plain_item(srv.role.value))
self._check_table.setItem(row, 3, _plain_item(str(osd_count)))
if ready:
self._check_table.setItem(row, 4, _status_item("✔ Готов", "#4caf50"))
elif not ssh_ok:
self._check_table.setItem(row, 4, _status_item("✘ SSH недоступен", "#ef5350"))
else:
self._check_table.setItem(row, 4, _status_item("⚠ Нет OSD", "#ffb74d"))
has_mon = any(s.role.value in ("mon", "all") for s in servers)
if not has_mon:
self._log_line("⚠ Нет ни одного MON-узла. Назначьте роль mon или all хотя бы одному серверу.")
self._btn_deploy.setEnabled(False)
return
# Кнопку очистки делаем доступной как только есть живые SSH-хосты.
# Так можно вручную сбросить остатки даже если кластер не развёрнут.
if any(self._tcp_check(s.ip_address) for s in servers):
self._btn_cleanup.setEnabled(True)
if all_ready:
self._btn_deploy.setEnabled(True)
self._log_line("✔ Все серверы готовы к развёртыванию. Нажмите «▶ Развернуть».")
else:
self._btn_deploy.setEnabled(False)
self._log_line("⚠ Не все серверы готовы. Устраните проблемы и повторите проверку.")
@staticmethod
def _tcp_check(ip: str, port: int = 22, timeout: float = 3.0) -> bool:
import socket
try:
with socket.create_connection((ip, port), timeout=timeout):
return True
except OSError:
return False
# ------------------------------------------------------------------
# Генерация конфигурации Ansible
# ------------------------------------------------------------------
def _generate_configs(self, cluster_id: int) -> str:
"""Рендерит inventory и playbook в временный каталог, возвращает его путь."""
with SessionLocal() as session:
clusters = list_clusters(session)
cluster = next(c for c in clusters if c.id == cluster_id)
servers = list_servers(session, cluster_id)
servers_data = []
for srv in servers:
osds = list_osd_devices(session, srv.id)
servers_data.append({
"hostname": srv.hostname,
"ip_address": srv.ip_address,
"role": srv.role.value,
"ssh_user": srv.ssh_user,
"ssh_key_path": str(Path(srv.ssh_key_path).expanduser()),
"osds": [
{
"path": d.device_path,
"type": d.device_type.value,
"role": d.osd_role.value,
}
for d in osds
],
})
deploy_dir = tempfile.mkdtemp(prefix="cephdeploy_")
env = Environment(
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
trim_blocks=True,
lstrip_blocks=True,
)
inv_path = os.path.join(deploy_dir, "inventory.ini")
Path(inv_path).write_text(
env.get_template("inventory.ini.j2").render(servers=servers_data),
encoding="utf-8",
)
play_path = os.path.join(deploy_dir, "playbook.yml")
Path(play_path).write_text(
env.get_template("ceph_bootstrap.yml.j2").render(
cluster={"name": cluster.name, "version": cluster.ceph_version},
servers=servers_data,
bootstrap_host=next(
(s for s in servers_data if s["role"] in ("mon", "all")),
servers_data[0],
),
),
encoding="utf-8",
)
return deploy_dir
# ------------------------------------------------------------------
# Запуск / остановка развёртывания
# ------------------------------------------------------------------
def _start_deploy(self) -> None:
cluster_id = self._cluster_combo.currentData()
if cluster_id is None:
return
self._log.clear()
try:
self._deploy_dir = self._generate_configs(cluster_id)
self._btn_open_dir.setEnabled(True)
except Exception as exc:
QMessageBox.critical(self, "Ошибка генерации конфигурации", str(exc))
return
inv = os.path.join(self._deploy_dir, "inventory.ini")
play = os.path.join(self._deploy_dir, "playbook.yml")
log_f = os.path.join(self._deploy_dir, "deploy.log")
self._log_line(f"📁 Рабочий каталог: {self._deploy_dir}")
self._log_line(f"▶ ansible-playbook -i {inv} {play}\n")
with SessionLocal() as session:
run = create_deployment_run(session, cluster_id, log_path=log_f)
session.commit()
self._run_id = run.id
self._mode = "deploy"
self._spawn_ansible(inv, play)
def _spawn_ansible(self, inv: str, play: str) -> None:
"""Запускает ansible-playbook через QProcess, используется и deploy, и cleanup."""
self._process = QProcess()
self._process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
self._process.readyReadStandardOutput.connect(self._on_output)
self._process.finished.connect(self._on_finished)
self._process.start("ansible-playbook", ["-i", inv, play])
if not self._process.waitForStarted(3000):
self._log_line(
"✘ Не удалось запустить ansible-playbook. "
"Убедитесь, что ansible-core установлен и доступен в PATH."
)
self._finish_run(success=False)
return
self._btn_deploy.setEnabled(False)
self._btn_cleanup.setEnabled(False)
self._btn_stop.setEnabled(True)
self._btn_check.setEnabled(False)
def _stop_deploy(self) -> None:
if self._process and self._process.state() != QProcess.ProcessState.NotRunning:
self._process.kill()
what = "Очистка" if self._mode == "cleanup" else "Развёртывание"
self._log_line(f"\n⏹ {what} остановлено пользователем.")
self._finish_run(success=False, cancelled=True)
# ------------------------------------------------------------------
# Очистка кластера
# ------------------------------------------------------------------
def _on_cleanup_clicked(self) -> None:
cluster_id = self._cluster_combo.currentData()
if cluster_id is None:
return
reply = QMessageBox.question(
self,
"Очистить кластер?",
"На всех серверах кластера будут удалены cephadm-кластеры, "
"конфиги /etc/ceph и данные /var/lib/ceph, podman-контейнеры ceph-*.\n\n"
"Продолжить?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
self._start_cleanup(cluster_id)
def _start_cleanup(self, cluster_id: int) -> None:
try:
self._deploy_dir = self._generate_cleanup_configs(cluster_id)
self._btn_open_dir.setEnabled(True)
except Exception as exc:
QMessageBox.critical(self, "Ошибка генерации конфигурации", str(exc))
return
inv = os.path.join(self._deploy_dir, "inventory.ini")
play = os.path.join(self._deploy_dir, "cleanup.yml")
self._log_line(f"\n📁 Рабочий каталог очистки: {self._deploy_dir}")
self._log_line(f"▶ ansible-playbook -i {inv} {play}\n")
# Cleanup не записываем в журнал DeploymentRun — это служебная операция
self._run_id = None
self._mode = "cleanup"
self._spawn_ansible(inv, play)
def _generate_cleanup_configs(self, cluster_id: int) -> str:
"""Рендерит inventory + cleanup playbook в отдельный временный каталог."""
with SessionLocal() as session:
clusters = list_clusters(session)
cluster = next(c for c in clusters if c.id == cluster_id)
servers = list_servers(session, cluster_id)
servers_data = [
{
"hostname": s.hostname,
"ip_address": s.ip_address,
"role": s.role.value,
"ssh_user": s.ssh_user,
"ssh_key_path": str(Path(s.ssh_key_path).expanduser()),
}
for s in servers
]
deploy_dir = tempfile.mkdtemp(prefix="cephdeploy_cleanup_")
env = Environment(
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
trim_blocks=True,
lstrip_blocks=True,
)
Path(deploy_dir, "inventory.ini").write_text(
env.get_template("inventory.ini.j2").render(servers=servers_data),
encoding="utf-8",
)
Path(deploy_dir, "cleanup.yml").write_text(
env.get_template("ceph_cleanup.yml.j2").render(
cluster={"name": cluster.name, "version": cluster.ceph_version},
servers=servers_data,
),
encoding="utf-8",
)
return deploy_dir
def _on_output(self) -> None:
if self._process is None:
return
data = self._process.readAllStandardOutput().data().decode(errors="replace")
cursor = self._log.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
self._log.setTextCursor(cursor)
self._log.insertPlainText(data)
self._log.ensureCursorVisible()
# Пишем в файл лога
if self._deploy_dir:
log_f = os.path.join(self._deploy_dir, "deploy.log")
with open(log_f, "a", encoding="utf-8") as f:
f.write(data)
def _on_finished(self, exit_code: int, _exit_status) -> None:
mode = self._mode
if exit_code == 0:
msg = (
"\n✔ Очистка завершена успешно."
if mode == "cleanup"
else "\n✔ Развёртывание завершено успешно!"
)
self._log_line(msg)
else:
label = "Очистка" if mode == "cleanup" else "Ansible"
self._log_line(f"\n✘ {label} завершился с кодом {exit_code}.")
self._finish_run(success=(exit_code == 0))
def _finish_run(self, *, success: bool, cancelled: bool = False) -> None:
mode = self._mode
# Журнал DeploymentRun записываем только для deploy, не для cleanup
if self._run_id is not None:
status = (
DeployStatus.CANCELLED if cancelled
else DeployStatus.SUCCESS if success
else DeployStatus.FAILED
)
log_f = (
os.path.join(self._deploy_dir, "deploy.log")
if self._deploy_dir else None
)
with SessionLocal() as session:
finish_deployment_run(session, self._run_id, status, log_path=log_f)
session.commit()
self._run_id = None
self._btn_deploy.setEnabled(True)
self._btn_stop.setEnabled(False)
self._btn_cleanup.setEnabled(True)
self._btn_check.setEnabled(True)
self._btn_open_dir.setEnabled(self._deploy_dir is not None)
self._process = None
self._mode = None
# После неудачного развёртывания — предлагаем почистить остатки,
# чтобы следующая попытка стартовала с чистого листа.
if mode == "deploy" and not success and not cancelled:
cluster_id = self._cluster_combo.currentData()
if cluster_id is not None:
reply = QMessageBox.question(
self,
"Очистить остатки?",
"Развёртывание завершилось с ошибкой. На серверах могли "
"остаться частично созданные ceph-юниты, контейнеры или "
"конфиги, которые помешают следующему запуску.\n\n"
"Запустить автоматическую очистку?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes,
)
if reply == QMessageBox.StandardButton.Yes:
self._start_cleanup(cluster_id)
# ------------------------------------------------------------------
def _open_deploy_dir(self) -> None:
if self._deploy_dir:
subprocess.Popen(["xdg-open", self._deploy_dir])
def _log_line(self, text: str) -> None:
cursor = self._log.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
self._log.setTextCursor(cursor)
self._log.insertPlainText(text + "\n")
self._log.ensureCursorVisible()
"""
Окно справки CephDeploy — руководство пользователя.
"""
from __future__ import annotations
from PyQt6.QtCore import Qt, QSize
from PyQt6.QtGui import QFont, QKeySequence, QShortcut
from PyQt6.QtWidgets import (
QDialog,
QHBoxLayout,
QListWidget,
QListWidgetItem,
QSplitter,
QTextBrowser,
QVBoxLayout,
QWidget,
)
# ---------------------------------------------------------------------------
# Содержимое справки
# ---------------------------------------------------------------------------
_CHAPTERS: list[tuple[str, str]] = [
("Обзор программы", """
<h2>CephDeploy — обзор программы</h2>
<p><b>CephDeploy</b> — десктопное приложение для автоматизированной установки и
мониторинга кластера <b>Ceph Reef</b> на узлах под управлением ALT Linux.</p>
<h3>Основные возможности</h3>
<ul>
<li>Сканирование сети и обнаружение серверов по SSH</li>
<li>Ведение профилей кластеров в локальной базе данных</li>
<li>Назначение OSD-дисков серверам</li>
<li>Автоматическое развёртывание Ceph через Ansible (cephadm)</li>
<li>Live-мониторинг состояния кластера</li>
<li>История запусков и экспорт отчёта в HTML</li>
</ul>
<h3>Технологический стек</h3>
<table border="0" cellpadding="4">
<tr><td><b>Интерфейс</b></td><td>Python 3.13 + PyQt6</td></tr>
<tr><td><b>База данных</b></td><td>SQLite + SQLAlchemy 2.x</td></tr>
<tr><td><b>SSH</b></td><td>paramiko 4.x</td></tr>
<tr><td><b>Шаблоны</b></td><td>Jinja2 3.x</td></tr>
<tr><td><b>Развёртывание</b></td><td>Ansible Core 2.x + cephadm</td></tr>
</table>
<h3>Типичный рабочий процесс</h3>
<ol>
<li>Создайте профиль кластера на странице <b>Кластеры</b></li>
<li>Найдите серверы через <b>Сканер сети</b> и добавьте их в кластер</li>
<li>Назначьте OSD-диски на странице <b>OSD</b></li>
<li>Запустите установку на странице <b>Развёртывание</b></li>
<li>Следите за состоянием на странице <b>Состояние</b></li>
<li>Экспортируйте отчёт через страницу <b>Отчёт</b></li>
</ol>
"""),
("Кластеры", """
<h2>Кластеры</h2>
<p>Страница управления профилями кластеров Ceph.</p>
<h3>Создание кластера</h3>
<ol>
<li>Нажмите кнопку <b>+ Создать кластер</b></li>
<li>Введите уникальное имя кластера (например, <code>prod-cluster-1</code>)</li>
<li>Выберите версию Ceph: <b>reef</b> (рекомендуется), <i>squid</i>, <i>quincy</i></li>
<li>При необходимости добавьте описание</li>
<li>Нажмите <b>Создать</b></li>
</ol>
<h3>Список серверов</h3>
<p>Выберите кластер в таблице — ниже появится список добавленных серверов.
Каждый сервер отображает hostname, IP-адрес, роль и SSH-пользователя.</p>
<p>Кнопка <b>✕</b> справа от сервера удаляет его из кластера.</p>
<h3>Удаление кластера</h3>
<p>Выберите кластер и нажмите <b>✕ Удалить кластер</b>. Будут удалены все
серверы и история развёртывания этого кластера.</p>
<h3>Роли серверов</h3>
<table border="0" cellpadding="4">
<tr><td><b>mon</b></td><td>Monitor — хранит состояние кластера</td></tr>
<tr><td><b>mgr</b></td><td>Manager — метрики и API</td></tr>
<tr><td><b>osd</b></td><td>Object Storage Daemon — хранение данных</td></tr>
<tr><td><b>mds</b></td><td>Metadata Server — для CephFS</td></tr>
<tr><td><b>rgw</b></td><td>RADOS Gateway — S3/Swift API</td></tr>
<tr><td><b>all</b></td><td>Все роли на одном узле (тестовый режим)</td></tr>
</table>
"""),
("Сканер сети", """
<h2>Сканер сети</h2>
<p>Автоматически находит серверы с открытым портом SSH в указанной подсети.</p>
<h3>Параметры сканирования</h3>
<ul>
<li><b>Подсеть (CIDR)</b> — диапазон адресов, например <code>192.168.0.0/24</code></li>
<li><b>SSH-пользователь</b> — имя пользователя для подключения</li>
<li><b>SSH-ключ</b> — путь к приватному ключу (по умолчанию <code>~/.ssh/id_ed25519</code>)</li>
<li><b>Проверять SSH-авторизацию</b> — если включено, собирает ОС, CPU, RAM и диски</li>
</ul>
<h3>Результаты сканирования</h3>
<p>Найденные хосты отображаются в таблице в режиме реального времени:</p>
<ul>
<li><span style="color:#4caf50">✔ Открыт / OK</span> — SSH доступен и авторизация успешна</li>
<li><span style="color:#ef5350">✘ Закрыт / Ошибка</span> — порт закрыт или ошибка авторизации</li>
<li><span style="color:#ffb74d">? </span> — авторизация не проверялась</li>
</ul>
<p>Столбец <b>Диски</b> показывает физические блочные устройства (H=HDD, S=SSD).
Наведите курсор для подробностей.</p>
<h3>Добавление сервера в кластер</h3>
<ol>
<li>Нажмите <b>+ Добавить</b> напротив найденного хоста</li>
<li>Выберите кластер из выпадающего списка</li>
<li>Назначьте роль (osd, mon, all и т.д.)</li>
<li>Проверьте SSH-пользователя и ключ</li>
<li>Нажмите <b>Добавить в кластер</b></li>
</ol>
<p><b>Важно:</b> кнопка «Добавить» доступна только для хостов с успешной SSH-авторизацией.</p>
"""),
("OSD — диски", """
<h2>OSD — управление дисками</h2>
<p>Страница назначения дисков OSD (Object Storage Daemon) серверам кластера.</p>
<h3>Выбор сервера</h3>
<p>Выберите кластер и сервер в выпадающих списках вверху страницы.
После выбора отобразится таблица назначенных OSD-устройств.</p>
<h3>Добавление OSD-устройства</h3>
<ol>
<li>Нажмите <b>+ Добавить устройство</b></li>
<li>Укажите путь: например <code>/dev/sdb</code>, <code>/dev/sdc</code></li>
<li>Выберите тип устройства: <b>HDD</b>, <b>SSD</b>, <b>NVMe</b></li>
<li>Укажите роль OSD:</li>
</ol>
<table border="0" cellpadding="4">
<tr><td><b>DATA</b></td><td>Основное хранение данных (обычно HDD)</td></tr>
<tr><td><b>WAL</b></td><td>Write-Ahead Log — ускорение записи (SSD/NVMe)</td></tr>
<tr><td><b>DB</b></td><td>BlueStore DB — индексы и метаданные (SSD/NVMe)</td></tr>
</table>
<h3>Рекомендации по конфигурации</h3>
<ul>
<li>Минимальная конфигурация: 1–2 диска DATA на узел</li>
<li>Для лучшей производительности: HDD для DATA + SSD/NVMe для WAL и DB</li>
<li>Не добавляйте системный диск (обычно <code>/dev/sda</code>)</li>
<li>Используйте незаразделённые диски — cephadm сам разметит их</li>
</ul>
<h3>Пример для Gefest</h3>
<table border="0" cellpadding="4">
<tr><td><code>/dev/sda</code></td><td>HDD 5.5T</td><td>DATA</td></tr>
<tr><td><code>/dev/sdd</code></td><td>HDD 5.5T</td><td>DATA</td></tr>
<tr><td><code>/dev/sde</code></td><td>HDD 5.5T</td><td>DATA</td></tr>
<tr><td><code>/dev/sdg</code></td><td>SSD 1.7T</td><td>WAL+DB</td></tr>
</table>
"""),
("Развёртывание", """
<h2>Развёртывание</h2>
<p>Автоматическая установка Ceph-кластера через Ansible и cephadm.</p>
<h3>Проверка готовности</h3>
<p>Нажмите <b>🔎 Проверить готовность</b> перед запуском. Программа проверяет:</p>
<ul>
<li>Доступность SSH-порта каждого сервера</li>
<li>Наличие хотя бы одного MON-узла (роль <code>mon</code> или <code>all</code>)</li>
<li>Наличие OSD-дисков на OSD-узлах</li>
</ul>
<p>Кнопка <b>▶ Развернуть</b> становится активной только когда все серверы готовы.</p>
<h3>Процесс развёртывания</h3>
<ol>
<li>CephDeploy генерирует <code>inventory.ini</code> и <code>playbook.yml</code> (Jinja2)</li>
<li>Запускает <code>ansible-playbook</code> в фоне</li>
<li>Весь вывод в реальном времени отображается в журнале</li>
<li>Результат сохраняется в базе данных (страница <b>Журнал</b>)</li>
</ol>
<h3>Этапы Ansible-плейбука</h3>
<ol>
<li><b>Подготовка узлов</b> — установка cephadm, проверка chronyc/firewalld</li>
<li><b>Bootstrap</b> — инициализация кластера на первом MON</li>
<li><b>Распространение ключа</b> — <code>/etc/ceph/ceph.pub</code> добавляется в
<code>~root/.ssh/authorized_keys</code> на всех узлах; без этого
cephadm-оркестратор не сможет SSH-иться на хосты</li>
<li><b>Добавление узлов</b> — <code>ceph orch host add</code></li>
<li><b>Развёртывание OSD</b> — <code>ceph orch daemon add osd</code></li>
</ol>
<h3>Кнопка «🗑 Очистить»</h3>
<p>Полная очистка кластера на всех узлах:
<code>cephadm rm-cluster --force --zap-osds</code>, удаление
<code>/etc/ceph</code>, <code>/var/lib/ceph</code>,
<code>/var/log/ceph</code>, остановка <code>ceph-*@*</code>-юнитов,
удаление всех podman/docker-контейнеров <code>ceph-*</code>. Операция
спрашивает подтверждение, в журнал запусков не записывается.</p>
<p>Если развёртывание завершилось с ошибкой, CephDeploy автоматически
предложит запустить очистку, чтобы следующая попытка стартовала с
чистого состояния.</p>
<h3>Кнопка «📁 Открыть каталог»</h3>
<p>После генерации конфигурации открывает временный каталог с файлами:
<code>inventory.ini</code>, <code>playbook.yml</code>, <code>deploy.log</code>.
Полезно для отладки.</p>
<h3>Требования</h3>
<ul>
<li>На управляющей машине: <code>ansible-core ≥ 2.12</code></li>
<li>На серверах: root/sudo доступ, Python 3, доступ к репозиторию Ceph</li>
<li>Сеть: все узлы должны быть доступны по SSH с управляющей машины</li>
</ul>
"""),
("Состояние кластера", """
<h2>Состояние кластера</h2>
<p>Live-дашборд состояния работающего Ceph-кластера через SSH.</p>
<h3>Как работает</h3>
<p>Программа подключается по SSH к первому MON-узлу кластера и за один
сеанс <code>cephadm shell</code> выполняет три команды, разделяя их
маркерами (это не требует установки <code>ceph-common</code> на узле):</p>
<table border="0" cellpadding="4">
<tr><td><code>ceph -s</code></td><td>Общее состояние кластера (health, PG, OSD)</td></tr>
<tr><td><code>ceph df</code></td><td>Использование дискового пространства</td></tr>
<tr><td><code>ceph osd tree</code></td><td>Дерево OSD-устройств</td></tr>
</table>
<p>Требуется <code>sudo</code> без пароля для пользователя SSH, так как
<code>cephadm shell</code> внутренне обращается к podman и systemd.</p>
<h3>Интерпретация ceph -s</h3>
<ul>
<li><span style="color:#4caf50"><b>HEALTH_OK</b></span> — кластер работает нормально</li>
<li><span style="color:#ffb74d"><b>HEALTH_WARN</b></span> — есть предупреждения, требуют внимания</li>
<li><span style="color:#ef5350"><b>HEALTH_ERR</b></span> — критическая ошибка, данные под угрозой</li>
</ul>
<h3>Авто-обновление</h3>
<p>Установите интервал в поле <b>Авто (с)</b> для автоматического обновления.
Значение <b>0</b> отключает авто-обновление. Рекомендуется 30–60 секунд.</p>
"""),
("Журнал", """
<h2>Журнал</h2>
<p>История всех запусков развёртывания с возможностью просмотра лога.</p>
<h3>Таблица запусков</h3>
<p>Для каждого запуска отображается:</p>
<ul>
<li><b>#</b> — порядковый номер запуска</li>
<li><b>Начало / Завершение</b> — дата и время</li>
<li><b>Длительность</b> — сколько времени заняло развёртывание</li>
<li><b>Статус</b>:
<span style="color:#4caf50">✔ Успех</span> /
<span style="color:#ef5350">✘ Ошибка</span> /
<span style="color:#ffb74d">⏹ Отменено</span> /
<span style="color:#42a5f5">⟳ Выполняется</span>
</li>
</ul>
<h3>Просмотр лога</h3>
<p>Нажмите на строку в таблице — ниже откроется полный вывод Ansible для
этого запуска. Лог сохраняется в файл в формате <code>deploy.log</code>
во временном каталоге (путь указан в столбце <b>Лог</b>).</p>
"""),
("Отчёт", """
<h2>Отчёт</h2>
<p>Формирование и экспорт конфигурации кластера в HTML-файл.</p>
<h3>Как сформировать отчёт</h3>
<ol>
<li>Выберите кластер в выпадающем списке</li>
<li>Нажмите <b>🔄 Сформировать</b></li>
<li>Просмотрите отчёт в области предпросмотра</li>
<li>Нажмите <b>💾 Сохранить HTML</b> для экспорта в файл</li>
</ol>
<h3>Содержимое отчёта</h3>
<ul>
<li>Общая информация о кластере (название, версия Ceph, дата создания)</li>
<li>Список серверов с ролями и количеством OSD-дисков</li>
<li>Детальная таблица OSD-устройств по всем серверам</li>
<li>История последних 20 запусков развёртывания</li>
</ul>
<p>Отчёт удобно прикладывать к технической документации или ВКР.</p>
"""),
("Настройки", """
<h2>Настройки</h2>
<p>Глобальные параметры приложения, сохраняемые между сеансами.</p>
<h3>SSH по умолчанию</h3>
<ul>
<li><b>Пользователь</b> — имя пользователя для SSH-подключений</li>
<li><b>Путь к SSH-ключу</b> — приватный ключ (поддерживается <code>~</code>)</li>
</ul>
<p>Эти значения подставляются по умолчанию в сканер сети и диалоги добавления серверов.</p>
<h3>Сканирование сети</h3>
<ul>
<li><b>Таймаут TCP</b> — время ожидания при проверке порта 22 (1–30 с)</li>
<li><b>Таймаут SSH</b> — время ожидания SSH-сессии (1–60 с)</li>
</ul>
<h3>Ansible</h3>
<ul>
<li><b>Путь к ansible-playbook</b> — укажите полный путь если команда
недоступна в PATH, например <code>/usr/bin/ansible-playbook</code></li>
</ul>
<h3>Мониторинг</h3>
<ul>
<li><b>Авто-обновление статуса</b> — интервал для страницы «Состояние» (0 = выкл)</li>
</ul>
<p>Настройки хранятся в <code>~/.config/cephdeploy/settings.json</code>.</p>
"""),
("Тестовая среда", """
<h2>Тестовая среда</h2>
<p>Для проверки программы без использования реальных серверов предусмотрена
тестовая среда на основе Docker-контейнеров.</p>
<h3>Быстрый старт</h3>
<pre style="background:#1e2330; color:#c0c8d8; padding:10px; border-radius:4px;">
cd ~/cephdeploy/test-env
./manage.sh start
</pre>
<p>Запустятся три контейнера:</p>
<table border="0" cellpadding="4">
<tr><td><b>mon-node</b></td><td>172.20.0.11</td><td>MON / MGR</td></tr>
<tr><td><b>osd-node1</b></td><td>172.20.0.12</td><td>OSD</td></tr>
<tr><td><b>osd-node2</b></td><td>172.20.0.13</td><td>OSD</td></tr>
</table>
<h3>Тестирование в CephDeploy</h3>
<ol>
<li>Сканер сети → подсеть <code>172.20.0.0/24</code></li>
<li>Добавить все три узла в кластер</li>
<li>На странице OSD назначить <code>/dev/loop0</code>, <code>/dev/loop1</code>
как DATA-диски</li>
<li>Развёртывание → проверить готовность → запустить</li>
</ol>
<h3>Управление</h3>
<pre style="background:#1e2330; color:#c0c8d8; padding:10px; border-radius:4px;">
./manage.sh status # проверить состояние
./manage.sh stop # остановить
./manage.sh destroy # удалить полностью
</pre>
"""),
("Горячие клавиши", """
<h2>Горячие клавиши</h2>
<table border="0" cellpadding="6">
<tr><td><b>F1</b></td><td>Открыть эту справку</td></tr>
<tr><td><b>Ctrl+Q</b></td><td>Выйти из программы</td></tr>
</table>
<h3>Навигация</h3>
<p>Используйте боковую панель для переключения между разделами.
Кнопка <b>⟳ Обновить</b> в шапке каждой страницы перезагружает данные из БД.</p>
"""),
]
# ---------------------------------------------------------------------------
# Окно справки
# ---------------------------------------------------------------------------
_WINDOW_STYLE = """
QDialog { background: #1a1f29; }
QSplitter::handle { background: #2e3340; }
QListWidget {
background: #1e2128;
border: none;
outline: none;
font-size: 13px;
}
QListWidget::item {
color: #c0c8d8;
padding: 8px 14px;
border-radius: 5px;
margin: 2px 5px;
}
QListWidget::item:selected {
background: #2e4a7a;
color: #ffffff;
}
QListWidget::item:hover:!selected { background: #2a3040; }
QTextBrowser {
background: #1e2330;
color: #c0c8d8;
border: none;
font-size: 13px;
padding: 8px;
}
QTextBrowser h2 { color: #8fbcbb; }
QTextBrowser h3 { color: #88c0d0; }
QTextBrowser a { color: #5e81ac; }
QScrollBar:vertical {
background: #1e2330; width: 8px; border-radius: 4px;
}
QScrollBar::handle:vertical {
background: #3a4050; border-radius: 4px; min-height: 30px;
}
"""
_CONTENT_CSS = """
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px;
color: #c0c8d8; background: #1e2330; }
h2 { color: #8fbcbb; border-bottom: 1px solid #2e3340;
padding-bottom: 6px; margin-top: 4px; }
h3 { color: #88c0d0; margin-top: 16px; }
p { margin: 8px 0; line-height: 1.6; }
ul, ol { margin: 8px 0 8px 20px; line-height: 1.7; }
li { margin-bottom: 3px; }
table { border-collapse: collapse; margin: 8px 0; }
td { padding: 3px 12px 3px 0; }
code, pre { background: #161b22; color: #c0c8d8; padding: 2px 6px;
border-radius: 3px; font-family: monospace; font-size: 12px; }
pre { padding: 10px; display: block; white-space: pre; overflow-x: auto; }
b { color: #e0e8f8; }
</style>
"""
class HelpWindow(QDialog):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle("Справка — CephDeploy")
self.resize(1000, 680)
self.setStyleSheet(_WINDOW_STYLE)
self._build_ui()
shortcut = QShortcut(QKeySequence("Escape"), self)
shortcut.activated.connect(self.close)
def _build_ui(self) -> None:
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
splitter = QSplitter(Qt.Orientation.Horizontal)
# ── Оглавление ────────────────────────────────────────────────
self._toc = QListWidget()
self._toc.setFixedWidth(220)
for title, _ in _CHAPTERS:
item = QListWidgetItem(title)
item.setSizeHint(QSize(210, 36))
self._toc.addItem(item)
self._toc.currentRowChanged.connect(self._on_chapter)
splitter.addWidget(self._toc)
# ── Содержимое ────────────────────────────────────────────────
self._browser = QTextBrowser()
self._browser.setOpenExternalLinks(False)
mono = QFont()
mono.setFamily("Segoe UI")
self._browser.setFont(mono)
splitter.addWidget(self._browser)
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
splitter.handle(1).setEnabled(False)
layout.addWidget(splitter)
self._toc.setCurrentRow(0)
def _on_chapter(self, index: int) -> None:
if 0 <= index < len(_CHAPTERS):
_, content = _CHAPTERS[index]
self._browser.setHtml(_CONTENT_CSS + content)
self._browser.verticalScrollBar().setValue(0)
def open_chapter(self, title: str) -> None:
"""Открывает раздел по заголовку (для перехода из меню)."""
for i, (t, _) in enumerate(_CHAPTERS):
if t == title:
self._toc.setCurrentRow(i)
break
"""
Страница «Журнал» — история запусков развёртывания.
"""
from __future__ import annotations
from pathlib import Path
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor, QFont
from PyQt6.QtWidgets import (
QAbstractItemView,
QComboBox,
QFileDialog,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QMessageBox,
QPushButton,
QTableWidget,
QTableWidgetItem,
QTextEdit,
QVBoxLayout,
)
from db import SessionLocal
from db.models import DeployStatus
from db.repository import list_clusters, list_deployment_runs
from ui.base_page import BasePage
_BOX_STYLE = (
"QGroupBox { color: #8fbcbb; font-weight: bold; "
"border: 1px solid #2e3340; border-radius: 6px; margin-top: 8px; }"
"QGroupBox::title { subcontrol-origin: margin; padding: 0 6px; }"
)
_TABLE_STYLE = """
QTableWidget {
background: #1e2330; gridline-color: #2a3040;
color: #c0c8d8; border: 1px solid #2e3340;
border-radius: 6px; font-size: 12px;
}
QTableWidget::item:selected { background: #2e4a7a; }
QHeaderView::section {
background: #1a2030; color: #8fbcbb; padding: 6px;
border: none; border-bottom: 1px solid #2e3340; font-weight: bold;
}
QTableWidget::item:alternate { background: #1c2230; }
"""
_STATUS_COLORS = {
DeployStatus.SUCCESS: "#4caf50",
DeployStatus.FAILED: "#ef5350",
DeployStatus.CANCELLED: "#ffb74d",
DeployStatus.RUNNING: "#42a5f5",
}
_STATUS_LABELS = {
DeployStatus.SUCCESS: "✔ Успех",
DeployStatus.FAILED: "✘ Ошибка",
DeployStatus.CANCELLED: "⏹ Отменено",
DeployStatus.RUNNING: "⟳ Выполняется",
}
_RUN_COLS = ["#", "Начало", "Завершение", "Длительность", "Статус", "Лог"]
def _plain_item(text: str) -> QTableWidgetItem:
item = QTableWidgetItem(text)
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
return item
def _colored_item(text: str, color: str) -> QTableWidgetItem:
item = QTableWidgetItem(text)
item.setForeground(QColor(color))
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
return item
class LogWidget(BasePage):
def __init__(self, parent=None) -> None:
super().__init__("📜 Журнал", "История запусков развёртывания")
self._runs: list = []
self._build_content()
def _build_content(self) -> None:
# ── Выбор кластера ────────────────────────────────────────────
sel_box = QGroupBox("Кластер")
sel_box.setStyleSheet(_BOX_STYLE)
sel_layout = QHBoxLayout(sel_box)
sel_layout.addWidget(QLabel("Кластер:"))
self._cluster_combo = QComboBox()
self._cluster_combo.setMinimumWidth(240)
self._cluster_combo.currentIndexChanged.connect(self._on_cluster_changed)
sel_layout.addWidget(self._cluster_combo)
sel_layout.addStretch()
self.content_layout.addWidget(sel_box)
# ── Таблица запусков ──────────────────────────────────────────
runs_box = QGroupBox("Запуски")
runs_box.setStyleSheet(_BOX_STYLE)
runs_layout = QVBoxLayout(runs_box)
self._table = QTableWidget(0, len(_RUN_COLS))
self._table.setHorizontalHeaderLabels(_RUN_COLS)
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self._table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self._table.setAlternatingRowColors(True)
self._table.verticalHeader().setVisible(False)
self._table.horizontalHeader().setStretchLastSection(True)
self._table.setFixedHeight(200)
self._table.setStyleSheet(_TABLE_STYLE)
for i, w in enumerate([40, 150, 150, 100, 100]):
self._table.setColumnWidth(i, w)
self._table.itemSelectionChanged.connect(self._on_run_selected)
runs_layout.addWidget(self._table)
self.content_layout.addWidget(runs_box)
# ── Просмотр лога ─────────────────────────────────────────────
log_box = QGroupBox("Лог развёртывания")
log_box.setStyleSheet(_BOX_STYLE)
log_layout = QVBoxLayout(log_box)
self._log_view = QTextEdit()
self._log_view.setReadOnly(True)
mono = QFont("Monospace")
mono.setStyleHint(QFont.StyleHint.TypeWriter)
mono.setPointSize(10)
self._log_view.setFont(mono)
self._log_view.setStyleSheet(
"QTextEdit { background: #0d1117; color: #c0c8d8; "
"border: 1px solid #2e3340; border-radius: 4px; }"
)
self._log_view.setPlaceholderText("Выберите запуск для просмотра лога...")
log_layout.addWidget(self._log_view)
# Кнопка скачивания лога выбранного запуска
btn_row = QHBoxLayout()
btn_row.addStretch()
self._btn_download = QPushButton("💾 Скачать лог")
self._btn_download.setFixedHeight(30)
self._btn_download.setEnabled(False)
self._btn_download.setStyleSheet(
"QPushButton { background: #1565c0; color: #fff; border-radius: 5px; "
"font-size: 13px; font-weight: bold; padding: 0 14px; }"
"QPushButton:hover { background: #1976d2; }"
"QPushButton:disabled { background: #333; color: #555; }"
)
self._btn_download.clicked.connect(self._download_log)
btn_row.addWidget(self._btn_download)
log_layout.addLayout(btn_row)
self.content_layout.addWidget(log_box, stretch=1)
self._load_clusters()
# ------------------------------------------------------------------
def _load_clusters(self) -> None:
self._cluster_combo.blockSignals(True)
self._cluster_combo.clear()
with SessionLocal() as session:
clusters = list_clusters(session)
if clusters:
for c in clusters:
self._cluster_combo.addItem(
f"{c.name} [{c.ceph_version}]", userData=c.id
)
else:
self._cluster_combo.addItem("— нет кластеров —", userData=None)
self._cluster_combo.blockSignals(False)
self._on_cluster_changed()
def refresh(self) -> None:
self._load_clusters()
def _on_cluster_changed(self) -> None:
cluster_id = self._cluster_combo.currentData()
self._table.setRowCount(0)
self._runs = []
self._log_view.clear()
self._btn_download.setEnabled(False)
if cluster_id is None:
return
with SessionLocal() as session:
self._runs = list_deployment_runs(session, cluster_id, limit=100)
for run in self._runs:
row = self._table.rowCount()
self._table.insertRow(row)
self._table.setRowHeight(row, 32)
self._table.setItem(row, 0, _plain_item(str(run.id)))
self._table.setItem(
row, 1, _plain_item(run.started_at.strftime("%Y-%m-%d %H:%M:%S"))
)
fin = (
run.finished_at.strftime("%Y-%m-%d %H:%M:%S")
if run.finished_at else "—"
)
self._table.setItem(row, 2, _plain_item(fin))
if run.finished_at:
secs = int((run.finished_at - run.started_at).total_seconds())
dur = f"{secs // 60}м {secs % 60}с"
else:
dur = "—"
self._table.setItem(row, 3, _plain_item(dur))
color = _STATUS_COLORS.get(run.status, "#888")
label = _STATUS_LABELS.get(run.status, run.status.value)
self._table.setItem(row, 4, _colored_item(label, color))
self._table.setItem(row, 5, _plain_item(run.log_path or "—"))
def _on_run_selected(self) -> None:
row = self._table.currentRow()
if row < 0 or row >= len(self._runs):
self._btn_download.setEnabled(False)
return
run = self._runs[row]
if not run.log_path:
self._log_view.setPlainText("Лог не сохранён.")
self._btn_download.setEnabled(False)
return
log_path = Path(run.log_path)
if not log_path.exists():
self._log_view.setPlainText(f"Файл лога не найден:\n{log_path}")
self._btn_download.setEnabled(False)
return
self._log_view.setPlainText(
log_path.read_text(encoding="utf-8", errors="replace")
)
self._btn_download.setEnabled(True)
def _download_log(self) -> None:
row = self._table.currentRow()
if row < 0 or row >= len(self._runs):
return
run = self._runs[row]
if not run.log_path:
return
src = Path(run.log_path)
if not src.exists():
QMessageBox.warning(self, "Ошибка", f"Файл лога не найден:\n{src}")
return
started = run.started_at.strftime("%Y%m%d_%H%M%S")
default_name = f"cephdeploy_run{run.id}_{started}.log"
dst, _ = QFileDialog.getSaveFileName(
self, "Сохранить лог", default_name, "Журнал (*.log);;Все файлы (*)"
)
if not dst:
return
try:
Path(dst).write_bytes(src.read_bytes())
except OSError as exc:
QMessageBox.critical(self, "Ошибка", f"Не удалось сохранить:\n{exc}")
return
QMessageBox.information(self, "Сохранено", f"Лог сохранён:\n{dst}")
"""
Главное окно CephDeploy: боковая навигация + стек страниц.
"""
from __future__ import annotations
from PyQt6.QtCore import Qt, QSize
from PyQt6.QtGui import QFont, QKeySequence, QShortcut
from PyQt6.QtWidgets import (
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QMainWindow,
QMenu,
QMenuBar,
QPushButton,
QVBoxLayout,
QWidget,
QStackedWidget,
)
from ui.base_page import BasePage
from ui.clusters_widget import ClustersWidget
from ui.deploy_widget import DeployWidget
from ui.log_widget import LogWidget
from ui.network_scan_widget import NetworkScanWidget
from ui.osd_widget import OSDWidget
from ui.report_widget import ReportWidget
from ui.help_window import HelpWindow
from ui.settings_widget import SettingsWidget
from ui.status_widget import StatusWidget
# ---------------------------------------------------------------------------
# Элементы навигации: (метка, subtitle)
# ---------------------------------------------------------------------------
_NAV_ITEMS: list[tuple[str, str]] = [
("🖥️ Кластеры", "Список профилей кластеров"),
("🔍 Сканер сети", "Поиск серверов в подсети"),
("🚀 Развёртывание", "Мастер установки Ceph"),
("📊 Состояние", "Дашборд кластера"),
("💾 OSD", "Управление дисками OSD"),
("📜 Журнал", "История запусков"),
("📄 Отчёт", "Экспорт в HTML"),
("⚙️ Настройки", "Параметры приложения"),
]
# Индексы страниц в _NAV_ITEMS
_CLUSTERS_PAGE_IDX = 0
_SCAN_PAGE_IDX = 1
_DEPLOY_PAGE_IDX = 2
_STATUS_PAGE_IDX = 3
_OSD_PAGE_IDX = 4
_LOG_PAGE_IDX = 5
_REPORT_PAGE_IDX = 6
_SETTINGS_PAGE_IDX = 7
# ---------------------------------------------------------------------------
# Боковая панель навигации
# ---------------------------------------------------------------------------
class NavPanel(QListWidget):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setFixedWidth(210)
self.setSpacing(2)
self.setStyleSheet(
"""
QListWidget {
background: #1e2128;
border: none;
outline: none;
}
QListWidget::item {
color: #c0c8d8;
padding: 10px 16px;
border-radius: 6px;
margin: 2px 6px;
font-size: 13px;
}
QListWidget::item:selected {
background: #2e4a7a;
color: #ffffff;
}
QListWidget::item:hover:!selected {
background: #2a3040;
}
"""
)
for label, _ in _NAV_ITEMS:
item = QListWidgetItem(label)
item.setSizeHint(QSize(198, 40))
self.addItem(item)
self.setCurrentRow(0)
# ---------------------------------------------------------------------------
# Главное окно
# ---------------------------------------------------------------------------
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("CephDeploy — управление кластером Ceph")
self.setMinimumSize(1100, 700)
self.resize(1280, 800)
self._help_window: HelpWindow | None = None
self._build_ui()
self._connect_signals()
self._apply_stylesheet()
self._setup_menu()
# ------------------------------------------------------------------
def _build_ui(self) -> None:
central = QWidget()
self.setCentralWidget(central)
root_layout = QVBoxLayout(central)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.setSpacing(0)
root_layout.addWidget(self._build_header())
body = QWidget()
body_layout = QHBoxLayout(body)
body_layout.setContentsMargins(0, 0, 0, 0)
body_layout.setSpacing(0)
self._nav = NavPanel()
body_layout.addWidget(self._nav)
sep = QWidget()
sep.setFixedWidth(1)
sep.setStyleSheet("background: #2e3340;")
body_layout.addWidget(sep)
self._stack = QStackedWidget()
self._pages: list[QWidget] = []
for i, (label, subtitle) in enumerate(_NAV_ITEMS):
clean = label.split(" ", 1)[-1].strip()
if i == _CLUSTERS_PAGE_IDX:
page: QWidget = ClustersWidget()
elif i == _SCAN_PAGE_IDX:
page = NetworkScanWidget()
elif i == _DEPLOY_PAGE_IDX:
page = DeployWidget()
elif i == _STATUS_PAGE_IDX:
page = StatusWidget()
elif i == _OSD_PAGE_IDX:
page = OSDWidget()
elif i == _LOG_PAGE_IDX:
page = LogWidget()
elif i == _REPORT_PAGE_IDX:
page = ReportWidget()
elif i == _SETTINGS_PAGE_IDX:
page = SettingsWidget()
else:
page = BasePage(clean, subtitle)
self._pages.append(page)
self._stack.addWidget(page)
body_layout.addWidget(self._stack, stretch=1)
root_layout.addWidget(body, stretch=1)
self.statusBar().showMessage("Готово")
def _build_header(self) -> QWidget:
header = QWidget()
header.setFixedHeight(52)
header.setStyleSheet(
"background: #161b22; border-bottom: 1px solid #2e3340;"
)
layout = QHBoxLayout(header)
layout.setContentsMargins(20, 0, 20, 0)
logo = QLabel("🐙 CephDeploy")
f = QFont()
f.setPointSize(15)
f.setBold(True)
logo.setFont(f)
logo.setStyleSheet("color: #e06c75;")
version = QLabel("v0.1.0 — ALT Linux / Ceph Reef")
version.setStyleSheet("color: #555e6e; font-size: 11px;")
version.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
btn_help = QPushButton("❓ Справка F1")
btn_help.setFixedHeight(30)
btn_help.setStyleSheet(
"QPushButton { background: #2a3040; color: #8fbcbb; "
"border: 1px solid #3a4050; border-radius: 5px; "
"font-size: 12px; padding: 0 12px; }"
"QPushButton:hover { background: #2e4a7a; color: #fff; }"
)
btn_help.clicked.connect(self._open_help)
layout.addWidget(logo)
layout.addStretch()
layout.addWidget(version)
layout.addSpacing(16)
layout.addWidget(btn_help)
return header
# ------------------------------------------------------------------
def _connect_signals(self) -> None:
self._nav.currentRowChanged.connect(self._on_nav_changed)
def _on_nav_changed(self, index: int) -> None:
self._stack.setCurrentIndex(index)
page = self._pages[index]
# Подсказка в статусной строке
_, subtitle = _NAV_ITEMS[index]
self.statusBar().showMessage(subtitle)
def _apply_stylesheet(self) -> None:
self.setStyleSheet(
"""
QMainWindow { background: #1a1f29; }
QStackedWidget { background: #1a1f29; }
QLabel { color: #c0c8d8; }
QStatusBar {
background: #161b22;
color: #555e6e;
border-top: 1px solid #2e3340;
}
QLineEdit {
background: #1e2330;
color: #c0c8d8;
border: 1px solid #3a4050;
border-radius: 4px;
padding: 4px 8px;
}
QLineEdit:focus { border-color: #4a90d9; }
QCheckBox { color: #c0c8d8; }
QGroupBox { color: #8fbcbb; }
QScrollBar:vertical {
background: #1e2330;
width: 8px;
border-radius: 4px;
}
QScrollBar::handle:vertical {
background: #3a4050;
border-radius: 4px;
min-height: 30px;
}
"""
)
# ------------------------------------------------------------------
def _setup_menu(self) -> None:
menubar = self.menuBar()
menubar.setStyleSheet(
"QMenuBar { background: #161b22; color: #c0c8d8; }"
"QMenuBar::item:selected { background: #2e4a7a; }"
"QMenu { background: #1e2330; color: #c0c8d8; border: 1px solid #2e3340; }"
"QMenu::item:selected { background: #2e4a7a; }"
)
help_menu = menubar.addMenu("Справка")
help_menu.addAction("Руководство пользователя F1", self._open_help)
help_menu.addSeparator()
help_menu.addAction("О программе", self._show_about)
shortcut = QShortcut(QKeySequence("F1"), self)
shortcut.activated.connect(self._open_help)
shortcut_quit = QShortcut(QKeySequence("Ctrl+Q"), self)
shortcut_quit.activated.connect(self.close)
def _open_help(self) -> None:
if self._help_window is None or not self._help_window.isVisible():
self._help_window = HelpWindow(self)
self._help_window.show()
self._help_window.raise_()
self._help_window.activateWindow()
def _show_about(self) -> None:
from PyQt6.QtWidgets import QMessageBox
QMessageBox.about(
self,
"О программе",
"<b>CephDeploy v0.1.0</b><br><br>"
"Программный комплекс для установки и анализа функционирования "
"распределённой системы хранения данных Ceph.<br><br>"
"Разработано в ООО Этерсофт (Санкт-Петербург).<br>"
"ВКР — СПбГТИ(ТУ), кафедра САПРиУ, 09.03.01.<br><br>"
"Стек: Python 3.13 · PyQt6 · SQLAlchemy · paramiko · Ansible",
)
def set_status(self, message: str) -> None:
self.statusBar().showMessage(message)
def navigate_to(self, index: int) -> None:
self._nav.setCurrentRow(index)
self._stack.setCurrentIndex(index)
"""
Страница «Сканер сети» — поиск потенциальных серверов для Ceph.
"""
from __future__ import annotations
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QColor, QFont
from PyQt6.QtWidgets import (
QAbstractItemView,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QProgressBar,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from core.network_scanner import HostInfo, ScanWorker
from db import SessionLocal
from db.repository import create_server, list_clusters
from ui.base_page import BasePage
# ---------------------------------------------------------------------------
# Цветовые метки
# ---------------------------------------------------------------------------
_GREEN = "#4caf50"
_YELLOW = "#ffb74d"
_RED = "#ef5350"
_GREY = "#546e7a"
def _status_item(text: str, color: str) -> QTableWidgetItem:
item = QTableWidgetItem(text)
item.setForeground(QColor(color))
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
return item
def _plain_item(text: str) -> QTableWidgetItem:
item = QTableWidgetItem(text)
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
return item
# ---------------------------------------------------------------------------
# Диалог «Добавить в кластер»
# ---------------------------------------------------------------------------
class AddToClusterDialog(QDialog):
"""Показывает информацию о хосте и сохраняет его в выбранный кластер."""
def __init__(self, info: HostInfo, ssh_user: str, ssh_key: str, parent=None) -> None:
super().__init__(parent)
self.info = info
self._ssh_user = ssh_user
self._ssh_key = ssh_key
self.setWindowTitle(f"Добавить сервер: {info.display_hostname()}")
self.setMinimumWidth(500)
self._build_ui()
def _build_ui(self) -> None:
layout = QVBoxLayout(self)
layout.setSpacing(14)
# Информация о хосте
info_box = QGroupBox("Информация о сервере")
form = QFormLayout(info_box)
form.setSpacing(6)
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
def info_row(label: str, value: str) -> None:
lbl = QLabel(value)
lbl.setStyleSheet("color: #aac; font-size: 12px;")
form.addRow(QLabel(label), lbl)
info_row("IP-адрес:", self.info.ip)
info_row("Hostname:", self.info.display_hostname())
info_row("ОС:", self.info.os_name or "—")
info_row("CPU (ядер):", str(self.info.cpu_count) if self.info.cpu_count else "—")
ram_str = (
f"{self.info.ram_mb // 1024} ГБ"
if self.info.ram_mb >= 1024
else f"{self.info.ram_mb} МБ"
) if self.info.ram_mb else "—"
info_row("RAM:", ram_str)
disk_lines = [
f"{d['name']} {d['size']} ({'HDD' if d['rota'] else 'SSD'})"
for d in self.info.disks
]
info_row("Диски:", " | ".join(disk_lines) if disk_lines else "—")
layout.addWidget(info_box)
# Настройки добавления
add_box = QGroupBox("Параметры добавления")
add_form = QFormLayout(add_box)
add_form.setSpacing(8)
add_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
# Выбор кластера
self._cluster_combo = QComboBox()
self._clusters: list = []
with SessionLocal() as session:
self._clusters = list_clusters(session)
if self._clusters:
for c in self._clusters:
self._cluster_combo.addItem(f"{c.name} [{c.ceph_version}]", userData=c.id)
else:
self._cluster_combo.addItem("— нет кластеров —", userData=None)
add_form.addRow("Кластер:", self._cluster_combo)
# Роль
self._role_combo = QComboBox()
self._role_combo.addItems(["osd", "mon", "mgr", "mds", "rgw", "all"])
add_form.addRow("Роль:", self._role_combo)
# SSH
self._user_edit = QLineEdit(self._ssh_user)
add_form.addRow("SSH-пользователь:", self._user_edit)
self._key_edit = QLineEdit(self._ssh_key)
add_form.addRow("SSH-ключ:", self._key_edit)
layout.addWidget(add_box)
# Кнопки
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel
)
buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Добавить в кластер")
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _on_accept(self) -> None:
cluster_id = self._cluster_combo.currentData()
if cluster_id is None:
QMessageBox.warning(self, "Ошибка", "Сначала создайте кластер на странице «Кластеры».")
return
try:
with SessionLocal() as session:
create_server(
session,
cluster_id=cluster_id,
hostname=self.info.display_hostname(),
ip_address=self.info.ip,
role=self._role_combo.currentText(),
ssh_user=self._user_edit.text().strip() or "amegami",
ssh_key_path=self._key_edit.text().strip() or "~/.ssh/id_ed25519",
)
session.commit()
except Exception as exc:
QMessageBox.critical(self, "Ошибка БД", str(exc))
return
self.accept()
# ---------------------------------------------------------------------------
# Основной виджет страницы
# ---------------------------------------------------------------------------
_COLUMNS = ["IP-адрес", "Hostname", "SSH", "Авторизация", "ОС", "CPU", "RAM", "Диски", "Действие"]
class NetworkScanWidget(BasePage):
"""
Страница сканирования подсети.
Позволяет:
- задать CIDR подсети и параметры SSH
- запустить/остановить сканирование
- просмотреть найденные хосты с их характеристиками
- добавить найденный хост в кластер
- обновить список (кнопка ⟳ в шапке)
"""
def __init__(self, parent=None) -> None:
super().__init__(
"🔍 Сканер сети",
"Поиск серверов с открытым SSH в указанной подсети",
)
self._worker: ScanWorker | None = None
self._results: list[HostInfo] = []
self._build_content()
# ------------------------------------------------------------------
# Построение UI
# ------------------------------------------------------------------
def _build_content(self) -> None:
# ── Панель настроек сканирования ──────────────────────────────
settings_box = QGroupBox("Параметры сканирования")
settings_box.setStyleSheet(
"QGroupBox { color: #8fbcbb; font-weight: bold; "
"border: 1px solid #2e3340; border-radius: 6px; margin-top: 8px; }"
"QGroupBox::title { subcontrol-origin: margin; padding: 0 6px; }"
)
settings_layout = QHBoxLayout(settings_box)
settings_layout.setSpacing(16)
form = QFormLayout()
form.setSpacing(8)
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self._subnet_edit = QLineEdit("192.168.0.0/24")
self._subnet_edit.setPlaceholderText("например 192.168.0.0/24")
self._subnet_edit.setFixedWidth(200)
self._user_edit = QLineEdit("amegami")
self._user_edit.setFixedWidth(140)
self._key_edit = QLineEdit("~/.ssh/id_ed25519")
self._key_edit.setFixedWidth(260)
self._auth_check = QCheckBox("Проверять SSH-авторизацию и собирать инфо")
self._auth_check.setChecked(True)
self._auth_check.setStyleSheet("color: #c0c8d8;")
form.addRow("Подсеть (CIDR):", self._subnet_edit)
form.addRow("SSH-пользователь:", self._user_edit)
form.addRow("SSH-ключ:", self._key_edit)
form.addRow("", self._auth_check)
settings_layout.addLayout(form)
settings_layout.addStretch()
# Кнопки запуска
btn_col = QVBoxLayout()
btn_col.setSpacing(8)
btn_col.setAlignment(Qt.AlignmentFlag.AlignVCenter)
self._btn_scan = QPushButton("▶ Сканировать")
self._btn_scan.setFixedSize(160, 36)
self._btn_scan.setStyleSheet(
"QPushButton { background: #2e7d32; color: #fff; "
"border-radius: 6px; font-size: 13px; font-weight: bold; }"
"QPushButton:hover { background: #388e3c; }"
"QPushButton:pressed { background: #1b5e20; }"
)
self._btn_scan.clicked.connect(self._start_scan)
self._btn_stop = QPushButton("⏹ Остановить")
self._btn_stop.setFixedSize(160, 36)
self._btn_stop.setEnabled(False)
self._btn_stop.setStyleSheet(
"QPushButton { background: #b71c1c; color: #fff; "
"border-radius: 6px; font-size: 13px; font-weight: bold; }"
"QPushButton:hover { background: #c62828; }"
"QPushButton:disabled { background: #333; color: #555; }"
)
self._btn_stop.clicked.connect(self._stop_scan)
btn_col.addWidget(self._btn_scan)
btn_col.addWidget(self._btn_stop)
settings_layout.addLayout(btn_col)
self.content_layout.addWidget(settings_box)
# ── Прогресс-бар ─────────────────────────────────────────────
progress_row = QHBoxLayout()
self._progress = QProgressBar()
self._progress.setFixedHeight(16)
self._progress.setTextVisible(True)
self._progress.setFormat("%v / %m IP проверено")
self._progress.setValue(0)
self._progress.setStyleSheet(
"QProgressBar { background: #1e2330; border: 1px solid #2e3340; "
"border-radius: 4px; color: #8fbcbb; }"
"QProgressBar::chunk { background: #2e7d32; border-radius: 3px; }"
)
self._lbl_status = QLabel("Готов к сканированию")
self._lbl_status.setStyleSheet("color: #5a6478; font-size: 12px;")
self._lbl_status.setFixedWidth(280)
self._lbl_status.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
progress_row.addWidget(self._progress, stretch=1)
progress_row.addWidget(self._lbl_status)
self.content_layout.addLayout(progress_row)
# ── Таблица результатов ───────────────────────────────────────
self._table = QTableWidget(0, len(_COLUMNS))
self._table.setHorizontalHeaderLabels(_COLUMNS)
self._table.setSelectionBehavior(
QAbstractItemView.SelectionBehavior.SelectRows
)
self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self._table.setAlternatingRowColors(True)
self._table.verticalHeader().setVisible(False)
self._table.horizontalHeader().setStretchLastSection(False)
self._table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Interactive
)
# Ширины колонок
widths = [110, 200, 60, 100, 180, 50, 70, 180, 120]
for i, w in enumerate(widths):
self._table.setColumnWidth(i, w)
self._table.setStyleSheet(
"""
QTableWidget {
background: #1e2330;
gridline-color: #2a3040;
color: #c0c8d8;
border: 1px solid #2e3340;
border-radius: 6px;
font-size: 12px;
}
QTableWidget::item:selected {
background: #2e4a7a;
}
QHeaderView::section {
background: #1a2030;
color: #8fbcbb;
padding: 6px;
border: none;
border-bottom: 1px solid #2e3340;
font-weight: bold;
}
QTableWidget::item:alternate {
background: #1c2230;
}
"""
)
self.content_layout.addWidget(self._table, stretch=1)
# ------------------------------------------------------------------
# Логика сканирования
# ------------------------------------------------------------------
def _start_scan(self) -> None:
if self._worker and self._worker.isRunning():
return
subnet = self._subnet_edit.text().strip()
if not subnet:
QMessageBox.warning(self, "Ошибка", "Укажите подсеть в формате CIDR.")
return
# Очищаем предыдущие результаты
self._table.setRowCount(0)
self._results.clear()
self._progress.setValue(0)
self._lbl_status.setText("Сканирование...")
self.set_loading(True)
self._btn_scan.setEnabled(False)
self._btn_stop.setEnabled(True)
self._worker = ScanWorker(
subnet=subnet,
ssh_user=self._user_edit.text().strip() or "amegami",
ssh_key_path=self._key_edit.text().strip() or "~/.ssh/id_ed25519",
check_auth=self._auth_check.isChecked(),
)
self._worker.host_found.connect(self._on_host_found)
self._worker.progress.connect(self._on_progress)
self._worker.finished_scan.connect(self._on_finished)
self._worker.error.connect(self._on_error)
self._worker.start()
def _stop_scan(self) -> None:
if self._worker:
self._worker.cancel()
self._lbl_status.setText("Остановлено пользователем")
self._set_scan_idle()
def _set_scan_idle(self) -> None:
self.set_loading(False)
self._btn_scan.setEnabled(True)
self._btn_stop.setEnabled(False)
# ------------------------------------------------------------------
# Слоты сигналов ScanWorker
# ------------------------------------------------------------------
def _on_host_found(self, info: HostInfo) -> None:
self._results.append(info)
self._add_table_row(info)
def _on_progress(self, current: int, total: int) -> None:
self._progress.setMaximum(total)
self._progress.setValue(current)
found = len(self._results)
self._lbl_status.setText(
f"Проверено: {current}/{total} | Найдено: {found}"
)
def _on_finished(self, found: int) -> None:
self._set_scan_idle()
self._lbl_status.setText(
f"Сканирование завершено. Найдено серверов: {found}"
)
def _on_error(self, message: str) -> None:
self._set_scan_idle()
self._lbl_status.setText(f"Ошибка: {message}")
QMessageBox.critical(self, "Ошибка сканирования", message)
# ------------------------------------------------------------------
# Таблица
# ------------------------------------------------------------------
def _add_table_row(self, info: HostInfo) -> None:
row = self._table.rowCount()
self._table.insertRow(row)
self._table.setRowHeight(row, 34)
# IP
self._table.setItem(row, 0, _plain_item(info.ip))
# Hostname
hn = info.hostname if info.hostname and info.hostname != info.ip else "—"
self._table.setItem(row, 1, _plain_item(hn))
# SSH
self._table.setItem(
row, 2,
_status_item("✔ Открыт" if info.ssh_open else "✘ Закрыт",
_GREEN if info.ssh_open else _RED)
)
# Авторизация
if not self._auth_check.isChecked():
auth_item = _status_item("—", _GREY)
elif info.ssh_auth_ok:
auth_item = _status_item("✔ OK", _GREEN)
elif info.error:
auth_item = _status_item("✘ Ошибка", _RED)
auth_item.setToolTip(info.error)
else:
auth_item = _status_item("?", _YELLOW)
self._table.setItem(row, 3, auth_item)
# ОС
self._table.setItem(row, 4, _plain_item(info.os_name or "—"))
# CPU
self._table.setItem(
row, 5,
_plain_item(str(info.cpu_count) if info.cpu_count else "—")
)
# RAM
if info.ram_mb:
ram_str = (
f"{info.ram_mb // 1024} ГБ"
if info.ram_mb >= 1024
else f"{info.ram_mb} МБ"
)
else:
ram_str = "—"
self._table.setItem(row, 6, _plain_item(ram_str))
# Диски
disk_str = ", ".join(
f"{d['name']} {d['size']} ({'H' if d['rota'] else 'S'})"
for d in info.disks
) or "—"
disk_item = _plain_item(disk_str)
disk_item.setToolTip("\n".join(
f"{d['name']} {d['size']} ({'HDD' if d['rota'] else 'SSD'})"
for d in info.disks
))
self._table.setItem(row, 7, disk_item)
# Кнопка «Добавить»
btn = QPushButton("+ Добавить")
btn.setFixedHeight(26)
btn.setEnabled(info.ssh_auth_ok)
btn.setStyleSheet(
"QPushButton { background: #1565c0; color: #fff; border-radius: 4px; "
"font-size: 11px; }"
"QPushButton:hover { background: #1976d2; }"
"QPushButton:disabled { background: #2a3040; color: #444; }"
)
btn.clicked.connect(lambda _, i=info: self._on_add_clicked(i))
cell_widget = QWidget()
cell_layout = QHBoxLayout(cell_widget)
cell_layout.setContentsMargins(4, 2, 4, 2)
cell_layout.addWidget(btn)
self._table.setCellWidget(row, 8, cell_widget)
def _on_add_clicked(self, info: HostInfo) -> None:
dlg = AddToClusterDialog(
info,
ssh_user=self._user_edit.text().strip() or "amegami",
ssh_key=self._key_edit.text().strip() or "~/.ssh/id_ed25519",
parent=self,
)
if dlg.exec() == QDialog.DialogCode.Accepted:
QMessageBox.information(
self,
"Добавлен",
f"Сервер {info.display_hostname()} ({info.ip}) сохранён в кластер.",
)
# ------------------------------------------------------------------
# Обновление (кнопка ⟳)
# ------------------------------------------------------------------
def refresh(self) -> None:
"""Повторно запускает сканирование с теми же параметрами."""
if self._worker and self._worker.isRunning():
return
self._start_scan()
"""
Страница «OSD» — назначение дисков OSD серверам кластера.
"""
from __future__ import annotations
import json
from pathlib import Path
import paramiko
from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import (
QAbstractItemView,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QMessageBox,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from db import SessionLocal
from db.models import DeviceType, OSDRole
from db.repository import (
add_osd_device,
delete_osd_device,
get_server,
list_clusters,
list_osd_devices,
list_servers,
)
from ui.base_page import BasePage
def _plain_item(text: str) -> QTableWidgetItem:
item = QTableWidgetItem(text)
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
return item
_TABLE_STYLE = """
QTableWidget {
background: #1e2330; gridline-color: #2a3040;
color: #c0c8d8; border: 1px solid #2e3340;
border-radius: 6px; font-size: 12px;
}
QTableWidget::item:selected { background: #2e4a7a; }
QHeaderView::section {
background: #1a2030; color: #8fbcbb; padding: 6px;
border: none; border-bottom: 1px solid #2e3340; font-weight: bold;
}
QTableWidget::item:alternate { background: #1c2230; }
"""
_BOX_STYLE = (
"QGroupBox { color: #8fbcbb; font-weight: bold; "
"border: 1px solid #2e3340; border-radius: 6px; margin-top: 8px; }"
"QGroupBox::title { subcontrol-origin: margin; padding: 0 6px; }"
)
_OSD_COLS = ["Устройство", "Тип", "Роль OSD", ""]
def _server_label(hostname: str, ip: str) -> str:
"""Человеческая подпись сервера для выпадашек: `hostname (ip)` либо просто ip."""
if hostname and hostname != ip:
return f"{hostname} ({ip})"
return ip
# ---------------------------------------------------------------------------
# Фоновый воркер: получает список дисков с сервера по SSH
# ---------------------------------------------------------------------------
class DiskFetchWorker(QThread):
"""Подключается по SSH и запрашивает список физических дисков через lsblk."""
# list[dict] — {name, size, rota, status, detail}
# status: "clean" | "has_fs" | "mounted"
result = pyqtSignal(list)
error = pyqtSignal(str)
def __init__(self, ip: str, user: str, key_path: str, parent=None) -> None:
super().__init__(parent)
self.ip = ip
self.user = user
self.key_path = str(Path(key_path).expanduser())
def run(self) -> None:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(
self.ip, username=self.user, key_filename=self.key_path,
timeout=8, banner_timeout=8, auth_timeout=8,
look_for_keys=False, allow_agent=False,
)
_, stdout, _ = client.exec_command(
"lsblk -J -o NAME,SIZE,ROTA,TYPE,FSTYPE,MOUNTPOINT,LABEL 2>/dev/null",
timeout=10,
)
raw = stdout.read().decode(errors="replace").strip()
try:
data = json.loads(raw or "{}")
except json.JSONDecodeError as exc:
self.error.emit(f"Не удалось разобрать вывод lsblk: {exc}")
return
disks: list[dict] = []
for dev in data.get("blockdevices", []):
dtype = dev.get("type")
if dtype not in ("disk", "loop"):
continue
mounts, fstypes, labels = self._collect_signs(dev)
if mounts:
status = "mounted"
detail = "Активно используется: " + ", ".join(mounts)
elif fstypes or labels:
status = "has_fs"
parts = []
if fstypes:
parts.append("ФС: " + ", ".join(sorted(set(fstypes))))
if labels:
parts.append("метки: " + ", ".join(labels))
detail = "; ".join(parts)
else:
status = "clean"
detail = "Разделы и ФС отсутствуют"
rota_raw = dev.get("rota")
# lsblk в разных версиях возвращает либо "1"/"0", либо 1/0
is_rota = str(rota_raw) == "1" if rota_raw is not None else True
disks.append({
"name": f"/dev/{dev['name']}",
"size": dev.get("size") or "?",
"rota": is_rota,
"kind": dtype, # "disk" | "loop"
"status": status,
"detail": detail,
})
self.result.emit(disks)
except Exception as exc:
self.error.emit(str(exc))
finally:
client.close()
@staticmethod
def _collect_signs(node: dict) -> tuple[list[str], list[str], list[str]]:
"""Рекурсивно собирает mountpoint'ы, fstype'ы и метки по всему поддереву диска."""
mounts: list[str] = []
fstypes: list[str] = []
labels: list[str] = []
mp = node.get("mountpoint")
if mp:
mounts.append(mp)
fs = node.get("fstype")
if fs:
fstypes.append(fs)
lb = node.get("label")
if lb:
labels.append(lb)
for ch in node.get("children") or []:
m2, f2, l2 = DiskFetchWorker._collect_signs(ch)
mounts.extend(m2)
fstypes.extend(f2)
labels.extend(l2)
return mounts, fstypes, labels
# ---------------------------------------------------------------------------
# Диалог добавления OSD-устройства — с выбором диска из списка
# ---------------------------------------------------------------------------
_DISK_COLS = ["Устройство", "Размер", "Тип", "Состояние", "Роль OSD"]
# Для столбца «Состояние»: подпись, цвет, tooltip-префикс
_STATUS_UI = {
"clean": ("✓ Чистый", "#4caf50"),
"has_fs": ("⚠ С данными", "#ffb74d"),
"mounted": ("✕ Смонтирован", "#ef5350"),
"used": ("● Уже добавлен", "#5a6478"),
}
class AddOSDDialog(QDialog):
def __init__(
self,
server_name: str,
ip: str,
ssh_user: str,
ssh_key: str,
existing_paths: set[str],
parent=None,
) -> None:
super().__init__(parent)
self.setWindowTitle(f"Добавить OSD-устройства — {server_name}")
self.setMinimumWidth(560)
self.setMinimumHeight(420)
self._existing = existing_paths
self._ip = ip
self._ssh_user = ssh_user
self._ssh_key = ssh_key
self._disks: list[dict] = []
self._row_status: dict[int, str] = {}
self._show_loop: bool = False
self._worker: DiskFetchWorker | None = None
self._build_ui()
self._fetch_disks()
def _build_ui(self) -> None:
layout = QVBoxLayout(self)
layout.setSpacing(12)
# Статусная строка + опции
top_row = QHBoxLayout()
self._lbl_status = QLabel("⟳ Получение списка дисков с сервера...")
self._lbl_status.setStyleSheet("color: #8fbcbb; font-size: 12px;")
top_row.addWidget(self._lbl_status)
top_row.addStretch()
self._chk_show_loop = QCheckBox("Показать loop-устройства (тестовая среда)")
self._chk_show_loop.setStyleSheet("QCheckBox { color: #8fbcbb; font-size: 11px; }")
self._chk_show_loop.toggled.connect(self._on_show_loop_toggled)
top_row.addWidget(self._chk_show_loop)
layout.addLayout(top_row)
# Таблица дисков
self._disk_table = QTableWidget(0, len(_DISK_COLS))
self._disk_table.setHorizontalHeaderLabels(_DISK_COLS)
self._disk_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self._disk_table.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)
self._disk_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self._disk_table.setAlternatingRowColors(True)
self._disk_table.verticalHeader().setVisible(False)
self._disk_table.horizontalHeader().setStretchLastSection(True)
self._disk_table.setStyleSheet(
"QTableWidget { background:#1e2330; gridline-color:#2a3040; "
"color:#c0c8d8; border:1px solid #2e3340; border-radius:6px; font-size:12px; }"
"QTableWidget::item:selected { background:#2e4a7a; }"
"QHeaderView::section { background:#1a2030; color:#8fbcbb; padding:6px; "
"border:none; border-bottom:1px solid #2e3340; font-weight:bold; }"
"QTableWidget::item:alternate { background:#1c2230; }"
)
for i, w in enumerate([160, 80, 70, 140]):
self._disk_table.setColumnWidth(i, w)
layout.addWidget(self._disk_table, stretch=1)
hint = QLabel(
"Зелёные диски пусты и готовы для OSD. Жёлтые содержат данные — "
"при добавлении в Ceph данные будут уничтожены. Красные смонтированы и недоступны."
)
hint.setStyleSheet("color: #5a6478; font-size: 11px;")
layout.addWidget(hint)
# Кнопки
self._buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
self._buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Добавить выбранные")
self._buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False)
self._buttons.accepted.connect(self._on_accept)
self._buttons.rejected.connect(self.reject)
layout.addWidget(self._buttons)
self._disk_table.itemSelectionChanged.connect(self._on_selection_changed)
# ------------------------------------------------------------------
def _fetch_disks(self) -> None:
self._worker = DiskFetchWorker(self._ip, self._ssh_user, self._ssh_key)
self._worker.result.connect(self._on_disks_fetched)
self._worker.error.connect(self._on_fetch_error)
self._worker.start()
def _on_disks_fetched(self, disks: list) -> None:
self._disks = disks
self._worker = None
self._redraw_table()
def _on_show_loop_toggled(self, checked: bool) -> None:
self._show_loop = checked
self._redraw_table()
def _redraw_table(self) -> None:
self._disk_table.setRowCount(0)
self._row_status.clear()
if not self._disks:
self._lbl_status.setText("Физические диски не обнаружены.")
return
visible = [
d for d in self._disks
if d.get("kind") != "loop" or self._show_loop
]
loop_total = sum(1 for d in self._disks if d.get("kind") == "loop")
clean_count = sum(1 for d in visible if d["status"] == "clean")
hidden_note = (
f" (скрыто loop-устройств: {loop_total})"
if loop_total and not self._show_loop
else ""
)
self._lbl_status.setText(
f"Найдено дисков: {len(visible)}. Подходит для OSD: {clean_count}." + hidden_note
)
for disk in visible:
row = self._disk_table.rowCount()
self._disk_table.insertRow(row)
self._disk_table.setRowHeight(row, 32)
already = disk["name"] in self._existing
is_rotational = disk["rota"]
is_loop = disk.get("kind") == "loop"
if is_loop:
disk_type = "LOOP"
default_role = "DATA"
else:
disk_type = "HDD" if is_rotational else "SSD"
default_role = "DATA" if is_rotational else "WAL"
# «used» имеет приоритет над фактическим состоянием диска
status = "used" if already else disk["status"]
self._row_status[row] = status
status_label, status_color = _STATUS_UI[status]
if status == "used":
tooltip = "Уже добавлено в конфигурацию OSD"
elif status == "mounted":
tooltip = f"{disk['detail']}. Выбор заблокирован."
elif status == "has_fs":
tooltip = (
f"{disk['detail']}. При добавлении в Ceph все данные будут уничтожены!"
)
else:
tooltip = disk["detail"]
name_item = QTableWidgetItem(disk["name"])
size_item = QTableWidgetItem(disk["size"])
type_item = QTableWidgetItem(disk_type)
stat_item = QTableWidgetItem(status_label)
stat_item.setForeground(QColor(status_color))
stat_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
role_combo = QComboBox()
for r in OSDRole:
role_combo.addItem(r.value.upper(), userData=r)
idx = role_combo.findText(default_role)
if idx >= 0:
role_combo.setCurrentIndex(idx)
items = [name_item, size_item, type_item, stat_item]
for col, item in enumerate(items):
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
item.setToolTip(tooltip)
if status == "used":
item.setForeground(QColor("#3a4a5a"))
self._disk_table.setItem(row, col, item)
self._disk_table.setCellWidget(row, 4, role_combo)
if status in ("mounted", "used"):
# Полностью блокируем выбор строки
for col in range(len(items)):
self._disk_table.item(row, col).setFlags(Qt.ItemFlag.NoItemFlags)
role_combo.setEnabled(False)
self._worker = None
def _on_fetch_error(self, error: str) -> None:
self._lbl_status.setText(f"Не удалось получить диски по SSH: {error}")
self._worker = None
def _on_selection_changed(self) -> None:
has_sel = len(self._disk_table.selectedItems()) > 0
self._buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(has_sel)
def _on_accept(self) -> None:
selected_rows = sorted({idx.row() for idx in self._disk_table.selectedIndexes()})
if not selected_rows:
QMessageBox.warning(self, "Ошибка", "Выберите хотя бы один диск.")
return
# Проверяем, что ни один из выбранных не уже добавлен
for row in selected_rows:
name = self._disk_table.item(row, 0).text()
if name in self._existing:
QMessageBox.warning(self, "Ошибка", f"{name} уже добавлен.")
return
# Предупреждение: среди выбранных есть диски с данными
dirty = [
self._disk_table.item(r, 0).text()
for r in selected_rows
if self._row_status.get(r) == "has_fs"
]
if dirty:
bullets = "\n".join(f" • {n}" for n in dirty)
reply = QMessageBox.warning(
self,
"Диски содержат данные",
"На следующих дисках обнаружена файловая система или метки.\n"
"При добавлении в Ceph OSD все данные на них будут БЕЗВОЗВРАТНО "
"уничтожены:\n\n"
f"{bullets}\n\nПродолжить?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
self.accept()
def get_selected_devices(self) -> list[tuple[str, str, str]]:
"""Возвращает список (path, device_type, osd_role) для выбранных дисков."""
result = []
selected_rows = sorted({idx.row() for idx in self._disk_table.selectedIndexes()})
for row in selected_rows:
path = self._disk_table.item(row, 0).text()
disk_type_text = self._disk_table.item(row, 2).text()
# В БД нет типа LOOP — сохраняем такие диски как hdd (тестовая среда)
dev_type = "ssd" if disk_type_text == "SSD" else "hdd"
role_combo: QComboBox = self._disk_table.cellWidget(row, 4)
role = role_combo.currentData().value
result.append((path, dev_type, role))
return result
# ---------------------------------------------------------------------------
# Основной виджет
# ---------------------------------------------------------------------------
class OSDWidget(BasePage):
def __init__(self, parent=None) -> None:
super().__init__("💾 OSD", "Управление дисками OSD")
self._server_id: int | None = None
self._server_name: str = ""
self._build_content()
def _build_content(self) -> None:
# ── Выбор кластера / сервера ──────────────────────────────────
sel_box = QGroupBox("Выбор сервера")
sel_box.setStyleSheet(_BOX_STYLE)
sel_layout = QHBoxLayout(sel_box)
sel_layout.setSpacing(16)
sel_layout.addWidget(QLabel("Кластер:"))
self._cluster_combo = QComboBox()
self._cluster_combo.setMinimumWidth(200)
self._cluster_combo.currentIndexChanged.connect(self._on_cluster_changed)
sel_layout.addWidget(self._cluster_combo)
sel_layout.addWidget(QLabel("Сервер:"))
self._server_combo = QComboBox()
self._server_combo.setMinimumWidth(240)
self._server_combo.currentIndexChanged.connect(self._on_server_changed)
sel_layout.addWidget(self._server_combo)
sel_layout.addStretch()
self.content_layout.addWidget(sel_box)
# ── Таблица OSD-устройств ─────────────────────────────────────
osd_box = QGroupBox("OSD-устройства")
osd_box.setStyleSheet(_BOX_STYLE)
osd_layout = QVBoxLayout(osd_box)
btn_row = QHBoxLayout()
self._btn_add = QPushButton("+ Добавить устройство")
self._btn_add.setFixedHeight(30)
self._btn_add.setEnabled(False)
self._btn_add.setStyleSheet(
"QPushButton { background: #1565c0; color: #fff; border-radius: 5px; "
"font-size: 13px; font-weight: bold; }"
"QPushButton:hover { background: #1976d2; }"
"QPushButton:disabled { background: #333; color: #555; }"
)
self._btn_add.clicked.connect(self._on_add)
btn_row.addWidget(self._btn_add)
btn_row.addStretch()
osd_layout.addLayout(btn_row)
self._osd_table = QTableWidget(0, len(_OSD_COLS))
self._osd_table.setHorizontalHeaderLabels(_OSD_COLS)
self._osd_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self._osd_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self._osd_table.setAlternatingRowColors(True)
self._osd_table.verticalHeader().setVisible(False)
self._osd_table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Interactive
)
self._osd_table.setVisible(False)
self._osd_table.setStyleSheet(_TABLE_STYLE)
for i, w in enumerate([260, 80, 80, 50]):
self._osd_table.setColumnWidth(i, w)
osd_layout.addWidget(self._osd_table)
self._lbl_hint = QLabel("Выберите кластер и сервер.")
self._lbl_hint.setStyleSheet("color: #5a6478; font-size: 12px;")
self._lbl_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
osd_layout.addWidget(self._lbl_hint)
self.content_layout.addWidget(osd_box, stretch=1)
self._load_clusters()
# ------------------------------------------------------------------
# Данные
# ------------------------------------------------------------------
def _load_clusters(self) -> None:
self._cluster_combo.blockSignals(True)
self._cluster_combo.clear()
with SessionLocal() as session:
clusters = list_clusters(session)
if clusters:
for c in clusters:
self._cluster_combo.addItem(f"{c.name} [{c.ceph_version}]", userData=c.id)
else:
self._cluster_combo.addItem("— нет кластеров —", userData=None)
self._cluster_combo.blockSignals(False)
self._on_cluster_changed()
def refresh(self) -> None:
self._load_clusters()
def _on_cluster_changed(self) -> None:
cluster_id = self._cluster_combo.currentData()
self._server_combo.blockSignals(True)
self._server_combo.clear()
if cluster_id is None:
self._server_combo.addItem("— нет серверов —", userData=None)
self._server_combo.blockSignals(False)
self._set_no_server()
return
with SessionLocal() as session:
servers = list_servers(session, cluster_id)
if servers:
for s in servers:
self._server_combo.addItem(
_server_label(s.hostname, s.ip_address), userData=s.id
)
else:
self._server_combo.addItem("— нет серверов —", userData=None)
self._server_combo.blockSignals(False)
self._on_server_changed()
def _on_server_changed(self) -> None:
server_id = self._server_combo.currentData()
if server_id is None:
self._set_no_server()
return
self._server_id = server_id
self._server_name = self._server_combo.currentText().split(" ")[0]
self._btn_add.setEnabled(True)
self._load_osd_devices()
def _set_no_server(self) -> None:
self._server_id = None
self._btn_add.setEnabled(False)
self._osd_table.setRowCount(0)
self._osd_table.setVisible(False)
self._lbl_hint.setText("Выберите кластер и сервер.")
self._lbl_hint.setVisible(True)
def _load_osd_devices(self) -> None:
if self._server_id is None:
return
with SessionLocal() as session:
devices = list_osd_devices(session, self._server_id)
self._osd_table.setRowCount(0)
if not devices:
self._osd_table.setVisible(False)
self._lbl_hint.setText(
"OSD-устройств нет. Нажмите «+ Добавить устройство»."
)
self._lbl_hint.setVisible(True)
return
self._lbl_hint.setVisible(False)
self._osd_table.setVisible(True)
for dev in devices:
row = self._osd_table.rowCount()
self._osd_table.insertRow(row)
self._osd_table.setRowHeight(row, 32)
self._osd_table.setItem(row, 0, _plain_item(dev.device_path))
self._osd_table.setItem(row, 1, _plain_item(dev.device_type.value.upper()))
self._osd_table.setItem(row, 2, _plain_item(dev.osd_role.value.upper()))
btn = QPushButton("✕")
btn.setFixedSize(28, 24)
btn.setToolTip("Удалить устройство")
btn.setStyleSheet(
"QPushButton { background: #b71c1c; color: #fff; "
"border-radius: 3px; font-size: 12px; }"
"QPushButton:hover { background: #c62828; }"
)
btn.clicked.connect(lambda _, did=dev.id: self._on_delete(did))
cell = QWidget()
cl = QHBoxLayout(cell)
cl.setContentsMargins(3, 2, 3, 2)
cl.addWidget(btn)
self._osd_table.setCellWidget(row, 3, cell)
# ------------------------------------------------------------------
# Действия
# ------------------------------------------------------------------
def _on_add(self) -> None:
if self._server_id is None:
return
with SessionLocal() as session:
server = get_server(session, self._server_id)
if server is None:
QMessageBox.critical(self, "Ошибка", "Сервер не найден в БД.")
return
ip = server.ip_address
ssh_user = server.ssh_user
ssh_key = server.ssh_key_path
existing = {d.device_path for d in list_osd_devices(session, self._server_id)}
dlg = AddOSDDialog(
self._server_name,
ip,
ssh_user,
ssh_key,
existing,
parent=self,
)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
devices = dlg.get_selected_devices()
if not devices:
return
try:
with SessionLocal() as session:
for path, dev_type, role in devices:
add_osd_device(session, self._server_id, path, dev_type, role)
session.commit()
except Exception as exc:
QMessageBox.critical(self, "Ошибка", str(exc))
return
self._load_osd_devices()
def _on_delete(self, device_id: int) -> None:
reply = QMessageBox.question(
self, "Удалить устройство?",
"Удалить это OSD-устройство из конфигурации?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
with SessionLocal() as session:
delete_osd_device(session, device_id)
session.commit()
self._load_osd_devices()
"""
Страница «Отчёт» — экспорт конфигурации кластера в HTML.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
QComboBox,
QFileDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QTextBrowser,
QVBoxLayout,
)
from core.resources import get_templates_dir
from db import SessionLocal
from db.repository import (
list_clusters,
list_deployment_runs,
list_osd_devices,
list_servers,
)
from ui.base_page import BasePage
_TEMPLATES_DIR = get_templates_dir()
_BOX_STYLE = (
"QGroupBox { color: #8fbcbb; font-weight: bold; "
"border: 1px solid #2e3340; border-radius: 6px; margin-top: 8px; }"
"QGroupBox::title { subcontrol-origin: margin; padding: 0 6px; }"
)
class ReportWidget(BasePage):
def __init__(self, parent=None) -> None:
super().__init__("📄 Отчёт", "Экспорт конфигурации кластера в HTML")
self._html: str = ""
self._cluster_name: str = ""
self._build_content()
def _build_content(self) -> None:
# ── Панель управления ─────────────────────────────────────────
ctrl_box = QGroupBox("Формирование отчёта")
ctrl_box.setStyleSheet(_BOX_STYLE)
ctrl_layout = QHBoxLayout(ctrl_box)
ctrl_layout.addWidget(QLabel("Кластер:"))
self._cluster_combo = QComboBox()
self._cluster_combo.setMinimumWidth(240)
ctrl_layout.addWidget(self._cluster_combo)
self._btn_gen = QPushButton("🔄 Сформировать")
self._btn_gen.setFixedHeight(30)
self._btn_gen.setEnabled(False)
self._btn_gen.setStyleSheet(
"QPushButton { background: #2a3040; color: #8fbcbb; "
"border: 1px solid #3a4050; border-radius: 5px; font-size: 13px; }"
"QPushButton:hover { background: #2e4a7a; color: #fff; }"
"QPushButton:disabled { color: #444; border-color: #2a3040; }"
)
self._btn_gen.clicked.connect(self._generate)
ctrl_layout.addWidget(self._btn_gen)
self._btn_save = QPushButton("💾 Сохранить HTML")
self._btn_save.setFixedHeight(30)
self._btn_save.setEnabled(False)
self._btn_save.setStyleSheet(
"QPushButton { background: #1565c0; color: #fff; "
"border-radius: 5px; font-size: 13px; font-weight: bold; }"
"QPushButton:hover { background: #1976d2; }"
"QPushButton:disabled { background: #333; color: #555; }"
)
self._btn_save.clicked.connect(self._save)
ctrl_layout.addWidget(self._btn_save)
ctrl_layout.addStretch()
self.content_layout.addWidget(ctrl_box)
# ── Предпросмотр ──────────────────────────────────────────────
preview_box = QGroupBox("Предпросмотр")
preview_box.setStyleSheet(_BOX_STYLE)
preview_layout = QVBoxLayout(preview_box)
self._browser = QTextBrowser()
self._browser.setOpenExternalLinks(False)
self._browser.setStyleSheet(
"QTextBrowser { background: #ffffff; border: 1px solid #2e3340; "
"border-radius: 4px; }"
)
preview_layout.addWidget(self._browser)
self.content_layout.addWidget(preview_box, stretch=1)
self._load_clusters()
# ------------------------------------------------------------------
def _load_clusters(self) -> None:
self._cluster_combo.blockSignals(True)
self._cluster_combo.clear()
with SessionLocal() as session:
clusters = list_clusters(session)
if clusters:
for c in clusters:
self._cluster_combo.addItem(
f"{c.name} [{c.ceph_version}]", userData=c.id
)
self._btn_gen.setEnabled(True)
else:
self._cluster_combo.addItem("— нет кластеров —", userData=None)
self._btn_gen.setEnabled(False)
self._cluster_combo.blockSignals(False)
def refresh(self) -> None:
self._load_clusters()
# ------------------------------------------------------------------
def _generate(self) -> None:
cluster_id = self._cluster_combo.currentData()
if cluster_id is None:
return
with SessionLocal() as session:
clusters = list_clusters(session)
cluster = next(c for c in clusters if c.id == cluster_id)
servers = list_servers(session, cluster_id)
servers_data = []
for srv in servers:
osds = list_osd_devices(session, srv.id)
servers_data.append({
"hostname": srv.hostname,
"ip_address": srv.ip_address,
"role": srv.role.value,
"ssh_user": srv.ssh_user,
"osd_count": len(osds),
"osds": [
{"path": d.device_path,
"type": d.device_type.value,
"role": d.osd_role.value}
for d in osds
],
})
runs_raw = list_deployment_runs(session, cluster_id, limit=20)
runs_data = []
for r in runs_raw:
if r.finished_at:
secs = int((r.finished_at - r.started_at).total_seconds())
dur = f"{secs // 60}м {secs % 60}с"
else:
dur = "—"
runs_data.append({
"id": r.id,
"started_at": r.started_at.strftime("%Y-%m-%d %H:%M:%S"),
"finished_at": (
r.finished_at.strftime("%Y-%m-%d %H:%M:%S")
if r.finished_at else None
),
"duration": dur,
"status": r.status.value,
})
env = Environment(
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
trim_blocks=True,
lstrip_blocks=True,
)
self._html = env.get_template("report.html.j2").render(
cluster={
"name": cluster.name,
"version": cluster.ceph_version,
"created_at": cluster.created_at.strftime("%Y-%m-%d %H:%M"),
},
servers=servers_data,
runs=runs_data,
generated_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
self._cluster_name = cluster.name
self._browser.setHtml(self._html)
self._btn_save.setEnabled(True)
def _save(self) -> None:
if not self._html:
return
default = f"report_{self._cluster_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html"
path, _ = QFileDialog.getSaveFileName(
self, "Сохранить отчёт", default, "HTML файлы (*.html)"
)
if not path:
return
Path(path).write_text(self._html, encoding="utf-8")
QMessageBox.information(self, "Сохранено", f"Отчёт сохранён:\n{path}")
"""
Страница «Настройки» — глобальные параметры приложения.
"""
from __future__ import annotations
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
QDoubleSpinBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSpinBox,
QVBoxLayout,
)
from core.config import AppConfig
from ui.base_page import BasePage
_BOX_STYLE = (
"QGroupBox { color: #8fbcbb; font-weight: bold; "
"border: 1px solid #2e3340; border-radius: 6px; margin-top: 8px; }"
"QGroupBox::title { subcontrol-origin: margin; padding: 0 6px; }"
)
_FIELD_STYLE = (
"QLineEdit, QSpinBox { background: #1e2330; color: #c0c8d8; "
"border: 1px solid #3a4050; border-radius: 4px; padding: 4px 8px; }"
"QLineEdit:focus, QSpinBox:focus { border-color: #4a90d9; }"
)
class SettingsWidget(BasePage):
def __init__(self, parent=None) -> None:
super().__init__("⚙️ Настройки", "Параметры приложения", show_refresh=False)
self._build_content()
self._load()
def _build_content(self) -> None:
form_style = "QLabel { color: #c0c8d8; }"
# ── SSH ───────────────────────────────────────────────────────
ssh_box = QGroupBox("SSH по умолчанию")
ssh_box.setStyleSheet(_BOX_STYLE)
ssh_form = QFormLayout(ssh_box)
ssh_form.setSpacing(10)
ssh_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self._ssh_user = QLineEdit()
self._ssh_user.setStyleSheet(_FIELD_STYLE)
self._ssh_user.setMaximumWidth(280)
ssh_form.addRow("Пользователь:", self._ssh_user)
self._ssh_key = QLineEdit()
self._ssh_key.setStyleSheet(_FIELD_STYLE)
self._ssh_key.setMaximumWidth(400)
self._ssh_key.setPlaceholderText("~/.ssh/id_ed25519")
ssh_form.addRow("Путь к SSH-ключу:", self._ssh_key)
self.content_layout.addWidget(ssh_box)
# ── Сканирование ──────────────────────────────────────────────
scan_box = QGroupBox("Сканирование сети")
scan_box.setStyleSheet(_BOX_STYLE)
scan_form = QFormLayout(scan_box)
scan_form.setSpacing(10)
scan_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self._tcp_timeout = QSpinBox()
self._tcp_timeout.setRange(1, 30)
self._tcp_timeout.setSuffix(" с")
self._tcp_timeout.setFixedWidth(90)
self._tcp_timeout.setStyleSheet(_FIELD_STYLE)
scan_form.addRow("Таймаут TCP:", self._tcp_timeout)
self._ssh_timeout = QSpinBox()
self._ssh_timeout.setRange(1, 60)
self._ssh_timeout.setSuffix(" с")
self._ssh_timeout.setFixedWidth(90)
self._ssh_timeout.setStyleSheet(_FIELD_STYLE)
scan_form.addRow("Таймаут SSH:", self._ssh_timeout)
self.content_layout.addWidget(scan_box)
# ── Ansible ───────────────────────────────────────────────────
ans_box = QGroupBox("Ansible")
ans_box.setStyleSheet(_BOX_STYLE)
ans_form = QFormLayout(ans_box)
ans_form.setSpacing(10)
ans_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self._ansible_bin = QLineEdit()
self._ansible_bin.setStyleSheet(_FIELD_STYLE)
self._ansible_bin.setMaximumWidth(400)
self._ansible_bin.setPlaceholderText("ansible-playbook")
ans_form.addRow("Путь к ansible-playbook:", self._ansible_bin)
self.content_layout.addWidget(ans_box)
# ── Мониторинг ────────────────────────────────────────────────
mon_box = QGroupBox("Мониторинг")
mon_box.setStyleSheet(_BOX_STYLE)
mon_form = QFormLayout(mon_box)
mon_form.setSpacing(10)
mon_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self._refresh_interval = QSpinBox()
self._refresh_interval.setRange(0, 300)
self._refresh_interval.setSuffix(" с")
self._refresh_interval.setSpecialValueText("выкл")
self._refresh_interval.setFixedWidth(90)
self._refresh_interval.setStyleSheet(_FIELD_STYLE)
mon_form.addRow("Авто-обновление статуса:", self._refresh_interval)
self.content_layout.addWidget(mon_box)
# ── Кнопки ───────────────────────────────────────────────────
btn_row = QHBoxLayout()
self._btn_save = QPushButton("💾 Сохранить")
self._btn_save.setFixedHeight(34)
self._btn_save.setStyleSheet(
"QPushButton { background: #1565c0; color: #fff; border-radius: 6px; "
"font-size: 13px; font-weight: bold; }"
"QPushButton:hover { background: #1976d2; }"
)
self._btn_save.clicked.connect(self._save)
self._btn_reset = QPushButton("↺ Сбросить")
self._btn_reset.setFixedHeight(34)
self._btn_reset.setStyleSheet(
"QPushButton { background: #2a3040; color: #8fbcbb; "
"border: 1px solid #3a4050; border-radius: 6px; font-size: 13px; }"
"QPushButton:hover { background: #2e4a7a; color: #fff; }"
)
self._btn_reset.clicked.connect(self._reset)
btn_row.addWidget(self._btn_save)
btn_row.addWidget(self._btn_reset)
btn_row.addStretch()
self.content_layout.addLayout(btn_row)
self.content_layout.addStretch()
# ------------------------------------------------------------------
def _load(self) -> None:
self._ssh_user.setText(AppConfig.get("ssh_user"))
self._ssh_key.setText(AppConfig.get("ssh_key_path"))
self._tcp_timeout.setValue(int(AppConfig.get("scan_tcp_timeout")))
self._ssh_timeout.setValue(int(AppConfig.get("scan_ssh_timeout")))
self._ansible_bin.setText(AppConfig.get("ansible_bin"))
self._refresh_interval.setValue(int(AppConfig.get("status_refresh_interval")))
def _save(self) -> None:
AppConfig.set_value("ssh_user", self._ssh_user.text().strip() or "amegami")
AppConfig.set_value("ssh_key_path", self._ssh_key.text().strip() or "~/.ssh/id_ed25519")
AppConfig.set_value("scan_tcp_timeout", self._tcp_timeout.value())
AppConfig.set_value("scan_ssh_timeout", self._ssh_timeout.value())
AppConfig.set_value("ansible_bin", self._ansible_bin.text().strip() or "ansible-playbook")
AppConfig.set_value("status_refresh_interval", self._refresh_interval.value())
try:
AppConfig.save()
QMessageBox.information(self, "Сохранено", "Настройки сохранены.")
except Exception as exc:
QMessageBox.critical(self, "Ошибка", f"Не удалось сохранить:\n{exc}")
def _reset(self) -> None:
AppConfig.load()
self._load()
"""
Страница «Состояние» — live-дашборд кластера Ceph через SSH.
"""
from __future__ import annotations
import paramiko
from PyQt6.QtCore import QThread, QTimer, pyqtSignal
from PyQt6.QtGui import QColor, QFont
from PyQt6.QtWidgets import (
QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSpinBox,
QTextEdit,
QVBoxLayout,
QWidget,
)
from db import SessionLocal
from db.repository import list_clusters, list_servers
from ui.base_page import BasePage
_BOX_STYLE = (
"QGroupBox { color: #8fbcbb; font-weight: bold; "
"border: 1px solid #2e3340; border-radius: 6px; margin-top: 8px; }"
"QGroupBox::title { subcontrol-origin: margin; padding: 0 6px; }"
)
_LOG_STYLE = (
"QTextEdit { background: #0d1117; color: #c0c8d8; "
"border: 1px solid #2e3340; border-radius: 4px; }"
)
# ---------------------------------------------------------------------------
# Фоновый воркер: подключается по SSH и собирает ceph-статистику
# ---------------------------------------------------------------------------
class CephStatusWorker(QThread):
result = pyqtSignal(dict) # {'ceph_s': str, 'osd_tree': str, 'df': str}
error = pyqtSignal(str)
def __init__(self, ip: str, user: str, key_path: str, parent=None) -> None:
super().__init__(parent)
self.ip = ip
self.user = user
self.key_path = key_path
# Маркеры используются для разделения вывода трёх ceph-команд,
# запущенных за один cephadm shell (чтобы не тратить время на три
# отдельных запуска podman-контейнера).
_M_DF = "___CEPHDEPLOY_DF___"
_M_TREE = "___CEPHDEPLOY_TREE___"
def run(self) -> None:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(
self.ip,
username=self.user,
key_filename=self.key_path,
timeout=10,
banner_timeout=10,
auth_timeout=10,
look_for_keys=False,
allow_agent=False,
)
def run_cmd(cmd: str, timeout: int = 30) -> str:
_, stdout, stderr = client.exec_command(cmd, timeout=timeout)
out = stdout.read().decode(errors="replace")
err = stderr.read().decode(errors="replace").strip()
return out if out.strip() else err
# На узле обычно нет ceph-common — ceph CLI отсутствует.
# cephadm shell поднимает CLI внутри podman-контейнера.
# stderr cephadm shell («Inferring fsid...», «Using recent ceph image...»)
# глушим в /dev/null, stderr самих ceph-команд сливаем в stdout
# через 2>&1, чтобы ошибка команды дошла до пользователя.
sudo = "" if self.user == "root" else "sudo "
inner = (
f"ceph -s 2>&1; echo {self._M_DF}; "
f"ceph df 2>&1; echo {self._M_TREE}; "
f"ceph osd tree 2>&1"
)
combined = run_cmd(
f'{sudo}cephadm shell -- bash -c "{inner}" 2>/dev/null',
timeout=45,
)
ceph_s, _, rest = combined.partition(self._M_DF)
df, _, tree = rest.partition(self._M_TREE)
self.result.emit({
"ceph_s": ceph_s.strip(),
"df": df.strip(),
"osd_tree": tree.strip(),
})
except paramiko.AuthenticationException:
self.error.emit("Ошибка авторизации SSH")
except Exception as exc:
self.error.emit(str(exc))
finally:
client.close()
# ---------------------------------------------------------------------------
# Виджет страницы
# ---------------------------------------------------------------------------
def _mono_edit() -> QTextEdit:
w = QTextEdit()
w.setReadOnly(True)
f = QFont("Monospace")
f.setStyleHint(QFont.StyleHint.TypeWriter)
f.setPointSize(10)
w.setFont(f)
w.setStyleSheet(_LOG_STYLE)
return w
class StatusWidget(BasePage):
def __init__(self, parent=None) -> None:
super().__init__("📊 Состояние", "Дашборд кластера Ceph")
self._worker: CephStatusWorker | None = None
self._timer = QTimer(self)
self._timer.timeout.connect(self._fetch)
self._build_content()
def _build_content(self) -> None:
# ── Управление ────────────────────────────────────────────────
ctrl_box = QGroupBox("Кластер")
ctrl_box.setStyleSheet(_BOX_STYLE)
ctrl_layout = QHBoxLayout(ctrl_box)
ctrl_layout.addWidget(QLabel("Кластер:"))
self._cluster_combo = QComboBox()
self._cluster_combo.setMinimumWidth(240)
self._cluster_combo.currentIndexChanged.connect(self._on_cluster_changed)
ctrl_layout.addWidget(self._cluster_combo)
self._btn_refresh = QPushButton("🔄 Обновить")
self._btn_refresh.setFixedHeight(30)
self._btn_refresh.setEnabled(False)
self._btn_refresh.setStyleSheet(
"QPushButton { background: #2a3040; color: #8fbcbb; "
"border: 1px solid #3a4050; border-radius: 5px; font-size: 13px; }"
"QPushButton:hover { background: #2e4a7a; color: #fff; }"
"QPushButton:disabled { color: #444; border-color: #2a3040; }"
)
self._btn_refresh.clicked.connect(self._fetch)
ctrl_layout.addWidget(self._btn_refresh)
ctrl_layout.addWidget(QLabel(" Авто (с):"))
self._spin_interval = QSpinBox()
self._spin_interval.setRange(0, 300)
self._spin_interval.setValue(0)
self._spin_interval.setSpecialValueText("выкл")
self._spin_interval.setFixedWidth(70)
self._spin_interval.setStyleSheet(
"QSpinBox { background: #1e2330; color: #c0c8d8; "
"border: 1px solid #3a4050; border-radius: 4px; padding: 2px 4px; }"
)
self._spin_interval.valueChanged.connect(self._on_interval_changed)
ctrl_layout.addWidget(self._spin_interval)
self._lbl_status = QLabel("Не подключено")
self._lbl_status.setStyleSheet("color: #5a6478; font-size: 12px;")
ctrl_layout.addWidget(self._lbl_status)
ctrl_layout.addStretch()
self.content_layout.addWidget(ctrl_box)
# ── Верхний ряд: ceph -s | ceph df ───────────────────────────
top_row = QHBoxLayout()
s_box = QGroupBox("ceph -s")
s_box.setStyleSheet(_BOX_STYLE)
s_layout = QVBoxLayout(s_box)
self._txt_status = _mono_edit()
s_layout.addWidget(self._txt_status)
top_row.addWidget(s_box, stretch=3)
df_box = QGroupBox("ceph df")
df_box.setStyleSheet(_BOX_STYLE)
df_layout = QVBoxLayout(df_box)
self._txt_df = _mono_edit()
df_layout.addWidget(self._txt_df)
top_row.addWidget(df_box, stretch=2)
self.content_layout.addLayout(top_row, stretch=2)
# ── Нижний: ceph osd tree ─────────────────────────────────────
tree_box = QGroupBox("ceph osd tree")
tree_box.setStyleSheet(_BOX_STYLE)
tree_layout = QVBoxLayout(tree_box)
self._txt_tree = _mono_edit()
tree_layout.addWidget(self._txt_tree)
self.content_layout.addWidget(tree_box, stretch=2)
self._load_clusters()
# ------------------------------------------------------------------
def _load_clusters(self) -> None:
self._cluster_combo.blockSignals(True)
self._cluster_combo.clear()
with SessionLocal() as session:
clusters = list_clusters(session)
if clusters:
for c in clusters:
self._cluster_combo.addItem(
f"{c.name} [{c.ceph_version}]", userData=c.id
)
else:
self._cluster_combo.addItem("— нет кластеров —", userData=None)
self._cluster_combo.blockSignals(False)
self._on_cluster_changed()
def refresh(self) -> None:
self._load_clusters()
def _on_cluster_changed(self) -> None:
cluster_id = self._cluster_combo.currentData()
has = cluster_id is not None
self._btn_refresh.setEnabled(has)
self._clear_panels()
if has:
self._lbl_status.setText("Готово к обновлению.")
def _on_interval_changed(self, value: int) -> None:
self._timer.stop()
if value > 0:
self._timer.start(value * 1000)
self._fetch()
def _clear_panels(self) -> None:
for w in (self._txt_status, self._txt_df, self._txt_tree):
w.clear()
# ------------------------------------------------------------------
def _fetch(self) -> None:
cluster_id = self._cluster_combo.currentData()
if cluster_id is None:
return
if self._worker and self._worker.isRunning():
return
# Ищем bootstrap-ноду (первый MON или первый сервер)
with SessionLocal() as session:
servers = list_servers(session, cluster_id)
if not servers:
self._lbl_status.setText("Нет серверов в кластере.")
return
mon = next(
(s for s in servers if s.role.value in ("mon", "all")),
servers[0],
)
self._lbl_status.setText(f"Подключение к {mon.hostname}…")
self._btn_refresh.setEnabled(False)
from pathlib import Path as _P
self._worker = CephStatusWorker(
ip=mon.ip_address,
user=mon.ssh_user,
key_path=str(_P(mon.ssh_key_path).expanduser()),
)
self._worker.result.connect(self._on_result)
self._worker.error.connect(self._on_error)
self._worker.start()
def _on_result(self, data: dict) -> None:
from datetime import datetime
self._txt_status.setPlainText(data.get("ceph_s", ""))
self._txt_df.setPlainText(data.get("df", ""))
self._txt_tree.setPlainText(data.get("osd_tree", ""))
ts = datetime.now().strftime("%H:%M:%S")
self._lbl_status.setText(f"Обновлено: {ts}")
self._btn_refresh.setEnabled(True)
self._worker = None
def _on_error(self, message: str) -> None:
self._lbl_status.setText(f"Ошибка: {message}")
self._txt_status.setPlainText(f"Не удалось получить данные:\n{message}")
self._btn_refresh.setEnabled(True)
self._worker = None
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment