Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
C
cephdeploy
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Registry
Registry
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Timofey
cephdeploy
Commits
766f2065
Commit
766f2065
authored
May 18, 2026
by
Тимофей Смирнов
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update Ceph cluster management workflows
parent
7bcb9076
Hide whitespace changes
Inline
Side-by-side
Showing
16 changed files
with
1979 additions
and
131 deletions
+1979
-131
.gitignore
.gitignore
+1
-0
prometheus_client.py
api/prometheus_client.py
+23
-8
config.py
core/config.py
+7
-6
ceph_bootstrap.yml.j2
templates/ceph_bootstrap.yml.j2
+104
-22
ceph_cleanup.yml.j2
templates/ceph_cleanup.yml.j2
+71
-9
report.html.j2
templates/report.html.j2
+28
-0
setup_monitoring.yml.j2
templates/setup_monitoring.yml.j2
+28
-11
analysis_widget.py
ui/analysis_widget.py
+180
-16
clusters_widget.py
ui/clusters_widget.py
+195
-7
deploy_widget.py
ui/deploy_widget.py
+105
-2
help_window.py
ui/help_window.py
+150
-14
main_window.py
ui/main_window.py
+13
-8
monitoring_dialog.py
ui/monitoring_dialog.py
+287
-17
osd_widget.py
ui/osd_widget.py
+426
-11
report_widget.py
ui/report_widget.py
+81
-0
storage_test_widget.py
ui/storage_test_widget.py
+280
-0
No files found.
.gitignore
View file @
766f2065
...
...
@@ -38,6 +38,7 @@ cephdeploy_*/
*.swp
*.swo
*~
.nfs*
# OS
.DS_Store
...
...
api/prometheus_client.py
View file @
766f2065
...
...
@@ -63,6 +63,24 @@ class PrometheusClient:
raise
PrometheusError
(
f
"Нет связи с Prometheus: {exc}"
)
from
exc
return
self
.
_parse
(
resp
)
def
targets
(
self
)
->
list
[
dict
]:
"""Возвращает activeTargets из /api/v1/targets."""
try
:
resp
=
self
.
_session
.
get
(
f
"{self._base}/api/v1/targets"
,
timeout
=
self
.
_timeout
,
)
except
requests
.
RequestException
as
exc
:
raise
PrometheusError
(
f
"Нет связи с Prometheus: {exc}"
)
from
exc
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
(
"activeTargets"
,
[])
def
_parse
(
self
,
resp
:
requests
.
Response
)
->
list
[
dict
]:
if
resp
.
status_code
>=
400
:
raise
PrometheusError
(
...
...
@@ -103,14 +121,14 @@ class PrometheusClient:
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]))"
)
"""Суммарная скорость чтения по всем
пулам
(байт/с)."""
return
self
.
scalar
(
"sum(rate(ceph_
pool_rd
_bytes[1m]))"
)
def
cluster_write_throughput
(
self
)
->
float
:
return
self
.
scalar
(
"sum(rate(ceph_
osd_op_w_in
_bytes[1m]))"
)
return
self
.
scalar
(
"sum(rate(ceph_
pool_wr
_bytes[1m]))"
)
def
cluster_iops
(
self
)
->
float
:
return
self
.
scalar
(
"sum(rate(ceph_
osd_op
[1m]))"
)
return
self
.
scalar
(
"sum(rate(ceph_
pool_rd[1m])) + sum(rate(ceph_pool_wr
[1m]))"
)
def
osd_latency_series
(
self
,
...
...
@@ -121,10 +139,7 @@ class PrometheusClient:
Возвращает {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])"
)
promql
=
"max(ceph_osd_apply_latency_ms) by (ceph_daemon)"
raw
=
self
.
query_range
(
promql
,
start
,
end
,
step
=
step
)
out
:
dict
[
str
,
list
[
tuple
[
float
,
float
]]]
=
{}
for
row
in
raw
:
...
...
core/config.py
View file @
766f2065
...
...
@@ -27,12 +27,13 @@ _DEFAULTS: dict = {
"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"
,
"monitoring_vmid"
:
100
,
"monitoring_ct_ip"
:
""
,
"monitoring_ct_gw"
:
""
,
"monitoring_ct_bridge"
:
""
,
"monitoring_ct_storage"
:
""
,
"monitoring_template_storage"
:
""
,
"monitoring_ct_template"
:
""
,
}
...
...
templates/ceph_bootstrap.yml.j2
View file @
766f2065
...
...
@@ -2,6 +2,69 @@
# Ansible-плейбук для развёртывания Ceph {{ cluster.version }} (cephadm)
# Кластер: {{ cluster.name }}
# Сгенерировано CephDeploy
{% set resolved_ceph_image = ceph_image | default('', true) %}
{% if not resolved_ceph_image %}
{% set resolved_ceph_image = {
'quincy': 'quay.io/ceph/ceph:v17',
'reef': 'quay.io/ceph/ceph:v18',
'squid': 'quay.io/ceph/ceph:v19',
}.get(cluster.version | default('', true) | lower, 'quay.io/ceph/ceph:v19') %}
{% endif %}
- name: Защита от установки поверх существующего Ceph
hosts: all
become: true
gather_facts: false
tasks:
- name: Проверить признаки существующего Ceph
ansible.builtin.shell: |
evidence=0
if [ -s /etc/ceph/ceph.conf ]; then
echo "/etc/ceph/ceph.conf"
evidence=1
fi
if [ -d /var/lib/ceph ] && timeout 3 find /var/lib/ceph -mindepth 1 -maxdepth 2 -print -quit 2>/dev/null | grep -q .; then
echo "/var/lib/ceph contains data"
evidence=1
fi
if command -v cephadm >/dev/null 2>&1 && timeout 3 cephadm ls 2>/dev/null | grep -q '"fsid"'; then
echo "cephadm cluster metadata exists"
evidence=1
fi
if command -v systemctl >/dev/null 2>&1 && timeout 3 systemctl list-units --all --no-legend 'ceph-*@*' 2>/dev/null | grep -q .; then
echo "ceph systemd units exist"
evidence=1
fi
if command -v lvs >/dev/null 2>&1 && timeout 3 lvs --noheadings -o lv_tags 2>/dev/null | grep -q 'ceph'; then
echo "Ceph LVM tags exist"
evidence=1
fi
if command -v podman >/dev/null 2>&1 && timeout 3 podman ps -a --filter name=ceph- -q 2>/dev/null | grep -q .; then
echo "ceph podman containers exist"
evidence=1
fi
if command -v docker >/dev/null 2>&1 && timeout 3 docker ps -a --filter name=ceph- -q 2>/dev/null | grep -q .; then
echo "ceph docker containers exist"
evidence=1
fi
exit "$evidence"
args:
executable: /bin/bash
register: existing_ceph
changed_when: false
failed_when: false
- name: Остановить развёртывание, если Ceph уже найден
ansible.builtin.fail:
{% raw %}
msg: >-
На узле {{ inventory_hostname }} уже есть признаки Ceph:
{{ existing_ceph.stdout_lines | join('; ') }}.
Развёртывание остановлено, чтобы не установить кластер поверх существующего.
Если это тестовый стенд, сначала используйте явную очистку.
{% endraw %}
when: existing_ceph.rc != 0
- name: Подготовка узлов
hosts: all
...
...
@@ -15,7 +78,10 @@
name:
- cephadm
- python3-pip
- python3-yaml
- python3-jinja2
- lvm2
- podman
state: present
update_cache: true
when: ansible_facts["os_family"] == "Debian"
...
...
@@ -25,7 +91,10 @@
package:
- cephadm
- python3-module-pip
- python3-module-yaml
- python3-module-jinja2
- lvm2
- podman
state: present
update_cache: true
when: ansible_facts["os_family"] == "Altlinux"
...
...
@@ -35,10 +104,34 @@
name:
- cephadm
- python3-pip
- python3-pyyaml
- python3-jinja2
- lvm2
- podman
state: present
when: ansible_facts["os_family"] == "RedHat"
- name: Проверить Python-зависимости cephadm
ansible.builtin.command: python3 -c "import yaml, jinja2"
changed_when: false
- name: Проверить запуск cephadm
ansible.builtin.command: /usr/sbin/cephadm version
changed_when: false
- name: Проверить container runtime
ansible.builtin.command: podman --version
changed_when: false
- name: Заранее загрузить Ceph container image
ansible.builtin.shell: |
set -o pipefail
podman image exists {{ resolved_ceph_image }} || podman pull {{ resolved_ceph_image }}
args:
executable: /bin/bash
register: ceph_image_pull
changed_when: "'Trying to pull' in (ceph_image_pull.stdout | default('')) or 'Copying blob' in (ceph_image_pull.stdout | default(''))"
# ── Проверка наличия chronyc и firewalld без зависимости от pkg-facts
- name: Проверить наличие chronyc
ansible.builtin.command: which chronyc
...
...
@@ -75,11 +168,13 @@
tasks:
- name: Запустить cephadm bootstrap
ansible.builtin.command: >
cephadm bootstrap
/usr/sbin/cephadm
--image {{ resolved_ceph_image }}
bootstrap
--mon-ip {{ bootstrap_host.ip_address }}
--initial-dashboard-user {{ dashboard.user }}
--initial-dashboard-password {{ dashboard.password }}
--skip-dashboard
--skip-monitoring-stack
--allow-mismatched-release
--allow-overwrite
args:
creates: /etc/ceph/ceph.conf
...
...
@@ -90,28 +185,15 @@
var: bootstrap_result.stdout_lines
when: bootstrap_result.stdout_lines is defined
# ── mgr-модул
и
для внешнего мониторинга ─────────────────────────────
# ── 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
ansible.builtin.command:
/usr/sbin/
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
...
...
@@ -156,7 +238,7 @@
# поэтому все ceph-команды идут через `cephadm shell -- ceph ...`,
# которое запускает их внутри podman-контейнера с Ceph.
- name: Подождать готовности оркестратора
ansible.builtin.command: cephadm shell -- ceph orch status
ansible.builtin.command:
/usr/sbin/
cephadm shell -- ceph orch status
register: orch_status
retries: 10
delay: 10
...
...
@@ -174,7 +256,7 @@
{% set hv = "{{ hostvars['" ~ s.hostname ~ "'].ansible_hostname | default('" ~ s.hostname ~ "') }}" %}
- name: Добавить хост {{ s.hostname }}
ansible.builtin.command: >
cephadm shell -- ceph orch host add {{ hv }} {{ s.ip_address }}
/usr/sbin/
cephadm shell -- ceph orch host add {{ hv }} {{ s.ip_address }}
register: add_host_result
failed_when: false
changed_when: add_host_result.rc == 0
...
...
@@ -196,7 +278,7 @@
{% for osd in s.osds %}
- name: Добавить OSD {{ osd.path }} на {{ s.hostname }} ({{ osd.type }}/{{ osd.role }})
ansible.builtin.command: >
cephadm shell -- ceph orch daemon add osd {{ osd_hv }}:{{ osd.path }}
/usr/sbin/
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(''))"
...
...
@@ -204,7 +286,7 @@
{% endfor %}
{% endfor %}
- name: Статус кластера
ansible.builtin.command: cephadm shell -- ceph -s
ansible.builtin.command:
/usr/sbin/
cephadm shell -- ceph -s
register: ceph_status
failed_when: false
changed_when: false
...
...
templates/ceph_cleanup.yml.j2
View file @
766f2065
...
...
@@ -38,13 +38,31 @@
failed_when: false
changed_when: true
- name: Остановить
любые оставшиеся ceph-*@
юниты
- name: Остановить
и отключить любые оставшиеся ceph systemd-
юниты
ansible.builtin.shell: |
set -o pipefail
for u in $(systemctl list-units --all --no-legend 'ceph-*@*' 2>/dev/null | awk '{print $1}'); do
systemctl stop "$u" 2>/dev/null || true
systemctl disable "$u" 2>/dev/null || true
done
if command -v systemctl >/dev/null 2>&1; then
for u in $(timeout 5 systemctl list-units --all --no-legend 'ceph-*' 2>/dev/null | awk '{print $1}'); do
timeout 10 systemctl stop "$u" 2>/dev/null || true
timeout 10 systemctl disable "$u" 2>/dev/null || true
timeout 5 systemctl reset-failed "$u" 2>/dev/null || true
done
fi
args:
executable: /bin/bash
changed_when: true
failed_when: false
- name: Удалить оставшиеся ceph systemd unit-файлы
ansible.builtin.shell: |
set -o pipefail
find /etc/systemd/system /run/systemd/system -maxdepth 2 \
\( -name 'ceph-*.target' -o -name 'ceph-*@.service' -o -name 'ceph-*@*.service' \) \
-exec rm -f {} + 2>/dev/null || true
if command -v systemctl >/dev/null 2>&1; then
timeout 10 systemctl daemon-reload 2>/dev/null || true
timeout 10 systemctl reset-failed 2>/dev/null || true
fi
args:
executable: /bin/bash
changed_when: true
...
...
@@ -63,17 +81,61 @@
- name: Убрать оставшиеся podman/docker-контейнеры ceph-*
ansible.builtin.shell: |
if command -v podman >/dev/null 2>&1; then
for c in $(podman ps -aq --filter name=ceph- 2>/dev/null); do
podman rm -f "$c" >/dev/null 2>&1 || true
for c in $(
timeout 5
podman ps -aq --filter name=ceph- 2>/dev/null); do
timeout 20
podman rm -f "$c" >/dev/null 2>&1 || true
done
fi
if command -v docker >/dev/null 2>&1; then
for c in $(docker ps -aq --filter name=ceph- 2>/dev/null); do
docker rm -f "$c" >/dev/null 2>&1 || true
for c in $(
timeout 5
docker ps -aq --filter name=ceph- 2>/dev/null); do
timeout 20
docker rm -f "$c" >/dev/null 2>&1 || true
done
fi
args:
executable: /bin/bash
changed_when: true
failed_when: false
- name: Проверить, что признаки Ceph удалены
ansible.builtin.shell: |
evidence=0
if [ -s /etc/ceph/ceph.conf ]; then
echo "/etc/ceph/ceph.conf"
evidence=1
fi
if [ -d /var/lib/ceph ] && timeout 3 find /var/lib/ceph -mindepth 1 -maxdepth 2 -print -quit 2>/dev/null | grep -q .; then
echo "/var/lib/ceph contains data"
evidence=1
fi
if command -v cephadm >/dev/null 2>&1 && timeout 3 cephadm ls 2>/dev/null | grep -q '"fsid"'; then
echo "cephadm cluster metadata exists"
evidence=1
fi
if command -v systemctl >/dev/null 2>&1 && timeout 3 systemctl list-units --all --no-legend 'ceph-*@*' 2>/dev/null | grep -q .; then
echo "ceph systemd units exist"
evidence=1
fi
if find /etc/systemd/system /run/systemd/system -maxdepth 2 \
\( -name 'ceph-*.target' -o -name 'ceph-*@.service' -o -name 'ceph-*@*.service' \) \
-print -quit 2>/dev/null | grep -q .; then
echo "ceph systemd unit files exist"
evidence=1
fi
if command -v lvs >/dev/null 2>&1 && timeout 3 lvs --noheadings -o lv_tags 2>/dev/null | grep -q 'ceph'; then
echo "Ceph LVM tags exist"
evidence=1
fi
if command -v podman >/dev/null 2>&1 && timeout 3 podman ps -a --filter name=ceph- -q 2>/dev/null | grep -q .; then
echo "ceph podman containers exist"
evidence=1
fi
if command -v docker >/dev/null 2>&1 && timeout 3 docker ps -a --filter name=ceph- -q 2>/dev/null | grep -q .; then
echo "ceph docker containers exist"
evidence=1
fi
exit "$evidence"
args:
executable: /bin/bash
register: cleanup_verify
changed_when: false
failed_when: cleanup_verify.rc != 0
{% endraw %}
templates/report.html.j2
View file @
766f2065
...
...
@@ -68,6 +68,34 @@
<p
style=
"color:#9e9e9e"
>
Серверов нет.
</p>
{% endif %}
<!-- Фактические диски -->
<h2>
Обнаруженные диски на серверах
</h2>
{% set total_disks = namespace(n=0) %}
{% for s in servers %}{% set total_disks.n = total_disks.n + (s.disks | length) %}{% endfor %}
{% if total_disks.n > 0 %}
<table>
<thead>
<tr><th>
Сервер
</th><th>
Диск
</th><th>
Размер
</th><th>
Тип
</th><th>
Состояние
</th><th>
Детали
</th></tr>
</thead>
<tbody>
{% for s in servers %}
{% for disk in s.disks %}
<tr>
<td>
{{ s.hostname }}
</td>
<td><code>
{{ disk.path }}
</code></td>
<td>
{{ disk.size }}
</td>
<td>
{{ disk.type }}
</td>
<td>
{{ disk.status }}
</td>
<td>
{{ disk.detail }}
</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
{% else %}
<p
style=
"color:#9e9e9e"
>
Диски не обнаружены или серверы недоступны по SSH.
</p>
{% endif %}
<!-- OSD -->
<h2>
OSD-устройства
</h2>
{% set total_osds = namespace(n=0) %}
...
...
templates/setup_monitoring.yml.j2
View file @
766f2065
...
...
@@ -18,6 +18,7 @@
ct_gw: "{{ monitoring.ct_gw }}"
ct_bridge: "{{ monitoring.ct_bridge }}"
ct_storage: "{{ monitoring.ct_storage }}"
template_storage: "{{ monitoring.template_storage }}"
ct_template: "{{ monitoring.ct_template }}"
grafana_admin_password: "{{ monitoring.grafana_password }}"
...
...
@@ -25,16 +26,17 @@
# На свежем PVE-хосте хранилище 'local' может не иметь в content типа
# 'vztmpl' — без него pveam download падает "storage 'local' does not
# support templates". Добавляем vztmpl к текущему списку content.
- name: Разрешить хранить LXC-шаблоны в
local
- name: Разрешить хранить LXC-шаблоны в
выбранном template storage
ansible.builtin.shell: |
set -eo pipefail
cur=$(awk '/^dir: local$/{flag=1; next}
storage="{{ '{{ template_storage }}' }}"
cur=$(awk -v storage="$storage" '$0 ~ "^[^[:space:]]+: " storage "$"{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"
pvesm set
"$storage"
--content "$new"
echo "VZTMPL_ADDED"
fi
args:
...
...
@@ -44,10 +46,25 @@
# Шаблон сравниваем по имени файла (работает и для debian-12-..., и для
# вручную положенного ALT-tarball).
- name: Проверить, не занят ли VMID
ansible.builtin.shell: |
set -o pipefail
if pct status {{ '{{ vmid }}' }} >/dev/null 2>&1; then
echo "VMID {{ '{{ vmid }}' }} already exists as LXC"
exit 1
fi
if qm status {{ '{{ vmid }}' }} >/dev/null 2>&1; then
echo "VMID {{ '{{ vmid }}' }} already exists as VM"
exit 1
fi
args:
executable: /bin/bash
changed_when: false
- name: Проверить наличие LXC-шаблона в хранилище
ansible.builtin.shell: |
set -o pipefail
pveam list
local | awk '{print $2}' | grep -q "{% raw %}{{ ct_template }}{% endraw %
}"
pveam list
{{ '{{ template_storage }}' }} | awk 'NR>1{n=$1; sub(/^.*vztmpl\//, "", n); print n}' | grep -q "{{ '{{ ct_template }}' }
}"
args:
executable: /bin/bash
register: tpl_check
...
...
@@ -61,15 +78,15 @@
- name: Скачать указанный LXC-шаблон, если его ещё нет
ansible.builtin.command: >
pveam download
local {% raw %}{{ ct_template }}{% endraw %
}
pveam download
{{ '{{ template_storage }}' }} {{ '{{ ct_template }}' }
}
when: tpl_check.rc != 0
register: tpl_download
changed_when: tpl_download.rc == 0
- name: Проверить, существует ли контейнер VMID
={% raw %}{{ vmid }}{% endraw %}
- name: Проверить, существует ли контейнер VMID
ansible.builtin.command: pct status {
% raw %}{{ vmid }}{% endraw %
}
ansible.builtin.command: pct status {
{ '{{ vmid }}' }
}
register: pct_status
failed_when: false
...
...
@@ -77,9 +94,9 @@
- name: Создать LXC-контейнер
ansible.builtin.command: >
pct create {
% raw %}{{ vmid }}{% endraw %
}
pct create {
{ '{{ vmid }}' }
}
local:vztmpl/{% raw %}{{ ct_template }}{% endraw %
}
{{ '{{ template_storage }}' }}:vztmpl/{{ '{{ ct_template }}' }
}
--hostname {% raw %}{{ ct_hostname }}{% endraw %}
...
...
@@ -280,7 +297,7 @@
loop:
- prometheus
- alertmanager
-
prometheus-
alertmanager
- grafana-server
register: svc_enable
failed_when: false
...
...
@@ -292,7 +309,7 @@
loop:
- prometheus
- alertmanager
-
prometheus-
alertmanager
failed_when: false
changed_when: true
...
...
ui/analysis_widget.py
View file @
766f2065
...
...
@@ -10,6 +10,7 @@ latency — кандидаты на профилактику).
from
__future__
import
annotations
import
time
from
urllib.parse
import
urlparse
import
matplotlib
matplotlib
.
use
(
"QtAgg"
)
...
...
@@ -23,6 +24,7 @@ from PyQt6.QtWidgets import (
QHBoxLayout
,
QHeaderView
,
QLabel
,
QLineEdit
,
QPushButton
,
QTableWidget
,
QTableWidgetItem
,
...
...
@@ -32,6 +34,8 @@ from PyQt6.QtWidgets import (
from
api.prometheus_client
import
PrometheusClient
,
PrometheusError
from
core.config
import
AppConfig
from
db
import
SessionLocal
from
db.repository
import
list_clusters
,
list_servers
from
ui.base_page
import
BasePage
_BOX_STYLE
=
(
...
...
@@ -87,11 +91,11 @@ class PromFetchWorker(QThread):
start
=
end
-
self
.
duration_s
throughput_write
=
cli
.
query_range
(
"sum(rate(ceph_
osd_op_w_in
_bytes[1m]))"
,
"sum(rate(ceph_
pool_wr
_bytes[1m]))"
,
start
,
end
,
step
=
self
.
step
,
)
throughput_read
=
cli
.
query_range
(
"sum(rate(ceph_
osd_op_r_out
_bytes[1m]))"
,
"sum(rate(ceph_
pool_rd
_bytes[1m]))"
,
start
,
end
,
step
=
self
.
step
,
)
latency_per_osd
=
cli
.
osd_latency_series
(
self
.
duration_s
,
step
=
self
.
step
)
...
...
@@ -105,12 +109,27 @@ class PromFetchWorker(QThread):
"read_bps"
:
cli
.
cluster_read_throughput
(),
"write_bps"
:
cli
.
cluster_write_throughput
(),
}
health_rows
=
cli
.
query
(
"ceph_health_status"
)
fsids
=
sorted
(
{
row
.
get
(
"metric"
,
{})
.
get
(
"ceph_cluster"
)
for
row
in
health_rows
if
row
.
get
(
"metric"
,
{})
.
get
(
"ceph_cluster"
)
}
)
ceph_targets
=
[
target
for
target
in
cli
.
targets
()
if
target
.
get
(
"labels"
,
{})
.
get
(
"job"
)
==
"ceph"
]
self
.
result
.
emit
({
"kpi"
:
kpi
,
"throughput_w"
:
throughput_write
,
"throughput_r"
:
throughput_read
,
"latency_per_osd"
:
latency_per_osd
,
"fsids"
:
fsids
,
"ceph_targets"
:
ceph_targets
,
})
except
PrometheusError
as
exc
:
self
.
error
.
emit
(
str
(
exc
))
...
...
@@ -162,25 +181,51 @@ class AnalysisWidget(BasePage):
super
()
.
__init__
(
"📈 Анализ"
,
"Анализ функционирования кластера Ceph"
)
self
.
_worker
:
PromFetchWorker
|
None
=
None
self
.
_timer
=
QTimer
(
self
)
self
.
_timer
.
setInterval
(
2000
)
self
.
_timer
.
timeout
.
connect
(
self
.
_fetch
)
self
.
_build_content
()
def
showEvent
(
self
,
event
)
->
None
:
super
()
.
showEvent
(
event
)
self
.
_refresh_source_label
()
self
.
_fetch
()
self
.
_timer
.
start
()
def
hideEvent
(
self
,
event
)
->
None
:
self
.
_timer
.
stop
()
super
()
.
hideEvent
(
event
)
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
)
self
.
_source_combo
=
QComboBox
()
self
.
_source_combo
.
setEditable
(
True
)
self
.
_source_combo
.
setMinimumWidth
(
260
)
self
.
_source_combo
.
lineEdit
()
.
setPlaceholderText
(
"http://host:9090"
)
self
.
_source_combo
.
setStyleSheet
(
"QComboBox, QLineEdit { background: #111722; color: #e0e8f8; "
"border: 1px solid #3a4050; border-radius: 4px; padding: 3px 6px; }"
)
ctrl_layout
.
addWidget
(
self
.
_source_combo
)
self
.
_btn_apply_source
=
QPushButton
(
"Применить"
)
self
.
_btn_apply_source
.
setFixedHeight
(
28
)
self
.
_btn_apply_source
.
clicked
.
connect
(
self
.
_apply_source
)
ctrl_layout
.
addWidget
(
self
.
_btn_apply_source
)
ctrl_layout
.
addWidget
(
QLabel
(
" Период:"
))
self
.
_period
=
QComboBox
()
self
.
_period
.
addItem
(
"1 мин"
,
(
"60"
,
"5s"
))
self
.
_period
.
addItem
(
"5 мин"
,
(
"300"
,
"10s"
))
self
.
_period
.
addItem
(
"30 мин"
,
(
"1800"
,
"15s"
))
self
.
_period
.
addItem
(
"1 час"
,
(
"3600"
,
"30s"
))
self
.
_period
.
addItem
(
"6 часов"
,
(
"21600"
,
"2m"
))
self
.
_period
.
addItem
(
"24 часа"
,
(
"86400"
,
"10m"
))
self
.
_period
.
setCurrentIndex
(
1
)
self
.
_period
.
currentIndexChanged
.
connect
(
self
.
_fetch
)
ctrl_layout
.
addWidget
(
self
.
_period
)
...
...
@@ -197,9 +242,21 @@ class AnalysisWidget(BasePage):
self
.
_lbl_status
=
QLabel
(
""
)
self
.
_lbl_status
.
setStyleSheet
(
"color: #5a6478; font-size: 12px;"
)
ctrl_layout
.
addWidget
(
self
.
_lbl_status
)
self
.
_lbl_auto
=
QLabel
(
"авто: 2с"
)
self
.
_lbl_auto
.
setStyleSheet
(
"color: #5a6478; font-size: 12px;"
)
ctrl_layout
.
addWidget
(
self
.
_lbl_auto
)
ctrl_layout
.
addStretch
()
self
.
content_layout
.
addWidget
(
ctrl_box
)
identity_box
=
QGroupBox
(
"Определение кластера"
)
identity_box
.
setStyleSheet
(
_BOX_STYLE
)
identity_layout
=
QVBoxLayout
(
identity_box
)
self
.
_lbl_cluster_identity
=
QLabel
(
"Кластер: —"
)
self
.
_lbl_cluster_identity
.
setStyleSheet
(
"color: #c0c8d8;"
)
self
.
_lbl_cluster_identity
.
setWordWrap
(
True
)
identity_layout
.
addWidget
(
self
.
_lbl_cluster_identity
)
self
.
content_layout
.
addWidget
(
identity_box
)
# ── KPI-карточки ──────────────────────────────────────────────
kpi_row
=
QHBoxLayout
()
self
.
_kpi_health
=
KPICard
(
"Здоровье"
)
...
...
@@ -231,7 +288,7 @@ class AnalysisWidget(BasePage):
osd_layout
=
QVBoxLayout
(
osd_box
)
self
.
_osd_table
=
QTableWidget
(
0
,
4
)
self
.
_osd_table
.
setHorizontalHeaderLabels
(
[
"OSD"
,
"avg latency, мс"
,
"тренд
24ч
,
%
"
,
"примечание"
]
[
"OSD"
,
"avg latency, мс"
,
"тренд
периода
,
%
"
,
"примечание"
]
)
self
.
_osd_table
.
horizontalHeader
()
.
setSectionResizeMode
(
QHeaderView
.
ResizeMode
.
Stretch
...
...
@@ -256,12 +313,55 @@ class AnalysisWidget(BasePage):
self
.
_fetch
()
def
_refresh_source_label
(
self
)
->
None
:
self
.
_lbl_source
.
setText
(
AppConfig
.
get
(
"prometheus_url"
)
or
"(не настроен)"
)
current
=
AppConfig
.
get
(
"prometheus_url"
)
or
""
candidates
=
[
current
,
AppConfig
.
get
(
"grafana_url"
)
or
""
,
]
urls
:
list
[
str
]
=
[]
for
value
in
candidates
:
value
=
(
value
or
""
)
.
strip
()
if
not
value
:
continue
if
value
.
endswith
(
":3000"
):
value
=
value
[:
-
5
]
+
":9090"
if
value
not
in
urls
:
urls
.
append
(
value
)
self
.
_source_combo
.
blockSignals
(
True
)
typed
=
self
.
_source_combo
.
currentText
()
.
strip
()
self
.
_source_combo
.
clear
()
self
.
_source_combo
.
addItems
(
urls
)
if
typed
and
typed
not
in
urls
:
self
.
_source_combo
.
insertItem
(
0
,
typed
)
if
current
:
idx
=
self
.
_source_combo
.
findText
(
current
)
if
idx
>=
0
:
self
.
_source_combo
.
setCurrentIndex
(
idx
)
elif
typed
:
self
.
_source_combo
.
setCurrentText
(
typed
)
self
.
_source_combo
.
blockSignals
(
False
)
def
_source_url
(
self
)
->
str
:
return
self
.
_source_combo
.
currentText
()
.
strip
()
.
rstrip
(
"/"
)
def
_apply_source
(
self
)
->
None
:
url
=
self
.
_source_url
()
if
not
url
:
self
.
_lbl_status
.
setText
(
"Укажите URL Prometheus."
)
return
AppConfig
.
set_value
(
"prometheus_url"
,
url
)
try
:
AppConfig
.
save
()
except
Exception
as
exc
:
self
.
_lbl_status
.
setText
(
f
"Не удалось сохранить URL: {exc}"
)
return
self
.
_refresh_source_label
()
self
.
_fetch
()
# ------------------------------------------------------------------
def
_fetch
(
self
)
->
None
:
url
=
AppConfig
.
get
(
"prometheus_url"
)
or
""
url
=
self
.
_source_url
()
or
AppConfig
.
get
(
"prometheus_url"
)
or
""
if
not
url
:
self
.
_lbl_status
.
setText
(
"URL Prometheus не задан — зайди в Настройки → Внешний мониторинг."
...
...
@@ -295,7 +395,7 @@ class AnalysisWidget(BasePage):
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_iops
.
set_value
(
f
"{kpi.get('iops', 0):.0f}"
,
sub
=
"ops/s
по pool
"
)
self
.
_kpi_read
.
set_value
(
_human_bps
(
kpi
.
get
(
"read_bps"
,
0
)))
self
.
_kpi_write
.
set_value
(
_human_bps
(
kpi
.
get
(
"write_bps"
,
0
)))
...
...
@@ -303,6 +403,7 @@ class AnalysisWidget(BasePage):
self
.
_draw_throughput
(
data
[
"throughput_w"
],
data
[
"throughput_r"
])
# Таблица
self
.
_fill_osd_table
(
data
[
"latency_per_osd"
])
self
.
_update_cluster_identity
(
data
)
self
.
_lbl_status
.
setText
(
f
"Обновлено: {datetime.now():
%
H:
%
M:
%
S}"
)
self
.
_btn_refresh
.
setEnabled
(
True
)
...
...
@@ -315,6 +416,57 @@ class AnalysisWidget(BasePage):
# ------------------------------------------------------------------
def
_update_cluster_identity
(
self
,
data
:
dict
)
->
None
:
targets
=
data
.
get
(
"ceph_targets"
)
or
[]
fsids
=
data
.
get
(
"fsids"
)
or
[]
target_hosts
=
[]
for
target
in
targets
:
labels
=
target
.
get
(
"labels"
,
{})
address
=
labels
.
get
(
"instance"
)
or
target
.
get
(
"scrapeUrl"
,
""
)
host
=
self
.
_host_from_address
(
address
)
if
host
and
host
not
in
target_hosts
:
target_hosts
.
append
(
host
)
matches
=
self
.
_match_clusters_by_ips
(
target_hosts
)
if
matches
:
cluster_text
=
", "
.
join
(
matches
)
else
:
cluster_text
=
"не найден в БД"
fsid_text
=
", "
.
join
(
fsids
)
if
fsids
else
"—"
target_text
=
", "
.
join
(
target_hosts
)
if
target_hosts
else
"—"
self
.
_lbl_cluster_identity
.
setText
(
f
"Кластер: {cluster_text} FSID: {fsid_text} Ceph targets: {target_text}"
)
@staticmethod
def
_host_from_address
(
address
:
str
)
->
str
:
address
=
(
address
or
""
)
.
strip
()
if
not
address
:
return
""
if
"://"
in
address
:
parsed
=
urlparse
(
address
)
return
parsed
.
hostname
or
""
return
address
.
rsplit
(
":"
,
1
)[
0
]
@staticmethod
def
_match_clusters_by_ips
(
ips
:
list
[
str
])
->
list
[
str
]:
if
not
ips
:
return
[]
wanted
=
set
(
ips
)
matches
:
list
[
str
]
=
[]
with
SessionLocal
()
as
session
:
for
cluster
in
list_clusters
(
session
):
servers
=
list_servers
(
session
,
cluster
.
id
)
matched
=
[
f
"{srv.hostname} ({srv.ip_address})"
for
srv
in
servers
if
srv
.
ip_address
in
wanted
]
if
matched
:
matches
.
append
(
f
"{cluster.name}: "
+
", "
.
join
(
matched
))
return
matches
def
_draw_throughput
(
self
,
w_series
:
list
,
r_series
:
list
)
->
None
:
ax
=
self
.
_ax_throughput
ax
.
clear
()
...
...
@@ -354,9 +506,7 @@ class AnalysisWidget(BasePage):
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
trend_pct
=
self
.
_trend_pct
(
values
)
rows
.
append
((
osd
,
avg
,
trend_pct
))
rows
.
sort
(
key
=
lambda
r
:
r
[
1
],
reverse
=
True
)
...
...
@@ -364,11 +514,9 @@ class AnalysisWidget(BasePage):
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
:
if
trend
>=
50
and
avg
>
5.0
:
note
=
"кандидат на обслуживание"
color
=
"#f44336"
elif
trend
>=
20
:
...
...
@@ -377,7 +525,7 @@ class AnalysisWidget(BasePage):
items
=
[
QTableWidgetItem
(
str
(
osd
)),
QTableWidgetItem
(
f
"{
ms
:.2f}"
),
QTableWidgetItem
(
f
"{
avg
:.2f}"
),
QTableWidgetItem
(
f
"{trend:+.1f}"
),
QTableWidgetItem
(
note
),
]
...
...
@@ -387,3 +535,19 @@ class AnalysisWidget(BasePage):
it
.
setTextAlignment
(
Qt
.
AlignmentFlag
.
AlignCenter
)
for
col
,
it
in
enumerate
(
items
):
self
.
_osd_table
.
setItem
(
i
,
col
,
it
)
@staticmethod
def
_trend_pct
(
values
:
list
[
float
])
->
float
:
usable
=
[
v
for
v
in
values
if
v
is
not
None
and
abs
(
v
)
>=
1e-9
]
if
len
(
usable
)
<
4
:
return
0.0
mid
=
len
(
usable
)
//
2
first_half
=
usable
[:
mid
]
second_half
=
usable
[
mid
:]
first_avg
=
sum
(
first_half
)
/
len
(
first_half
)
second_avg
=
sum
(
second_half
)
/
len
(
second_half
)
if
abs
(
first_avg
)
<
1e-9
and
abs
(
second_avg
)
<
1e-9
:
return
0.0
if
abs
(
first_avg
)
<
1e-9
:
return
100.0
return
((
second_avg
-
first_avg
)
/
first_avg
)
*
100.0
ui/clusters_widget.py
View file @
766f2065
...
...
@@ -4,7 +4,13 @@
from
__future__
import
annotations
import
os
import
tempfile
from
pathlib
import
Path
from
jinja2
import
Environment
,
FileSystemLoader
from
PyQt6.QtCore
import
Qt
from
PyQt6.QtCore
import
QProcess
,
QProcessEnvironment
from
PyQt6.QtWidgets
import
(
QAbstractItemView
,
QComboBox
,
...
...
@@ -24,16 +30,20 @@ from PyQt6.QtWidgets import (
QWidget
,
)
from
core.resources
import
get_templates_dir
from
db
import
SessionLocal
from
db.repository
import
(
create_cluster
,
delete_cluster
,
delete_server
,
get_cluster
,
list_clusters
,
list_servers
,
)
from
ui.base_page
import
BasePage
_TEMPLATES_DIR
=
get_templates_dir
()
def
_plain_item
(
text
:
str
)
->
QTableWidgetItem
:
item
=
QTableWidgetItem
(
text
)
...
...
@@ -131,6 +141,10 @@ class ClustersWidget(BasePage):
super
()
.
__init__
(
"🖥️ Кластеры"
,
"Список профилей кластеров"
)
self
.
_clusters
:
list
=
[]
self
.
_selected_cluster_id
:
int
|
None
=
None
self
.
_delete_process
:
QProcess
|
None
=
None
self
.
_delete_cluster_id
:
int
|
None
=
None
self
.
_delete_deploy_dir
:
str
|
None
=
None
self
.
_delete_log
:
list
[
str
]
=
[]
self
.
_build_content
()
self
.
refresh
()
...
...
@@ -308,16 +322,190 @@ class ClustersWidget(BasePage):
def
_on_delete_cluster
(
self
)
->
None
:
if
self
.
_selected_cluster_id
is
None
:
return
if
self
.
_delete_process
is
not
None
:
QMessageBox
.
information
(
self
,
"Удаление уже выполняется"
,
"Дождитесь завершения текущей очистки кластера."
,
)
return
with
SessionLocal
()
as
session
:
cluster
=
get_cluster
(
session
,
self
.
_selected_cluster_id
)
servers
=
list_servers
(
session
,
self
.
_selected_cluster_id
)
if
cluster
is
None
:
self
.
refresh
()
return
cluster_name
=
cluster
.
name
if
not
servers
:
reply
=
QMessageBox
.
question
(
self
,
"Удалить кластер?"
,
"В профиле нет серверов, поэтому на узлах нечего очищать.
\n\n
"
"Удалить только локальную запись кластера из CephDeploy?"
,
QMessageBox
.
StandardButton
.
Yes
|
QMessageBox
.
StandardButton
.
No
,
QMessageBox
.
StandardButton
.
No
,
)
if
reply
==
QMessageBox
.
StandardButton
.
Yes
:
self
.
_delete_local_cluster
(
self
.
_selected_cluster_id
)
return
reply
=
QMessageBox
.
question
(
self
,
"Удалить кластер?"
,
"Все серверы и данные этого кластера будут удалены. Продолжить?"
,
self
,
"Удалить кластер?"
,
"CephDeploy запустит очистку на всех серверах кластера: "
"cephadm rm-cluster --zap-osds, удаление /etc/ceph, /var/lib/ceph, "
"/var/log/ceph, ceph systemd-юнитов и контейнеров ceph-*.
\n\n
"
"Локальная запись кластера будет удалена только после успешной очистки.
\n\n
"
f
"Удалить кластер «{cluster_name}»?"
,
QMessageBox
.
StandardButton
.
Yes
|
QMessageBox
.
StandardButton
.
No
,
QMessageBox
.
StandardButton
.
No
,
)
if
reply
!=
QMessageBox
.
StandardButton
.
Yes
:
return
self
.
_start_delete_cleanup
(
self
.
_selected_cluster_id
)
def
_delete_local_cluster
(
self
,
cluster_id
:
int
)
->
None
:
with
SessionLocal
()
as
session
:
delete_cluster
(
session
,
cluster_id
)
session
.
commit
()
self
.
refresh
()
def
_start_delete_cleanup
(
self
,
cluster_id
:
int
)
->
None
:
try
:
deploy_dir
=
self
.
_generate_cleanup_configs
(
cluster_id
)
except
Exception
as
exc
:
QMessageBox
.
critical
(
self
,
"Ошибка генерации очистки"
,
str
(
exc
))
return
inv
=
os
.
path
.
join
(
deploy_dir
,
"inventory.ini"
)
play
=
os
.
path
.
join
(
deploy_dir
,
"cleanup.yml"
)
process
=
QProcess
(
self
)
env
=
QProcessEnvironment
.
systemEnvironment
()
env
.
insert
(
"ANSIBLE_PIPELINING"
,
"True"
)
env
.
insert
(
"ANSIBLE_FORKS"
,
"10"
)
env
.
insert
(
"ANSIBLE_HOST_KEY_CHECKING"
,
"False"
)
process
.
setProcessEnvironment
(
env
)
process
.
setProcessChannelMode
(
QProcess
.
ProcessChannelMode
.
MergedChannels
)
process
.
readyReadStandardOutput
.
connect
(
self
.
_on_delete_cleanup_output
)
process
.
finished
.
connect
(
self
.
_on_delete_cleanup_finished
)
self
.
_delete_process
=
process
self
.
_delete_cluster_id
=
cluster_id
self
.
_delete_deploy_dir
=
deploy_dir
self
.
_delete_log
=
[
f
"Рабочий каталог очистки: {deploy_dir}"
,
f
"ansible-playbook -i {inv} {play}"
,
]
self
.
_set_delete_busy
(
True
)
process
.
start
(
"ansible-playbook"
,
[
"-i"
,
inv
,
play
])
if
not
process
.
waitForStarted
(
3000
):
self
.
_set_delete_busy
(
False
)
self
.
_delete_process
=
None
self
.
_delete_cluster_id
=
None
self
.
_delete_deploy_dir
=
None
QMessageBox
.
critical
(
self
,
"Ошибка запуска очистки"
,
"Не удалось запустить ansible-playbook. "
"Убедитесь, что ansible-core установлен и доступен в PATH."
,
)
def
_generate_cleanup_configs
(
self
,
cluster_id
:
int
)
->
str
:
with
SessionLocal
()
as
session
:
cluster
=
get_cluster
(
session
,
cluster_id
)
if
cluster
is
None
:
raise
RuntimeError
(
"Кластер не найден."
)
servers
=
list_servers
(
session
,
cluster_id
)
if
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_path"
:
str
(
Path
(
s
.
ssh_key_path
)
.
expanduser
()),
}
for
s
in
servers
]
cluster_data
=
{
"name"
:
cluster
.
name
,
"version"
:
cluster
.
ceph_version
}
deploy_dir
=
tempfile
.
mkdtemp
(
prefix
=
"cephdeploy_delete_cleanup_"
)
env
=
Environment
(
loader
=
FileSystemLoader
(
str
(
_TEMPLATES_DIR
)),
trim_blocks
=
True
,
lstrip_blocks
=
True
,
)
Path
(
deploy_dir
,
"inventory.ini"
)
.
write_text
(
env
.
get_template
(
"inventory.ini.j2"
)
.
render
(
servers
=
servers_data
),
encoding
=
"utf-8"
,
)
Path
(
deploy_dir
,
"cleanup.yml"
)
.
write_text
(
env
.
get_template
(
"ceph_cleanup.yml.j2"
)
.
render
(
cluster
=
cluster_data
,
servers
=
servers_data
,
),
encoding
=
"utf-8"
,
)
return
deploy_dir
def
_on_delete_cleanup_output
(
self
)
->
None
:
if
self
.
_delete_process
is
None
:
return
data
=
(
self
.
_delete_process
.
readAllStandardOutput
()
.
data
()
.
decode
(
errors
=
"replace"
)
)
self
.
_delete_log
.
append
(
data
)
if
self
.
_delete_deploy_dir
:
log_f
=
os
.
path
.
join
(
self
.
_delete_deploy_dir
,
"delete_cleanup.log"
)
with
open
(
log_f
,
"a"
,
encoding
=
"utf-8"
)
as
f
:
f
.
write
(
data
)
def
_on_delete_cleanup_finished
(
self
,
exit_code
:
int
,
_exit_status
)
->
None
:
cluster_id
=
self
.
_delete_cluster_id
deploy_dir
=
self
.
_delete_deploy_dir
log_tail
=
""
.
join
(
self
.
_delete_log
)[
-
3000
:]
self
.
_set_delete_busy
(
False
)
self
.
_delete_process
=
None
self
.
_delete_cluster_id
=
None
self
.
_delete_deploy_dir
=
None
if
exit_code
==
0
and
cluster_id
is
not
None
:
self
.
_delete_local_cluster
(
cluster_id
)
QMessageBox
.
information
(
self
,
"Кластер удалён"
,
"Очистка на серверах завершена успешно, локальная запись удалена."
,
)
return
QMessageBox
.
critical
(
self
,
"Очистка не завершена"
,
"Кластер не удалён из CephDeploy, потому что очистка на серверах "
f
"завершилась с кодом {exit_code}.
\n\n
"
f
"Рабочий каталог: {deploy_dir}
\n\n
"
f
"Последние строки вывода:
\n
{log_tail}"
,
)
def
_set_delete_busy
(
self
,
busy
:
bool
)
->
None
:
self
.
_btn_create
.
setEnabled
(
not
busy
)
self
.
_btn_delete_cluster
.
setEnabled
(
not
busy
and
self
.
_selected_cluster_id
is
not
None
)
self
.
_server_table
.
setEnabled
(
not
busy
)
self
.
_cluster_table
.
setEnabled
(
not
busy
)
self
.
_btn_delete_cluster
.
setText
(
"Очистка..."
if
busy
else
"✕ Удалить кластер"
)
if
reply
==
QMessageBox
.
StandardButton
.
Yes
:
with
SessionLocal
()
as
session
:
delete_cluster
(
session
,
self
.
_selected_cluster_id
)
session
.
commit
()
self
.
refresh
()
def
_on_delete_server
(
self
,
server_id
:
int
)
->
None
:
reply
=
QMessageBox
.
question
(
...
...
ui/deploy_widget.py
View file @
766f2065
...
...
@@ -5,12 +5,13 @@
from
__future__
import
annotations
import
os
import
shlex
import
subprocess
import
tempfile
from
pathlib
import
Path
from
jinja2
import
Environment
,
FileSystemLoader
from
PyQt6.QtCore
import
Qt
,
QProcess
from
PyQt6.QtCore
import
Qt
,
QProcess
,
QProcessEnvironment
from
PyQt6.QtGui
import
QColor
,
QFont
,
QTextCursor
from
PyQt6.QtWidgets
import
(
QAbstractItemView
,
...
...
@@ -65,6 +66,50 @@ _TABLE_STYLE = """
_CHECK_COLS
=
[
"Сервер"
,
"IP-адрес"
,
"Роль"
,
"OSD-дисков"
,
"Готовность"
]
_CEPH_GUARD_SCRIPT
=
r"""
evidence=0
if [ -s /etc/ceph/ceph.conf ]; then
echo "/etc/ceph/ceph.conf"
evidence=1
fi
if [ -d /var/lib/ceph ] && timeout 3 find /var/lib/ceph -mindepth 1 -maxdepth 2 -print -quit 2>/dev/null | grep -q .; then
echo "/var/lib/ceph contains data"
evidence=1
fi
if command -v cephadm >/dev/null 2>&1 && timeout 3 cephadm ls 2>/dev/null | grep -q '"fsid"'; then
echo "cephadm cluster metadata exists"
evidence=1
fi
if command -v systemctl >/dev/null 2>&1 && timeout 3 systemctl list-units --all --no-legend 'ceph-*@*' 2>/dev/null | grep -q .; then
echo "ceph systemd units exist"
evidence=1
fi
if command -v lvs >/dev/null 2>&1 && timeout 3 lvs --noheadings -o lv_tags 2>/dev/null | grep -q 'ceph'; then
echo "Ceph LVM tags exist"
evidence=1
fi
if command -v podman >/dev/null 2>&1 && timeout 3 podman ps -a --filter name=ceph- -q 2>/dev/null | grep -q .; then
echo "ceph podman containers exist"
evidence=1
fi
if command -v docker >/dev/null 2>&1 && timeout 3 docker ps -a --filter name=ceph- -q 2>/dev/null | grep -q .; then
echo "ceph docker containers exist"
evidence=1
fi
exit "$evidence"
"""
.
strip
()
_CEPH_IMAGES
=
{
"quincy"
:
"quay.io/ceph/ceph:v17"
,
"reef"
:
"quay.io/ceph/ceph:v18"
,
"squid"
:
"quay.io/ceph/ceph:v19"
,
}
def
_ceph_image_for_version
(
version
:
str
)
->
str
:
normalized
=
(
version
or
""
)
.
strip
()
.
lower
()
return
_CEPH_IMAGES
.
get
(
normalized
,
"quay.io/ceph/ceph:v19"
)
def
_plain_item
(
text
:
str
)
->
QTableWidgetItem
:
item
=
QTableWidgetItem
(
text
)
...
...
@@ -268,8 +313,9 @@ class DeployWidget(BasePage):
osd_count
=
len
(
list_osd_devices
(
session
,
srv
.
id
))
ssh_ok
=
self
.
_tcp_check
(
srv
.
ip_address
)
ceph_check
=
self
.
_detect_existing_ceph
(
srv
)
if
ssh_ok
else
(
"unknown"
,
""
)
needs_osd
=
srv
.
role
.
value
in
(
"osd"
,
"all"
)
ready
=
ssh_ok
and
(
not
needs_osd
or
osd_count
>
0
)
ready
=
ssh_ok
and
ceph_check
[
0
]
==
"clean"
and
(
not
needs_osd
or
osd_count
>
0
)
if
not
ready
:
all_ready
=
False
...
...
@@ -285,6 +331,16 @@ class DeployWidget(BasePage):
self
.
_check_table
.
setItem
(
row
,
4
,
_status_item
(
"✔ Готов"
,
"#4caf50"
))
elif
not
ssh_ok
:
self
.
_check_table
.
setItem
(
row
,
4
,
_status_item
(
"✘ SSH недоступен"
,
"#ef5350"
))
elif
ceph_check
[
0
]
==
"exists"
:
self
.
_check_table
.
setItem
(
row
,
4
,
_status_item
(
"✘ Ceph уже есть"
,
"#ef5350"
))
self
.
_log_line
(
f
"✘ На {srv.hostname} ({srv.ip_address}) уже обнаружен Ceph: {ceph_check[1]}"
)
elif
ceph_check
[
0
]
==
"unknown"
:
self
.
_check_table
.
setItem
(
row
,
4
,
_status_item
(
"✘ Ceph не проверен"
,
"#ef5350"
))
self
.
_log_line
(
f
"✘ Не удалось проверить Ceph на {srv.hostname} ({srv.ip_address}): {ceph_check[1]}"
)
else
:
self
.
_check_table
.
setItem
(
row
,
4
,
_status_item
(
"⚠ Нет OSD"
,
"#ffb74d"
))
...
...
@@ -315,6 +371,47 @@ class DeployWidget(BasePage):
except
OSError
:
return
False
@staticmethod
def
_detect_existing_ceph
(
srv
)
->
tuple
[
str
,
str
]:
"""Возвращает ('clean'|'exists'|'unknown', детали) для защиты от установки поверх Ceph."""
key_path
=
str
(
Path
(
srv
.
ssh_key_path
)
.
expanduser
())
remote
=
f
"{srv.ssh_user}@{srv.ip_address}"
quoted_script
=
shlex
.
quote
(
_CEPH_GUARD_SCRIPT
)
cmd
=
[
"ssh"
,
"-x"
,
"-o"
,
"BatchMode=yes"
,
"-o"
,
"ForwardX11=no"
,
"-o"
,
"ConnectTimeout=5"
,
"-o"
,
"StrictHostKeyChecking=no"
,
"-i"
,
key_path
,
remote
,
f
"timeout 20 sudo -n bash -c {quoted_script}"
,
]
try
:
result
=
subprocess
.
run
(
cmd
,
text
=
True
,
capture_output
=
True
,
timeout
=
25
,
check
=
False
,
)
except
subprocess
.
TimeoutExpired
:
return
"unknown"
,
"проверка Ceph превысила таймаут 25 секунд"
except
OSError
as
exc
:
return
"unknown"
,
str
(
exc
)
details
=
"
\n
"
.
join
(
line
.
strip
()
for
line
in
(
result
.
stdout
+
"
\n
"
+
result
.
stderr
)
.
splitlines
()
if
line
.
strip
()
)
if
result
.
returncode
==
0
:
return
"clean"
,
""
if
result
.
returncode
==
1
and
result
.
stdout
.
strip
():
return
"exists"
,
"; "
.
join
(
result
.
stdout
.
splitlines
())
return
"unknown"
,
details
or
f
"ssh/sudo завершился с кодом {result.returncode}"
# ------------------------------------------------------------------
# Генерация конфигурации Ansible
# ------------------------------------------------------------------
...
...
@@ -361,6 +458,7 @@ class DeployWidget(BasePage):
Path
(
play_path
)
.
write_text
(
env
.
get_template
(
"ceph_bootstrap.yml.j2"
)
.
render
(
cluster
=
{
"name"
:
cluster
.
name
,
"version"
:
cluster
.
ceph_version
},
ceph_image
=
_ceph_image_for_version
(
cluster
.
ceph_version
),
servers
=
servers_data
,
bootstrap_host
=
next
(
(
s
for
s
in
servers_data
if
s
[
"role"
]
in
(
"mon"
,
"all"
)),
...
...
@@ -412,6 +510,11 @@ class DeployWidget(BasePage):
def
_spawn_ansible
(
self
,
inv
:
str
,
play
:
str
)
->
None
:
"""Запускает ansible-playbook через QProcess, используется и deploy, и cleanup."""
self
.
_process
=
QProcess
()
env
=
QProcessEnvironment
.
systemEnvironment
()
env
.
insert
(
"ANSIBLE_PIPELINING"
,
"True"
)
env
.
insert
(
"ANSIBLE_FORKS"
,
"10"
)
env
.
insert
(
"ANSIBLE_HOST_KEY_CHECKING"
,
"False"
)
self
.
_process
.
setProcessEnvironment
(
env
)
self
.
_process
.
setProcessChannelMode
(
QProcess
.
ProcessChannelMode
.
MergedChannels
)
self
.
_process
.
readyReadStandardOutput
.
connect
(
self
.
_on_output
)
self
.
_process
.
finished
.
connect
(
self
.
_on_finished
)
...
...
ui/help_window.py
View file @
766f2065
...
...
@@ -24,8 +24,9 @@ from PyQt6.QtWidgets import (
_CHAPTERS
:
list
[
tuple
[
str
,
str
]]
=
[
(
"Обзор программы"
,
"""
<h2>CephDeploy — обзор программы</h2>
<p><b>CephDeploy</b> — десктопное приложение для автоматизированной установки и
мониторинга кластера <b>Ceph Reef</b> на узлах под управлением ALT Linux.</p>
<p><b>CephDeploy</b> — десктопное приложение для подготовки серверов,
автоматизированного развёртывания Ceph через cephadm, проверки работы
хранилища и анализа метрик внешнего мониторинга.</p>
<h3>Основные возможности</h3>
<ul>
...
...
@@ -34,6 +35,9 @@ _CHAPTERS: list[tuple[str, str]] = [
<li>Назначение OSD-дисков серверам</li>
<li>Автоматическое развёртывание Ceph через Ansible (cephadm)</li>
<li>Live-мониторинг состояния кластера</li>
<li>Развёртывание внешнего стека Prometheus / Grafana / Alertmanager в LXC на PVE</li>
<li>Проверка записи, чтения и репликации через RADOS smoke-test</li>
<li>Анализ нагрузки, задержек и трендов по данным Prometheus</li>
<li>История запусков и экспорт отчёта в HTML</li>
</ul>
...
...
@@ -52,9 +56,17 @@ _CHAPTERS: list[tuple[str, str]] = [
<li>Найдите серверы через <b>Сканер сети</b> и добавьте их в кластер</li>
<li>Назначьте OSD-диски на странице <b>OSD</b></li>
<li>Запустите установку на странице <b>Развёртывание</b></li>
<li>Проверяйте ход установки в <b>Журнале</b></li>
<li>Следите за состоянием на странице <b>Состояние</b></li>
<li>Разверните внешний мониторинг через меню <b>Мониторинг</b> и смотрите метрики в <b>Анализе</b></li>
<li>Запустите smoke-test на странице <b>Проверка</b></li>
<li>Экспортируйте отчёт через страницу <b>Отчёт</b></li>
</ol>
<h3>Порядок вкладок</h3>
<p>Основная навигация расположена слева: <b>Кластеры</b>, <b>Сканер сети</b>,
<b>OSD</b>, <b>Развёртывание</b>, <b>Журнал</b>, <b>Состояние</b>,
<b>Анализ</b>, <b>Отчёт</b>, <b>Проверка</b>, <b>Настройки</b>.</p>
"""
),
(
"Кластеры"
,
"""
...
...
@@ -144,6 +156,26 @@ _CHAPTERS: list[tuple[str, str]] = [
<tr><td><b>DB</b></td><td>BlueStore DB — индексы и метаданные (SSD/NVMe)</td></tr>
</table>
<h3>Применение к уже работающему кластеру</h3>
<p>Добавление устройства во вкладке <b>OSD</b> сначала сохраняет диск в
локальную конфигурацию CephDeploy. Чтобы прицепить этот диск к уже
развёрнутому кластеру без полного повторного развёртывания, нажмите
<b>Прицепить</b> в строке устройства.</p>
<p>CephDeploy подключится к MON/ALL-узлу выбранного кластера и выполнит
команду <code>ceph orch daemon add osd <hostname>:<device></code>.
Hostname целевого узла определяется по SSH с самого сервера, чтобы команда
совпадала с именем хоста в cephadm-оркестраторе.</p>
<p><b>Важно:</b> операция применяется к живому кластеру и может уничтожить
данные на выбранном диске. Используйте только свободные несистемные диски.</p>
<h3>Удаление OSD</h3>
<p>Кнопка <b>Отцепить</b> ищет OSD по hostname и имени диска в метаданных
Ceph и запускает штатную команду <code>ceph orch osd rm <id> --zap</code>.
Ceph может выполнять перенос данных не мгновенно; если удаление небезопасно,
команда будет отклонена самим кластером.</p>
<p>Красная кнопка <b>✕</b> удаляет только запись из локальной конфигурации
CephDeploy. Она не удаляет OSD из живого кластера.</p>
<h3>Рекомендации по конфигурации</h3>
<ul>
<li>Минимальная конфигурация: 1–2 диска DATA на узел</li>
...
...
@@ -215,6 +247,17 @@ _CHAPTERS: list[tuple[str, str]] = [
<li>На серверах: root/sudo доступ, Python 3, доступ к репозиторию Ceph</li>
<li>Сеть: все узлы должны быть доступны по SSH с управляющей машины</li>
</ul>
<h3>Защита от повторного развёртывания</h3>
<p>Перед bootstrap плейбук проверяет, не установлен ли уже Ceph на выбранном
узле. Если на машине остались контейнеры, юниты или каталог
<code>/var/lib/ceph</code> от прошлой попытки, сначала выполните очистку.</p>
<h3>Версия и образ Ceph</h3>
<p>Версия берётся из профиля кластера. Если в шаблоне указан отдельный
container image, он передаётся cephadm до команды <code>bootstrap</code>.
Для тестовых стендов допускается несовпадение релиза cephadm и образа,
когда это явно разрешено в шаблоне развёртывания.</p>
"""
),
(
"Состояние кластера"
,
"""
...
...
@@ -243,14 +286,36 @@ _CHAPTERS: list[tuple[str, str]] = [
<h3>Авто-обновление</h3>
<p>Установите интервал в поле <b>Авто (с)</b> для автоматического обновления.
Значение <b>0</b> отключает авто-обновление. Рекомендуется 30–60 секунд.</p>
<h3>Когда использовать этот раздел</h3>
<p><b>Состояние</b> подходит для оперативной проверки текущего кластера:
health, ёмкость, OSD tree, PG и вывод команд Ceph. Исторические графики,
нагрузка и тренды находятся на странице <b>Анализ</b> и требуют Prometheus.</p>
"""
),
(
"Анализ функционирования"
,
"""
<h2>Анализ функционирования кластера</h2>
<p>Страница <b>Анализ</b> опрашивает внешний <b>Prometheus</b>, в котором
хранятся метрики ceph-mgr exporter, и показывает исторические показатели
кластера. URL Prometheus задаётся в <b>Настройки → Внешний мониторинг</b>,
сам Prometheus разворачивается через меню <b>Мониторинг → Развернуть…</b>.</p>
кластера. URL Prometheus можно выбрать или изменить прямо на странице
анализа, а также сохранить в <b>Настройки → Внешний мониторинг</b>.
Сам Prometheus разворачивается через меню <b>Мониторинг → Развернуть…</b>.</p>
<h3>Источник данных и автообновление</h3>
<ul>
<li><b>Источник Prometheus</b> — выпадающий список с сохранённым URL и
возможностью ввести другой адрес вручную.</li>
<li><b>Применить</b> — сохраняет выбранный источник и сразу обновляет данные.</li>
<li>Если вкладка <b>Анализ</b> открыта, данные обновляются автоматически
примерно каждые 2 секунды. Если предыдущий запрос ещё выполняется,
следующий пропускается.</li>
</ul>
<h3>Определение кластера</h3>
<p>Блок <b>Определение кластера</b> показывает, какие targets Prometheus
собирает для job <code>ceph</code>, и сопоставляет IP-адреса targets с
серверами из локальной базы CephDeploy. Так можно понять, метрики какого
кластера сейчас отображаются в анализе.</p>
<h3>Карточки KPI</h3>
<ul>
...
...
@@ -258,19 +323,22 @@ _CHAPTERS: list[tuple[str, str]] = [
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>
<li><b>IOPS</b> — скорость операций по метрикам Ceph за короткое окно.</li>
<li><b>Чтение / Запись</b> — скорость чтения и записи по pool-метрикам Ceph.</li>
</ul>
<h3>График пропускной способности</h3>
<p>Линия записи (красная) и чтения (синяя) за выбранный период (1ч / 6ч / 24ч).
<p>Линия записи и чтения строится за выбранный период. Доступны короткие
интервалы для тестов и диагностики: <b>1 мин</b>, <b>5 мин</b>,
<b>30 мин</b>, <b>1 час</b>, <b>6 часов</b>, <b>24 часа</b>.
Используется <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>
<p>Для каждого OSD показываются состояние, средняя задержка и
<b>тренд периода,
%
</b>. Тренд считается по выбранному интервалу как сравнение
первой и второй половины периода; пустые или нулевые точки задержки не
используются, чтобы не получать ложные значения <code>-100
%
</code>.</p>
<p>Строки подсвечиваются:</p>
<ul>
<li><span style="color:#ff9800"><b>оранжевым</b></span> — задержка растёт
более чем на 20
%
;</li>
...
...
@@ -291,10 +359,30 @@ Grafana и настраивает scrape-targets на ceph-mgr exporter кажд
<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><b>VMID</b>, <b>IP / шлюз / bridge</b>, <b>хранилище</b>,
<b>хранилище шаблонов</b> и <b>шаблон</b> LXC.</li>
<li>Пароль <code>admin</code> для Grafana.</li>
</ul>
<h3>Получение параметров с PVE</h3>
<p>Мастер не опирается на жёстко заданные значения. Кнопка
<b>Проверить PVE</b> подключается к выбранному PVE-хосту по SSH и запрашивает:</p>
<ul>
<li>занятые VMID и ближайший свободный VMID;</li>
<li>доступные хранилища для rootfs контейнера;</li>
<li>хранилища, где лежат LXC-шаблоны;</li>
<li>список LXC-шаблонов через <code>pveam list <storage></code>;</li>
<li>сетевые bridge-интерфейсы PVE.</li>
</ul>
<p>Если вручную указан занятый VMID, развёртывание остановится до создания
контейнера. При смене хранилища шаблонов список LXC-шаблонов перечитывается
именно с выбранного storage.</p>
<h3>Сетевые значения по умолчанию</h3>
<p>IP, шлюз и bridge подставляются так, чтобы контейнер был доступен из
обычной рабочей сети стенда. Перед запуском проверьте, что адрес свободен,
а gateway соответствует подсети контейнера.</p>
<h3>Что попадает внутрь контейнера</h3>
<ul>
<li><b>/etc/prometheus/prometheus.yml</b> — job <code>ceph</code> со всеми mon/mgr-узлами (<code>:9283</code>).</li>
...
...
@@ -339,6 +427,46 @@ Grafana и настраивает scrape-targets на ceph-mgr exporter кажд
во временном каталоге (путь указан в столбце <b>Лог</b>).</p>
"""
),
(
"Проверка работы хранилища"
,
"""
<h2>Проверка работы хранилища</h2>
<p>Страница <b>Проверка</b> запускает короткий RADOS smoke-test: создаёт
или использует тестовый pool, записывает объект, читает его обратно,
сравнивает контрольные суммы и показывает, на какие OSD попал объект.</p>
<h3>Для чего нужна проверка</h3>
<ul>
<li>Убедиться, что кластер принимает запись после развёртывания.</li>
<li>Проверить чтение данных из pool.</li>
<li>Увидеть mapping объекта и acting set OSD.</li>
<li>Создать небольшую нагрузку, которую затем можно увидеть в Prometheus
на странице <b>Анализ</b>.</li>
</ul>
<h3>Параметры теста</h3>
<ul>
<li><b>Кластер</b> — профиль из локальной базы CephDeploy.</li>
<li><b>MON-узел</b> — сервер, через который будет выполнена команда
<code>cephadm shell</code> / <code>rados</code>.</li>
<li><b>Pool</b> — по умолчанию <code>cephdeploy-test</code>. Если pool
отсутствует, он создаётся автоматически.</li>
<li><b>Объект</b> — имя временного RADOS-объекта.</li>
<li><b>Размер</b> — объём тестовых данных для записи.</li>
<li><b>Удалить объект после проверки</b> — оставляет pool, но удаляет
тестовый объект после успешного сравнения.</li>
</ul>
<h3>Что считается успешным результатом</h3>
<p>Проверка успешна, если запись завершилась без ошибки, объект прочитан
обратно, а checksum исходного и прочитанного файла совпал. В журнале теста
также выводятся <code>ceph osd map</code>, PG pool и краткое состояние
кластера.</p>
<h3>Связь с анализом нагрузки</h3>
<p>После теста метрики записи и чтения появятся в <b>Анализе</b> только если
Prometheus уже собирает ceph-mgr exporter выбранного кластера. Для коротких
smoke-test удобно выбирать период <b>1 мин</b> или <b>5 мин</b>.</p>
"""
),
(
"Отчёт"
,
"""
<h2>Отчёт</h2>
<p>Формирование и экспорт конфигурации кластера в HTML-файл.</p>
...
...
@@ -355,9 +483,13 @@ Grafana и настраивает scrape-targets на ceph-mgr exporter кажд
<ul>
<li>Общая информация о кластере (название, версия Ceph, дата создания)</li>
<li>Список серверов с ролями и количеством OSD-дисков</li>
<li>Фактически обнаруженные диски на серверах на момент формирования отчёта</li>
<li>Детальная таблица OSD-устройств по всем серверам</li>
<li>История последних 20 запусков развёртывания</li>
</ul>
<p>При формировании отчёта CephDeploy подключается к серверам по SSH и
перечитывает <code>lsblk</code>, поэтому новые диски VM появляются в отчёте
даже до назначения их как OSD.</p>
<p>Отчёт удобно прикладывать к технической документации или ВКР.</p>
"""
),
...
...
@@ -401,8 +533,12 @@ Grafana и настраивает scrape-targets на ceph-mgr exporter кажд
<ul>
<li><b>URL Prometheus / Grafana / Alertmanager</b> — проставляются автоматически
после успешного развёртывания через меню Мониторинг.</li>
<li><b>PVE-хост, VMID, IP, шлюз, bridge, хранилище, шаблон</b> — параметры
создаваемого LXC-контейнера. Шаблон по умолчанию — ALT p11.</li>
<li><b>PVE-хост, VMID, IP, шлюз, bridge, хранилище, хранилище шаблонов,
шаблон</b> — последние использованные параметры создаваемого
LXC-контейнера.</li>
<li>Актуальные значения VMID, storage, template storage, LXC templates и
bridge лучше получать кнопкой <b>Проверить PVE</b> в мастере
развёртывания мониторинга.</li>
</ul>
<p>Настройки хранятся в <code>~/.config/cephdeploy/settings.json</code>.</p>
...
...
ui/main_window.py
View file @
766f2065
...
...
@@ -31,6 +31,7 @@ from ui.osd_widget import OSDWidget
from
ui.report_widget
import
ReportWidget
from
ui.help_window
import
HelpWindow
from
ui.settings_widget
import
SettingsWidget
from
ui.storage_test_widget
import
StorageTestWidget
from
ui.status_widget
import
StatusWidget
...
...
@@ -41,25 +42,27 @@ from ui.status_widget import StatusWidget
_NAV_ITEMS
:
list
[
tuple
[
str
,
str
]]
=
[
(
"🖥️ Кластеры"
,
"Список профилей кластеров"
),
(
"🔍 Сканер сети"
,
"Поиск серверов в подсети"
),
(
"💾 OSD"
,
"Управление дисками OSD"
),
(
"🚀 Развёртывание"
,
"Мастер установки Ceph"
),
(
"📜 Журнал"
,
"История запусков"
),
(
"📊 Состояние"
,
"Дашборд кластера"
),
(
"📈 Анализ"
,
"Анализ функционирования кластера"
),
(
"💾 OSD"
,
"Управление дисками OSD"
),
(
"📜 Журнал"
,
"История запусков"
),
(
"📄 Отчёт"
,
"Экспорт в HTML"
),
(
"🧪 Проверка"
,
"Проверка записи и чтения из хранилища"
),
(
"⚙️ Настройки"
,
"Параметры приложения"
),
]
# Индексы страниц в _NAV_ITEMS
_CLUSTERS_PAGE_IDX
=
0
_SCAN_PAGE_IDX
=
1
_
DEPLOY_PAGE_IDX
=
2
_
STATUS
_PAGE_IDX
=
3
_
ANALYSIS_PAGE_IDX
=
4
_
OSD_PAGE_IDX
=
5
_
LOG_PAGE_IDX
=
6
_
OSD_PAGE_IDX
=
2
_
DEPLOY
_PAGE_IDX
=
3
_
LOG_PAGE_IDX
=
4
_
STATUS_PAGE_IDX
=
5
_
ANALYSIS_PAGE_IDX
=
6
_REPORT_PAGE_IDX
=
7
_SETTINGS_PAGE_IDX
=
8
_STORAGE_TEST_PAGE_IDX
=
8
_SETTINGS_PAGE_IDX
=
9
# ---------------------------------------------------------------------------
...
...
@@ -162,6 +165,8 @@ class MainWindow(QMainWindow):
page
=
DeployWidget
()
elif
i
==
_STATUS_PAGE_IDX
:
page
=
StatusWidget
()
elif
i
==
_STORAGE_TEST_PAGE_IDX
:
page
=
StorageTestWidget
()
elif
i
==
_ANALYSIS_PAGE_IDX
:
page
=
AnalysisWidget
()
elif
i
==
_OSD_PAGE_IDX
:
...
...
ui/monitoring_dialog.py
View file @
766f2065
...
...
@@ -9,6 +9,8 @@ PVE-узле и по успеху записывает URL'ы в AppConfig —
from
__future__
import
annotations
import
os
import
re
import
subprocess
import
tempfile
from
pathlib
import
Path
...
...
@@ -76,11 +78,21 @@ class MonitoringDialog(QDialog):
form
.
addRow
(
"Кластер:"
,
self
.
_cluster_combo
)
self
.
_pve_host
=
QLineEdit
()
form
.
addRow
(
"PVE-хост:"
,
self
.
_pve_host
)
pve_row
=
QHBoxLayout
()
pve_row
.
addWidget
(
self
.
_pve_host
,
stretch
=
1
)
self
.
_btn_probe
=
QPushButton
(
"Проверить PVE"
)
self
.
_btn_probe
.
clicked
.
connect
(
self
.
_refresh_pve_options
)
pve_row
.
addWidget
(
self
.
_btn_probe
)
form
.
addRow
(
"PVE-хост:"
,
pve_row
)
self
.
_vmid
=
QSpinBox
()
self
.
_vmid
.
setRange
(
100
,
999999
)
form
.
addRow
(
"VMID контейнера:"
,
self
.
_vmid
)
vmid_row
=
QHBoxLayout
()
vmid_row
.
addWidget
(
self
.
_vmid
)
self
.
_btn_next_vmid
=
QPushButton
(
"Свободный"
)
self
.
_btn_next_vmid
.
clicked
.
connect
(
self
.
_fill_next_vmid
)
vmid_row
.
addWidget
(
self
.
_btn_next_vmid
)
form
.
addRow
(
"VMID контейнера:"
,
vmid_row
)
self
.
_ct_ip
=
QLineEdit
()
form
.
addRow
(
"IP контейнера:"
,
self
.
_ct_ip
)
...
...
@@ -88,13 +100,21 @@ class MonitoringDialog(QDialog):
self
.
_ct_gw
=
QLineEdit
()
form
.
addRow
(
"Шлюз:"
,
self
.
_ct_gw
)
self
.
_ct_bridge
=
QLineEdit
()
self
.
_ct_bridge
=
QComboBox
()
self
.
_ct_bridge
.
setEditable
(
True
)
form
.
addRow
(
"Bridge:"
,
self
.
_ct_bridge
)
self
.
_ct_storage
=
QLineEdit
()
self
.
_ct_storage
=
QComboBox
()
self
.
_ct_storage
.
setEditable
(
True
)
form
.
addRow
(
"Хранилище:"
,
self
.
_ct_storage
)
self
.
_ct_template
=
QLineEdit
()
self
.
_template_storage
=
QComboBox
()
self
.
_template_storage
.
setEditable
(
True
)
self
.
_template_storage
.
currentTextChanged
.
connect
(
self
.
_on_template_storage_changed
)
form
.
addRow
(
"Хранилище шаблонов:"
,
self
.
_template_storage
)
self
.
_ct_template
=
QComboBox
()
self
.
_ct_template
.
setEditable
(
True
)
form
.
addRow
(
"Шаблон LXC:"
,
self
.
_ct_template
)
self
.
_grafana_pw
=
QLineEdit
()
...
...
@@ -153,16 +173,244 @@ class MonitoringDialog(QDialog):
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
.
_vmid
.
setValue
(
self
.
_vmid
.
minimum
())
self
.
_ct_ip
.
clear
()
self
.
_ct_gw
.
clear
()
self
.
_set_combo_items
(
self
.
_ct_bridge
,
[],
""
)
self
.
_set_combo_items
(
self
.
_ct_storage
,
[],
""
)
self
.
_set_combo_items
(
self
.
_template_storage
,
[],
""
)
self
.
_set_combo_items
(
self
.
_ct_template
,
[],
""
)
self
.
_grafana_pw
.
setText
(
AppConfig
.
get
(
"grafana_password"
))
# ------------------------------------------------------------------
@staticmethod
def
_combo_text
(
combo
:
QComboBox
)
->
str
:
return
combo
.
currentText
()
.
strip
()
@staticmethod
def
_set_combo_items
(
combo
:
QComboBox
,
items
:
list
[
str
],
current
:
str
=
""
)
->
None
:
seen
:
set
[
str
]
=
set
()
cleaned
:
list
[
str
]
=
[]
for
item
in
items
:
item
=
(
item
or
""
)
.
strip
()
if
item
and
item
not
in
seen
:
cleaned
.
append
(
item
)
seen
.
add
(
item
)
combo
.
blockSignals
(
True
)
combo
.
clear
()
combo
.
addItems
(
cleaned
)
if
current
:
idx
=
combo
.
findText
(
current
)
if
idx
<
0
:
combo
.
insertItem
(
0
,
current
)
idx
=
0
combo
.
setCurrentIndex
(
idx
)
combo
.
blockSignals
(
False
)
def
_run_pve_command
(
self
,
remote_cmd
:
str
,
timeout
:
int
=
20
)
->
subprocess
.
CompletedProcess
[
str
]:
pve_host
=
self
.
_pve_host
.
text
()
.
strip
()
if
not
pve_host
:
raise
RuntimeError
(
"Укажите PVE-хост."
)
ssh_user
=
AppConfig
.
get
(
"ssh_user"
)
or
"root"
ssh_key
=
str
(
Path
(
AppConfig
.
get
(
"ssh_key_path"
)
or
"~/.ssh/id_ed25519"
)
.
expanduser
())
return
subprocess
.
run
(
[
"ssh"
,
"-x"
,
"-T"
,
"-o"
,
"BatchMode=yes"
,
"-o"
,
"ForwardX11=no"
,
"-o"
,
"ConnectTimeout=8"
,
"-o"
,
"StrictHostKeyChecking=no"
,
"-i"
,
ssh_key
,
f
"{ssh_user}@{pve_host}"
,
remote_cmd
,
],
text
=
True
,
capture_output
=
True
,
timeout
=
timeout
,
check
=
False
,
)
def
_pve_stdout
(
self
,
remote_cmd
:
str
,
timeout
:
int
=
20
)
->
str
:
result
=
self
.
_run_pve_command
(
remote_cmd
,
timeout
=
timeout
)
if
result
.
returncode
!=
0
:
details
=
(
result
.
stderr
or
result
.
stdout
or
""
)
.
strip
()
raise
RuntimeError
(
details
or
f
"ssh завершился с кодом {result.returncode}"
)
return
result
.
stdout
.
strip
()
def
_fill_next_vmid
(
self
)
->
None
:
try
:
raw
=
self
.
_pve_stdout
(
"sudo -n pvesh get /cluster/nextid"
,
timeout
=
15
)
match
=
re
.
search
(
r"\d+"
,
raw
)
if
not
match
:
raise
RuntimeError
(
f
"Не удалось разобрать VMID из ответа: {raw}"
)
self
.
_vmid
.
setValue
(
int
(
match
.
group
(
0
)))
self
.
_append
(
f
"VMID: выбран свободный {self._vmid.value()}
\n
"
)
except
Exception
as
exc
:
QMessageBox
.
warning
(
self
,
"VMID"
,
f
"Не удалось получить свободный VMID:
\n
{exc}"
)
def
_refresh_pve_options
(
self
)
->
None
:
self
.
_append
(
"Проверяю PVE-хост и загружаю доступные параметры...
\n
"
)
try
:
probe
=
self
.
_pve_stdout
(
r"""
set -e
echo __NEXTID__
sudo -n pvesh get /cluster/nextid
echo __ROOTDIR_STORAGES__
timeout 25 sudo -n pvesm status 2>/dev/null | awk 'NR>1 && $3=="active" && $2!="pbs"{print $1, $2}' || true
echo __TEMPLATE_STORAGES__
timeout 25 sudo -n pvesm status --content vztmpl 2>/dev/null | awk 'NR>1 && $3=="active"{print $1}' || true
echo __BRIDGES__
for b in /sys/class/net/*/bridge; do
name=${b
%
/bridge}; name=${name##*/}
case "$name" in fwbr*|fwln*|fwpr*|tap*|veth*) continue;; esac
addr=$(ip -4 -o addr show dev "$name" 2>/dev/null | awk '{print $4}' | head -1)
gw=$(ip -4 route show default dev "$name" 2>/dev/null | awk '{print $3; exit}')
echo "$name ${addr:-} ${gw:-}"
done
echo __TEMPLATES__
timeout 20 sudo -n pveam available --section system 2>/dev/null | awk 'NR>1{print $2}' | grep -E 'debian-12|ubuntu-24|ubuntu-22|alt|ALT' | head -80 || true
"""
,
timeout
=
70
,
)
sections
=
self
.
_parse_probe_sections
(
probe
)
nextid
=
self
.
_first_int
(
sections
.
get
(
"NEXTID"
,
""
))
if
nextid
:
self
.
_vmid
.
setValue
(
nextid
)
self
.
_append
(
f
"VMID: выбран свободный {nextid}
\n
"
)
root_storage_rows
=
[
line
.
split
()
for
line
in
sections
.
get
(
"ROOTDIR_STORAGES"
,
""
)
.
splitlines
()]
root_storages
=
[
row
[
0
]
for
row
in
root_storage_rows
if
row
]
template_storages
=
sections
.
get
(
"TEMPLATE_STORAGES"
,
""
)
.
splitlines
()
bridge_rows
=
[
line
.
split
()
for
line
in
sections
.
get
(
"BRIDGES"
,
""
)
.
splitlines
()]
bridges
=
[
row
[
0
]
for
row
in
bridge_rows
if
row
]
templates
=
sections
.
get
(
"TEMPLATES"
,
""
)
.
splitlines
()
chosen_bridge
=
self
.
_select_bridge
(
bridge_rows
)
self
.
_set_combo_items
(
self
.
_ct_storage
,
root_storages
,
self
.
_select_storage
(
root_storage_rows
))
self
.
_set_combo_items
(
self
.
_template_storage
,
template_storages
or
[
"local"
],
template_storages
[
0
]
if
template_storages
else
"local"
,
)
self
.
_set_combo_items
(
self
.
_ct_bridge
,
bridges
,
chosen_bridge
[
0
]
if
chosen_bridge
else
""
)
self
.
_set_combo_items
(
self
.
_ct_template
,
templates
,
self
.
_select_template
(
templates
))
if
chosen_bridge
:
_bridge
,
cidr
,
gw
=
chosen_bridge
if
gw
:
self
.
_ct_gw
.
setText
(
gw
)
suggested_ip
=
self
.
_suggest_ct_ip
(
cidr
,
gw
)
if
suggested_ip
:
self
.
_ct_ip
.
setText
(
suggested_ip
)
self
.
_append
(
"PVE: параметры обновлены.
\n
"
)
except
Exception
as
exc
:
QMessageBox
.
warning
(
self
,
"Проверка PVE"
,
f
"Не удалось загрузить параметры PVE:
\n
{exc}"
)
def
_on_template_storage_changed
(
self
,
storage
:
str
)
->
None
:
storage
=
storage
.
strip
()
if
not
storage
or
not
self
.
_pve_host
.
text
()
.
strip
():
return
try
:
templates
=
self
.
_templates_for_storage
(
storage
)
self
.
_set_combo_items
(
self
.
_ct_template
,
templates
,
self
.
_select_template
(
templates
))
self
.
_append
(
f
"Шаблоны LXC обновлены для storage {storage}.
\n
"
)
except
Exception
as
exc
:
self
.
_append
(
f
"Не удалось обновить шаблоны LXC для {storage}: {exc}
\n
"
)
def
_templates_for_storage
(
self
,
storage
:
str
)
->
list
[
str
]:
local_raw
=
self
.
_pve_stdout
(
"sudo -n pveam list "
+
self
.
_shell_quote
(
storage
)
+
" 2>/dev/null | awk 'NR>1{n=$1; sub(/^.*vztmpl
\\
//,
\"\"
, n); print n}'"
,
timeout
=
30
,
)
local_templates
=
local_raw
.
splitlines
()
if
local_templates
:
return
local_templates
available_raw
=
self
.
_pve_stdout
(
"timeout 20 sudo -n pveam available --section system 2>/dev/null "
"| awk 'NR>1{print $2}' "
"| grep -E 'debian-12|ubuntu-24|ubuntu-22|alt|ALT' "
"| head -80 || true"
,
timeout
=
30
,
)
return
available_raw
.
splitlines
()
@staticmethod
def
_shell_quote
(
value
:
str
)
->
str
:
return
"'"
+
value
.
replace
(
"'"
,
"'
\"
'
\"
'"
)
+
"'"
@staticmethod
def
_parse_probe_sections
(
output
:
str
)
->
dict
[
str
,
str
]:
sections
:
dict
[
str
,
list
[
str
]]
=
{}
current
=
""
for
line
in
output
.
splitlines
():
marker
=
re
.
fullmatch
(
r"__([A-Z0-9_]+)__"
,
line
.
strip
())
if
marker
:
current
=
marker
.
group
(
1
)
sections
[
current
]
=
[]
elif
current
:
sections
[
current
]
.
append
(
line
)
return
{
key
:
"
\n
"
.
join
(
value
)
.
strip
()
for
key
,
value
in
sections
.
items
()}
@staticmethod
def
_first_int
(
text
:
str
)
->
int
|
None
:
match
=
re
.
search
(
r"\d+"
,
text
)
return
int
(
match
.
group
(
0
))
if
match
else
None
@staticmethod
def
_select_template
(
templates
:
list
[
str
])
->
str
:
for
needle
in
(
"debian-12-standard"
,
"ubuntu-24"
,
"ubuntu-22"
):
for
template
in
templates
:
if
needle
in
template
:
return
template
return
templates
[
0
]
if
templates
else
""
@staticmethod
def
_select_storage
(
rows
:
list
[
list
[
str
]])
->
str
:
for
preferred_type
in
(
"zfspool"
,
"lvmthin"
,
"lvm"
,
"dir"
):
for
row
in
rows
:
if
len
(
row
)
>
1
and
row
[
1
]
==
preferred_type
:
return
row
[
0
]
return
rows
[
0
][
0
]
if
rows
and
rows
[
0
]
else
""
@staticmethod
def
_select_bridge
(
rows
:
list
[
list
[
str
]])
->
tuple
[
str
,
str
,
str
]
|
None
:
parsed
=
[(
row
[
0
],
row
[
1
]
if
len
(
row
)
>
1
else
""
,
row
[
2
]
if
len
(
row
)
>
2
else
""
)
for
row
in
rows
if
row
]
for
item
in
parsed
:
if
item
[
0
]
==
"vmbr0"
:
return
item
return
parsed
[
0
]
if
parsed
else
None
@staticmethod
def
_suggest_ct_ip
(
cidr
:
str
,
gateway
:
str
)
->
str
:
if
not
cidr
or
"/"
not
in
cidr
:
return
""
ip
,
prefix
=
cidr
.
split
(
"/"
,
1
)
parts
=
ip
.
split
(
"."
)
if
len
(
parts
)
!=
4
:
return
""
host
=
"250"
if
gateway
:
gw_parts
=
gateway
.
split
(
"."
)
if
len
(
gw_parts
)
==
4
:
parts
=
gw_parts
return
"."
.
join
(
parts
[:
3
]
+
[
host
])
+
f
"/{prefix}"
def
_validate_vmid_available
(
self
)
->
None
:
vmid
=
self
.
_vmid
.
value
()
result
=
self
.
_run_pve_command
(
f
"sudo -n pct status {vmid} >/dev/null 2>&1 || sudo -n qm status {vmid} >/dev/null 2>&1"
,
timeout
=
15
,
)
if
result
.
returncode
==
0
:
raise
RuntimeError
(
f
"VMID {vmid} уже занят. Нажмите «Свободный» или укажите другой VMID."
)
def
_append
(
self
,
text
:
str
)
->
None
:
self
.
_log
.
moveCursor
(
self
.
_log
.
textCursor
()
.
MoveOperation
.
End
)
self
.
_log
.
insertPlainText
(
text
)
...
...
@@ -194,11 +442,32 @@ class MonitoringDialog(QDialog):
"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
(),
"ct_bridge"
:
self
.
_combo_text
(
self
.
_ct_bridge
),
"ct_storage"
:
self
.
_combo_text
(
self
.
_ct_storage
),
"template_storage"
:
self
.
_combo_text
(
self
.
_template_storage
),
"ct_template"
:
self
.
_combo_text
(
self
.
_ct_template
),
"grafana_password"
:
self
.
_grafana_pw
.
text
()
or
"admin"
,
}
missing
=
[
label
for
label
,
value
in
(
(
"PVE-хост"
,
monitoring
[
"pve_host"
]),
(
"IP контейнера"
,
monitoring
[
"ct_ip"
]),
(
"шлюз"
,
monitoring
[
"ct_gw"
]),
(
"bridge"
,
monitoring
[
"ct_bridge"
]),
(
"хранилище"
,
monitoring
[
"ct_storage"
]),
(
"хранилище шаблонов"
,
monitoring
[
"template_storage"
]),
(
"шаблон LXC"
,
monitoring
[
"ct_template"
]),
)
if
not
value
]
if
missing
:
raise
RuntimeError
(
"Не заполнены параметры: "
+
", "
.
join
(
missing
)
+
". Нажмите «Проверить PVE» для автозаполнения."
)
self
.
_validate_vmid_available
()
deploy_dir
=
tempfile
.
mkdtemp
(
prefix
=
"cephdeploy_monitoring_"
)
env
=
Environment
(
...
...
@@ -304,9 +573,10 @@ class MonitoringDialog(QDialog):
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
())
AppConfig
.
set_value
(
"monitoring_ct_bridge"
,
self
.
_combo_text
(
self
.
_ct_bridge
))
AppConfig
.
set_value
(
"monitoring_ct_storage"
,
self
.
_combo_text
(
self
.
_ct_storage
))
AppConfig
.
set_value
(
"monitoring_template_storage"
,
self
.
_combo_text
(
self
.
_template_storage
))
AppConfig
.
set_value
(
"monitoring_ct_template"
,
self
.
_combo_text
(
self
.
_ct_template
))
try
:
AppConfig
.
save
()
except
Exception
as
exc
:
...
...
ui/osd_widget.py
View file @
766f2065
...
...
@@ -5,6 +5,7 @@
from
__future__
import
annotations
import
json
import
shlex
from
pathlib
import
Path
import
paramiko
...
...
@@ -31,7 +32,7 @@ from PyQt6.QtWidgets import (
from
core.config
import
AppConfig
from
db
import
SessionLocal
from
db.models
import
DeviceType
,
OSDRole
from
db.models
import
DeviceType
,
OSD
Device
,
OSDRole
,
Server
,
Server
Role
from
db.repository
import
(
add_osd_device
,
delete_osd_device
,
...
...
@@ -69,7 +70,7 @@ _BOX_STYLE = (
"QGroupBox::title { subcontrol-origin: margin; padding: 0 6px; }"
)
_OSD_COLS
=
[
"Устройство"
,
"Тип"
,
"Роль OSD"
,
""
]
_OSD_COLS
=
[
"Устройство"
,
"Тип"
,
"Роль OSD"
,
"
Действия
"
]
def
_server_label
(
hostname
:
str
,
ip
:
str
)
->
str
:
...
...
@@ -184,6 +185,206 @@ class DiskFetchWorker(QThread):
return
mounts
,
fstypes
,
labels
class
OSDApplyWorker
(
QThread
):
"""Добавляет OSD в уже работающий кластер через cephadm/ceph orch."""
result
=
pyqtSignal
(
str
)
error
=
pyqtSignal
(
str
)
def
__init__
(
self
,
mon_ip
:
str
,
mon_user
:
str
,
mon_key
:
str
,
target_ip
:
str
,
target_user
:
str
,
target_key
:
str
,
device_path
:
str
,
parent
=
None
,
)
->
None
:
super
()
.
__init__
(
parent
)
self
.
mon_ip
=
mon_ip
self
.
mon_user
=
mon_user
self
.
mon_key
=
str
(
Path
(
mon_key
)
.
expanduser
())
self
.
target_ip
=
target_ip
self
.
target_user
=
target_user
self
.
target_key
=
str
(
Path
(
target_key
)
.
expanduser
())
self
.
device_path
=
device_path
def
run
(
self
)
->
None
:
target_client
=
paramiko
.
SSHClient
()
mon_client
=
paramiko
.
SSHClient
()
target_client
.
set_missing_host_key_policy
(
paramiko
.
AutoAddPolicy
())
mon_client
.
set_missing_host_key_policy
(
paramiko
.
AutoAddPolicy
())
try
:
timeout
=
max
(
20
,
int
(
AppConfig
.
get
(
"scan_ssh_timeout"
)
or
8
))
target_client
.
connect
(
self
.
target_ip
,
username
=
self
.
target_user
,
key_filename
=
self
.
target_key
,
timeout
=
timeout
,
banner_timeout
=
timeout
,
auth_timeout
=
timeout
,
look_for_keys
=
False
,
allow_agent
=
False
,
)
_
,
stdout
,
stderr
=
target_client
.
exec_command
(
"hostname -s 2>/dev/null || hostname"
,
timeout
=
timeout
,
)
host_name
=
stdout
.
read
()
.
decode
(
errors
=
"replace"
)
.
strip
()
host_err
=
stderr
.
read
()
.
decode
(
errors
=
"replace"
)
.
strip
()
if
not
host_name
:
raise
RuntimeError
(
host_err
or
"Не удалось определить hostname целевого узла"
)
mon_client
.
connect
(
self
.
mon_ip
,
username
=
self
.
mon_user
,
key_filename
=
self
.
mon_key
,
timeout
=
timeout
,
banner_timeout
=
timeout
,
auth_timeout
=
timeout
,
look_for_keys
=
False
,
allow_agent
=
False
,
)
sudo
=
""
if
self
.
mon_user
==
"root"
else
"sudo -n "
spec
=
f
"{host_name}:{self.device_path}"
cmd
=
(
f
"{sudo}/usr/sbin/cephadm shell -- ceph orch daemon add osd "
f
"{shlex.quote(spec)}"
)
_
,
stdout
,
stderr
=
mon_client
.
exec_command
(
cmd
,
timeout
=
300
)
out
=
stdout
.
read
()
.
decode
(
errors
=
"replace"
)
.
strip
()
err
=
stderr
.
read
()
.
decode
(
errors
=
"replace"
)
.
strip
()
rc
=
stdout
.
channel
.
recv_exit_status
()
combined
=
"
\n
"
.
join
(
part
for
part
in
(
out
,
err
)
if
part
)
if
rc
!=
0
:
raise
RuntimeError
(
combined
or
f
"Команда завершилась с кодом {rc}"
)
self
.
result
.
emit
(
combined
or
f
"OSD {spec} добавлен в оркестратор Ceph."
)
except
Exception
as
exc
:
self
.
error
.
emit
(
str
(
exc
))
finally
:
target_client
.
close
()
mon_client
.
close
()
class
OSDRemoveWorker
(
QThread
):
"""Запускает штатное удаление OSD из работающего кластера."""
result
=
pyqtSignal
(
str
)
error
=
pyqtSignal
(
str
)
def
__init__
(
self
,
mon_ip
:
str
,
mon_user
:
str
,
mon_key
:
str
,
target_ip
:
str
,
target_user
:
str
,
target_key
:
str
,
device_path
:
str
,
parent
=
None
,
)
->
None
:
super
()
.
__init__
(
parent
)
self
.
mon_ip
=
mon_ip
self
.
mon_user
=
mon_user
self
.
mon_key
=
str
(
Path
(
mon_key
)
.
expanduser
())
self
.
target_ip
=
target_ip
self
.
target_user
=
target_user
self
.
target_key
=
str
(
Path
(
target_key
)
.
expanduser
())
self
.
device_path
=
device_path
def
run
(
self
)
->
None
:
target_client
=
paramiko
.
SSHClient
()
mon_client
=
paramiko
.
SSHClient
()
target_client
.
set_missing_host_key_policy
(
paramiko
.
AutoAddPolicy
())
mon_client
.
set_missing_host_key_policy
(
paramiko
.
AutoAddPolicy
())
try
:
timeout
=
max
(
20
,
int
(
AppConfig
.
get
(
"scan_ssh_timeout"
)
or
8
))
target_client
.
connect
(
self
.
target_ip
,
username
=
self
.
target_user
,
key_filename
=
self
.
target_key
,
timeout
=
timeout
,
banner_timeout
=
timeout
,
auth_timeout
=
timeout
,
look_for_keys
=
False
,
allow_agent
=
False
,
)
_
,
stdout
,
stderr
=
target_client
.
exec_command
(
"hostname -s 2>/dev/null || hostname"
,
timeout
=
timeout
,
)
host_name
=
stdout
.
read
()
.
decode
(
errors
=
"replace"
)
.
strip
()
host_err
=
stderr
.
read
()
.
decode
(
errors
=
"replace"
)
.
strip
()
if
not
host_name
:
raise
RuntimeError
(
host_err
or
"Не удалось определить hostname целевого узла"
)
mon_client
.
connect
(
self
.
mon_ip
,
username
=
self
.
mon_user
,
key_filename
=
self
.
mon_key
,
timeout
=
timeout
,
banner_timeout
=
timeout
,
auth_timeout
=
timeout
,
look_for_keys
=
False
,
allow_agent
=
False
,
)
sudo
=
""
if
self
.
mon_user
==
"root"
else
"sudo -n "
disk_name
=
Path
(
self
.
device_path
)
.
name
lookup_script
=
(
"python3 - <<'PY'
\n
"
"import json, subprocess, sys
\n
"
f
"host = {host_name!r}
\n
"
f
"disk = {disk_name!r}
\n
"
"raw = subprocess.check_output(['ceph', 'osd', 'metadata', '-f', 'json'], text=True)
\n
"
"matches = []
\n
"
"for row in json.loads(raw):
\n
"
" devices = [d.strip() for d in str(row.get('devices') or '').split(',') if d.strip()]
\n
"
" if row.get('hostname') == host and disk in devices:
\n
"
" matches.append(str(row.get('id')))
\n
"
"if not matches:
\n
"
" print(f'OSD for {host}:{disk} not found', file=sys.stderr)
\n
"
" sys.exit(2)
\n
"
"if len(matches) > 1:
\n
"
" print('ambiguous OSD ids: ' + ', '.join(matches), file=sys.stderr)
\n
"
" sys.exit(3)
\n
"
"print(matches[0])
\n
"
"PY"
)
lookup_cmd
=
(
f
"{sudo}/usr/sbin/cephadm shell -- bash -lc "
f
"{shlex.quote(lookup_script)}"
)
_
,
stdout
,
stderr
=
mon_client
.
exec_command
(
lookup_cmd
,
timeout
=
120
)
osd_id
=
stdout
.
read
()
.
decode
(
errors
=
"replace"
)
.
strip
()
err
=
stderr
.
read
()
.
decode
(
errors
=
"replace"
)
.
strip
()
rc
=
stdout
.
channel
.
recv_exit_status
()
if
rc
!=
0
or
not
osd_id
:
raise
RuntimeError
(
err
or
f
"Не удалось найти OSD для {host_name}:{disk_name}"
)
rm_cmd
=
(
f
"{sudo}/usr/sbin/cephadm shell -- ceph orch osd rm "
f
"{shlex.quote(osd_id)} --zap"
)
_
,
stdout
,
stderr
=
mon_client
.
exec_command
(
rm_cmd
,
timeout
=
300
)
out
=
stdout
.
read
()
.
decode
(
errors
=
"replace"
)
.
strip
()
err
=
stderr
.
read
()
.
decode
(
errors
=
"replace"
)
.
strip
()
rc
=
stdout
.
channel
.
recv_exit_status
()
combined
=
"
\n
"
.
join
(
part
for
part
in
(
out
,
err
)
if
part
)
if
rc
!=
0
:
raise
RuntimeError
(
combined
or
f
"Команда завершилась с кодом {rc}"
)
self
.
result
.
emit
(
combined
or
f
"Запущено удаление osd.{osd_id} для {host_name}:{self.device_path}."
)
except
Exception
as
exc
:
self
.
error
.
emit
(
str
(
exc
))
finally
:
target_client
.
close
()
mon_client
.
close
()
# ---------------------------------------------------------------------------
# Диалог добавления OSD-устройства — с выбором диска из списка
# ---------------------------------------------------------------------------
...
...
@@ -453,6 +654,8 @@ class OSDWidget(BasePage):
super
()
.
__init__
(
"💾 OSD"
,
"Управление дисками OSD"
)
self
.
_server_id
:
int
|
None
=
None
self
.
_server_name
:
str
=
""
self
.
_apply_worker
:
OSDApplyWorker
|
None
=
None
self
.
_remove_worker
:
OSDRemoveWorker
|
None
=
None
self
.
_build_content
()
def
_build_content
(
self
)
->
None
:
...
...
@@ -508,7 +711,7 @@ class OSDWidget(BasePage):
)
self
.
_osd_table
.
setVisible
(
False
)
self
.
_osd_table
.
setStyleSheet
(
_TABLE_STYLE
)
for
i
,
w
in
enumerate
([
2
60
,
80
,
80
,
5
0
]):
for
i
,
w
in
enumerate
([
2
40
,
80
,
80
,
26
0
]):
self
.
_osd_table
.
setColumnWidth
(
i
,
w
)
osd_layout
.
addWidget
(
self
.
_osd_table
)
...
...
@@ -605,19 +808,45 @@ class OSDWidget(BasePage):
self
.
_osd_table
.
setItem
(
row
,
1
,
_plain_item
(
dev
.
device_type
.
value
.
upper
()))
self
.
_osd_table
.
setItem
(
row
,
2
,
_plain_item
(
dev
.
osd_role
.
value
.
upper
()))
btn
=
QPushButton
(
"✕"
)
btn
.
setFixedSize
(
28
,
24
)
btn
.
setToolTip
(
"Удалить устройство"
)
btn
.
setStyleSheet
(
btn_apply
=
QPushButton
(
"Прицепить"
)
btn_apply
.
setFixedHeight
(
24
)
btn_apply
.
setToolTip
(
"Добавить этот диск в работающий Ceph-кластер"
)
btn_apply
.
setStyleSheet
(
"QPushButton { background: #2e7d32; color: #fff; "
"border-radius: 3px; font-size: 11px; padding: 0 8px; }"
"QPushButton:hover { background: #388e3c; }"
"QPushButton:disabled { background: #333; color: #555; }"
)
btn_apply
.
clicked
.
connect
(
lambda
_
,
did
=
dev
.
id
:
self
.
_on_apply
(
did
))
btn_remove
=
QPushButton
(
"Отцепить"
)
btn_remove
.
setFixedHeight
(
24
)
btn_remove
.
setToolTip
(
"Запустить удаление этого OSD из работающего Ceph-кластера"
)
btn_remove
.
setStyleSheet
(
"QPushButton { background: #ef6c00; color: #fff; "
"border-radius: 3px; font-size: 11px; padding: 0 8px; }"
"QPushButton:hover { background: #fb8c00; }"
"QPushButton:disabled { background: #333; color: #555; }"
)
btn_remove
.
clicked
.
connect
(
lambda
_
,
did
=
dev
.
id
:
self
.
_on_remove_live
(
did
))
btn_delete
=
QPushButton
(
"✕"
)
btn_delete
.
setFixedSize
(
28
,
24
)
btn_delete
.
setToolTip
(
"Удалить только запись из конфигурации CephDeploy"
)
btn_delete
.
setStyleSheet
(
"QPushButton { background: #b71c1c; color: #fff; "
"border-radius: 3px; font-size: 12px; }"
"QPushButton:hover { background: #c62828; }"
"QPushButton:disabled { background: #333; color: #555; }"
)
btn
.
clicked
.
connect
(
lambda
_
,
did
=
dev
.
id
:
self
.
_on_delete
(
did
))
btn
_delete
.
clicked
.
connect
(
lambda
_
,
did
=
dev
.
id
:
self
.
_on_delete
(
did
))
cell
=
QWidget
()
cl
=
QHBoxLayout
(
cell
)
cl
.
setContentsMargins
(
3
,
2
,
3
,
2
)
cl
.
addWidget
(
btn
)
cl
.
setSpacing
(
6
)
cl
.
addWidget
(
btn_apply
)
cl
.
addWidget
(
btn_remove
)
cl
.
addWidget
(
btn_delete
)
self
.
_osd_table
.
setCellWidget
(
row
,
3
,
cell
)
# ------------------------------------------------------------------
...
...
@@ -661,10 +890,196 @@ class OSDWidget(BasePage):
return
self
.
_load_osd_devices
()
@staticmethod
def
_role_value
(
server
:
Server
)
->
str
:
role
=
server
.
role
return
role
.
value
if
hasattr
(
role
,
"value"
)
else
str
(
role
)
def
_set_apply_busy
(
self
,
busy
:
bool
)
->
None
:
self
.
_btn_add
.
setEnabled
((
not
busy
)
and
self
.
_server_id
is
not
None
)
self
.
_cluster_combo
.
setEnabled
(
not
busy
)
self
.
_server_combo
.
setEnabled
(
not
busy
)
self
.
_osd_table
.
setEnabled
(
not
busy
)
def
_find_live_context
(
self
,
device_id
:
int
)
->
dict
|
None
:
cluster_id
=
self
.
_cluster_combo
.
currentData
()
if
cluster_id
is
None
:
QMessageBox
.
warning
(
self
,
"Ошибка"
,
"Сначала выберите кластер."
)
return
None
with
SessionLocal
()
as
session
:
device
=
session
.
get
(
OSDDevice
,
device_id
)
if
device
is
None
:
QMessageBox
.
critical
(
self
,
"Ошибка"
,
"OSD-устройство не найдено в БД."
)
return
None
target
=
get_server
(
session
,
device
.
server_id
)
if
target
is
None
:
QMessageBox
.
critical
(
self
,
"Ошибка"
,
"Сервер устройства не найден в БД."
)
return
None
if
target
.
cluster_id
!=
cluster_id
:
QMessageBox
.
warning
(
self
,
"Ошибка"
,
"Устройство относится к другому кластеру. Обновите страницу."
,
)
return
None
servers
=
list_servers
(
session
,
cluster_id
)
mon
=
next
(
(
s
for
s
in
servers
if
self
.
_role_value
(
s
)
in
(
ServerRole
.
MON
.
value
,
ServerRole
.
ALL
.
value
)
),
None
,
)
if
mon
is
None
:
QMessageBox
.
warning
(
self
,
"Нет MON-узла"
,
"Для операции с живым кластером нужен сервер с ролью mon или all."
,
)
return
None
return
{
"device_path"
:
device
.
device_path
,
"target_name"
:
_server_label
(
target
.
hostname
,
target
.
ip_address
),
"target_ip"
:
target
.
ip_address
,
"target_user"
:
target
.
ssh_user
,
"target_key"
:
target
.
ssh_key_path
,
"mon_name"
:
_server_label
(
mon
.
hostname
,
mon
.
ip_address
),
"mon_ip"
:
mon
.
ip_address
,
"mon_user"
:
mon
.
ssh_user
,
"mon_key"
:
mon
.
ssh_key_path
,
}
def
_on_apply
(
self
,
device_id
:
int
)
->
None
:
if
self
.
_apply_worker
is
not
None
:
QMessageBox
.
information
(
self
,
"OSD уже добавляется"
,
"Дождитесь завершения текущей операции."
,
)
return
ctx
=
self
.
_find_live_context
(
device_id
)
if
ctx
is
None
:
return
reply
=
QMessageBox
.
warning
(
self
,
"Прицепить диск к кластеру?"
,
f
"Диск {ctx['device_path']} на узле {ctx['target_name']} будет добавлен в работающий "
"Ceph-кластер как OSD.
\n\n
"
"Ceph подготовит устройство под OSD, данные на диске могут быть уничтожены.
\n\n
"
f
"Команда будет выполнена через MON/ALL-узел: {ctx['mon_name']}.
\n\n
"
"Продолжить?"
,
QMessageBox
.
StandardButton
.
Yes
|
QMessageBox
.
StandardButton
.
No
,
QMessageBox
.
StandardButton
.
No
,
)
if
reply
!=
QMessageBox
.
StandardButton
.
Yes
:
return
self
.
_set_apply_busy
(
True
)
self
.
_apply_worker
=
OSDApplyWorker
(
mon_ip
=
ctx
[
"mon_ip"
],
mon_user
=
ctx
[
"mon_user"
],
mon_key
=
ctx
[
"mon_key"
],
target_ip
=
ctx
[
"target_ip"
],
target_user
=
ctx
[
"target_user"
],
target_key
=
ctx
[
"target_key"
],
device_path
=
ctx
[
"device_path"
],
)
self
.
_apply_worker
.
result
.
connect
(
self
.
_on_apply_success
)
self
.
_apply_worker
.
error
.
connect
(
self
.
_on_apply_error
)
self
.
_apply_worker
.
start
()
def
_on_apply_success
(
self
,
output
:
str
)
->
None
:
self
.
_set_apply_busy
(
False
)
self
.
_apply_worker
=
None
QMessageBox
.
information
(
self
,
"OSD добавлен"
,
"Команда добавления OSD выполнена.
\n\n
"
+
output
,
)
def
_on_apply_error
(
self
,
error
:
str
)
->
None
:
self
.
_set_apply_busy
(
False
)
self
.
_apply_worker
=
None
QMessageBox
.
critical
(
self
,
"Не удалось добавить OSD"
,
"Команда добавления OSD завершилась ошибкой:
\n\n
"
+
error
,
)
def
_on_remove_live
(
self
,
device_id
:
int
)
->
None
:
if
self
.
_remove_worker
is
not
None
:
QMessageBox
.
information
(
self
,
"OSD уже удаляется"
,
"Дождитесь завершения текущей операции."
,
)
return
ctx
=
self
.
_find_live_context
(
device_id
)
if
ctx
is
None
:
return
reply
=
QMessageBox
.
warning
(
self
,
"Отцепить OSD от кластера?"
,
f
"CephDeploy найдёт OSD для диска {ctx['device_path']} на узле "
f
"{ctx['target_name']} и запустит штатное удаление через "
"ceph orch osd rm --zap.
\n\n
"
"Ceph может начать перенос данных и завершить удаление не сразу. "
"Если удаление небезопасно для текущей репликации, команда будет "
"отклонена Ceph.
\n\n
"
"Запись в конфигурации CephDeploy после этого останется. Удалите её "
"красной кнопкой только когда OSD действительно удалён из кластера.
\n\n
"
f
"Команда будет выполнена через MON/ALL-узел: {ctx['mon_name']}.
\n\n
"
"Продолжить?"
,
QMessageBox
.
StandardButton
.
Yes
|
QMessageBox
.
StandardButton
.
No
,
QMessageBox
.
StandardButton
.
No
,
)
if
reply
!=
QMessageBox
.
StandardButton
.
Yes
:
return
self
.
_set_apply_busy
(
True
)
self
.
_remove_worker
=
OSDRemoveWorker
(
mon_ip
=
ctx
[
"mon_ip"
],
mon_user
=
ctx
[
"mon_user"
],
mon_key
=
ctx
[
"mon_key"
],
target_ip
=
ctx
[
"target_ip"
],
target_user
=
ctx
[
"target_user"
],
target_key
=
ctx
[
"target_key"
],
device_path
=
ctx
[
"device_path"
],
)
self
.
_remove_worker
.
result
.
connect
(
self
.
_on_remove_success
)
self
.
_remove_worker
.
error
.
connect
(
self
.
_on_remove_error
)
self
.
_remove_worker
.
start
()
def
_on_remove_success
(
self
,
output
:
str
)
->
None
:
self
.
_set_apply_busy
(
False
)
self
.
_remove_worker
=
None
QMessageBox
.
information
(
self
,
"Удаление OSD запущено"
,
"Команда удаления OSD выполнена.
\n\n
"
+
output
,
)
def
_on_remove_error
(
self
,
error
:
str
)
->
None
:
self
.
_set_apply_busy
(
False
)
self
.
_remove_worker
=
None
QMessageBox
.
critical
(
self
,
"Не удалось удалить OSD"
,
"Команда удаления OSD завершилась ошибкой:
\n\n
"
+
error
,
)
def
_on_delete
(
self
,
device_id
:
int
)
->
None
:
reply
=
QMessageBox
.
question
(
self
,
"Удалить устройство?"
,
"Удалить это OSD-устройство из конфигурации?"
,
self
,
"Удалить запись из конфигурации?"
,
"Удалить это OSD-устройство только из конфигурации CephDeploy?
\n\n
"
"Живой Ceph-кластер при этом не изменится. Если диск уже был "
"прицеплен к кластеру, сначала используйте кнопку «Отцепить»."
,
QMessageBox
.
StandardButton
.
Yes
|
QMessageBox
.
StandardButton
.
No
,
)
if
reply
==
QMessageBox
.
StandardButton
.
Yes
:
...
...
ui/report_widget.py
View file @
766f2065
...
...
@@ -4,9 +4,11 @@
from
__future__
import
annotations
import
json
from
datetime
import
datetime
from
pathlib
import
Path
import
paramiko
from
jinja2
import
Environment
,
FileSystemLoader
from
PyQt6.QtCore
import
Qt
from
PyQt6.QtWidgets
import
(
...
...
@@ -21,6 +23,7 @@ from PyQt6.QtWidgets import (
QVBoxLayout
,
)
from
core.config
import
AppConfig
from
core.resources
import
get_templates_dir
from
db
import
SessionLocal
from
db.repository
import
(
...
...
@@ -40,6 +43,79 @@ _BOX_STYLE = (
)
def
_collect_disk_signs
(
node
:
dict
)
->
tuple
[
list
[
str
],
list
[
str
]]:
mounts
:
list
[
str
]
=
[]
fstypes
:
list
[
str
]
=
[]
mp
=
node
.
get
(
"mountpoint"
)
fs
=
node
.
get
(
"fstype"
)
if
mp
:
mounts
.
append
(
mp
)
if
fs
:
fstypes
.
append
(
fs
)
for
child
in
node
.
get
(
"children"
)
or
[]:
child_mounts
,
child_fstypes
=
_collect_disk_signs
(
child
)
mounts
.
extend
(
child_mounts
)
fstypes
.
extend
(
child_fstypes
)
return
mounts
,
fstypes
def
_fetch_server_disks
(
ip
:
str
,
user
:
str
,
key_path
:
str
)
->
list
[
dict
]:
client
=
paramiko
.
SSHClient
()
client
.
set_missing_host_key_policy
(
paramiko
.
AutoAddPolicy
())
try
:
timeout
=
max
(
10
,
int
(
AppConfig
.
get
(
"scan_ssh_timeout"
)
or
8
))
client
.
connect
(
ip
,
username
=
user
,
key_filename
=
str
(
Path
(
key_path
)
.
expanduser
()),
timeout
=
timeout
,
banner_timeout
=
timeout
,
auth_timeout
=
timeout
,
look_for_keys
=
False
,
allow_agent
=
False
,
)
_
,
stdout
,
_
=
client
.
exec_command
(
"lsblk -J -o NAME,SIZE,ROTA,TYPE,FSTYPE,MOUNTPOINT,LABEL 2>/dev/null"
,
timeout
=
timeout
+
10
,
)
raw
=
stdout
.
read
()
.
decode
(
errors
=
"replace"
)
.
strip
()
data
=
json
.
loads
(
raw
or
"{}"
)
disks
:
list
[
dict
]
=
[]
for
dev
in
data
.
get
(
"blockdevices"
,
[]):
if
dev
.
get
(
"type"
)
not
in
(
"disk"
,
"loop"
):
continue
mounts
,
fstypes
=
_collect_disk_signs
(
dev
)
if
mounts
:
status
=
"используется"
detail
=
", "
.
join
(
mounts
)
elif
fstypes
:
status
=
"есть ФС"
detail
=
", "
.
join
(
sorted
(
set
(
fstypes
)))
else
:
status
=
"свободен"
detail
=
"разделы и ФС не найдены"
rota
=
str
(
dev
.
get
(
"rota"
))
==
"1"
if
dev
.
get
(
"rota"
)
is
not
None
else
True
kind
=
"LOOP"
if
dev
.
get
(
"type"
)
==
"loop"
else
(
"HDD"
if
rota
else
"SSD"
)
disks
.
append
({
"path"
:
f
"/dev/{dev.get('name')}"
,
"size"
:
dev
.
get
(
"size"
)
or
"?"
,
"type"
:
kind
,
"status"
:
status
,
"detail"
:
detail
,
})
return
disks
except
Exception
as
exc
:
return
[{
"path"
:
"—"
,
"size"
:
"—"
,
"type"
:
"—"
,
"status"
:
"ошибка"
,
"detail"
:
str
(
exc
),
}]
finally
:
client
.
close
()
class
ReportWidget
(
BasePage
):
def
__init__
(
self
,
parent
=
None
)
->
None
:
super
()
.
__init__
(
"📄 Отчёт"
,
"Экспорт конфигурации кластера в HTML"
)
...
...
@@ -143,6 +219,11 @@ class ReportWidget(BasePage):
"ip_address"
:
srv
.
ip_address
,
"role"
:
srv
.
role
.
value
,
"ssh_user"
:
srv
.
ssh_user
,
"disks"
:
_fetch_server_disks
(
srv
.
ip_address
,
srv
.
ssh_user
,
srv
.
ssh_key_path
,
),
"osd_count"
:
len
(
osds
),
"osds"
:
[
{
"path"
:
d
.
device_path
,
...
...
ui/storage_test_widget.py
0 → 100644
View file @
766f2065
"""
Страница проверки работы Ceph-хранилища.
Запускает небольшой rados smoke-test: создаёт pool, записывает объект,
читает его обратно, сравнивает checksum и показывает состояние репликации.
"""
from
__future__
import
annotations
import
shlex
from
pathlib
import
Path
from
PyQt6.QtCore
import
QProcess
from
PyQt6.QtGui
import
QFont
from
PyQt6.QtWidgets
import
(
QCheckBox
,
QComboBox
,
QFormLayout
,
QHBoxLayout
,
QLabel
,
QLineEdit
,
QMessageBox
,
QPushButton
,
QSpinBox
,
QTextEdit
,
QWidget
,
)
from
db
import
SessionLocal
from
db.repository
import
list_clusters
,
list_servers
from
ui.base_page
import
BasePage
_LOG_STYLE
=
(
"QTextEdit { background: #0d1117; color: #c0c8d8; "
"border: 1px solid #2e3340; border-radius: 4px; font-family: monospace; }"
)
class
StorageTestWidget
(
BasePage
):
def
__init__
(
self
)
->
None
:
super
()
.
__init__
(
"Проверка работы хранилища"
,
"Запись, чтение и проверка репликации тестового объекта через rados"
,
)
self
.
_process
:
QProcess
|
None
=
None
self
.
_build_ui
()
self
.
refresh
()
# ------------------------------------------------------------------
def
_build_ui
(
self
)
->
None
:
panel
=
QWidget
()
panel
.
setStyleSheet
(
"QWidget { background: #202631; border: 1px solid #2e3340; "
"border-radius: 6px; }"
"QLabel { color: #c0c8d8; background: transparent; border: none; }"
"QLineEdit, QComboBox, QSpinBox { background: #111722; color: #e0e8f8; "
"border: 1px solid #3a4050; border-radius: 4px; padding: 4px 6px; }"
)
form
=
QFormLayout
(
panel
)
form
.
setContentsMargins
(
14
,
12
,
14
,
12
)
form
.
setSpacing
(
8
)
self
.
_cluster_combo
=
QComboBox
()
form
.
addRow
(
"Кластер:"
,
self
.
_cluster_combo
)
self
.
_pool_edit
=
QLineEdit
(
"cephdeploy-test"
)
form
.
addRow
(
"Pool:"
,
self
.
_pool_edit
)
self
.
_object_edit
=
QLineEdit
(
"cephdeploy-test-object"
)
form
.
addRow
(
"Объект:"
,
self
.
_object_edit
)
self
.
_size_mb
=
QSpinBox
()
self
.
_size_mb
.
setRange
(
1
,
1024
)
self
.
_size_mb
.
setValue
(
100
)
self
.
_size_mb
.
setSuffix
(
" MB"
)
form
.
addRow
(
"Размер записи:"
,
self
.
_size_mb
)
self
.
_replicas
=
QSpinBox
()
self
.
_replicas
.
setRange
(
1
,
10
)
self
.
_replicas
.
setValue
(
3
)
form
.
addRow
(
"Реплик:"
,
self
.
_replicas
)
self
.
_cleanup
=
QCheckBox
(
"Удалить тестовый объект после проверки"
)
self
.
_cleanup
.
setStyleSheet
(
"background: transparent; border: none; color: #c0c8d8;"
)
form
.
addRow
(
""
,
self
.
_cleanup
)
btn_row
=
QHBoxLayout
()
self
.
_btn_run
=
QPushButton
(
"Запустить проверку"
)
self
.
_btn_run
.
clicked
.
connect
(
self
.
_start_test
)
self
.
_btn_stop
=
QPushButton
(
"Остановить"
)
self
.
_btn_stop
.
setEnabled
(
False
)
self
.
_btn_stop
.
clicked
.
connect
(
self
.
_stop_test
)
btn_row
.
addWidget
(
self
.
_btn_run
)
btn_row
.
addWidget
(
self
.
_btn_stop
)
btn_row
.
addStretch
()
form
.
addRow
(
""
,
btn_row
)
hint
=
QLabel
(
"Тест создаёт pool при необходимости, включает application=rados, "
"записывает объект, читает его обратно, сравнивает sha256 и выводит ceph -s."
)
hint
.
setWordWrap
(
True
)
hint
.
setStyleSheet
(
"color: #8fbcbb; background: transparent; border: none;"
)
self
.
_log
=
QTextEdit
()
self
.
_log
.
setReadOnly
(
True
)
self
.
_log
.
setStyleSheet
(
_LOG_STYLE
)
font
=
QFont
(
"Monospace"
)
font
.
setStyleHint
(
QFont
.
StyleHint
.
TypeWriter
)
font
.
setPointSize
(
9
)
self
.
_log
.
setFont
(
font
)
self
.
content_layout
.
addWidget
(
panel
)
self
.
content_layout
.
addWidget
(
hint
)
self
.
content_layout
.
addWidget
(
self
.
_log
,
stretch
=
1
)
# ------------------------------------------------------------------
def
refresh
(
self
)
->
None
:
current
=
self
.
_cluster_combo
.
currentData
()
self
.
_cluster_combo
.
clear
()
with
SessionLocal
()
as
session
:
for
cluster
in
list_clusters
(
session
):
self
.
_cluster_combo
.
addItem
(
f
"{cluster.name} [{cluster.ceph_version}]"
,
userData
=
cluster
.
id
,
)
if
current
is
not
None
:
idx
=
self
.
_cluster_combo
.
findData
(
current
)
if
idx
>=
0
:
self
.
_cluster_combo
.
setCurrentIndex
(
idx
)
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
_start_test
(
self
)
->
None
:
cluster_id
=
self
.
_cluster_combo
.
currentData
()
if
cluster_id
is
None
:
QMessageBox
.
warning
(
self
,
"Нет кластера"
,
"В БД нет ни одного кластера."
)
return
pool
=
self
.
_pool_edit
.
text
()
.
strip
()
obj
=
self
.
_object_edit
.
text
()
.
strip
()
if
not
pool
or
not
obj
:
QMessageBox
.
warning
(
self
,
"Параметры"
,
"Укажите pool и имя объекта."
)
return
with
SessionLocal
()
as
session
:
servers
=
list_servers
(
session
,
cluster_id
)
if
not
servers
:
QMessageBox
.
warning
(
self
,
"Нет серверов"
,
"В кластере нет серверов."
)
return
host
=
next
((
s
for
s
in
servers
if
s
.
role
.
value
in
(
"mon"
,
"all"
)),
servers
[
0
])
ssh_key
=
str
(
Path
(
host
.
ssh_key_path
)
.
expanduser
())
remote
=
f
"{host.ssh_user}@{host.ip_address}"
script
=
self
.
_build_remote_script
(
pool
=
pool
,
obj
=
obj
,
size_mb
=
self
.
_size_mb
.
value
(),
replicas
=
self
.
_replicas
.
value
(),
cleanup
=
self
.
_cleanup
.
isChecked
(),
)
remote_cmd
=
(
"sudo -n /usr/sbin/cephadm shell -- bash -lc "
+
shlex
.
quote
(
script
)
)
self
.
_log
.
clear
()
self
.
_append
(
f
"Узел запуска: {host.hostname} ({host.ip_address})
\n
"
)
self
.
_append
(
f
"Pool: {pool}, object: {obj}, size: {self._size_mb.value()} MB
\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
(
"ssh"
,
[
"-x"
,
"-T"
,
"-o"
,
"BatchMode=yes"
,
"-o"
,
"ForwardX11=no"
,
"-o"
,
"ConnectTimeout=8"
,
"-o"
,
"StrictHostKeyChecking=no"
,
"-i"
,
ssh_key
,
remote
,
remote_cmd
,
],
)
if
not
self
.
_process
.
waitForStarted
(
3000
):
self
.
_append
(
"Не удалось запустить SSH-команду.
\n
"
)
return
self
.
_btn_run
.
setEnabled
(
False
)
self
.
_btn_stop
.
setEnabled
(
True
)
@staticmethod
def
_build_remote_script
(
*
,
pool
:
str
,
obj
:
str
,
size_mb
:
int
,
replicas
:
int
,
cleanup
:
bool
)
->
str
:
pool_q
=
shlex
.
quote
(
pool
)
obj_q
=
shlex
.
quote
(
obj
)
cleanup_flag
=
"1"
if
cleanup
else
"0"
return
f
"""
set -euo pipefail
pool={pool_q}
obj={obj_q}
size_mb={int(size_mb)}
replicas={int(replicas)}
cleanup={cleanup_flag}
src=/tmp/cephdeploy-rados-src.bin
dst=/tmp/cephdeploy-rados-dst.bin
echo "== Ceph status before =="
ceph -s
echo
echo "== Ensure pool =="
if ! ceph osd pool ls | grep -Fxq "$pool"; then
ceph osd pool create "$pool" 1
fi
ceph osd pool application enable "$pool" rados --yes-i-really-mean-it || true
ceph osd pool set "$pool" size "$replicas"
if [ "$replicas" -gt 1 ]; then
ceph osd pool set "$pool" min_size 2
fi
ceph osd pool get "$pool" size
ceph osd pool get "$pool" min_size
echo
echo "== Write test object =="
dd if=/dev/urandom of="$src" bs=1M count="$size_mb" status=progress
src_sum=$(sha256sum "$src" | awk '{{print $1}}')
rados -p "$pool" put "$obj" "$src"
rados -p "$pool" stat "$obj"
echo
echo "== Read and verify =="
rados -p "$pool" get "$obj" "$dst"
dst_sum=$(sha256sum "$dst" | awk '{{print $1}}')
echo "source sha256: $src_sum"
echo "read sha256: $dst_sum"
test "$src_sum" = "$dst_sum"
echo "checksum: OK"
echo
echo "== Placement and replication =="
ceph osd map "$pool" "$obj"
ceph pg ls-by-pool "$pool"
echo
echo "== Ceph status after =="
ceph -s
if [ "$cleanup" = "1" ]; then
echo
echo "== Cleanup =="
rados -p "$pool" rm "$obj" || true
fi
rm -f "$src" "$dst"
"""
def
_stop_test
(
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_run
.
setEnabled
(
True
)
self
.
_btn_stop
.
setEnabled
(
False
)
self
.
_append
(
f
"
\n
Код возврата: {exit_code}
\n
"
)
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment