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
......@@ -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
......
......@@ -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(
......
......@@ -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