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 json
import time import time
import requests import requests
from typing import Dict from typing import Dict, Optional, List, Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -9,28 +33,41 @@ class Tools:
""" """
Мониторинг TrueNAS через Open WebUI. Мониторинг TrueNAS через Open WebUI.
REST с перебором альтернативных путей. REST с перебором альтернативных путей.
TrueNAS 25.10 (Goldeye). TrueNAS 25.10 (Goldeye/Goliath).
""" """
# -----------------------------------------------------------------
# Настройки (Valves) — задаются в UI Open WebUI
# -----------------------------------------------------------------
class Valves(BaseModel): class Valves(BaseModel):
TRUENAS_URL: str = Field( TRUENAS_URL: str = Field(
default="http://192.168.31.100", default="http://192.168.31.100",
description="IP-адрес или домен TrueNAS (http://...)", description="IP-адрес или домен TrueNAS (http://... или https://...)",
) )
TRUENAS_API_KEY: str = Field( TRUENAS_API_KEY: str = Field(
default="", default="",
description="API-ключ для доступа к TrueNAS (Settings → API Keys)", description="API-ключ для доступа к TrueNAS (Settings → API Keys)",
) )
CACHE_TTL: int = Field( 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( verify_ssl: bool = Field(
default=False, description="Проверять TLS-сертификат TrueNAS" default=False,
description="Проверять TLS-сертификат TrueNAS",
) )
request_timeout: int = Field( request_timeout: int = Field(
default=30, description="Таймаут HTTP-запроса в секундах" default=15,
description="Таймаут HTTP-запроса в секундах",
) )
# -----------------------------------------------------------------
# Инициализация
# -----------------------------------------------------------------
def __init__(self): def __init__(self):
self.valves = self.Valves() self.valves = self.Valves()
self._cache: Dict[str, tuple[float, str]] = {} self._cache: Dict[str, tuple[float, str]] = {}
@@ -42,10 +79,12 @@ class Tools:
except Exception: except Exception:
pass pass
# -----------------------------------------------------------------
# Вспомогательные свойства
# -----------------------------------------------------------------
@property @property
def _base(self) -> str: def _base(self) -> str:
# Всегда читаем актуальное значение из Valves — оно может """Всегда читаем актуальное значение из Valves — оно может меняться в UI после создания экземпляра Tools."""
# меняться в UI уже после создания экземпляра Tools.
return self.valves.TRUENAS_URL.rstrip("/") return self.valves.TRUENAS_URL.rstrip("/")
@property @property
@@ -55,18 +94,37 @@ class Tools:
"Content-Type": "application/json", "Content-Type": "application/json",
} }
def clear_cache(self): # -----------------------------------------------------------------
# Кэш
# -----------------------------------------------------------------
def clear_cache(self) -> str:
"""Сбрасывает весь кэш запросов. Возвращает количество удалённых записей."""
n = len(self._cache)
self._cache.clear() 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() now = time.time()
if cache_key in self._cache:
if cache_key in self._cache and ttl > 0:
t, d = self._cache[cache_key] t, d = self._cache[cache_key]
if now - t < self.valves.CACHE_TTL: if now - t < ttl:
return d return d
url = f"{self._base}/api/v2.0/{endpoint}"
try: try:
r = requests.get( r = requests.get(
f"{self._base}/api/v2.0/{endpoint}", url,
headers=self._headers, headers=self._headers,
timeout=self.valves.request_timeout, timeout=self.valves.request_timeout,
verify=self.valves.verify_ssl, verify=self.valves.verify_ssl,
@@ -74,15 +132,56 @@ class Tools:
if r.status_code == 200: if r.status_code == 200:
data = r.json() data = r.json()
s = json.dumps(data, default=str) s = json.dumps(data, default=str)
if self.valves.CACHE_TTL > 0: if ttl > 0:
self._cache[cache_key] = (now, s) self._cache[cache_key] = (now, s)
return s return s
self._last_error = f"HTTP {r.status_code} на {endpoint}: {r.text[:300]}" self._last_error = f"HTTP {r.status_code} на {endpoint}: {r.text[:300]}"
return None 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: except Exception as e:
self._last_error = f"Исключение на {endpoint}: {type(e).__name__}: {e}" self._last_error = f"Исключение на {endpoint}: {type(e).__name__}: {e}"
return None 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: def get_debug_info(self) -> str:
""" """
Диагностика соединения с TrueNAS: проверяет базовый URL, HTTP/HTTPS, Диагностика соединения с TrueNAS: проверяет базовый URL, HTTP/HTTPS,
@@ -94,7 +193,11 @@ class Tools:
lines.append(f"🔧 TRUENAS_URL из настроек: {base}") lines.append(f"🔧 TRUENAS_URL из настроек: {base}")
lines.append( lines.append(
"🔑 API-ключ задан: " "🔑 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] candidates = [base]
@@ -142,13 +245,439 @@ class Tools:
) )
return "\n".join(lines) return "\n".join(lines)
def _try_endpoints(self, cache_key: str, endpoints: list) -> str: def validate_api_key(self) -> str:
for ep in endpoints: """
result = self._rest_get(f"{cache_key}_{ep.replace('/', '_')}", ep) Проверяет валидность API-ключа и доступность основных эндпоинтов.
if result is not None: Возвращает подробный отчёт с результатами проверки.
return result """
return f"⚠️ Данные недоступны. Попробованы пути: {', '.join(endpoints)}" 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: def get_system_status(self) -> str:
raw = self._rest_get("info", "system/info") raw = self._rest_get("info", "system/info")
if raw is None: if raw is None:
@@ -158,18 +687,25 @@ class Tools:
except Exception: except Exception:
return "Ошибка парсинга" return "Ошибка парсинга"
# Получаем RAM из system/get_info/ # RAM (через _get_memory_info с перебором эндпоинтов)
ram_s = "Н" mem = self._get_memory_info()
ram_raw = self._rest_get("mem_info", "system/get_info/") ram_s = mem["total_human"]
if ram_raw: 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: try:
mem_data = json.loads(ram_raw) cpu_temp_s = f" | 🌡️ {float(cpu_temp):.1f} °C"
mem_bytes = mem_data.get("memory_size") except (TypeError, ValueError):
if mem_bytes: cpu_temp_s = f" | 🌡️ {cpu_temp}"
ram_gb = mem_bytes / (1024**3)
ram_s = f"{ram_gb:.1f} GB"
except Exception:
pass
ver = d.get("version", "N/A") ver = d.get("version", "N/A")
model = d.get("model", "N/A") model = d.get("model", "N/A")
@@ -190,11 +726,14 @@ class Tools:
return ( return (
f"🖥️ TrueNAS: {ver}\n" f"🖥️ TrueNAS: {ver}\n"
f"⏱️ Uptime: {up_s}\n" f"⏱️ Uptime: {up_s}\n"
f"🔧 Процессор: {model}\n" f"🔧 Процессор: {model}{cpu_temp_s}\n"
f"📊 Load Average: {load_s}\n" f"📊 Load Average: {load_s}\n"
f"💾 RAM: {ram_s}" f"💾 RAM: {ram_s}"
) )
# -----------------------------------------------------------------
# Пулы ZFS
# -----------------------------------------------------------------
def get_pools_status(self) -> str: def get_pools_status(self) -> str:
raw = self._try_endpoints("pools", ["pool/query", "pool", "storage/pool"]) raw = self._try_endpoints("pools", ["pool/query", "pool", "storage/pool"])
if raw.startswith("⚠️"): if raw.startswith("⚠️"):
@@ -213,11 +752,17 @@ class Tools:
total = p.get("size", 0) total = p.get("size", 0)
free = p.get("free", 0) free = p.get("free", 0)
used_pct = ((total - free) / total * 100) if total > 0 else 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( 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) return "\n".join(rows)
# -----------------------------------------------------------------
# Диски (с температурой SMART)
# -----------------------------------------------------------------
def get_disks_temp_and_info(self) -> str: def get_disks_temp_and_info(self) -> str:
raw = self._try_endpoints("disks", ["disk/query", "disk", "storage/disk"]) raw = self._try_endpoints("disks", ["disk/query", "disk", "storage/disk"])
if raw.startswith("⚠️"): if raw.startswith("⚠️"):
@@ -235,9 +780,32 @@ class Tools:
serial = d.get("serial", "N/A") serial = d.get("serial", "N/A")
size = f"{int(d['size']) / (1024**3):.1f} GB" if d.get("size") else "N/A" size = f"{int(d['size']) / (1024**3):.1f} GB" if d.get("size") else "N/A"
model = d.get("model", "") 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) return "\n".join(rows)
# -----------------------------------------------------------------
# Docker-приложения (TrueNAS Apps)
# -----------------------------------------------------------------
def get_apps_status(self) -> str: def get_apps_status(self) -> str:
"""Список установленных приложений (TrueNAS Apps, docker compose).""" """Список установленных приложений (TrueNAS Apps, docker compose)."""
raw = self._try_endpoints("apps", ["app/query", "app", "chart/release/query"]) raw = self._try_endpoints("apps", ["app/query", "app", "chart/release/query"])
@@ -265,6 +833,9 @@ class Tools:
rows.append(f"{icon} {name} | статус: {state} | версия: {version}") rows.append(f"{icon} {name} | статус: {state} | версия: {version}")
return "\n".join(rows) return "\n".join(rows)
# -----------------------------------------------------------------
# Виртуальные машины (libvirt/KVM)
# -----------------------------------------------------------------
def get_vms_status(self) -> str: def get_vms_status(self) -> str:
"""Список виртуальных машин (libvirt/KVM).""" """Список виртуальных машин (libvirt/KVM)."""
raw = self._try_endpoints("vms", ["vm/query", "vm"]) raw = self._try_endpoints("vms", ["vm/query", "vm"])
@@ -287,10 +858,14 @@ class Tools:
autostart = "" if vm.get("autostart") else "" autostart = "" if vm.get("autostart") else ""
icon = "🟢" if str(state).upper() == "RUNNING" else "" icon = "🟢" if str(state).upper() == "RUNNING" else ""
rows.append( 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) return "\n".join(rows)
# -----------------------------------------------------------------
# Контейнеры (virt/incus)
# -----------------------------------------------------------------
def get_containers_status(self) -> str: def get_containers_status(self) -> str:
"""Лёгкие контейнеры / инстансы (incus/virt, LXC-подобные).""" """Лёгкие контейнеры / инстансы (incus/virt, LXC-подобные)."""
raw = self._try_endpoints( raw = self._try_endpoints(
@@ -312,9 +887,14 @@ class Tools:
image = c.get("image", {}) image = c.get("image", {})
image_s = image.get("name", "N/A") if isinstance(image, dict) else image image_s = image.get("name", "N/A") if isinstance(image, dict) else image
icon = "🟢" if str(status).upper() == "RUNNING" else "" 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) return "\n".join(rows)
# -----------------------------------------------------------------
# Алерты
# -----------------------------------------------------------------
def get_active_alerts(self) -> str: def get_active_alerts(self) -> str:
raw = self._rest_get("alerts", "alert/list") raw = self._rest_get("alerts", "alert/list")
if raw is None: if raw is None:
@@ -335,17 +915,31 @@ class Tools:
rows.append(f"{imap.get(level, '')} [{level}] {klass}: {msg}") rows.append(f"{imap.get(level, '')} [{level}] {klass}: {msg}")
return "\n".join(rows) return "\n".join(rows)
# -----------------------------------------------------------------
# Полный отчёт
# -----------------------------------------------------------------
def get_full_report(self) -> str: def get_full_report(self) -> str:
return ( sections = [
"══════════════════════════════════════════════════\n" ("СИСТЕМА", self.get_system_status()),
"📋 ПОЛНЫЙ ОТЧЁТ TRUENAS\n" ("ПАМЯТЬ (RAM)", self.get_memory_info()),
"(REST перебор путей)\n" ("ТЕМПЕРАТУРА CPU", self.get_cpu_temperature()),
"══════════════════════════════════════════════════\n\n" ("ZFS ARC-КЭШ", self.get_arc_stats()),
"--- СИСТЕМА ---\n" + self.get_system_status() + "\n\n" ("ПУЛЫ ZFS", self.get_pools_status()),
"--- ПУЛЫ ZFS ---\n" + self.get_pools_status() + "\n\n" ("ДИСКИ", self.get_disks_temp_and_info()),
"--- ДИСКИ ---\n" + self.get_disks_temp_and_info() + "\n\n" ("ПРИЛОЖЕНИЯ", self.get_apps_status()),
"--- ПРИЛОЖЕНИЯ ---\n" + self.get_apps_status() + "\n\n" ("ВИРТУАЛЬНЫЕ МАШИНЫ", self.get_vms_status()),
"--- ВИРТУАЛЬНЫЕ МАШИНЫ ---\n" + self.get_vms_status() + "\n\n" ("КОНТЕЙНЕРЫ (virt/incus)", self.get_containers_status()),
"--- КОНТЕЙНЕРЫ (virt/incus) ---\n" + self.get_containers_status() + "\n\n" ("АЛЕРТЫ", self.get_active_alerts()),
"--- АЛЕРТЫ ---\n" + 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)