truenas_tools.py: добавил RAM (multi-endpoint), температуры CPU/дисков, ARC-статистику, валидацию API-ключа, кэширование

This commit is contained in:
2026-08-01 14:40:44 +03:00
parent 6a6da0593c
commit 1fbf9a1e90
+644 -50
View File
@@ -1,7 +1,31 @@
"""
truenas_tools.py — мониторинг TrueNAS через Open WebUI.
Полный набор инструментов для интеграции TrueNAS 25.10 (Goldeye/Goliath)
в Open WebUI в виде пользовательского Tool.
Возможности:
• Системная информация (версия, uptime, CPU, load average, RAM, температура CPU)
• Детальная информация о RAM с перебором нескольких эндпоинтов API
• Пулы ZFS (статус, занятость, фрагментация)
• Диски (модель, серийник, объём, температура SMART)
• Docker-приложения (TrueNAS Apps)
• Виртуальные машины (libvirt/KVM)
• Контейнеры (virt/incus)
• Алерты TrueNAS
• Статистика ZFS ARC-кэша
• Диагностика соединения и валидация API-ключа
• Кэширование ответов с настраиваемым TTL
История версий:
v2.0 — добавлены RAM (multi-endpoint), температуры CPU/дисков, ARC, валидация API
v1.0 — базовый мониторинг (система, пулы, диски, VM, контейнеры, алерты)
"""
import json
import time
import requests
from typing import Dict
from typing import Dict, Optional, List, Any
from pydantic import BaseModel, Field
@@ -9,28 +33,41 @@ class Tools:
"""
Мониторинг TrueNAS через Open WebUI.
REST с перебором альтернативных путей.
TrueNAS 25.10 (Goldeye).
TrueNAS 25.10 (Goldeye/Goliath).
"""
# -----------------------------------------------------------------
# Настройки (Valves) — задаются в UI Open WebUI
# -----------------------------------------------------------------
class Valves(BaseModel):
TRUENAS_URL: str = Field(
default="http://192.168.31.100",
description="IP-адрес или домен TrueNAS (http://...)",
description="IP-адрес или домен TrueNAS (http://... или https://...)",
)
TRUENAS_API_KEY: str = Field(
default="",
description="API-ключ для доступа к TrueNAS (Settings → API Keys)",
)
CACHE_TTL: int = Field(
default=30, description="Время кэширования в секундах (0 = без кэша)"
default=30,
description="Время кэширования ответов в секундах (0 = без кэша)",
)
MEM_CACHE_TTL: int = Field(
default=60,
description="TTL для данных о RAM в секундах (RAM редко меняется)",
)
verify_ssl: bool = Field(
default=False, description="Проверять TLS-сертификат TrueNAS"
default=False,
description="Проверять TLS-сертификат TrueNAS",
)
request_timeout: int = Field(
default=30, description="Таймаут HTTP-запроса в секундах"
default=15,
description="Таймаут HTTP-запроса в секундах",
)
# -----------------------------------------------------------------
# Инициализация
# -----------------------------------------------------------------
def __init__(self):
self.valves = self.Valves()
self._cache: Dict[str, tuple[float, str]] = {}
@@ -42,10 +79,12 @@ class Tools:
except Exception:
pass
# -----------------------------------------------------------------
# Вспомогательные свойства
# -----------------------------------------------------------------
@property
def _base(self) -> str:
# Всегда читаем актуальное значение из Valves — оно может
# меняться в UI уже после создания экземпляра Tools.
"""Всегда читаем актуальное значение из Valves — оно может меняться в UI после создания экземпляра Tools."""
return self.valves.TRUENAS_URL.rstrip("/")
@property
@@ -55,18 +94,37 @@ class Tools:
"Content-Type": "application/json",
}
def clear_cache(self):
# -----------------------------------------------------------------
# Кэш
# -----------------------------------------------------------------
def clear_cache(self) -> str:
"""Сбрасывает весь кэш запросов. Возвращает количество удалённых записей."""
n = len(self._cache)
self._cache.clear()
return f"🧹 Кэш очищен ({n} записей удалено)."
def _rest_get(self, cache_key: str, endpoint: str):
def _rest_get(
self,
cache_key: str,
endpoint: str,
cache_ttl: Optional[int] = None,
) -> Optional[str]:
"""
GET-запрос к TrueNAS API с кэшированием.
Возвращает JSON-строку при успехе или None при ошибке.
"""
ttl = cache_ttl if cache_ttl is not None else self.valves.CACHE_TTL
now = time.time()
if cache_key in self._cache:
if cache_key in self._cache and ttl > 0:
t, d = self._cache[cache_key]
if now - t < self.valves.CACHE_TTL:
if now - t < ttl:
return d
url = f"{self._base}/api/v2.0/{endpoint}"
try:
r = requests.get(
f"{self._base}/api/v2.0/{endpoint}",
url,
headers=self._headers,
timeout=self.valves.request_timeout,
verify=self.valves.verify_ssl,
@@ -74,15 +132,56 @@ class Tools:
if r.status_code == 200:
data = r.json()
s = json.dumps(data, default=str)
if self.valves.CACHE_TTL > 0:
if ttl > 0:
self._cache[cache_key] = (now, s)
return s
self._last_error = f"HTTP {r.status_code} на {endpoint}: {r.text[:300]}"
return None
except requests.exceptions.SSLError as e:
self._last_error = f"SSL-ошибка на {endpoint}: {e}"
return None
except requests.exceptions.ConnectionError as e:
self._last_error = f"Ошибка соединения с {endpoint}: {e}"
return None
except requests.exceptions.Timeout:
self._last_error = f"Таймаут на {endpoint}"
return None
except Exception as e:
self._last_error = f"Исключение на {endpoint}: {type(e).__name__}: {e}"
return None
def _try_endpoints(self, cache_key: str, endpoints: List[str]) -> str:
"""
Перебирает эндпоинты по очереди и возвращает первый удачный ответ.
"""
for ep in endpoints:
result = self._rest_get(f"{cache_key}_{ep.replace('/', '_')}", ep)
if result is not None:
return result
return f"⚠️ Данные недоступны. Попробованы пути: {', '.join(endpoints)}"
# -----------------------------------------------------------------
# Форматирование
# -----------------------------------------------------------------
def _format_bytes(self, num_bytes: Any) -> str:
"""Форматирует байты в человекочитаемый вид (KiB/MiB/GiB/TiB)."""
if num_bytes is None:
return "Н"
try:
n = float(num_bytes)
except (TypeError, ValueError):
return str(num_bytes)
if n < 0:
return "Н"
for unit in ["B", "KiB", "MiB", "GiB", "TiB"]:
if abs(n) < 1024.0:
return f"{n:.1f} {unit}"
n /= 1024.0
return f"{n:.1f} PiB"
# -----------------------------------------------------------------
# Диагностика и валидация
# -----------------------------------------------------------------
def get_debug_info(self) -> str:
"""
Диагностика соединения с TrueNAS: проверяет базовый URL, HTTP/HTTPS,
@@ -94,7 +193,11 @@ class Tools:
lines.append(f"🔧 TRUENAS_URL из настроек: {base}")
lines.append(
"🔑 API-ключ задан: "
+ ("да" if self.valves.TRUENAS_API_KEY else "❌ НЕТ — заполни Valves.TRUENAS_API_KEY")
+ (
"да"
if self.valves.TRUENAS_API_KEY
else "❌ НЕТ — заполни Valves.TRUENAS_API_KEY"
)
)
candidates = [base]
@@ -142,13 +245,439 @@ class Tools:
)
return "\n".join(lines)
def _try_endpoints(self, cache_key: str, endpoints: list) -> str:
for ep in endpoints:
result = self._rest_get(f"{cache_key}_{ep.replace('/', '_')}", ep)
if result is not None:
return result
return f"⚠️ Данные недоступны. Попробованы пути: {', '.join(endpoints)}"
def validate_api_key(self) -> str:
"""
Проверяет валидность API-ключа и доступность основных эндпоинтов.
Возвращает подробный отчёт с результатами проверки.
"""
lines = ["🔐 ВАЛИДАЦИЯ API-КЛЮЧА TRUENAS", ""]
if not self.valves.TRUENAS_API_KEY:
lines.append("❌ API-ключ не задан в Valves.TRUENAS_API_KEY")
return "\n".join(lines)
lines.append(f"🔑 Ключ: {'*' * 8}{self.valves.TRUENAS_API_KEY[-4:]}")
lines.append(f"🌐 URL: {self._base}")
lines.append("")
# Проверяем несколько эндпоинтов — каждый со своим минимальным набором прав
checks = [
("system/info", "базовая информация о системе"),
("pool/query", "список пулов ZFS"),
("disk/query", "список дисков"),
("app/query", "список приложений"),
("alert/list", "список алертов"),
]
ok = 0
for endpoint, descr in checks:
url = f"{self._base}/api/v2.0/{endpoint}"
try:
r = requests.get(
url,
headers=self._headers,
timeout=self.valves.request_timeout,
verify=self.valves.verify_ssl,
)
if r.status_code == 200:
lines.append(f"{endpoint:20s}{descr}")
ok += 1
elif r.status_code in (401, 403):
lines.append(f"🔑 {endpoint:20s} — 401/403 (нет прав)")
else:
lines.append(f"⚠️ {endpoint:20s} — HTTP {r.status_code}")
except Exception as e:
lines.append(f"🔌 {endpoint:20s} — ошибка: {type(e).__name__}")
lines.append("")
lines.append(f"Итого: {ok}/{len(checks)} эндпоинтов доступны.")
if ok == len(checks):
lines.append("🎉 API-ключ валиден и имеет все необходимые права.")
elif ok == 0:
lines.append("❌ API-ключ не работает или сервер недоступен.")
else:
lines.append(
"⚠️ Частичный доступ — проверьте права ключа (Read Only / Full Access)."
)
return "\n".join(lines)
# -----------------------------------------------------------------
# Оперативная память (RAM)
# -----------------------------------------------------------------
# Эндпоинты, в которых может быть поле общей RAM (в порядке приоритета)
_MEM_TOTAL_ENDPOINTS = [
"system/get_info", # старый middleware, часто содержит memory_size
"system/info", # базовый
"system/memory", # отдельный эндпоинт (новые версии)
"hardware", # список оборудования
"hardware/info", # подробно об оборудовании
"system/advanced", # настройки системы
]
# Возможные названия поля с общей RAM
_MEM_TOTAL_KEYS = [
"memory_size", # system/get_info
"memory", # system/info, hardware/info
"total_memory",
"memory_total",
"total_ram",
"physmem", # FreeBSD-style
"phys_mem",
"total", # вложенные объекты
]
# Эндпоинты для детальной информации (used/free/available/swap)
_MEM_DETAIL_ENDPOINTS = [
"stats",
"reporting/get_data",
"system/memory",
]
def _get_memory_info(self) -> Dict[str, Any]:
"""
Собирает информацию о RAM через несколько эндпоинтов.
Возвращает dict с полями:
total_bytes, total_human,
used_bytes, used_human,
free_bytes, free_human,
available_bytes, available_human,
swap_total, swap_used,
source_endpoint, source_field
"""
result: Dict[str, Any] = {
"total_bytes": None,
"total_human": "Н",
"used_bytes": None,
"used_human": None,
"free_bytes": None,
"free_human": None,
"available_bytes": None,
"available_human": None,
"swap_total": None,
"swap_used": None,
"source_endpoint": None,
"source_field": None,
}
# 1. Ищем total в списке эндпоинтов
for ep in self._MEM_TOTAL_ENDPOINTS:
raw = self._rest_get(
f"mem_total_{ep.replace('/', '_')}", ep, cache_ttl=self.valves.MEM_CACHE_TTL
)
if raw is None:
continue
try:
data = json.loads(raw)
except Exception:
continue
# Данные могут быть списком или словарём
items = data if isinstance(data, list) else [data]
found = False
for item in items:
if not isinstance(item, dict):
continue
for key in self._MEM_TOTAL_KEYS:
if key in item and item[key] is not None:
val = item[key]
bytes_val = self._to_bytes(val)
if bytes_val and bytes_val > 0:
result["total_bytes"] = bytes_val
result["source_endpoint"] = ep
result["source_field"] = key
found = True
break
if found:
break
if found:
break
# 2. Human-readable total
if result["total_bytes"]:
result["total_human"] = self._format_bytes(result["total_bytes"])
# 3. Пробуем получить used/free/swap через stats или memory
for ep in self._MEM_DETAIL_ENDPOINTS:
raw = self._rest_get(
f"mem_detail_{ep.replace('/', '_')}",
ep,
cache_ttl=self.valves.MEM_CACHE_TTL,
)
if raw is None:
continue
try:
data = json.loads(raw)
except Exception:
continue
if not isinstance(data, (dict, list)):
continue
items = data if isinstance(data, list) else [data]
for item in items:
if not isinstance(item, dict):
continue
# Некоторые версии кладут всё в один объект
for src_key, dst_key in [
("used", "used_bytes"),
("free", "free_bytes"),
("available", "available_bytes"),
("swap_total", "swap_total"),
("swap_used", "swap_used"),
("swap_free", "swap_used"),
]:
if src_key in item and item[src_key] is not None:
val = self._to_bytes(item[src_key])
if val is not None:
result[dst_key] = val
# 4. Human-readable для деталей
for k in ["used", "free", "available", "swap_total", "swap_used"]:
bytes_key = f"{k}_bytes" if k != "swap_total" else "swap_total"
human_key = f"{k}_human" if k != "swap_total" else "swap_total_human"
if k == "swap_total":
if result["swap_total"]:
result["swap_total_human"] = self._format_bytes(result["swap_total"])
else:
if result[f"{k}_bytes"]:
result[f"{k}_human"] = self._format_bytes(result[f"{k}_bytes"])
return result
def _to_bytes(self, val: Any) -> Optional[int]:
"""
Приводит значение RAM к байтам. Поддерживает:
• int / float (считаются байтами, если > 1 MB)
• строку с числом и суффиксом (KiB, MiB, GiB, KB, MB, GB)
• строку с просто числом
"""
if val is None:
return None
if isinstance(val, (int, float)):
n = float(val)
# Если значение подозрительно маленькое — возможно это MiB
if 0 < n < 1024 * 1024:
# Скорее всего это MiB
return int(n * 1024 * 1024)
return int(n)
if isinstance(val, str):
s = val.strip().replace(",", ".")
mult = 1
for suffix, m in [
("KIB", 1024),
("MIB", 1024**2),
("GIB", 1024**3),
("TIB", 1024**4),
("KB", 1000),
("MB", 1000**2),
("GB", 1000**3),
("TB", 1000**4),
("B", 1),
]:
if s.upper().endswith(suffix):
s = s[: -len(suffix)].strip()
mult = m
break
try:
return int(float(s) * mult)
except ValueError:
return None
return None
def get_memory_info(self) -> str:
"""Отдельный эндпоинт: детальная информация о RAM."""
m = self._get_memory_info()
lines = ["💾 ИНФОРМАЦИЯ О ПАМЯТИ", ""]
lines.append(f" Всего: {m['total_human']}")
if m["used_human"]:
pct = ""
if m["used_bytes"] and m["total_bytes"]:
pct = f" ({m['used_bytes'] / m['total_bytes'] * 100:.1f}%)"
lines.append(f" Использовано: {m['used_human']}{pct}")
if m["free_human"]:
lines.append(f" Свободно: {m['free_human']}")
if m["available_human"]:
lines.append(f" Доступно: {m['available_human']}")
if m.get("swap_total_human"):
lines.append(f" Swap всего: {m['swap_total_human']}")
if m.get("swap_used") is not None:
swap_used_h = self._format_bytes(m["swap_used"])
lines.append(f" Swap исп.: {swap_used_h}")
if m["source_endpoint"]:
lines.append(
f"\n 📡 Источник: эндпоинт `{m['source_endpoint']}` "
f"(поле `{m['source_field']}`)"
)
else:
lines.append(
"\n ⚠️ RAM не удалось получить ни из одного эндпоинта. "
"Возможно, требуется Full Access API-ключ."
)
return "\n".join(lines)
# -----------------------------------------------------------------
# Температура CPU
# -----------------------------------------------------------------
def get_cpu_temperature(self) -> str:
"""
Температура CPU и сенсоров.
TrueNAS Scale может возвращать её в system/info (поле cpu_temperature)
или в hardware/info.
"""
endpoints = ["system/info", "system/get_info", "hardware/info", "hardware"]
found_any = False
lines = ["🌡️ ТЕМПЕРАТУРА CPU И СЕНСОРЫ", ""]
for ep in endpoints:
raw = self._rest_get(
f"cputemp_{ep.replace('/', '_')}", ep, cache_ttl=self.valves.MEM_CACHE_TTL
)
if raw is None:
continue
try:
data = json.loads(raw)
except Exception:
continue
items = data if isinstance(data, list) else [data]
for item in items:
if not isinstance(item, dict):
continue
# Прямое поле cpu_temperature
cpu_temp = (
item.get("cpu_temperature")
or item.get("cpu_temp")
or item.get("temperature")
)
if cpu_temp is not None:
found_any = True
try:
t = float(cpu_temp)
lines.append(f" 🌡️ CPU: {t:.1f} °C (эндпоинт {ep})")
except (TypeError, ValueError):
lines.append(f" 🌡️ CPU: {cpu_temp} (эндпоинт {ep})")
# Вложенный объект sensors / temperatures
sensors = item.get("sensors") or item.get("temperatures") or {}
if isinstance(sensors, dict):
for name, val in sensors.items():
if val is None:
continue
try:
t = float(val)
lines.append(f" 🌡️ {name}: {t:.1f} °C")
found_any = True
except (TypeError, ValueError):
lines.append(f" 🌡️ {name}: {val}")
found_any = True
elif isinstance(sensors, list):
for s in sensors:
if not isinstance(s, dict):
continue
name = s.get("name") or s.get("identifier") or "sensor"
val = s.get("value") or s.get("temperature") or s.get("temp")
if val is not None:
try:
t = float(val)
lines.append(f" 🌡️ {name}: {t:.1f} °C")
found_any = True
except (TypeError, ValueError):
lines.append(f" 🌡️ {name}: {val}")
found_any = True
if not found_any:
lines.append(
" ⚠️ Температура CPU недоступна через API.\n"
" Возможные причины: не поддерживается в этой версии TrueNAS,\n"
" нет прав на чтение сенсоров, или требуется ipmi/sensors модуль."
)
return "\n".join(lines)
# -----------------------------------------------------------------
# Статистика ZFS ARC-кэша
# -----------------------------------------------------------------
def get_arc_stats(self) -> str:
"""
Статистика ZFS ARC-кэша (если доступна через API).
ARC — адаптивный кэш чтения ZFS, обычно занимает до 50% RAM.
"""
endpoints = ["stats", "zfs/arc", "zfs/pool/stats", "pool/statistics"]
lines = ["📊 СТАТИСТИКА ZFS ARC-КЭША", ""]
found = False
for ep in endpoints:
raw = self._rest_get(
f"arc_{ep.replace('/', '_')}", ep, cache_ttl=self.valves.CACHE_TTL
)
if raw is None:
continue
try:
data = json.loads(raw)
except Exception:
continue
items = data if isinstance(data, list) else [data]
for item in items:
if not isinstance(item, dict):
continue
# Разные варианты названий
arc_keys = {
"arc_size": "Размер ARC",
"arc_max_size": "Лимит ARC",
"arc_min_size": "Минимум ARC",
"arc_hits": "Попадания",
"arc_misses": "Промахи",
"arc_miss_percent": "Процент промахов",
"arc_c": "Целевой размер",
"cache_size": "Размер кэша",
"cache_used": "Использовано кэша",
"evict_skip": "Пропуски вытеснения",
"demand_data_hits": "Попадания данных (demand)",
"demand_metadata_hits": "Попадания метаданных (demand)",
}
for key, descr in arc_keys.items():
if key in item and item[key] is not None:
val = item[key]
if "size" in key or "cache" in key:
val_s = self._format_bytes(val)
elif "percent" in key:
try:
val_s = f"{float(val):.2f}%"
except (TypeError, ValueError):
val_s = str(val)
else:
val_s = f"{val}"
lines.append(f" {descr:30s} {val_s}")
found = True
# Иногда данные лежат во вложенном объекте
arc_obj = item.get("arc") or item.get("zfs") or {}
if isinstance(arc_obj, dict):
for k, v in arc_obj.items():
if isinstance(v, (int, float, str)):
lines.append(f" arc.{k}: {v}")
found = True
if not found:
lines.append(
" ⚠️ ARC-статистика недоступна через эти эндпоинты.\n"
" Попробуйте: zpool/iostat, arcstat (через SSH) или zfs-stats."
)
return "\n".join(lines)
# -----------------------------------------------------------------
# Системная информация (с RAM и температурой CPU)
# -----------------------------------------------------------------
def get_system_status(self) -> str:
raw = self._rest_get("info", "system/info")
if raw is None:
@@ -158,18 +687,25 @@ class Tools:
except Exception:
return "Ошибка парсинга"
# Получаем RAM из system/get_info/
ram_s = "Н"
ram_raw = self._rest_get("mem_info", "system/get_info/")
if ram_raw:
# RAM (через _get_memory_info с перебором эндпоинтов)
mem = self._get_memory_info()
ram_s = mem["total_human"]
if mem["used_human"] and mem["total_bytes"]:
used_pct = (
(mem["used_bytes"] / mem["total_bytes"] * 100)
if mem["used_bytes"]
else 0
)
ram_s += f" (исп. {mem['used_human']}, {used_pct:.0f}%)"
# Температура CPU (если есть в этом же ответе)
cpu_temp_s = ""
cpu_temp = d.get("cpu_temperature") or d.get("cpu_temp") or d.get("temperature")
if cpu_temp is not None:
try:
mem_data = json.loads(ram_raw)
mem_bytes = mem_data.get("memory_size")
if mem_bytes:
ram_gb = mem_bytes / (1024**3)
ram_s = f"{ram_gb:.1f} GB"
except Exception:
pass
cpu_temp_s = f" | 🌡️ {float(cpu_temp):.1f} °C"
except (TypeError, ValueError):
cpu_temp_s = f" | 🌡️ {cpu_temp}"
ver = d.get("version", "N/A")
model = d.get("model", "N/A")
@@ -190,11 +726,14 @@ class Tools:
return (
f"🖥️ TrueNAS: {ver}\n"
f"⏱️ Uptime: {up_s}\n"
f"🔧 Процессор: {model}\n"
f"🔧 Процессор: {model}{cpu_temp_s}\n"
f"📊 Load Average: {load_s}\n"
f"💾 RAM: {ram_s}"
)
# -----------------------------------------------------------------
# Пулы ZFS
# -----------------------------------------------------------------
def get_pools_status(self) -> str:
raw = self._try_endpoints("pools", ["pool/query", "pool", "storage/pool"])
if raw.startswith("⚠️"):
@@ -213,11 +752,17 @@ class Tools:
total = p.get("size", 0)
free = p.get("free", 0)
used_pct = ((total - free) / total * 100) if total > 0 else 0
frag = p.get("fragmentation")
frag_s = f" | фрагм.: {frag}" if frag not in (None, "", "N/A") else ""
rows.append(
f"{icon} '{name}' | {status} | {total/1024**4:.2f} TiB, {used_pct:.1f}% занято"
f"{icon} '{name}' | {status} | "
f"{total/1024**4:.2f} TiB, {used_pct:.1f}% занято{frag_s}"
)
return "\n".join(rows)
# -----------------------------------------------------------------
# Диски (с температурой SMART)
# -----------------------------------------------------------------
def get_disks_temp_and_info(self) -> str:
raw = self._try_endpoints("disks", ["disk/query", "disk", "storage/disk"])
if raw.startswith("⚠️"):
@@ -235,9 +780,32 @@ class Tools:
serial = d.get("serial", "N/A")
size = f"{int(d['size']) / (1024**3):.1f} GB" if d.get("size") else "N/A"
model = d.get("model", "")
rows.append(f"💾 {name} ({dtype}) | {model} | {serial} | {size}")
# Температура SMART
temp = d.get("temperature")
temp_s = ""
if temp is not None:
try:
t = float(temp)
icon = (
"🟢"
if t < 45
else "🟡"
if t < 55
else "🟠"
if t < 65
else "🔴"
)
temp_s = f" | {icon} {t:.0f}°C"
except (TypeError, ValueError):
temp_s = f" | {temp}°C"
rows.append(
f"💾 {name} ({dtype}) | {model} | {serial} | {size}{temp_s}"
)
return "\n".join(rows)
# -----------------------------------------------------------------
# Docker-приложения (TrueNAS Apps)
# -----------------------------------------------------------------
def get_apps_status(self) -> str:
"""Список установленных приложений (TrueNAS Apps, docker compose)."""
raw = self._try_endpoints("apps", ["app/query", "app", "chart/release/query"])
@@ -265,6 +833,9 @@ class Tools:
rows.append(f"{icon} {name} | статус: {state} | версия: {version}")
return "\n".join(rows)
# -----------------------------------------------------------------
# Виртуальные машины (libvirt/KVM)
# -----------------------------------------------------------------
def get_vms_status(self) -> str:
"""Список виртуальных машин (libvirt/KVM)."""
raw = self._try_endpoints("vms", ["vm/query", "vm"])
@@ -287,10 +858,14 @@ class Tools:
autostart = "" if vm.get("autostart") else ""
icon = "🟢" if str(state).upper() == "RUNNING" else ""
rows.append(
f"{icon} {name} | статус: {state} | vCPU: {vcpus} | RAM: {mem_s} | автозапуск: {autostart}"
f"{icon} {name} | статус: {state} | vCPU: {vcpus} | "
f"RAM: {mem_s} | автозапуск: {autostart}"
)
return "\n".join(rows)
# -----------------------------------------------------------------
# Контейнеры (virt/incus)
# -----------------------------------------------------------------
def get_containers_status(self) -> str:
"""Лёгкие контейнеры / инстансы (incus/virt, LXC-подобные)."""
raw = self._try_endpoints(
@@ -312,9 +887,14 @@ class Tools:
image = c.get("image", {})
image_s = image.get("name", "N/A") if isinstance(image, dict) else image
icon = "🟢" if str(status).upper() == "RUNNING" else ""
rows.append(f"{icon} {name} | тип: {itype} | статус: {status} | образ: {image_s}")
rows.append(
f"{icon} {name} | тип: {itype} | статус: {status} | образ: {image_s}"
)
return "\n".join(rows)
# -----------------------------------------------------------------
# Алерты
# -----------------------------------------------------------------
def get_active_alerts(self) -> str:
raw = self._rest_get("alerts", "alert/list")
if raw is None:
@@ -335,17 +915,31 @@ class Tools:
rows.append(f"{imap.get(level, '')} [{level}] {klass}: {msg}")
return "\n".join(rows)
# -----------------------------------------------------------------
# Полный отчёт
# -----------------------------------------------------------------
def get_full_report(self) -> str:
return (
"══════════════════════════════════════════════════\n"
"📋 ПОЛНЫЙ ОТЧЁТ TRUENAS\n"
"(REST перебор путей)\n"
"══════════════════════════════════════════════════\n\n"
"--- СИСТЕМА ---\n" + self.get_system_status() + "\n\n"
"--- ПУЛЫ ZFS ---\n" + self.get_pools_status() + "\n\n"
"--- ДИСКИ ---\n" + self.get_disks_temp_and_info() + "\n\n"
"--- ПРИЛОЖЕНИЯ ---\n" + self.get_apps_status() + "\n\n"
"--- ВИРТУАЛЬНЫЕ МАШИНЫ ---\n" + self.get_vms_status() + "\n\n"
"--- КОНТЕЙНЕРЫ (virt/incus) ---\n" + self.get_containers_status() + "\n\n"
"--- АЛЕРТЫ ---\n" + self.get_active_alerts()
)
sections = [
("СИСТЕМА", self.get_system_status()),
("ПАМЯТЬ (RAM)", self.get_memory_info()),
("ТЕМПЕРАТУРА CPU", self.get_cpu_temperature()),
("ZFS ARC-КЭШ", self.get_arc_stats()),
("ПУЛЫ ZFS", self.get_pools_status()),
("ДИСКИ", self.get_disks_temp_and_info()),
("ПРИЛОЖЕНИЯ", self.get_apps_status()),
("ВИРТУАЛЬНЫЕ МАШИНЫ", self.get_vms_status()),
("КОНТЕЙНЕРЫ (virt/incus)", self.get_containers_status()),
("АЛЕРТЫ", self.get_active_alerts()),
]
out = [
"══════════════════════════════════════════════════",
"📋 ПОЛНЫЙ ОТЧЁТ TRUENAS",
"(REST перебор путей)",
"══════════════════════════════════════════════════",
"",
]
for title, content in sections:
out.append(f"--- {title} ---")
out.append(content)
out.append("")
return "\n".join(out)