Add monitoring UI and application icon

parent 9df7fcb4
"""API-клиенты CephDeploy (ceph-mgr Dashboard REST, Prometheus)."""
"""
Клиент REST API ceph-mgr dashboard.
Используется как резервный канал мониторинга кластера: минует SSH и
podman-обёртку cephadm shell, обращаясь напрямую к mgr-узлу по HTTPS.
Самоподписанный сертификат: verify=False + подавление InsecureRequestWarning.
"""
from __future__ import annotations
from typing import Any
import requests
import urllib3
from urllib3.exceptions import InsecureRequestWarning
urllib3.disable_warnings(InsecureRequestWarning)
class DashboardError(Exception):
pass
class DashboardClient:
def __init__(
self,
host: str,
port: int = 8443,
user: str = "admin",
password: str = "admin",
timeout: float = 10.0,
) -> None:
self._base = f"https://{host}:{port}"
self._user = user
self._password = password
self._timeout = timeout
self._token: str | None = None
self._session = requests.Session()
self._session.verify = False
self._session.headers["Accept"] = "application/vnd.ceph.api.v1.0+json"
# ------------------------------------------------------------------
# Авторизация
# ------------------------------------------------------------------
def login(self) -> None:
try:
resp = self._session.post(
f"{self._base}/api/auth",
json={"username": self._user, "password": self._password},
timeout=self._timeout,
)
except requests.RequestException as exc:
raise DashboardError(f"Нет связи с dashboard: {exc}") from exc
if resp.status_code != 201 and resp.status_code != 200:
raise DashboardError(
f"Авторизация отклонена: HTTP {resp.status_code} — {resp.text[:200]}"
)
data = resp.json()
self._token = data.get("token")
if not self._token:
raise DashboardError("Ответ dashboard не содержит token")
self._session.headers["Authorization"] = f"Bearer {self._token}"
def _ensure_login(self) -> None:
if self._token is None:
self.login()
# ------------------------------------------------------------------
# Запрос с автоматическим ре-логином, если токен истёк
# ------------------------------------------------------------------
def _get(self, path: str) -> Any:
self._ensure_login()
url = f"{self._base}{path}"
resp = self._session.get(url, timeout=self._timeout)
if resp.status_code == 401:
self._token = None
self._ensure_login()
resp = self._session.get(url, timeout=self._timeout)
if resp.status_code >= 400:
raise DashboardError(
f"GET {path}: HTTP {resp.status_code} — {resp.text[:200]}"
)
return resp.json()
# ------------------------------------------------------------------
# Высокоуровневые методы
# ------------------------------------------------------------------
def health_minimal(self) -> dict:
return self._get("/api/health/minimal")
def health_full(self) -> dict:
return self._get("/api/health/full")
def osd_list(self) -> list[dict]:
return self._get("/api/osd")
def mon_list(self) -> list[dict]:
data = self._get("/api/monitor")
return data.get("mon_status", {}).get("monmap", {}).get("mons", [])
def pool_list(self) -> list[dict]:
return self._get("/api/pool")
def hosts(self) -> list[dict]:
return self._get("/api/host")
"""
Клиент Prometheus HTTP API (PromQL).
Подключается к Prometheus-серверу в LXC-контейнере, развёрнутому playbook
`setup_monitoring.yml` на PVE-узле. Используется страницей «Анализ»:
мгновенные значения через /api/v1/query и ряды через /api/v1/query_range.
"""
from __future__ import annotations
import time
from typing import Any
import requests
class PrometheusError(Exception):
pass
class PrometheusClient:
def __init__(self, base_url: str, timeout: float = 10.0) -> None:
self._base = base_url.rstrip("/")
self._timeout = timeout
self._session = requests.Session()
# ------------------------------------------------------------------
# Низкоуровневые запросы
# ------------------------------------------------------------------
def query(self, promql: str) -> list[dict]:
"""Мгновенное значение: возвращает список результатов (metric + value)."""
try:
resp = self._session.get(
f"{self._base}/api/v1/query",
params={"query": promql},
timeout=self._timeout,
)
except requests.RequestException as exc:
raise PrometheusError(f"Нет связи с Prometheus: {exc}") from exc
return self._parse(resp)
def query_range(
self,
promql: str,
start: float,
end: float,
step: str = "30s",
) -> list[dict]:
"""Ряд значений за период [start; end]; step — шаг ('15s', '1m', '5m'...)."""
try:
resp = self._session.get(
f"{self._base}/api/v1/query_range",
params={
"query": promql,
"start": start,
"end": end,
"step": step,
},
timeout=self._timeout,
)
except requests.RequestException as exc:
raise PrometheusError(f"Нет связи с Prometheus: {exc}") from exc
return self._parse(resp)
def _parse(self, resp: requests.Response) -> list[dict]:
if resp.status_code >= 400:
raise PrometheusError(
f"HTTP {resp.status_code}: {resp.text[:200]}"
)
data = resp.json()
if data.get("status") != "success":
raise PrometheusError(
f"Prometheus status={data.get('status')}: {data.get('error')}"
)
return data.get("data", {}).get("result", [])
# ------------------------------------------------------------------
# Хелперы для Ceph-метрик (ceph-mgr prometheus exporter)
# ------------------------------------------------------------------
def scalar(self, promql: str, default: float = 0.0) -> float:
"""Ожидает единственный результат и возвращает его value (float)."""
result = self.query(promql)
if not result:
return default
try:
return float(result[0]["value"][1])
except (IndexError, KeyError, ValueError, TypeError):
return default
def health_status(self) -> int:
"""0=OK, 1=WARN, 2=ERR (значение метрики ceph_health_status)."""
return int(self.scalar("ceph_health_status", default=-1))
def osd_up_count(self) -> int:
return int(self.scalar("sum(ceph_osd_up)"))
def osd_in_count(self) -> int:
return int(self.scalar("sum(ceph_osd_in)"))
def mon_quorum_count(self) -> int:
return int(self.scalar("sum(ceph_mon_quorum_status)"))
def cluster_read_throughput(self) -> float:
"""Суммарная скорость чтения по всем OSD (байт/с)."""
return self.scalar("sum(rate(ceph_osd_op_r_out_bytes[1m]))")
def cluster_write_throughput(self) -> float:
return self.scalar("sum(rate(ceph_osd_op_w_in_bytes[1m]))")
def cluster_iops(self) -> float:
return self.scalar("sum(rate(ceph_osd_op[1m]))")
def osd_latency_series(
self,
duration_seconds: int = 3600,
step: str = "1m",
) -> dict[str, list[tuple[float, float]]]:
"""Latency per OSD за последние duration_seconds секунд.
Возвращает {osd_name: [(ts, value), ...]}."""
end = time.time()
start = end - duration_seconds
promql = (
"rate(ceph_osd_op_latency_sum[1m]) "
"/ rate(ceph_osd_op_latency_count[1m])"
)
raw = self.query_range(promql, start, end, step=step)
out: dict[str, list[tuple[float, float]]] = {}
for row in raw:
metric = row.get("metric", {})
name = metric.get("ceph_daemon") or metric.get("instance", "?")
values = []
for ts, val in row.get("values", []):
try:
values.append((float(ts), float(val)))
except (ValueError, TypeError):
continue
out[name] = values
return out
def pg_state_summary(self) -> dict[str, int]:
"""Сколько PG в каждом состоянии (active+clean, degraded, ...)."""
raw = self.query("ceph_pg_total by (state)") if False else self.query(
"sum(ceph_pg_total) by (state)"
)
out: dict[str, int] = {}
for row in raw:
state = row.get("metric", {}).get("state", "?")
try:
out[state] = int(float(row["value"][1]))
except (IndexError, KeyError, ValueError, TypeError):
continue
return out
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg" x1="16" y1="16" x2="112" y2="112" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#24364f"/>
<stop offset="1" stop-color="#151b26"/>
</linearGradient>
<linearGradient id="mark" x1="35" y1="27" x2="94" y2="101" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#8fbcbb"/>
<stop offset="1" stop-color="#4a90d9"/>
</linearGradient>
</defs>
<rect x="10" y="10" width="108" height="108" rx="24" fill="url(#bg)"/>
<path
d="M64 24 95 42v35L64 95 33 77V42l31-18Z"
fill="none"
stroke="url(#mark)"
stroke-width="8"
stroke-linejoin="round"
/>
<path
d="M64 24v35m31-17L64 59 33 42m31 17v36"
fill="none"
stroke="#c0c8d8"
stroke-width="5"
stroke-linecap="round"
stroke-linejoin="round"
opacity=".72"
/>
<circle cx="64" cy="24" r="9" fill="#e06c75"/>
<circle cx="95" cy="42" r="8" fill="#8fbcbb"/>
<circle cx="95" cy="77" r="8" fill="#4a90d9"/>
<circle cx="64" cy="95" r="9" fill="#e06c75"/>
<circle cx="33" cy="77" r="8" fill="#8fbcbb"/>
<circle cx="33" cy="42" r="8" fill="#4a90d9"/>
<circle cx="64" cy="59" r="10" fill="#f2cc60"/>
</svg>
......@@ -15,6 +15,7 @@ a = Analysis(
binaries=[],
datas=[
(str(ROOT / 'templates'), 'templates'),
(str(ROOT / 'assets'), 'assets'),
],
hiddenimports=[
# SQLAlchemy диалект SQLite
......@@ -68,7 +69,7 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None,
icon=str(ROOT / 'assets' / 'cephdeploy.svg'),
)
coll = COLLECT(
......
......@@ -16,6 +16,23 @@ _DEFAULTS: dict = {
"scan_ssh_timeout": 8,
"ansible_bin": "ansible-playbook",
"status_refresh_interval": 30,
# ── Мониторинг: ceph-mgr Dashboard API ──────────────────────────────
"dashboard_port": 8443,
"dashboard_user": "admin",
"dashboard_password": "admin",
# ── Мониторинг: внешний LXC (Prometheus + Grafana + Alertmanager) ──
"prometheus_url": "", # http://<ct_ip>:9090 — заполняется после setup_monitoring
"grafana_url": "", # http://<ct_ip>:3000
"alertmanager_url": "", # http://<ct_ip>:9093
"grafana_password": "admin",
# ── Параметры создания LXC на PVE-хосте ─────────────────────────────
"monitoring_pve_host": "gefest.office.etersoft.ru",
"monitoring_vmid": 200,
"monitoring_ct_ip": "192.168.0.20/24",
"monitoring_ct_gw": "192.168.0.1",
"monitoring_ct_bridge": "vmbr0",
"monitoring_ct_storage": "local-zfs",
"monitoring_ct_template": "debian-12-standard_12.12-1_amd64.tar.zst",
}
......
......@@ -15,6 +15,18 @@ def get_templates_dir() -> Path:
return Path(__file__).resolve().parent.parent / "templates"
def get_assets_dir() -> Path:
"""Возвращает путь к каталогу статических ресурсов."""
if hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS) / "assets"
return Path(__file__).resolve().parent.parent / "assets"
def get_app_icon_path() -> Path:
"""Возвращает путь к эмблеме приложения."""
return get_assets_dir() / "cephdeploy.svg"
def get_db_path() -> Path:
"""
Путь к SQLite-базе данных.
......
......@@ -77,8 +77,8 @@
ansible.builtin.command: >
cephadm bootstrap
--mon-ip {{ bootstrap_host.ip_address }}
--initial-dashboard-user admin
--initial-dashboard-password admin
--initial-dashboard-user {{ dashboard.user }}
--initial-dashboard-password {{ dashboard.password }}
--skip-monitoring-stack
--allow-overwrite
args:
......@@ -90,6 +90,28 @@
var: bootstrap_result.stdout_lines
when: bootstrap_result.stdout_lines is defined
# ── mgr-модули для внешнего мониторинга ─────────────────────────────
# prometheus: экспортёр метрик на :9283 — его опрашивает Prometheus-сервер,
# развёрнутый отдельным playbook setup_monitoring.yml в LXC на PVE-узле.
# dashboard: REST API на :{{ dashboard.port }} — резервный канал status_widget.
- name: Включить mgr-модуль prometheus
ansible.builtin.command: cephadm shell -- ceph mgr module enable prometheus
register: mod_prom
failed_when: false
changed_when: mod_prom.rc == 0
- name: Включить mgr-модуль dashboard
ansible.builtin.command: cephadm shell -- ceph mgr module enable dashboard
register: mod_dash
failed_when: false
changed_when: mod_dash.rc == 0
- name: Настроить порт dashboard
ansible.builtin.command: >
cephadm shell -- ceph config set mgr mgr/dashboard/ssl_server_port {{ dashboard.port }}
failed_when: false
changed_when: false
- name: Прочитать публичный ключ cephadm-оркестратора
ansible.builtin.slurp:
src: /etc/ceph/ceph.pub
......@@ -130,13 +152,17 @@
become: true
tasks:
# `ceph` CLI не установлен на узлах (пакета ceph-common нет),
# поэтому все ceph-команды идут через `cephadm shell -- ceph ...`,
# которое запускает их внутри podman-контейнера с Ceph.
- name: Подождать готовности оркестратора
ansible.builtin.command: ceph orch status
ansible.builtin.command: cephadm shell -- ceph orch status
register: orch_status
retries: 10
delay: 10
until: "'available' in orch_status.stdout"
failed_when: false
changed_when: false
{% for s in servers if s.hostname != bootstrap_host.hostname %}
{#
......@@ -144,12 +170,11 @@
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 }}
cephadm shell -- ceph orch host add {{ hv }} {{ s.ip_address }}
register: add_host_result
failed_when: false
changed_when: add_host_result.rc == 0
......@@ -171,7 +196,7 @@
{% 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 }}
cephadm shell -- ceph orch daemon add osd {{ osd_hv }}:{{ osd.path }}
register: osd_result
failed_when: false
changed_when: "'Created osd' in (osd_result.stdout | default(''))"
......@@ -179,9 +204,10 @@
{% endfor %}
{% endfor %}
- name: Статус кластера
ansible.builtin.command: ceph -s
ansible.builtin.command: cephadm shell -- ceph -s
register: ceph_status
failed_when: false
changed_when: false
- name: Вывод статуса
ansible.builtin.debug:
......
{
"id": null,
"uid": "ceph-overview",
"title": "Ceph Overview",
"timezone": "browser",
"schemaVersion": 38,
"version": 0,
"refresh": "30s",
"time": {
"from": "now-1h",
"to": "now"
},
"tags": [
"ceph",
"cephdeploy"
],
"panels": [
{
"id": 1,
"type": "stat",
"title": "Health",
"gridPos": {
"h": 4,
"w": 4,
"x": 0,
"y": 0
},
"targets": [
{
"expr": "ceph_health_status",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"mappings": [
{
"type": "value",
"options": {
"0": {
"text": "HEALTH_OK",
"color": "green"
}
}
},
{
"type": "value",
"options": {
"1": {
"text": "HEALTH_WARN",
"color": "orange"
}
}
},
{
"type": "value",
"options": {
"2": {
"text": "HEALTH_ERR",
"color": "red"
}
}
}
],
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 1
},
{
"color": "red",
"value": 2
}
]
}
}
},
"options": {
"colorMode": "background",
"graphMode": "none"
}
},
{
"id": 2,
"type": "stat",
"title": "OSDs up / in",
"gridPos": {
"h": 4,
"w": 4,
"x": 4,
"y": 0
},
"targets": [
{
"expr": "sum(ceph_osd_up)",
"refId": "A",
"legendFormat": "up"
},
{
"expr": "sum(ceph_osd_in)",
"refId": "B",
"legendFormat": "in"
}
],
"options": {
"colorMode": "value",
"graphMode": "none"
}
},
{
"id": 3,
"type": "stat",
"title": "MON в кворуме",
"gridPos": {
"h": 4,
"w": 4,
"x": 8,
"y": 0
},
"targets": [
{
"expr": "sum(ceph_mon_quorum_status)",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "orange",
"value": 1
},
{
"color": "green",
"value": 3
}
]
}
}
}
},
{
"id": 4,
"type": "stat",
"title": "Пулы",
"gridPos": {
"h": 4,
"w": 4,
"x": 12,
"y": 0
},
"targets": [
{
"expr": "count(ceph_pool_metadata)",
"refId": "A"
}
]
},
{
"id": 5,
"type": "stat",
"title": "Суммарная ёмкость (raw)",
"gridPos": {
"h": 4,
"w": 4,
"x": 16,
"y": 0
},
"targets": [
{
"expr": "ceph_cluster_total_bytes",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes"
}
}
},
{
"id": 6,
"type": "stat",
"title": "Занято",
"gridPos": {
"h": 4,
"w": 4,
"x": 20,
"y": 0
},
"targets": [
{
"expr": "ceph_cluster_total_used_bytes",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes",
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "percentage",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 70
},
{
"color": "red",
"value": 85
}
]
}
}
}
},
{
"id": 7,
"type": "timeseries",
"title": "Пропускная способность (чтение / запись)",
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 4
},
"targets": [
{
"expr": "sum(rate(ceph_osd_op_r_out_bytes[1m]))",
"refId": "A",
"legendFormat": "чтение"
},
{
"expr": "sum(rate(ceph_osd_op_w_in_bytes[1m]))",
"refId": "B",
"legendFormat": "запись"
}
],
"fieldConfig": {
"defaults": {
"unit": "Bps",
"custom": {
"drawStyle": "line",
"lineWidth": 2,
"fillOpacity": 10
}
}
}
},
{
"id": 8,
"type": "timeseries",
"title": "IOPS (чтение / запись)",
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 4
},
"targets": [
{
"expr": "sum(rate(ceph_osd_op_r[1m]))",
"refId": "A",
"legendFormat": "r ops/s"
},
{
"expr": "sum(rate(ceph_osd_op_w[1m]))",
"refId": "B",
"legendFormat": "w ops/s"
}
],
"fieldConfig": {
"defaults": {
"unit": "ops",
"custom": {
"drawStyle": "line",
"lineWidth": 2,
"fillOpacity": 10
}
}
}
},
{
"id": 9,
"type": "timeseries",
"title": "Средняя задержка OSD, сек",
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 12
},
"targets": [
{
"expr": "rate(ceph_osd_op_latency_sum[1m]) / rate(ceph_osd_op_latency_count[1m])",
"refId": "A",
"legendFormat": "{{ceph_daemon}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "s",
"custom": {
"drawStyle": "line",
"lineWidth": 1,
"fillOpacity": 0
}
}
}
},
{
"id": 10,
"type": "table",
"title": "Пулы: использование",
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 20
},
"targets": [
{
"expr": "ceph_pool_stored * on(pool_id) group_left(name) ceph_pool_metadata",
"refId": "A",
"format": "table",
"instant": true,
"legendFormat": "stored"
},
{
"expr": "ceph_pool_objects * on(pool_id) group_left(name) ceph_pool_metadata",
"refId": "B",
"format": "table",
"instant": true,
"legendFormat": "objects"
},
{
"expr": "ceph_pool_max_avail * on(pool_id) group_left(name) ceph_pool_metadata",
"refId": "C",
"format": "table",
"instant": true,
"legendFormat": "max_avail"
}
],
"transformations": [
{
"id": "merge",
"options": {}
},
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"__name__": true,
"instance": true,
"job": true,
"pool_id": false
},
"renameByName": {
"name": "Пул",
"Value #A": "Stored",
"Value #B": "Objects",
"Value #C": "Max avail"
}
}
}
],
"fieldConfig": {
"defaults": {},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Stored"
},
"properties": [
{
"id": "unit",
"value": "bytes"
}
]
},
{
"matcher": {
"id": "byName",
"options": "Max avail"
},
"properties": [
{
"id": "unit",
"value": "bytes"
}
]
}
]
}
}
]
}
\ No newline at end of file
---
# Ansible-плейбук развёртывания стека мониторинга для CephDeploy.
# LXC-контейнер на Proxmox VE-узле {{ monitoring.pve_host }}
# (шаблон — по умолчанию Debian 12 Standard из стандартного PVE-репозитория),
# внутри: Prometheus, Alertmanager, Grafana.
# Prometheus опрашивает ceph-mgr exporter (порт 9283) на всех MON/MGR-узлах кластера.
# Сгенерировано CephDeploy.
- name: Подготовка PVE-узла и создание LXC-контейнера
hosts: {{ monitoring.pve_host }}
become: true
gather_facts: false
vars:
vmid: {{ monitoring.vmid }}
ct_hostname: monitoring-{{ cluster.name | replace(' ', '-') }}
ct_ip: "{{ monitoring.ct_ip }}"
ct_gw: "{{ monitoring.ct_gw }}"
ct_bridge: "{{ monitoring.ct_bridge }}"
ct_storage: "{{ monitoring.ct_storage }}"
ct_template: "{{ monitoring.ct_template }}"
grafana_admin_password: "{{ monitoring.grafana_password }}"
tasks:
# На свежем PVE-хосте хранилище 'local' может не иметь в content типа
# 'vztmpl' — без него pveam download падает "storage 'local' does not
# support templates". Добавляем vztmpl к текущему списку content.
- name: Разрешить хранить LXC-шаблоны в local
ansible.builtin.shell: |
set -eo pipefail
cur=$(awk '/^dir: local$/{flag=1; next}
flag && /^[^[:space:]]/{flag=0}
flag && /content/{sub(/^[[:space:]]+content[[:space:]]+/, ""); print; exit}
' /etc/pve/storage.cfg)
if ! echo "$cur" | tr ',' '\n' | grep -qx 'vztmpl'; then
new="${cur:+$cur,}vztmpl"
pvesm set local --content "$new"
echo "VZTMPL_ADDED"
fi
args:
executable: /bin/bash
register: vztmpl_cfg
changed_when: "'VZTMPL_ADDED' in (vztmpl_cfg.stdout | default(''))"
# Шаблон сравниваем по имени файла (работает и для debian-12-..., и для
# вручную положенного ALT-tarball).
- name: Проверить наличие LXC-шаблона в хранилище
ansible.builtin.shell: |
set -o pipefail
pveam list local | awk '{print $2}' | grep -q "{% raw %}{{ ct_template }}{% endraw %}"
args:
executable: /bin/bash
register: tpl_check
failed_when: false
changed_when: false
- name: Обновить список доступных LXC-шаблонов
ansible.builtin.command: pveam update
when: tpl_check.rc != 0
changed_when: false
- name: Скачать указанный LXC-шаблон, если его ещё нет
ansible.builtin.command: >
pveam download local {% raw %}{{ ct_template }}{% endraw %}
when: tpl_check.rc != 0
register: tpl_download
changed_when: tpl_download.rc == 0
- name: Проверить, существует ли контейнер VMID={% raw %}{{ vmid }}{% endraw %}
ansible.builtin.command: pct status {% raw %}{{ vmid }}{% endraw %}
register: pct_status
failed_when: false
changed_when: false
- name: Создать LXC-контейнер
ansible.builtin.command: >
pct create {% raw %}{{ vmid }}{% endraw %}
local:vztmpl/{% raw %}{{ ct_template }}{% endraw %}
--hostname {% raw %}{{ ct_hostname }}{% endraw %}
--cores 2
--memory 2048
--swap 512
--rootfs {% raw %}{{ ct_storage }}{% endraw %}:8
--net0 name=eth0,bridge={% raw %}{{ ct_bridge }}{% endraw %},ip={% raw %}{{ ct_ip }}{% endraw %},gw={% raw %}{{ ct_gw }}{% endraw %}
--ostype debian
--nameserver 8.8.8.8
--unprivileged 1
--features nesting=1
--onboot 1
when: pct_status.rc != 0
register: pct_create
- name: Запустить контейнер
ansible.builtin.command: pct start {% raw %}{{ vmid }}{% endraw %}
register: pct_start
failed_when: false
changed_when: "'already running' not in (pct_start.stderr | default(''))"
- name: Подождать запуск контейнера
ansible.builtin.wait_for:
timeout: 60
delegate_to: localhost
become: false
# Pакет grafana в стандартном Debian 12 main отсутствует — подключаем
# репозиторий apt.grafana.com (OSS-ветка).
- name: Установить базовые пакеты в контейнере
ansible.builtin.command: >
pct exec {% raw %}{{ vmid }}{% endraw %} --
bash -c "apt-get update -y && apt-get install -y
ca-certificates curl gnupg apt-transport-https"
register: apt_base
retries: 3
delay: 10
until: apt_base.rc == 0
changed_when: apt_base.rc == 0
- name: Добавить GPG-ключ репозитория Grafana
ansible.builtin.command: >
pct exec {% raw %}{{ vmid }}{% endraw %} --
bash -c "mkdir -p /etc/apt/keyrings &&
curl -fsSL https://apt.grafana.com/gpg.key |
gpg --batch --yes --dearmor -o /etc/apt/keyrings/grafana.gpg"
register: gpg_add
changed_when: gpg_add.rc == 0
- name: Подключить APT-репозиторий Grafana
ansible.builtin.command: >
pct exec {% raw %}{{ vmid }}{% endraw %} --
bash -c "echo 'deb [signed-by=/etc/apt/keyrings/grafana.gpg]
https://apt.grafana.com stable main' >
/etc/apt/sources.list.d/grafana.list"
changed_when: true
- name: Установить prometheus, alertmanager, grafana
ansible.builtin.command: >
pct exec {% raw %}{{ vmid }}{% endraw %} --
bash -c "apt-get update -y && apt-get install -y
prometheus prometheus-alertmanager grafana"
register: apt_result
retries: 3
delay: 10
until: apt_result.rc == 0
changed_when: apt_result.rc == 0
- name: Сгенерировать prometheus.yml
ansible.builtin.copy:
dest: /tmp/cephdeploy_prometheus.yml
mode: '0644'
content: |
global:
scrape_interval: 15s
evaluation_interval: 15s
alerting:
alertmanagers:
- static_configs:
- targets: ['localhost:9093']
rule_files:
- /etc/prometheus/rules/*.yml
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'alertmanager'
static_configs:
- targets: ['localhost:9093']
- job_name: 'ceph'
static_configs:
- targets:
{% for s in servers if s.role in ('mon', 'mgr', 'all') %}
- '{{ s.ip_address }}:9283'
{% endfor %}
metric_relabel_configs:
- source_labels: [ceph_daemon]
target_label: daemon
- name: Сгенерировать alertmanager.yml
ansible.builtin.copy:
dest: /tmp/cephdeploy_alertmanager.yml
mode: '0644'
content: |
global:
resolve_timeout: 5m
route:
group_by: ['alertname', 'cluster']
group_wait: 30s
group_interval: 5m
repeat_interval: 3h
receiver: 'default'
receivers:
- name: 'default'
inhibit_rules:
- source_match:
severity: 'critical'
target_match:
severity: 'warning'
equal: ['alertname', 'cluster']
- name: Сгенерировать правила алертов Ceph
ansible.builtin.copy:
dest: /tmp/cephdeploy_ceph_alerts.yml
mode: '0644'
# !unsafe — чтобы Ansible не обрабатывал содержимое как Jinja:
# внутри - шаблон Prometheus, где $labels это его собственный DSL.
# Блок {% raw %}..{% endraw %} защищает ещё и от нашего, CephDeploy-Jinja.
{% raw %}
content: !unsafe |
groups:
- name: ceph
rules:
- alert: CephHealthError
expr: ceph_health_status == 2
for: 5m
labels: { severity: critical }
annotations:
summary: "Ceph HEALTH_ERR"
description: "Кластер Ceph в состоянии HEALTH_ERR более 5 минут."
- alert: CephHealthWarning
expr: ceph_health_status == 1
for: 15m
labels: { severity: warning }
annotations:
summary: "Ceph HEALTH_WARN"
description: "Кластер Ceph в HEALTH_WARN более 15 минут."
- alert: CephOSDDown
expr: ceph_osd_up == 0
for: 5m
labels: { severity: critical }
annotations:
summary: "OSD {{ $labels.ceph_daemon }} недоступен"
- alert: CephMonQuorumLost
expr: ceph_mon_quorum_status == 0
for: 2m
labels: { severity: critical }
annotations:
summary: "Монитор {{ $labels.ceph_daemon }} выпал из кворума"
- alert: CephPoolNearFull
expr: (ceph_pool_stored / ceph_pool_max_avail) > 0.8
for: 10m
labels: { severity: warning }
annotations:
summary: "Пул {{ $labels.name }} заполнен более чем на 80%"
{% endraw %}
- name: Скопировать конфиги внутрь контейнера
ansible.builtin.command: >
pct push {% raw %}{{ vmid }}{% endraw %} {% raw %}{{ item.src }}{% endraw %} {% raw %}{{ item.dst }}{% endraw %}
loop:
- { src: /tmp/cephdeploy_prometheus.yml, dst: /etc/prometheus/prometheus.yml }
- { src: /tmp/cephdeploy_alertmanager.yml, dst: /etc/alertmanager/alertmanager.yml }
- { src: /tmp/cephdeploy_ceph_alerts.yml, dst: /etc/prometheus/rules/ceph.yml }
changed_when: true
- name: Создать каталог правил prometheus внутри контейнера
ansible.builtin.command: >
pct exec {% raw %}{{ vmid }}{% endraw %} -- mkdir -p /etc/prometheus/rules
changed_when: false
- name: Включить и перезапустить сервисы мониторинга
ansible.builtin.command: >
pct exec {% raw %}{{ vmid }}{% endraw %} --
systemctl enable --now {% raw %}{{ item }}{% endraw %}
loop:
- prometheus
- alertmanager
- grafana-server
register: svc_enable
failed_when: false
changed_when: svc_enable.rc == 0
- name: Перечитать конфиги (reload)
ansible.builtin.command: >
pct exec {% raw %}{{ vmid }}{% endraw %} -- systemctl reload {% raw %}{{ item }}{% endraw %}
loop:
- prometheus
- alertmanager
failed_when: false
changed_when: true
# ── Grafana: пароль admin + provisioning datasource/dashboards ─────
# reset-admin-password в grafana-cli пишет не в ту БД (известный баг
# Debian-пакета). Надёжный путь: задать admin_password в grafana.ini
# ДО первого старта grafana-server; при первом запуске он инициализирует
# БД с этим паролем.
- name: Задать admin_password в /etc/grafana/grafana.ini (до первого старта)
ansible.builtin.shell: |
pct exec {% raw %}{{ vmid }}{% endraw %} -- bash -c '
sed -i "/^\[security\]/,/^\[/ {
s/^;admin_user = admin/admin_user = admin/
s/^;admin_password = admin/admin_password = {% raw %}{{ grafana_admin_password }}{% endraw %}/
}" /etc/grafana/grafana.ini
'
changed_when: true
- name: Сгенерировать provisioning datasource (Prometheus)
ansible.builtin.copy:
dest: /tmp/cephdeploy_grafana_ds.yaml
mode: '0644'
content: |
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://localhost:9090
isDefault: true
- name: Сгенерировать provisioning dashboards
ansible.builtin.copy:
dest: /tmp/cephdeploy_grafana_dash.yaml
mode: '0644'
content: |
apiVersion: 1
providers:
- name: cephdeploy
orgId: 1
folder: Ceph
type: file
disableDeletion: false
updateIntervalSeconds: 60
options:
path: /var/lib/grafana/dashboards
- name: Скопировать provisioning-конфиги в контейнер
ansible.builtin.command: >
pct push {% raw %}{{ vmid }}{% endraw %} {% raw %}{{ item.src }}{% endraw %} {% raw %}{{ item.dst }}{% endraw %}
loop:
- { src: /tmp/cephdeploy_grafana_ds.yaml, dst: /etc/grafana/provisioning/datasources/prometheus.yaml }
- { src: /tmp/cephdeploy_grafana_dash.yaml, dst: /etc/grafana/provisioning/dashboards/cephdeploy.yaml }
changed_when: true
- name: Создать каталог дашбордов и выставить права
ansible.builtin.command: >
pct exec {% raw %}{{ vmid }}{% endraw %} -- bash -c
"mkdir -p /var/lib/grafana/dashboards &&
chown -R grafana:grafana /var/lib/grafana/dashboards"
changed_when: false
# ─── Ceph Overview dashboard ────────────────────────────────────
- name: Скопировать Ceph Overview dashboard на PVE-узел
ansible.builtin.copy:
src: grafana/ceph_overview.json
dest: /tmp/cephdeploy_ceph_overview.json
mode: '0644'
- name: Перенести dashboard JSON в контейнер
ansible.builtin.command: >
pct push {% raw %}{{ vmid }}{% endraw %} /tmp/cephdeploy_ceph_overview.json
/var/lib/grafana/dashboards/ceph_overview.json
changed_when: true
- name: Установить права на dashboard JSON
ansible.builtin.command: >
pct exec {% raw %}{{ vmid }}{% endraw %} -- chown grafana:grafana
/var/lib/grafana/dashboards/ceph_overview.json
changed_when: false
- name: Итоговый статус
ansible.builtin.debug:
msg:
- "LXC VMID={% raw %}{{ vmid }}{% endraw %} ({% raw %}{{ ct_hostname }}{% endraw %})"
- "Prometheus: http://{% raw %}{{ ct_ip | regex_replace('/.*$', '') }}{% endraw %}:9090"
- "Alertmanager: http://{% raw %}{{ ct_ip | regex_replace('/.*$', '') }}{% endraw %}:9093"
- "Grafana: http://{% raw %}{{ ct_ip | regex_replace('/.*$', '') }}{% endraw %}:3000 (admin / {% raw %}{{ grafana_admin_password }}{% endraw %})"
......@@ -2,6 +2,10 @@
# Создаёт 3 loop-устройства из файлов в /var/lib/ceph-disks для имитации OSD.
# В docker-контейнере ядро не создаёт новые loop-nodes автоматически через
# LOOP_CTL_GET_FREE, поэтому нужные ноды заводятся mknod'ом вручную.
#
# На каждом loop-устройстве создаётся GPT с одним разделом (loopNp1).
# Причина: ceph-volume фильтрует TYPE=loop («Device type is not acceptable»),
# но принимает TYPE=part — OSD разворачиваем на /dev/loop20p1 и т.д.
DISK_DIR="/var/lib/ceph-disks"
mkdir -p "$DISK_DIR"
......@@ -19,20 +23,36 @@ for i in 1 2 3; do
dd if=/dev/zero of="$IMG" bs=1M count=4096 status=none
fi
# Уже привязан?
LOOP_DEV="/dev/loop$((19 + i))"
# Привязываем, если ещё не привязан. -P заставляет ядро сканировать таблицу разделов.
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
elif losetup -P "$LOOP_DEV" "$IMG" 2>/dev/null; then
echo "Attached $IMG to $LOOP_DEV"
elif LOOP_DEV=$(losetup -f --show "$IMG" 2>/dev/null); then
elif LOOP_DEV=$(losetup -fP --show "$IMG" 2>/dev/null); then
echo "Attached $IMG to $LOOP_DEV (auto-assigned)"
else
echo "WARN: failed to attach $IMG" >&2
continue
fi
# Создаём GPT + один раздел на весь диск, если ещё нет.
N="${LOOP_DEV##/dev/loop}"
SYS_PART="/sys/block/loop${N}/loop${N}p1"
if [ ! -e "$SYS_PART" ]; then
echo "Partitioning $LOOP_DEV..."
parted -s "$LOOP_DEV" mklabel gpt mkpart osd 1MiB 100%
partprobe "$LOOP_DEV" 2>/dev/null || partx -a "$LOOP_DEV" 2>/dev/null || true
fi
# В docker-контейнере devtmpfs не создаёт partition-ноды автоматически —
# ядро знает о партиции (sysfs), но файла /dev/loopNp1 нет. Создаём mknod'ом,
# взяв major:minor из sysfs.
if [ -e "$SYS_PART/dev" ] && [ ! -e "${LOOP_DEV}p1" ]; then
read MAJ MIN < <(tr ':' ' ' < "$SYS_PART/dev")
mknod "${LOOP_DEV}p1" b "$MAJ" "$MIN"
echo "Created node ${LOOP_DEV}p1 ($MAJ:$MIN)"
fi
done
......
"""
Страница «Анализ» — модуль анализа функционирования Ceph-кластера.
Опрашивает внешний Prometheus (LXC на PVE-узле, развёрнутый playbook
setup_monitoring.yml), отображает KPI + графики трендов + таблицу OSD
с эвристикой «предиктивного обслуживания» (OSD с монотонным ростом
latency — кандидаты на профилактику).
"""
from __future__ import annotations
import time
import matplotlib
matplotlib.use("QtAgg")
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
from PyQt6.QtCore import QThread, QTimer, Qt, pyqtSignal
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import (
QComboBox,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from api.prometheus_client import PrometheusClient, PrometheusError
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; }"
)
_KPI_STYLE = (
"QLabel#title { color: #8fbcbb; font-size: 11px; }"
"QLabel#value { color: #e5e9f0; font-size: 22px; font-weight: bold; }"
"QLabel#sub { color: #5a6478; font-size: 10px; }"
)
_HEALTH_TEXT = {0: "HEALTH_OK", 1: "HEALTH_WARN", 2: "HEALTH_ERR", -1: "нет связи"}
_HEALTH_COLOR = {
0: "#4caf50",
1: "#ff9800",
2: "#f44336",
-1: "#5a6478",
}
def _human_bps(n: float) -> str:
n = float(n or 0)
for unit in ("Б/с", "КБ/с", "МБ/с", "ГБ/с"):
if abs(n) < 1024.0:
return f"{n:.1f} {unit}"
n /= 1024.0
return f"{n:.1f} ТБ/с"
# ---------------------------------------------------------------------------
# Воркер: опрашивает Prometheus, возвращает KPI + данные для графиков.
# ---------------------------------------------------------------------------
class PromFetchWorker(QThread):
result = pyqtSignal(dict)
error = pyqtSignal(str)
def __init__(self, url: str, duration_s: int, step: str, parent=None) -> None:
super().__init__(parent)
self.url = url
self.duration_s = duration_s
self.step = step
def run(self) -> None:
try:
cli = PrometheusClient(self.url)
end = time.time()
start = end - self.duration_s
throughput_write = cli.query_range(
"sum(rate(ceph_osd_op_w_in_bytes[1m]))",
start, end, step=self.step,
)
throughput_read = cli.query_range(
"sum(rate(ceph_osd_op_r_out_bytes[1m]))",
start, end, step=self.step,
)
latency_per_osd = cli.osd_latency_series(self.duration_s, step=self.step)
kpi = {
"health": cli.health_status(),
"osd_up": cli.osd_up_count(),
"osd_in": cli.osd_in_count(),
"mon_quorum": cli.mon_quorum_count(),
"iops": cli.cluster_iops(),
"read_bps": cli.cluster_read_throughput(),
"write_bps": cli.cluster_write_throughput(),
}
self.result.emit({
"kpi": kpi,
"throughput_w": throughput_write,
"throughput_r": throughput_read,
"latency_per_osd": latency_per_osd,
})
except PrometheusError as exc:
self.error.emit(str(exc))
except Exception as exc:
self.error.emit(f"{type(exc).__name__}: {exc}")
# ---------------------------------------------------------------------------
# KPI-карточка
# ---------------------------------------------------------------------------
class KPICard(QGroupBox):
def __init__(self, title: str, parent=None) -> None:
super().__init__(parent)
self.setStyleSheet(_BOX_STYLE + _KPI_STYLE)
layout = QVBoxLayout(self)
layout.setSpacing(2)
layout.setContentsMargins(12, 10, 12, 10)
self._title = QLabel(title)
self._title.setObjectName("title")
layout.addWidget(self._title)
self._value = QLabel("—")
self._value.setObjectName("value")
layout.addWidget(self._value)
self._sub = QLabel("")
self._sub.setObjectName("sub")
layout.addWidget(self._sub)
def set_value(self, v: str, sub: str = "", color: str | None = None) -> None:
self._value.setText(v)
self._sub.setText(sub)
if color:
self._value.setStyleSheet(f"color: {color};")
else:
self._value.setStyleSheet("")
# ---------------------------------------------------------------------------
# Виджет страницы
# ---------------------------------------------------------------------------
class AnalysisWidget(BasePage):
def __init__(self, parent=None) -> None:
super().__init__("📈 Анализ", "Анализ функционирования кластера Ceph")
self._worker: PromFetchWorker | 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)
self._lbl_source = QLabel(AppConfig.get("prometheus_url") or "(не настроен)")
self._lbl_source.setStyleSheet("color: #c0c8d8;")
ctrl_layout.addWidget(QLabel("Prometheus:"))
ctrl_layout.addWidget(self._lbl_source)
ctrl_layout.addWidget(QLabel(" Период:"))
self._period = QComboBox()
self._period.addItem("1 час", ("3600", "30s"))
self._period.addItem("6 часов", ("21600", "2m"))
self._period.addItem("24 часа", ("86400", "10m"))
self._period.currentIndexChanged.connect(self._fetch)
ctrl_layout.addWidget(self._period)
self._btn_refresh = QPushButton("🔄 Обновить")
self._btn_refresh.setFixedHeight(28)
self._btn_refresh.clicked.connect(self._fetch)
self._btn_refresh.setStyleSheet(
"QPushButton { background: #2a3040; color: #8fbcbb; "
"border: 1px solid #3a4050; border-radius: 5px; padding: 2px 12px; }"
"QPushButton:hover { background: #2e4a7a; color: #fff; }"
)
ctrl_layout.addWidget(self._btn_refresh)
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)
# ── KPI-карточки ──────────────────────────────────────────────
kpi_row = QHBoxLayout()
self._kpi_health = KPICard("Здоровье")
self._kpi_osd = KPICard("OSD")
self._kpi_mon = KPICard("MON в кворуме")
self._kpi_iops = KPICard("IOPS")
self._kpi_read = KPICard("Чтение")
self._kpi_write = KPICard("Запись")
for w in (
self._kpi_health, self._kpi_osd, self._kpi_mon,
self._kpi_iops, self._kpi_read, self._kpi_write,
):
kpi_row.addWidget(w)
self.content_layout.addLayout(kpi_row)
# ── График пропускной способности ────────────────────────────
chart_box = QGroupBox("Пропускная способность кластера (bytes/s)")
chart_box.setStyleSheet(_BOX_STYLE)
chart_layout = QVBoxLayout(chart_box)
self._fig_throughput = Figure(figsize=(6, 2.5), facecolor="#0d1117")
self._ax_throughput = self._fig_throughput.add_subplot(111)
self._canvas_throughput = FigureCanvas(self._fig_throughput)
chart_layout.addWidget(self._canvas_throughput)
self.content_layout.addWidget(chart_box, stretch=1)
# ── Таблица OSD + предиктивное обслуживание ──────────────────
osd_box = QGroupBox("OSD: средняя задержка и кандидаты на обслуживание")
osd_box.setStyleSheet(_BOX_STYLE)
osd_layout = QVBoxLayout(osd_box)
self._osd_table = QTableWidget(0, 4)
self._osd_table.setHorizontalHeaderLabels(
["OSD", "avg latency, мс", "тренд 24ч, %", "примечание"]
)
self._osd_table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch
)
self._osd_table.verticalHeader().setVisible(False)
self._osd_table.setStyleSheet(
"QTableWidget { background:#1e2330; color:#c0c8d8; "
"gridline-color:#2a3040; border:1px solid #2e3340; border-radius:6px; }"
"QHeaderView::section { background:#1a2030; color:#8fbcbb; "
"padding:6px; border:none; font-weight:bold; }"
)
osd_layout.addWidget(self._osd_table)
self.content_layout.addWidget(osd_box, stretch=1)
self._refresh_source_label()
# ------------------------------------------------------------------
def refresh(self) -> None:
"""Вызывается MainWindow при смене страницы."""
self._refresh_source_label()
self._fetch()
def _refresh_source_label(self) -> None:
self._lbl_source.setText(AppConfig.get("prometheus_url") or "(не настроен)")
# ------------------------------------------------------------------
def _fetch(self) -> None:
url = AppConfig.get("prometheus_url") or ""
if not url:
self._lbl_status.setText(
"URL Prometheus не задан — зайди в Настройки → Внешний мониторинг."
)
return
if self._worker and self._worker.isRunning():
return
duration_s, step = self._period.currentData()
self._lbl_status.setText("Запрос…")
self._btn_refresh.setEnabled(False)
self._worker = PromFetchWorker(url, int(duration_s), step)
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
kpi = data["kpi"]
# KPI
h = int(kpi.get("health", -1))
self._kpi_health.set_value(
_HEALTH_TEXT.get(h, "?"), color=_HEALTH_COLOR.get(h, "#c0c8d8"),
)
self._kpi_osd.set_value(
f"{kpi.get('osd_up', 0)} / {kpi.get('osd_in', 0)}",
sub="up / in",
)
self._kpi_mon.set_value(str(kpi.get("mon_quorum", 0)))
self._kpi_iops.set_value(f"{kpi.get('iops', 0):.0f}", sub="ops/s (rate 1m)")
self._kpi_read.set_value(_human_bps(kpi.get("read_bps", 0)))
self._kpi_write.set_value(_human_bps(kpi.get("write_bps", 0)))
# График
self._draw_throughput(data["throughput_w"], data["throughput_r"])
# Таблица
self._fill_osd_table(data["latency_per_osd"])
self._lbl_status.setText(f"Обновлено: {datetime.now():%H:%M:%S}")
self._btn_refresh.setEnabled(True)
self._worker = None
def _on_error(self, message: str) -> None:
self._lbl_status.setText(f"Ошибка: {message}")
self._btn_refresh.setEnabled(True)
self._worker = None
# ------------------------------------------------------------------
def _draw_throughput(self, w_series: list, r_series: list) -> None:
ax = self._ax_throughput
ax.clear()
ax.set_facecolor("#0d1117")
for spine in ax.spines.values():
spine.set_color("#2e3340")
ax.tick_params(colors="#8fbcbb", labelsize=8)
ax.grid(True, color="#2a3040", linewidth=0.5)
def plot_series(series, color, label):
if not series:
return
row = series[0]
values = row.get("values", [])
xs = [float(v[0]) for v in values]
ys = [float(v[1]) for v in values]
if xs:
xs = [x - xs[0] for x in xs] # t=0 ...
ax.plot(xs, ys, color=color, label=label, linewidth=1.5)
plot_series(w_series, "#ef5350", "запись")
plot_series(r_series, "#42a5f5", "чтение")
ax.set_xlabel("время, с от начала периода", color="#8fbcbb", fontsize=8)
ax.set_ylabel("байт/с", color="#8fbcbb", fontsize=8)
leg = ax.legend(loc="upper right", facecolor="#1a2030", edgecolor="#2e3340")
for t in leg.get_texts():
t.set_color("#c0c8d8")
self._fig_throughput.tight_layout()
self._canvas_throughput.draw()
def _fill_osd_table(self, latency: dict[str, list[tuple[float, float]]]) -> None:
rows = []
for osd, series in latency.items():
if not series:
continue
values = [v for _, v in series if v is not None]
if not values:
continue
avg = sum(values) / len(values)
first = values[0] if values[0] else avg or 1e-9
last = values[-1]
trend_pct = ((last - first) / first) * 100.0 if first else 0.0
rows.append((osd, avg, trend_pct))
rows.sort(key=lambda r: r[1], reverse=True)
rows = rows[:20] # ограничим
self._osd_table.setRowCount(len(rows))
for i, (osd, avg, trend) in enumerate(rows):
# avg: секунды → миллисекунды
ms = avg * 1000.0
note = ""
color = None
if trend >= 50 and avg > 0.005:
note = "кандидат на обслуживание"
color = "#f44336"
elif trend >= 20:
note = "рост задержки"
color = "#ff9800"
items = [
QTableWidgetItem(str(osd)),
QTableWidgetItem(f"{ms:.2f}"),
QTableWidgetItem(f"{trend:+.1f}"),
QTableWidgetItem(note),
]
for it in items:
if color:
it.setForeground(QColor(color))
it.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
for col, it in enumerate(items):
self._osd_table.setItem(i, col, it)
......@@ -15,6 +15,7 @@ from PyQt6.QtWidgets import (
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
......@@ -129,6 +130,7 @@ class BasePage(QWidget):
title: str,
subtitle: str = "",
show_refresh: bool = True,
scrollable: bool = False,
parent=None,
) -> None:
super().__init__(parent)
......@@ -148,7 +150,17 @@ class BasePage(QWidget):
self.content_layout.setContentsMargins(20, 16, 20, 16)
self.content_layout.setSpacing(12)
root.addWidget(self._content_area, stretch=1)
if scrollable:
# Оборачиваем content_area в QScrollArea — нужно когда форма длиннее окна
# (например, страница Настройки с несколькими группами).
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.Shape.NoFrame)
scroll.setStyleSheet("QScrollArea { background: #1a1f29; border: none; }")
scroll.setWidget(self._content_area)
root.addWidget(scroll, stretch=1)
else:
root.addWidget(self._content_area, stretch=1)
# ------------------------------------------------------------------
......
......@@ -28,6 +28,7 @@ from PyQt6.QtWidgets import (
QWidget,
)
from core.config import AppConfig
from core.resources import get_templates_dir
from db import SessionLocal
from db.models import DeployStatus
......@@ -363,6 +364,11 @@ class DeployWidget(BasePage):
(s for s in servers_data if s["role"] in ("mon", "all")),
servers_data[0],
),
dashboard={
"port": AppConfig.get("dashboard_port"),
"user": AppConfig.get("dashboard_user"),
"password": AppConfig.get("dashboard_password"),
},
),
encoding="utf-8",
)
......
......@@ -219,19 +219,19 @@ _CHAPTERS: list[tuple[str, str]] = [
("Состояние кластера", """
<h2>Состояние кластера</h2>
<p>Live-дашборд состояния работающего Ceph-кластера через SSH.</p>
<p>Live-дашборд состояния работающего Ceph-кластера.</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>Два источника данных</h3>
<p>В верхней панели есть переключатель <b>Источник: SSH / REST API</b>.</p>
<ul>
<li><b>SSH</b> — классический путь: SSH на MON-узел и выполнение
<code>ceph -s</code>, <code>ceph df</code>, <code>ceph osd tree</code>
через <code>cephadm shell</code>. Требует <code>sudo</code> без пароля.</li>
<li><b>REST API</b> — прямой запрос к встроенному <b>ceph-mgr dashboard</b>
(HTTPS, самоподписанный cert). URL-порт, пользователь и пароль
задаются в Настройках. Авторизация — JWT-токен, получаемый на
<code>/api/auth</code>. Канал не требует SSH и работает быстрее.</li>
</ul>
<h3>Интерпретация ceph -s</h3>
<ul>
......@@ -245,6 +245,76 @@ _CHAPTERS: list[tuple[str, str]] = [
Значение <b>0</b> отключает авто-обновление. Рекомендуется 30–60 секунд.</p>
"""),
("Анализ функционирования", """
<h2>Анализ функционирования кластера</h2>
<p>Страница <b>Анализ</b> опрашивает внешний <b>Prometheus</b>, в котором
хранятся метрики ceph-mgr exporter, и показывает исторические показатели
кластера. URL Prometheus задаётся в <b>Настройки → Внешний мониторинг</b>,
сам Prometheus разворачивается через меню <b>Мониторинг → Развернуть…</b>.</p>
<h3>Карточки KPI</h3>
<ul>
<li><b>Здоровье</b> — текущее значение <code>ceph_health_status</code>:
OK / WARN / ERR.</li>
<li><b>OSD</b> — число up / in OSD-демонов.</li>
<li><b>MON в кворуме</b> — сколько мониторов в кворуме.</li>
<li><b>IOPS</b> — <code>rate(ceph_osd_op[1m])</code>, суммарно по кластеру.</li>
<li><b>Чтение / Запись</b> — <code>rate(ceph_osd_op_r_out_bytes[1m])</code>
и <code>...w_in_bytes[1m]</code>.</li>
</ul>
<h3>График пропускной способности</h3>
<p>Линия записи (красная) и чтения (синяя) за выбранный период (1ч / 6ч / 24ч).
Используется <code>query_range</code> Prometheus-API с подходящим шагом.</p>
<h3>Таблица OSD и «предиктивное обслуживание»</h3>
<p>Для каждого OSD считается средняя задержка <code>ceph_osd_op_latency_sum /
ceph_osd_op_latency_count</code> и тренд за выбранный период (относительный
прирост в %). Строки подсвечиваются:</p>
<ul>
<li><span style="color:#ff9800"><b>оранжевым</b></span> — задержка растёт
более чем на 20%;</li>
<li><span style="color:#f44336"><b>красным</b></span> — рост &gt; 50% и
текущая задержка выше 5 мс — OSD помечается как
<b>кандидат на обслуживание</b>.</li>
</ul>
"""),
("Мониторинг (Prometheus/Grafana/Alertmanager)", """
<h2>Развёртывание внешнего стека мониторинга</h2>
<p>Через меню <b>Мониторинг → Развернуть Prometheus / Grafana / Alertmanager</b>
открывается мастер, который создаёт <b>LXC-контейнер</b> на PVE-хосте
(по умолчанию <code>gefest</code>), ставит туда Prometheus, Alertmanager и
Grafana и настраивает scrape-targets на ceph-mgr exporter каждого MON/MGR-узла.</p>
<h3>Что запросит мастер</h3>
<ul>
<li>Кластер (из БД) — определяет список scrape-targets.</li>
<li><b>PVE-хост</b> — куда идёт SSH для <code>pct create</code>.</li>
<li><b>VMID</b>, <b>IP / шлюз / bridge</b>, <b>хранилище</b>, <b>шаблон</b> LXC.</li>
<li>Пароль <code>admin</code> для Grafana.</li>
</ul>
<h3>Что попадает внутрь контейнера</h3>
<ul>
<li><b>/etc/prometheus/prometheus.yml</b> — job <code>ceph</code> со всеми mon/mgr-узлами (<code>:9283</code>).</li>
<li><b>/etc/prometheus/rules/ceph.yml</b> — правила алертов (CephHealthError,
CephOSDDown, CephMonQuorumLost, CephPoolNearFull).</li>
<li><b>/etc/alertmanager/alertmanager.yml</b> — базовая маршрутизация.</li>
<li>Grafana — добавляется datasource Prometheus по
<code>http://localhost:9090</code>.</li>
</ul>
<h3>После успешного развёртывания</h3>
<p>В AppConfig автоматически записываются URL'ы:</p>
<ul>
<li><code>prometheus_url</code> = <code>http://&lt;ct_ip&gt;:9090</code></li>
<li><code>grafana_url</code> = <code>http://&lt;ct_ip&gt;:3000</code></li>
<li><code>alertmanager_url</code> = <code>http://&lt;ct_ip&gt;:9093</code></li>
</ul>
<p>Их подхватывают страница <b>Анализ</b> и раздел «Внешний мониторинг» в Настройках.</p>
"""),
("Журнал", """
<h2>Журнал</h2>
<p>История всех запусков развёртывания с возможностью просмотра лога.</p>
......@@ -319,6 +389,22 @@ _CHAPTERS: list[tuple[str, str]] = [
<li><b>Авто-обновление статуса</b> — интервал для страницы «Состояние» (0 = выкл)</li>
</ul>
<h3>Ceph Dashboard (REST API)</h3>
<ul>
<li><b>Порт</b> — порт mgr-dashboard (по умолчанию 8443).</li>
<li><b>Пользователь / Пароль</b> — учётная запись администратора, создаётся
в ходе bootstrap (<code>--initial-dashboard-user / --password</code>).</li>
</ul>
<p>Эти значения использует альтернативный источник страницы «Состояние».</p>
<h3>Внешний мониторинг (LXC на PVE-узле)</h3>
<ul>
<li><b>URL Prometheus / Grafana / Alertmanager</b> — проставляются автоматически
после успешного развёртывания через меню Мониторинг.</li>
<li><b>PVE-хост, VMID, IP, шлюз, bridge, хранилище, шаблон</b> — параметры
создаваемого LXC-контейнера. Шаблон по умолчанию — ALT p11.</li>
</ul>
<p>Настройки хранятся в <code>~/.config/cephdeploy/settings.json</code>.</p>
"""),
......
......@@ -5,7 +5,7 @@
from __future__ import annotations
from PyQt6.QtCore import Qt, QSize
from PyQt6.QtGui import QFont, QKeySequence, QShortcut
from PyQt6.QtGui import QFont, QIcon, QKeySequence, QShortcut
from PyQt6.QtWidgets import (
QHBoxLayout,
QLabel,
......@@ -20,6 +20,8 @@ from PyQt6.QtWidgets import (
QStackedWidget,
)
from core.resources import get_app_icon_path
from ui.analysis_widget import AnalysisWidget
from ui.base_page import BasePage
from ui.clusters_widget import ClustersWidget
from ui.deploy_widget import DeployWidget
......@@ -41,6 +43,7 @@ _NAV_ITEMS: list[tuple[str, str]] = [
("🔍 Сканер сети", "Поиск серверов в подсети"),
("🚀 Развёртывание", "Мастер установки Ceph"),
("📊 Состояние", "Дашборд кластера"),
("📈 Анализ", "Анализ функционирования кластера"),
("💾 OSD", "Управление дисками OSD"),
("📜 Журнал", "История запусков"),
("📄 Отчёт", "Экспорт в HTML"),
......@@ -52,10 +55,11 @@ _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
_ANALYSIS_PAGE_IDX = 4
_OSD_PAGE_IDX = 5
_LOG_PAGE_IDX = 6
_REPORT_PAGE_IDX = 7
_SETTINGS_PAGE_IDX = 8
# ---------------------------------------------------------------------------
......@@ -108,6 +112,8 @@ class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("CephDeploy — управление кластером Ceph")
self._app_icon = QIcon(str(get_app_icon_path()))
self.setWindowIcon(self._app_icon)
self.setMinimumSize(1100, 700)
self.resize(1280, 800)
......@@ -156,6 +162,8 @@ class MainWindow(QMainWindow):
page = DeployWidget()
elif i == _STATUS_PAGE_IDX:
page = StatusWidget()
elif i == _ANALYSIS_PAGE_IDX:
page = AnalysisWidget()
elif i == _OSD_PAGE_IDX:
page = OSDWidget()
elif i == _LOG_PAGE_IDX:
......@@ -184,12 +192,16 @@ class MainWindow(QMainWindow):
layout = QHBoxLayout(header)
layout.setContentsMargins(20, 0, 20, 0)
logo = QLabel("🐙 CephDeploy")
logo_icon = QLabel()
logo_icon.setFixedSize(30, 30)
logo_icon.setPixmap(self._app_icon.pixmap(30, 30))
logo = QLabel("CephDeploy")
f = QFont()
f.setPointSize(15)
f.setBold(True)
logo.setFont(f)
logo.setStyleSheet("color: #e06c75;")
logo.setStyleSheet("color: #e6edf3;")
version = QLabel("v0.1.0 — ALT Linux / Ceph Reef")
version.setStyleSheet("color: #555e6e; font-size: 11px;")
......@@ -207,6 +219,8 @@ class MainWindow(QMainWindow):
)
btn_help.clicked.connect(self._open_help)
layout.addWidget(logo_icon)
layout.addSpacing(8)
layout.addWidget(logo)
layout.addStretch()
layout.addWidget(version)
......@@ -270,6 +284,12 @@ class MainWindow(QMainWindow):
"QMenu { background: #1e2330; color: #c0c8d8; border: 1px solid #2e3340; }"
"QMenu::item:selected { background: #2e4a7a; }"
)
mon_menu = menubar.addMenu("Мониторинг")
mon_menu.addAction(
"Развернуть Prometheus / Grafana / Alertmanager…",
self._open_monitoring_dialog,
)
help_menu = menubar.addMenu("Справка")
help_menu.addAction("Руководство пользователя F1", self._open_help)
help_menu.addSeparator()
......@@ -288,6 +308,11 @@ class MainWindow(QMainWindow):
self._help_window.raise_()
self._help_window.activateWindow()
def _open_monitoring_dialog(self) -> None:
from ui.monitoring_dialog import MonitoringDialog
dlg = MonitoringDialog(self)
dlg.exec()
def _show_about(self) -> None:
from PyQt6.QtWidgets import QMessageBox
QMessageBox.about(
......
"""
Диалог развёртывания стека мониторинга (LXC + Prometheus/Grafana/Alertmanager).
Рендерит templates/setup_monitoring.yml.j2, запускает ansible-playbook на
PVE-узле и по успеху записывает URL'ы в AppConfig — дальше их подхватывают
страница «Анализ» и «Настройки → Внешний мониторинг».
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from PyQt6.QtCore import QProcess, Qt
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSpinBox,
QTextEdit,
QVBoxLayout,
)
from core.config import AppConfig
from core.resources import get_templates_dir
from db import SessionLocal
from db.repository import list_clusters, list_servers
_TEMPLATES_DIR = get_templates_dir()
_LOG_STYLE = (
"QTextEdit { background: #0d1117; color: #c0c8d8; "
"border: 1px solid #2e3340; border-radius: 4px; font-family: monospace; }"
)
class MonitoringDialog(QDialog):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle("Развернуть мониторинг (Prometheus/Grafana/Alertmanager)")
self.setMinimumSize(820, 620)
self._process: QProcess | None = None
self._deploy_dir: str | None = None
self._build_ui()
self._load_defaults()
# ------------------------------------------------------------------
def _build_ui(self) -> None:
root = QVBoxLayout(self)
info = QLabel(
"Будет создан LXC-контейнер (ALT p11) на выбранном PVE-узле, "
"внутри поднимутся Prometheus, Alertmanager и Grafana. Prometheus "
"будет опрашивать mgr-exporter на всех MON/MGR-узлах кластера."
)
info.setWordWrap(True)
info.setStyleSheet("color: #8fbcbb; font-size: 12px;")
root.addWidget(info)
form = QFormLayout()
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
form.setSpacing(8)
self._cluster_combo = QComboBox()
form.addRow("Кластер:", self._cluster_combo)
self._pve_host = QLineEdit()
form.addRow("PVE-хост:", self._pve_host)
self._vmid = QSpinBox()
self._vmid.setRange(100, 999999)
form.addRow("VMID контейнера:", self._vmid)
self._ct_ip = QLineEdit()
form.addRow("IP контейнера:", self._ct_ip)
self._ct_gw = QLineEdit()
form.addRow("Шлюз:", self._ct_gw)
self._ct_bridge = QLineEdit()
form.addRow("Bridge:", self._ct_bridge)
self._ct_storage = QLineEdit()
form.addRow("Хранилище:", self._ct_storage)
self._ct_template = QLineEdit()
form.addRow("Шаблон LXC:", self._ct_template)
self._grafana_pw = QLineEdit()
self._grafana_pw.setEchoMode(QLineEdit.EchoMode.Password)
form.addRow("Пароль Grafana (admin):", self._grafana_pw)
root.addLayout(form)
# Лог
self._log = QTextEdit()
self._log.setReadOnly(True)
self._log.setStyleSheet(_LOG_STYLE)
f = QFont("Monospace")
f.setStyleHint(QFont.StyleHint.TypeWriter)
f.setPointSize(9)
self._log.setFont(f)
root.addWidget(self._log, stretch=1)
# Кнопки
btn_row = QHBoxLayout()
self._btn_start = QPushButton("🚀 Развернуть")
self._btn_start.setFixedHeight(32)
self._btn_start.setStyleSheet(
"QPushButton { background: #1565c0; color: #fff; border-radius: 6px; "
"font-weight: bold; padding: 4px 18px; }"
"QPushButton:hover { background: #1976d2; }"
"QPushButton:disabled { background: #333; color: #555; }"
)
self._btn_start.clicked.connect(self._start)
self._btn_stop = QPushButton("⏹ Остановить")
self._btn_stop.setFixedHeight(32)
self._btn_stop.setEnabled(False)
self._btn_stop.clicked.connect(self._stop)
btn_row.addWidget(self._btn_start)
btn_row.addWidget(self._btn_stop)
btn_row.addStretch()
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
btns.rejected.connect(self.reject)
btn_row.addWidget(btns)
root.addLayout(btn_row)
# ------------------------------------------------------------------
def _load_defaults(self) -> None:
with SessionLocal() as session:
for c in list_clusters(session):
self._cluster_combo.addItem(
f"{c.name} [{c.ceph_version}]", userData=c.id
)
self._pve_host.setText(AppConfig.get("monitoring_pve_host"))
self._vmid.setValue(int(AppConfig.get("monitoring_vmid")))
self._ct_ip.setText(AppConfig.get("monitoring_ct_ip"))
self._ct_gw.setText(AppConfig.get("monitoring_ct_gw"))
self._ct_bridge.setText(AppConfig.get("monitoring_ct_bridge"))
self._ct_storage.setText(AppConfig.get("monitoring_ct_storage"))
self._ct_template.setText(AppConfig.get("monitoring_ct_template"))
self._grafana_pw.setText(AppConfig.get("grafana_password"))
# ------------------------------------------------------------------
def _append(self, text: str) -> None:
self._log.moveCursor(self._log.textCursor().MoveOperation.End)
self._log.insertPlainText(text)
self._log.moveCursor(self._log.textCursor().MoveOperation.End)
def _gen_configs(self, cluster_id: int) -> str:
with SessionLocal() as session:
cluster = next(
(c for c in list_clusters(session) if c.id == cluster_id), None,
)
servers = list_servers(session, cluster_id)
if not cluster or not servers:
raise RuntimeError("Кластер или серверы не найдены в БД.")
servers_data = [
{
"hostname": s.hostname,
"ip_address": s.ip_address,
"role": s.role.value,
"ssh_user": s.ssh_user,
"ssh_key": s.ssh_key_path,
}
for s in servers
]
pve_host = self._pve_host.text().strip()
monitoring = {
"pve_host": pve_host,
"vmid": self._vmid.value(),
"ct_ip": self._ct_ip.text().strip(),
"ct_gw": self._ct_gw.text().strip(),
"ct_bridge": self._ct_bridge.text().strip(),
"ct_storage": self._ct_storage.text().strip(),
"ct_template": self._ct_template.text().strip(),
"grafana_password": self._grafana_pw.text() or "admin",
}
deploy_dir = tempfile.mkdtemp(prefix="cephdeploy_monitoring_")
env = Environment(
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
trim_blocks=True,
lstrip_blocks=True,
)
play = env.get_template("setup_monitoring.yml.j2").render(
cluster={"name": cluster.name, "version": cluster.ceph_version},
servers=servers_data,
monitoring=monitoring,
)
play_path = os.path.join(deploy_dir, "setup_monitoring.yml")
Path(play_path).write_text(play, encoding="utf-8")
# Копируем Grafana-дашборды в deploy_dir — ansible.builtin.copy в playbook'е
# ссылается на них по относительному пути (src: grafana/*.json).
src_grafana_dir = _TEMPLATES_DIR / "grafana"
if src_grafana_dir.exists():
dst_grafana_dir = Path(deploy_dir) / "grafana"
dst_grafana_dir.mkdir(exist_ok=True)
for f in src_grafana_dir.iterdir():
if f.is_file():
(dst_grafana_dir / f.name).write_bytes(f.read_bytes())
# Inventory для PVE-узла (один хост).
# ansible_remote_tmp=/var/tmp — важно: $HOME на PVE может быть chmod 700,
# и тогда root (через become) не смог бы прочитать /home/<user>/.ansible/tmp,
# ansible падает с "Permission denied" при десериализации модуля.
ssh_user = AppConfig.get("ssh_user") or "root"
ssh_key = AppConfig.get("ssh_key_path") or "~/.ssh/id_ed25519"
inv_path = os.path.join(deploy_dir, "inventory.ini")
Path(inv_path).write_text(
"[all:vars]\n"
"ansible_python_interpreter=/usr/bin/python3\n"
"ansible_remote_tmp=/var/tmp/.ansible-${USER}\n\n"
"[pve]\n"
f"{pve_host} ansible_host={pve_host} "
f"ansible_user={ssh_user} "
f"ansible_ssh_private_key_file={ssh_key}\n",
encoding="utf-8",
)
return deploy_dir
def _start(self) -> None:
cluster_id = self._cluster_combo.currentData()
if cluster_id is None:
QMessageBox.warning(self, "Нет кластера", "В БД нет ни одного кластера.")
return
self._log.clear()
try:
self._deploy_dir = self._gen_configs(cluster_id)
except Exception as exc:
QMessageBox.critical(self, "Ошибка", f"Не удалось сгенерировать playbook:\n{exc}")
return
inv = os.path.join(self._deploy_dir, "inventory.ini")
play = os.path.join(self._deploy_dir, "setup_monitoring.yml")
self._append(f"▶ {AppConfig.get('ansible_bin')} -i {inv} {play}\n\n")
self._process = QProcess(self)
self._process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
self._process.readyReadStandardOutput.connect(self._on_output)
self._process.finished.connect(self._on_finished)
self._process.start(
AppConfig.get("ansible_bin") or "ansible-playbook",
["-i", inv, play],
)
if not self._process.waitForStarted(3000):
self._append("✘ Не удалось запустить ansible-playbook.\n")
return
self._btn_start.setEnabled(False)
self._btn_stop.setEnabled(True)
def _stop(self) -> None:
if self._process and self._process.state() != QProcess.ProcessState.NotRunning:
self._process.kill()
self._append("\n⏹ Остановлено пользователем.\n")
# ------------------------------------------------------------------
def _on_output(self) -> None:
if not self._process:
return
data = bytes(self._process.readAllStandardOutput()).decode(errors="replace")
self._append(data)
def _on_finished(self, exit_code: int, _status) -> None:
self._btn_start.setEnabled(True)
self._btn_stop.setEnabled(False)
if exit_code == 0:
ip = (self._ct_ip.text().split("/")[0] or "").strip()
if ip:
AppConfig.set_value("prometheus_url", f"http://{ip}:9090")
AppConfig.set_value("grafana_url", f"http://{ip}:3000")
AppConfig.set_value("alertmanager_url", f"http://{ip}:9093")
AppConfig.set_value("grafana_password", self._grafana_pw.text() or "admin")
# Сохраним параметры LXC — чтобы при повторном запуске форма их помнила
AppConfig.set_value("monitoring_pve_host", self._pve_host.text().strip())
AppConfig.set_value("monitoring_vmid", self._vmid.value())
AppConfig.set_value("monitoring_ct_ip", self._ct_ip.text().strip())
AppConfig.set_value("monitoring_ct_gw", self._ct_gw.text().strip())
AppConfig.set_value("monitoring_ct_bridge", self._ct_bridge.text().strip())
AppConfig.set_value("monitoring_ct_storage", self._ct_storage.text().strip())
AppConfig.set_value("monitoring_ct_template", self._ct_template.text().strip())
try:
AppConfig.save()
except Exception as exc:
self._append(f"\nПредупреждение: не удалось сохранить AppConfig: {exc}\n")
self._append(
f"\n✔ Готово. Prometheus http://{ip}:9090, "
f"Grafana http://{ip}:3000, Alertmanager http://{ip}:9093\n"
)
else:
self._append(f"\n✘ Код возврата: {exit_code}\n")
......@@ -36,7 +36,12 @@ _FIELD_STYLE = (
class SettingsWidget(BasePage):
def __init__(self, parent=None) -> None:
super().__init__("⚙️ Настройки", "Параметры приложения", show_refresh=False)
super().__init__(
"⚙️ Настройки",
"Параметры приложения",
show_refresh=False,
scrollable=True,
)
self._build_content()
self._load()
......@@ -118,6 +123,104 @@ class SettingsWidget(BasePage):
self.content_layout.addWidget(mon_box)
# ── Ceph Dashboard REST API ───────────────────────────────────
dash_box = QGroupBox("Ceph Dashboard (REST API)")
dash_box.setStyleSheet(_BOX_STYLE)
dash_form = QFormLayout(dash_box)
dash_form.setSpacing(10)
dash_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self._dash_port = QSpinBox()
self._dash_port.setRange(1, 65535)
self._dash_port.setFixedWidth(160)
self._dash_port.setStyleSheet(_FIELD_STYLE)
dash_form.addRow("Порт:", self._dash_port)
self._dash_user = QLineEdit()
self._dash_user.setStyleSheet(_FIELD_STYLE)
self._dash_user.setMinimumWidth(320)
self._dash_user.setMaximumWidth(360)
dash_form.addRow("Пользователь:", self._dash_user)
self._dash_password = QLineEdit()
self._dash_password.setStyleSheet(_FIELD_STYLE)
self._dash_password.setMinimumWidth(320)
self._dash_password.setMaximumWidth(360)
self._dash_password.setEchoMode(QLineEdit.EchoMode.Password)
dash_form.addRow("Пароль:", self._dash_password)
self.content_layout.addWidget(dash_box)
# ── Внешний стек (Prometheus / Grafana / Alertmanager) ────────
ext_box = QGroupBox("Внешний мониторинг (LXC на PVE-узле)")
ext_box.setStyleSheet(_BOX_STYLE)
ext_form = QFormLayout(ext_box)
ext_form.setSpacing(10)
ext_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self._prom_url = QLineEdit()
self._prom_url.setStyleSheet(_FIELD_STYLE)
self._prom_url.setMaximumWidth(400)
self._prom_url.setPlaceholderText("http://192.168.0.20:9090")
ext_form.addRow("URL Prometheus:", self._prom_url)
self._grafana_url = QLineEdit()
self._grafana_url.setStyleSheet(_FIELD_STYLE)
self._grafana_url.setMaximumWidth(400)
self._grafana_url.setPlaceholderText("http://192.168.0.20:3000")
ext_form.addRow("URL Grafana:", self._grafana_url)
self._am_url = QLineEdit()
self._am_url.setStyleSheet(_FIELD_STYLE)
self._am_url.setMaximumWidth(400)
self._am_url.setPlaceholderText("http://192.168.0.20:9093")
ext_form.addRow("URL Alertmanager:", self._am_url)
self._grafana_pw = QLineEdit()
self._grafana_pw.setStyleSheet(_FIELD_STYLE)
self._grafana_pw.setMaximumWidth(280)
self._grafana_pw.setEchoMode(QLineEdit.EchoMode.Password)
ext_form.addRow("Пароль Grafana (admin):", self._grafana_pw)
self._mon_pve = QLineEdit()
self._mon_pve.setStyleSheet(_FIELD_STYLE)
self._mon_pve.setMaximumWidth(400)
ext_form.addRow("PVE-хост:", self._mon_pve)
self._mon_vmid = QSpinBox()
self._mon_vmid.setRange(100, 999999)
self._mon_vmid.setFixedWidth(110)
self._mon_vmid.setStyleSheet(_FIELD_STYLE)
ext_form.addRow("VMID контейнера:", self._mon_vmid)
self._mon_ct_ip = QLineEdit()
self._mon_ct_ip.setStyleSheet(_FIELD_STYLE)
self._mon_ct_ip.setMaximumWidth(280)
self._mon_ct_ip.setPlaceholderText("192.168.0.20/24")
ext_form.addRow("IP контейнера:", self._mon_ct_ip)
self._mon_ct_gw = QLineEdit()
self._mon_ct_gw.setStyleSheet(_FIELD_STYLE)
self._mon_ct_gw.setMaximumWidth(280)
ext_form.addRow("Шлюз:", self._mon_ct_gw)
self._mon_ct_bridge = QLineEdit()
self._mon_ct_bridge.setStyleSheet(_FIELD_STYLE)
self._mon_ct_bridge.setMaximumWidth(180)
ext_form.addRow("Bridge:", self._mon_ct_bridge)
self._mon_ct_storage = QLineEdit()
self._mon_ct_storage.setStyleSheet(_FIELD_STYLE)
self._mon_ct_storage.setMaximumWidth(180)
ext_form.addRow("Хранилище:", self._mon_ct_storage)
self._mon_ct_template = QLineEdit()
self._mon_ct_template.setStyleSheet(_FIELD_STYLE)
self._mon_ct_template.setMaximumWidth(400)
ext_form.addRow("Шаблон LXC:", self._mon_ct_template)
self.content_layout.addWidget(ext_box)
# ── Кнопки ───────────────────────────────────────────────────
btn_row = QHBoxLayout()
self._btn_save = QPushButton("💾 Сохранить")
......@@ -154,6 +257,22 @@ class SettingsWidget(BasePage):
self._ansible_bin.setText(AppConfig.get("ansible_bin"))
self._refresh_interval.setValue(int(AppConfig.get("status_refresh_interval")))
self._dash_port.setValue(int(AppConfig.get("dashboard_port")))
self._dash_user.setText(AppConfig.get("dashboard_user"))
self._dash_password.setText(AppConfig.get("dashboard_password"))
self._prom_url.setText(AppConfig.get("prometheus_url") or "")
self._grafana_url.setText(AppConfig.get("grafana_url") or "")
self._am_url.setText(AppConfig.get("alertmanager_url") or "")
self._grafana_pw.setText(AppConfig.get("grafana_password"))
self._mon_pve.setText(AppConfig.get("monitoring_pve_host"))
self._mon_vmid.setValue(int(AppConfig.get("monitoring_vmid")))
self._mon_ct_ip.setText(AppConfig.get("monitoring_ct_ip"))
self._mon_ct_gw.setText(AppConfig.get("monitoring_ct_gw"))
self._mon_ct_bridge.setText(AppConfig.get("monitoring_ct_bridge"))
self._mon_ct_storage.setText(AppConfig.get("monitoring_ct_storage"))
self._mon_ct_template.setText(AppConfig.get("monitoring_ct_template"))
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")
......@@ -161,6 +280,23 @@ class SettingsWidget(BasePage):
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())
AppConfig.set_value("dashboard_port", self._dash_port.value())
AppConfig.set_value("dashboard_user", self._dash_user.text().strip() or "admin")
AppConfig.set_value("dashboard_password", self._dash_password.text() or "admin")
AppConfig.set_value("prometheus_url", self._prom_url.text().strip())
AppConfig.set_value("grafana_url", self._grafana_url.text().strip())
AppConfig.set_value("alertmanager_url", self._am_url.text().strip())
AppConfig.set_value("grafana_password", self._grafana_pw.text() or "admin")
AppConfig.set_value("monitoring_pve_host", self._mon_pve.text().strip())
AppConfig.set_value("monitoring_vmid", self._mon_vmid.value())
AppConfig.set_value("monitoring_ct_ip", self._mon_ct_ip.text().strip())
AppConfig.set_value("monitoring_ct_gw", self._mon_ct_gw.text().strip())
AppConfig.set_value("monitoring_ct_bridge", self._mon_ct_bridge.text().strip())
AppConfig.set_value("monitoring_ct_storage", self._mon_ct_storage.text().strip())
AppConfig.set_value("monitoring_ct_template", self._mon_ct_template.text().strip())
try:
AppConfig.save()
QMessageBox.information(self, "Сохранено", "Настройки сохранены.")
......
......@@ -8,17 +8,21 @@ import paramiko
from PyQt6.QtCore import QThread, QTimer, pyqtSignal
from PyQt6.QtGui import QColor, QFont
from PyQt6.QtWidgets import (
QButtonGroup,
QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QRadioButton,
QSpinBox,
QTextEdit,
QVBoxLayout,
QWidget,
)
from api.mgr_dashboard import DashboardClient, DashboardError
from core.config import AppConfig
from db import SessionLocal
from db.repository import list_clusters, list_servers
from ui.base_page import BasePage
......@@ -110,6 +114,149 @@ class CephStatusWorker(QThread):
# ---------------------------------------------------------------------------
# Фоновый воркер: опрос через REST API ceph-mgr dashboard (без SSH)
# ---------------------------------------------------------------------------
class DashboardAPIWorker(QThread):
result = pyqtSignal(dict)
error = pyqtSignal(str)
def __init__(
self,
host: str,
port: int,
user: str,
password: str,
parent=None,
) -> None:
super().__init__(parent)
self.host = host
self.port = port
self.user = user
self.password = password
def run(self) -> None:
try:
client = DashboardClient(
host=self.host,
port=self.port,
user=self.user,
password=self.password,
)
health = client.health_minimal()
osds = client.osd_list()
pools = client.pool_list()
self.result.emit({
"ceph_s": self._format_status(health),
"df": self._format_df(pools),
"osd_tree": self._format_osd_tree(osds),
})
except DashboardError as exc:
self.error.emit(str(exc))
except Exception as exc:
self.error.emit(f"{type(exc).__name__}: {exc}")
# Форматы приближены к ceph -s / ceph df / ceph osd tree, но строятся из JSON.
@staticmethod
def _format_status(h: dict) -> str:
lines = []
status = h.get("health", {}).get("status", "?")
fsid = h.get("fsid", "?")
lines.append(" cluster:")
lines.append(f" id: {fsid}")
lines.append(f" health: {status}")
for check in h.get("health", {}).get("checks", {}).values():
sev = check.get("severity", "?")
msg = check.get("summary", {}).get("message", "")
lines.append(f" [{sev}] {msg}")
lines.append("")
lines.append(" services:")
mon = h.get("mon_status", {}).get("monmap", {})
mons = mon.get("mons", [])
quorum = h.get("mon_status", {}).get("quorum", [])
lines.append(
f" mon: {len(mons)} daemons, quorum "
+ ",".join(m.get("name", "?") for m in mons if m.get("rank") in quorum)
)
mgr = h.get("mgr_map", {})
active = mgr.get("active_name")
stby = [s.get("name", "?") for s in mgr.get("standbys", [])]
lines.append(
f" mgr: {active or 'none'}"
+ (f" (standbys: {', '.join(stby)})" if stby else "")
)
osdmap = h.get("osd_map", {})
up = osdmap.get("num_up_osds", 0)
inn = osdmap.get("num_in_osds", 0)
total = osdmap.get("num_osds", 0)
lines.append(f" osd: {total} osds: {up} up, {inn} in")
lines.append("")
lines.append(" data:")
pools = h.get("pools", 0)
objects = h.get("df", {}).get("stats", {}).get("total_objects", 0)
used = h.get("df", {}).get("stats", {}).get("total_used_bytes", 0)
avail = h.get("df", {}).get("stats", {}).get("total_avail_bytes", 0)
lines.append(f" pools: {pools} pools")
lines.append(f" objects: {objects}")
lines.append(
f" usage: {_human(used)} used, {_human(avail)} avail"
)
return "\n".join(lines)
@staticmethod
def _format_df(pools: list) -> str:
if not pools:
return "(нет пулов)"
lines = [f"{'POOL':<24} {'STORED':>12} {'OBJECTS':>10} {'USED':>12}"]
for p in pools:
name = p.get("pool_name", "?")
stats = p.get("stats", {})
stored = stats.get("stored", {}).get("latest", 0) if isinstance(
stats.get("stored"), dict
) else stats.get("stored", 0)
objects = stats.get("objects", 0)
used = stats.get("bytes_used", 0) if "bytes_used" in stats else stored
lines.append(
f"{name:<24} {_human(stored):>12} {objects:>10} {_human(used):>12}"
)
return "\n".join(lines)
@staticmethod
def _format_osd_tree(osds: list) -> str:
if not osds:
return "(нет OSD)"
lines = [f"{'ID':>4} {'HOST':<20} {'STATUS':<8} {'CLASS':<6} {'WEIGHT':>8}"]
for o in osds:
osd_id = o.get("osd", "?")
host = o.get("host", "?") if isinstance(o.get("host"), str) else "?"
status = o.get("state", [])
status_s = "up" if "up" in status else "down"
cls = o.get("tree", {}).get("device_class", "?")
weight = o.get("tree", {}).get("crush_weight", 0)
lines.append(
f"{osd_id:>4} {host:<20} {status_s:<8} {cls:<6} {weight:>8.3f}"
)
return "\n".join(lines)
def _human(n: float) -> str:
try:
n = float(n)
except (TypeError, ValueError):
return "?"
for unit in ("B", "KiB", "MiB", "GiB", "TiB", "PiB"):
if abs(n) < 1024.0:
return f"{n:.1f} {unit}"
n /= 1024.0
return f"{n:.1f} EiB"
# ---------------------------------------------------------------------------
# Виджет страницы
# ---------------------------------------------------------------------------
......@@ -128,7 +275,7 @@ def _mono_edit() -> QTextEdit:
class StatusWidget(BasePage):
def __init__(self, parent=None) -> None:
super().__init__("📊 Состояние", "Дашборд кластера Ceph")
self._worker: CephStatusWorker | None = None
self._worker: QThread | None = None
self._timer = QTimer(self)
self._timer.timeout.connect(self._fetch)
self._build_content()
......@@ -170,6 +317,19 @@ class StatusWidget(BasePage):
self._spin_interval.valueChanged.connect(self._on_interval_changed)
ctrl_layout.addWidget(self._spin_interval)
# Переключатель источника данных: SSH + cephadm shell vs REST API mgr.
ctrl_layout.addWidget(QLabel(" Источник:"))
self._rb_ssh = QRadioButton("SSH")
self._rb_api = QRadioButton("REST API")
self._rb_ssh.setStyleSheet("QRadioButton { color: #c0c8d8; }")
self._rb_api.setStyleSheet("QRadioButton { color: #c0c8d8; }")
self._rb_ssh.setChecked(True)
self._source_group = QButtonGroup(self)
self._source_group.addButton(self._rb_ssh)
self._source_group.addButton(self._rb_api)
ctrl_layout.addWidget(self._rb_ssh)
ctrl_layout.addWidget(self._rb_api)
self._lbl_status = QLabel("Не подключено")
self._lbl_status.setStyleSheet("color: #5a6478; font-size: 12px;")
ctrl_layout.addWidget(self._lbl_status)
......@@ -264,15 +424,24 @@ class StatusWidget(BasePage):
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()),
)
if self._rb_api.isChecked():
self._lbl_status.setText(f"REST API → {mon.ip_address}…")
self._worker = DashboardAPIWorker(
host=mon.ip_address,
port=int(AppConfig.get("dashboard_port")),
user=AppConfig.get("dashboard_user"),
password=AppConfig.get("dashboard_password"),
)
else:
self._lbl_status.setText(f"SSH → {mon.hostname}…")
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()
......
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