truenas_tools.py: добавил RAM (multi-endpoint), температуры CPU/дисков, ARC-статистику, валидацию API-ключа, кэширование
This commit is contained in:
+644
-50
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user