1076 lines
55 KiB
Python
1076 lines
55 KiB
Python
"""
|
||
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): мониторинг + установка, запуск, остановка, удаление, обновление
|
||
• Custom App из docker-compose файла
|
||
• Каталог приложений (TRUENAS, TrueCharts, Community…): список и детали
|
||
• Виртуальные машины (libvirt/KVM)
|
||
• Контейнеры (virt/incus)
|
||
• Алерты TrueNAS
|
||
• Статистика ZFS ARC-кэша
|
||
• Диагностика соединения и валидация API-ключа
|
||
• Кэширование ответов с настраиваемым TTL (только для GET-запросов)
|
||
|
||
История версий:
|
||
v2.1 — управление приложениями: list_catalogs, list_app_catalog, get_app_info,
|
||
install_app (catalog), install_custom_app (compose), app_action, app_status,
|
||
wait_for_app, общий метод _rest_request (POST/PUT/DELETE)
|
||
v2.0 — добавлены RAM (multi-endpoint), температуры CPU/дисков, ARC, валидация API
|
||
v1.0 — базовый мониторинг (система, пулы, диски, VM, контейнеры, алерты)
|
||
"""
|
||
|
||
import json
|
||
import time
|
||
import requests
|
||
from typing import Dict, Optional, List, Any, Union
|
||
from pydantic import BaseModel, Field
|
||
|
||
|
||
class Tools:
|
||
"""
|
||
Мониторинг и управление TrueNAS через Open WebUI.
|
||
REST с перебором альтернативных путей.
|
||
TrueNAS 25.10 (Goldeye/Goliath).
|
||
"""
|
||
|
||
class Valves(BaseModel):
|
||
TRUENAS_URL: str = Field(
|
||
default="http://192.168.31.100",
|
||
description="IP-адрес или домен TrueNAS (http://... или https://...)",
|
||
)
|
||
TRUENAS_API_KEY: str = Field(
|
||
default="",
|
||
description="API-ключ для доступа к TrueNAS (Settings → API Keys). Для установки приложений нужен Full Access.",
|
||
)
|
||
CACHE_TTL: int = Field(
|
||
default=30,
|
||
description="Время кэширования ответов в секундах (0 = без кэша, только для GET).",
|
||
)
|
||
MEM_CACHE_TTL: int = Field(
|
||
default=60,
|
||
description="TTL для данных о RAM в секундах (RAM редко меняется).",
|
||
)
|
||
verify_ssl: bool = Field(
|
||
default=False,
|
||
description="Проверять TLS-сертификат TrueNAS",
|
||
)
|
||
request_timeout: int = Field(
|
||
default=15,
|
||
description="Таймаут обычных HTTP-запросов в секундах.",
|
||
)
|
||
install_timeout: int = Field(
|
||
default=900,
|
||
description="Таймаут операций установки/удаления приложений в секундах (по умолчанию 15 минут).",
|
||
)
|
||
poll_interval: int = Field(
|
||
default=5,
|
||
description="Интервал опроса состояния приложения (секунды) при wait_for_app.",
|
||
)
|
||
default_catalog: str = Field(
|
||
default="TRUENAS",
|
||
description="Каталог приложений по умолчанию (TRUENAS, TrueCharts, Community и т.д.).",
|
||
)
|
||
default_train: str = Field(
|
||
default="stable",
|
||
description="Канал (train) по умолчанию — stable, latest, enterprise и т.д.",
|
||
)
|
||
|
||
def __init__(self):
|
||
self.valves = self.Valves()
|
||
self._cache: Dict[str, tuple[float, str]] = {}
|
||
self._last_error = "нет данных (запросы ещё не выполнялись)"
|
||
try:
|
||
import urllib3
|
||
|
||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||
except Exception:
|
||
pass
|
||
|
||
@property
|
||
def _base(self) -> str:
|
||
return self.valves.TRUENAS_URL.rstrip("/")
|
||
|
||
@property
|
||
def _headers(self) -> dict:
|
||
return {
|
||
"Authorization": f"Bearer {self.valves.TRUENAS_API_KEY}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
|
||
def clear_cache(self) -> str:
|
||
n = len(self._cache)
|
||
self._cache.clear()
|
||
return f"🧹 Кэш очищен ({n} записей удалено)."
|
||
|
||
def _rest_get(self, cache_key: str, endpoint: str, cache_ttl: Optional[int] = None) -> Optional[str]:
|
||
ttl = cache_ttl if cache_ttl is not None else self.valves.CACHE_TTL
|
||
now = time.time()
|
||
if cache_key in self._cache and ttl > 0:
|
||
t, d = self._cache[cache_key]
|
||
if now - t < ttl:
|
||
return d
|
||
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:
|
||
data = r.json()
|
||
s = json.dumps(data, default=str)
|
||
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 _rest_request(self, method: str, endpoint: str, payload: Optional[dict] = None, timeout: Optional[int] = None) -> Optional[Any]:
|
||
method = method.upper()
|
||
if method not in ("POST", "PUT", "DELETE", "PATCH"):
|
||
self._last_error = f"_rest_request: неподдерживаемый метод {method}"
|
||
return None
|
||
url = f"{self._base}/api/v2.0/{endpoint}"
|
||
t = timeout if timeout is not None else self.valves.request_timeout
|
||
try:
|
||
r = requests.request(method, url, headers=self._headers, json=payload if payload is not None else {}, timeout=t, verify=self.valves.verify_ssl)
|
||
if r.status_code in (200, 201, 202, 204):
|
||
if not r.text:
|
||
return {"status": "ok", "http_code": r.status_code}
|
||
try:
|
||
return r.json()
|
||
except Exception:
|
||
return {"status": "ok", "text": r.text, "http_code": r.status_code}
|
||
self._last_error = f"{method} {endpoint} → HTTP {r.status_code}: {r.text[:400]}"
|
||
return None
|
||
except requests.exceptions.SSLError as e:
|
||
self._last_error = f"SSL-ошибка {method} {endpoint}: {e}"
|
||
return None
|
||
except requests.exceptions.ConnectionError as e:
|
||
self._last_error = f"Соединение {method} {endpoint}: {e}"
|
||
return None
|
||
except requests.exceptions.Timeout:
|
||
self._last_error = f"Таймаут {method} {endpoint} (> {t}с)"
|
||
return None
|
||
except Exception as e:
|
||
self._last_error = f"{method} {endpoint}: {type(e).__name__}: {e}"
|
||
return None
|
||
|
||
def _post(self, endpoint: str, payload: Optional[dict] = None, timeout: Optional[int] = None) -> Optional[Any]:
|
||
return self._rest_request("POST", endpoint, payload, timeout)
|
||
|
||
def _put(self, endpoint: str, payload: Optional[dict] = None, timeout: Optional[int] = None) -> Optional[Any]:
|
||
return self._rest_request("PUT", endpoint, payload, timeout)
|
||
|
||
def _delete(self, endpoint: str, payload: Optional[dict] = None, timeout: Optional[int] = None) -> Optional[Any]:
|
||
return self._rest_request("DELETE", endpoint, payload, timeout)
|
||
|
||
def _format_bytes(self, num_bytes: Any) -> str:
|
||
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:
|
||
lines = []
|
||
base = self._base
|
||
lines.append(f"🔧 TRUENAS_URL из настроек: {base}")
|
||
lines.append("🔑 API-ключ задан: " + ("да" if self.valves.TRUENAS_API_KEY else "❌ НЕТ — заполни Valves.TRUENAS_API_KEY"))
|
||
candidates = [base]
|
||
if base.startswith("http://"):
|
||
candidates.append("https://" + base[len("http://"):])
|
||
elif base.startswith("https://"):
|
||
candidates.append("http://" + base[len("https://"):])
|
||
for url in candidates:
|
||
lines.append(f"\n▶️ Проверка: {url}/api/v2.0/system/info")
|
||
try:
|
||
r = requests.get(f"{url}/api/v2.0/system/info", headers=self._headers, timeout=self.valves.request_timeout, verify=False)
|
||
lines.append(f" Код ответа: {r.status_code}")
|
||
if r.status_code == 200:
|
||
lines.append(" ✅ Успех! Обнови TRUENAS_URL на этот вариант, если он отличается от текущего.")
|
||
elif r.status_code in (401, 403):
|
||
lines.append(" 🔑 Ошибка авторизации — проверь API-ключ (Settings → API Keys) и что он активен.")
|
||
else:
|
||
lines.append(f" ⚠️ Тело ответа (первые 300 симв.): {r.text[:300]}")
|
||
except requests.exceptions.SSLError as e:
|
||
lines.append(f" 🔒 Ошибка SSL: {e}")
|
||
except requests.exceptions.ConnectionError as e:
|
||
lines.append(f" 🔌 Ошибка соединения: {e}")
|
||
except requests.exceptions.Timeout:
|
||
lines.append(" ⏱️ Таймаут — сервер не отвечает за 10 секунд.")
|
||
except Exception as e:
|
||
lines.append(f" ❓ Прочая ошибка: {type(e).__name__}: {e}")
|
||
lines.append("\nℹ️ Последняя зафиксированная ошибка внутренних запросов: " + getattr(self, "_last_error", "нет данных"))
|
||
return "\n".join(lines)
|
||
|
||
def validate_api_key(self) -> str:
|
||
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", "список алертов"),
|
||
("catalog", "список каталогов приложений"),
|
||
]
|
||
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)} эндпоинтов доступны.")
|
||
try:
|
||
r = requests.post(f"{self._base}/api/v2.0/app/available", headers=self._headers, json={}, timeout=self.valves.request_timeout, verify=self.valves.verify_ssl)
|
||
if r.status_code in (200, 201):
|
||
lines.append("🎉 POST app/available — ключ работает на запись (можно ставить приложения).")
|
||
elif r.status_code in (401, 403):
|
||
lines.append("🔑 POST app/available → 401/403 — у ключа нет прав на установку приложений (Read Only?).")
|
||
else:
|
||
lines.append(f"⚠️ POST app/available → HTTP {r.status_code} — нужен Full Access ключ для управления приложениями.")
|
||
except Exception as e:
|
||
lines.append(f"⚠️ POST app/available — ошибка: {type(e).__name__}")
|
||
if ok == len(checks):
|
||
lines.append("🎉 API-ключ валиден и имеет все необходимые права на чтение.")
|
||
elif ok == 0:
|
||
lines.append("❌ API-ключ не работает или сервер недоступен.")
|
||
else:
|
||
lines.append("⚠️ Частичный доступ — проверьте права ключа (Read Only / Full Access).")
|
||
return "\n".join(lines)
|
||
|
||
_MEM_TOTAL_ENDPOINTS = [
|
||
"system/get_info",
|
||
"system/info",
|
||
"system/memory",
|
||
"hardware",
|
||
"hardware/info",
|
||
"system/advanced",
|
||
]
|
||
_MEM_TOTAL_KEYS = [
|
||
"memory_size", "memory", "total_memory", "memory_total",
|
||
"total_ram", "physmem", "phys_mem", "total",
|
||
]
|
||
_MEM_DETAIL_ENDPOINTS = ["stats", "reporting/get_data", "system/memory"]
|
||
|
||
def _get_memory_info(self) -> Dict[str, Any]:
|
||
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,
|
||
}
|
||
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
|
||
if result["total_bytes"]:
|
||
result["total_human"] = self._format_bytes(result["total_bytes"])
|
||
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
|
||
for k in ["used", "free", "available"]:
|
||
if result[f"{k}_bytes"]:
|
||
result[f"{k}_human"] = self._format_bytes(result[f"{k}_bytes"])
|
||
if result["swap_total"]:
|
||
result["swap_total_human"] = self._format_bytes(result["swap_total"])
|
||
return result
|
||
|
||
def _to_bytes(self, val: Any) -> Optional[int]:
|
||
if val is None:
|
||
return None
|
||
if isinstance(val, (int, float)):
|
||
n = float(val)
|
||
if 0 < n < 1024 * 1024:
|
||
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:
|
||
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:
|
||
lines.append(f" Swap исп.: {self._format_bytes(m['swap_used'])}")
|
||
if m["source_endpoint"]:
|
||
lines.append(f"\n 📡 Источник: эндпоинт `{m['source_endpoint']}` (поле `{m['source_field']}`)")
|
||
else:
|
||
lines.append("\n ⚠️ RAM не удалось получить ни из одного эндпоинта.")
|
||
return "\n".join(lines)
|
||
|
||
def get_cpu_temperature(self) -> str:
|
||
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_temp = item.get("cpu_temperature") or item.get("cpu_temp") or item.get("temperature")
|
||
if cpu_temp is not None:
|
||
found_any = True
|
||
try:
|
||
lines.append(f" 🌡️ CPU: {float(cpu_temp):.1f} °C (эндпоинт {ep})")
|
||
except (TypeError, ValueError):
|
||
lines.append(f" 🌡️ CPU: {cpu_temp} (эндпоинт {ep})")
|
||
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:
|
||
lines.append(f" 🌡️ {name}: {float(val):.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:
|
||
lines.append(f" 🌡️ {name}: {float(val):.1f} °C")
|
||
found_any = True
|
||
except (TypeError, ValueError):
|
||
lines.append(f" 🌡️ {name}: {val}")
|
||
found_any = True
|
||
if not found_any:
|
||
lines.append(" ⚠️ Температура CPU недоступна через API.")
|
||
return "\n".join(lines)
|
||
|
||
def get_arc_stats(self) -> str:
|
||
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-статистика недоступна через эти эндпоинты.")
|
||
return "\n".join(lines)
|
||
|
||
def get_system_status(self) -> str:
|
||
raw = self._rest_get("info", "system/info")
|
||
if raw is None:
|
||
return "⚠️ Ошибка получения информации о системе"
|
||
try:
|
||
d = json.loads(raw)
|
||
except Exception:
|
||
return "Ошибка парсинга"
|
||
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_temp_s = ""
|
||
cpu_temp = d.get("cpu_temperature") or d.get("cpu_temp") or d.get("temperature")
|
||
if cpu_temp is not None:
|
||
try:
|
||
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")
|
||
load = d.get("loadavg", [])
|
||
load_s = (f"1мин: {load[0]:.2f}, 5мин: {load[1]:.2f}, 15мин: {load[2]:.2f}" if len(load) >= 3 else str(load) if load else "Н/Д")
|
||
up = d.get("uptime_seconds")
|
||
if up:
|
||
dd, r = divmod(int(up), 86400)
|
||
hh, r = divmod(r, 3600)
|
||
mm, _ = divmod(r, 60)
|
||
up_s = f"{dd}д {hh}ч {mm}м"
|
||
else:
|
||
up_s = d.get("uptime_string", "Н/Д")
|
||
return f"🖥️ TrueNAS: {ver}\n⏱️ Uptime: {up_s}\n🔧 Процессор: {model}{cpu_temp_s}\n📊 Load Average: {load_s}\n💾 RAM: {ram_s}"
|
||
|
||
def get_pools_status(self) -> str:
|
||
raw = self._try_endpoints("pools", ["pool/query", "pool", "storage/pool"])
|
||
if raw.startswith("⚠️"):
|
||
return raw
|
||
try:
|
||
pools = json.loads(raw)
|
||
except Exception:
|
||
return "Ошибка парсинга"
|
||
if not pools:
|
||
return "Активные пулы не найдены."
|
||
rows = []
|
||
for p in pools:
|
||
name = p.get("name", "?")
|
||
status = p.get("status", "?")
|
||
icon = "✅" if p.get("healthy", False) else "⚠️"
|
||
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}% занято{frag_s}")
|
||
return "\n".join(rows)
|
||
|
||
def get_disks_temp_and_info(self) -> str:
|
||
raw = self._try_endpoints("disks", ["disk/query", "disk", "storage/disk"])
|
||
if raw.startswith("⚠️"):
|
||
return raw
|
||
try:
|
||
disks = json.loads(raw)
|
||
except Exception:
|
||
return "Ошибка парсинга"
|
||
if not disks:
|
||
return "Диски не обнаружены."
|
||
rows = []
|
||
for d in disks:
|
||
name = d.get("name", "N/A")
|
||
dtype = d.get("type", "N/A")
|
||
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", "")
|
||
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)
|
||
|
||
def get_apps_status(self) -> str:
|
||
raw = self._try_endpoints("apps", ["app/query", "app", "chart/release/query"])
|
||
if raw.startswith("⚠️"):
|
||
return raw
|
||
try:
|
||
apps = json.loads(raw)
|
||
except Exception:
|
||
return "Ошибка парсинга"
|
||
if not apps:
|
||
return "Приложения не установлены."
|
||
imap = {"RUNNING":"🟢","STOPPED":"⚪","DEPLOYING":"🟡","CRASHED":"🔴","STOPPING":"🟠"}
|
||
rows = []
|
||
for a in apps:
|
||
name = a.get("name", a.get("id", "?"))
|
||
state = a.get("state", a.get("status", "N/A"))
|
||
version = a.get("human_version", a.get("version", "N/A"))
|
||
icon = imap.get(str(state).upper(), "⚪")
|
||
rows.append(f"{icon} {name} | статус: {state} | версия: {version}")
|
||
return "\n".join(rows)
|
||
|
||
def get_vms_status(self) -> str:
|
||
raw = self._try_endpoints("vms", ["vm/query", "vm"])
|
||
if raw.startswith("⚠️"):
|
||
return raw
|
||
try:
|
||
vms = json.loads(raw)
|
||
except Exception:
|
||
return "Ошибка парсинга"
|
||
if not vms:
|
||
return "Виртуальные машины не найдены."
|
||
rows = []
|
||
for vm in vms:
|
||
name = vm.get("name", "?")
|
||
status = vm.get("status", {})
|
||
state = status.get("state", "N/A") if isinstance(status, dict) else status
|
||
vcpus = vm.get("vcpus", "N/A")
|
||
memory = vm.get("memory")
|
||
mem_s = f"{int(memory) / 1024:.0f} MB" if memory else "N/A"
|
||
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}")
|
||
return "\n".join(rows)
|
||
|
||
def get_containers_status(self) -> str:
|
||
raw = self._try_endpoints("containers", ["virt/instance", "virt/instance/query", "container/query"])
|
||
if raw.startswith("⚠️"):
|
||
return raw
|
||
try:
|
||
instances = json.loads(raw)
|
||
except Exception:
|
||
return "Ошибка парсинга"
|
||
if not instances:
|
||
return "Контейнеры не найдены."
|
||
rows = []
|
||
for c in instances:
|
||
name = c.get("name", c.get("id", "?"))
|
||
status = c.get("status", c.get("state", "N/A"))
|
||
itype = c.get("type", "N/A")
|
||
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}")
|
||
return "\n".join(rows)
|
||
|
||
def get_active_alerts(self) -> str:
|
||
raw = self._rest_get("alerts", "alert/list")
|
||
if raw is None:
|
||
return "⚠️ Ошибка получения алертов"
|
||
try:
|
||
alerts = json.loads(raw)
|
||
except Exception:
|
||
return "Ошибка парсинга"
|
||
active = [a for a in alerts if not a.get("dismissed", False)]
|
||
if not active:
|
||
return "✅ Все системы работают штатно."
|
||
rows = []
|
||
imap = {"CRITICAL":"🔴","WARNING":"🟡","INFO":"🔵"}
|
||
for a in active:
|
||
level = a.get("level", "INFO")
|
||
klass = a.get("klass", "Alert")
|
||
msg = a.get("formatted", a.get("message", "?"))
|
||
rows.append(f"{imap.get(level, '⚪')} [{level}] {klass}: {msg}")
|
||
return "\n".join(rows)
|
||
|
||
# -----------------------------------------------------------------
|
||
# Каталоги и приложения (v2.1+)
|
||
# -----------------------------------------------------------------
|
||
def list_catalogs(self) -> str:
|
||
raw = self._try_endpoints("catalogs", ["catalog", "catalog/list"])
|
||
if raw.startswith("⚠️"):
|
||
return raw
|
||
try:
|
||
catalogs = json.loads(raw)
|
||
except Exception:
|
||
return "Ошибка парсинга"
|
||
if not catalogs:
|
||
return "Каталоги не найдены."
|
||
rows = ["📚 КАТАЛОГИ ПРИЛОЖЕНИЙ TRUENAS", ""]
|
||
items = catalogs if isinstance(catalogs, list) else list(catalogs.values() if isinstance(catalogs, dict) else [])
|
||
for c in items:
|
||
if not isinstance(c, dict):
|
||
continue
|
||
label = c.get("label") or c.get("name") or "?"
|
||
ttype = c.get("type", "")
|
||
trains = c.get("trains") or c.get("preferred_trains") or []
|
||
trains_s = ", ".join(str(t) for t in trains) if isinstance(trains, list) and trains else "stable"
|
||
repo = c.get("repository") or c.get("branch") or "main"
|
||
healthy = "✅" if c.get("healthy", True) else "⚠️"
|
||
rows.append(f"{healthy} {label} | тип: {ttype} | trains: {trains_s} | repo: {repo}")
|
||
if len(rows) == 1:
|
||
return "Каталоги не распознаны (формат ответа изменился)."
|
||
return "\n".join(rows)
|
||
|
||
def list_app_catalog(self, catalog: Optional[str] = None, category: str = "", search: str = "", limit: int = 100) -> str:
|
||
catalog = catalog or self.valves.default_catalog
|
||
lines = [f"📦 КАТАЛОГ ПРИЛОЖЕНИЙ: {catalog}", ""]
|
||
payload: Dict[str, Any] = {"catalog_name": catalog, "retrieve_all_trains": True}
|
||
if category:
|
||
payload["categories"] = [category]
|
||
endpoints = ["app/available", "catalog/items", "chart/release/names"]
|
||
apps: List[Dict[str, Any]] = []
|
||
used_ep = None
|
||
for ep in endpoints:
|
||
res = self._post(ep, payload, timeout=self.valves.request_timeout)
|
||
if res is None:
|
||
continue
|
||
if isinstance(res, list):
|
||
apps = res
|
||
used_ep = ep
|
||
break
|
||
if isinstance(res, dict):
|
||
for key in ("items", "applications", "releases", "apps"):
|
||
if key in res and isinstance(res[key], list):
|
||
apps = res[key]
|
||
used_ep = ep
|
||
break
|
||
if apps:
|
||
break
|
||
if not apps:
|
||
fallback_raw = self._rest_get(f"catalog_items_{catalog}_{category}", f"catalog?label={catalog}")
|
||
if fallback_raw:
|
||
try:
|
||
data = json.loads(fallback_raw)
|
||
if isinstance(data, dict):
|
||
for v in data.values():
|
||
if isinstance(v, dict) and "items" in v:
|
||
apps = v["items"]
|
||
used_ep = "catalog?label="
|
||
break
|
||
except Exception:
|
||
pass
|
||
if not apps:
|
||
return f"⚠️ Не удалось получить каталог {catalog}.\nПоследняя ошибка: {self._last_error}"
|
||
search_lc = search.lower().strip() if search else ""
|
||
shown = []
|
||
for a in apps:
|
||
if not isinstance(a, dict):
|
||
continue
|
||
name = a.get("name") or a.get("app_name") or a.get("id") or "?"
|
||
title = a.get("title") or a.get("name") or a.get("app_name") or "?"
|
||
descr = a.get("description") or a.get("summary") or a.get("short_description") or ""
|
||
cats = a.get("categories") or a.get("category") or []
|
||
if isinstance(cats, str):
|
||
cats = [cats]
|
||
if search_lc:
|
||
haystack = f"{name} {title} {descr} {' '.join(cats)}".lower()
|
||
if search_lc not in haystack:
|
||
continue
|
||
if category:
|
||
if isinstance(cats, list) and category.lower() not in [c.lower() for c in cats]:
|
||
continue
|
||
shown.append({
|
||
"name": name,
|
||
"title": title,
|
||
"version": a.get("human_version") or a.get("latest_version") or a.get("version") or "?",
|
||
"categories": cats,
|
||
"description": descr[:160] + ("…" if len(descr) > 160 else ""),
|
||
})
|
||
if len(shown) >= limit:
|
||
break
|
||
lines.append(f"📡 Источник: {used_ep}")
|
||
lines.append(f"📊 Всего в каталоге: {len(apps)} | показано: {len(shown)}")
|
||
lines.append("")
|
||
for a in shown:
|
||
cats_s = ", ".join(a["categories"][:3]) if a["categories"] else "—"
|
||
lines.append(f"• {a['name']} (v{a['version']}) [{cats_s}]")
|
||
if a["title"] and a["title"] != a["name"]:
|
||
lines.append(f" {a['title']}")
|
||
if a["description"]:
|
||
lines.append(f" {a['description']}")
|
||
if not shown:
|
||
lines.append("⚠️ По фильтру ничего не найдено.")
|
||
return "\n".join(lines)
|
||
|
||
def get_app_info(self, app_name: str, catalog: Optional[str] = None, train: Optional[str] = None) -> str:
|
||
if not app_name:
|
||
return "❌ Ошибка: app_name обязателен."
|
||
catalog = catalog or self.valves.default_catalog
|
||
train = train or self.valves.default_train
|
||
lines = [f"🔍 ИНФОРМАЦИЯ О ПРИЛОЖЕНИИ: {app_name}", ""]
|
||
lines.append(f"📚 Каталог: {catalog} | 🚂 Train: {train}")
|
||
lines.append("")
|
||
info_payload: Dict[str, Any] = {"catalog_name": catalog, "app_name": app_name, "train": train}
|
||
info = self._post("app/info", info_payload, timeout=self.valves.request_timeout)
|
||
if info is None:
|
||
info = self._post("catalog/item", info_payload, timeout=self.valves.request_timeout)
|
||
if info is None:
|
||
lines.append(f"⚠️ Не удалось получить информацию: {self._last_error}")
|
||
return "\n".join(lines)
|
||
title = info.get("title") or info.get("name") or app_name
|
||
descr = info.get("description") or info.get("summary") or ""
|
||
ver = info.get("human_version") or info.get("version") or info.get("latest_version") or "?"
|
||
cats = info.get("categories") or []
|
||
if isinstance(cats, str):
|
||
cats = [cats]
|
||
home = info.get("home") or info.get("home_url") or info.get("url") or ""
|
||
icon = info.get("icon_url") or info.get("icon") or ""
|
||
maintainer = (info.get("maintainers", [{}])[0].get("name") if isinstance(info.get("maintainers"), list) and info.get("maintainers") else info.get("maintainer", ""))
|
||
lines.append(f"📛 Title: {title}")
|
||
lines.append(f"📦 Версия: {ver}")
|
||
if cats:
|
||
lines.append(f"🗂 Категории: {', '.join(cats)}")
|
||
if maintainer:
|
||
lines.append(f"👤 Maintainer: {maintainer}")
|
||
if home:
|
||
lines.append(f"🌐 Дом. страница: {home}")
|
||
if icon:
|
||
lines.append(f"🖼 Иконка: {icon}")
|
||
if descr:
|
||
lines.append("")
|
||
lines.append(f"📝 Описание: {descr}")
|
||
lines.append("")
|
||
params_payload: Dict[str, Any] = {
|
||
"app_name": app_name,
|
||
"catalog_name": catalog,
|
||
"train": train,
|
||
"version": info.get("version") or info.get("latest_version"),
|
||
}
|
||
params = self._post("app/get_install_params", params_payload, timeout=self.valves.request_timeout)
|
||
if params and isinstance(params, dict):
|
||
lines.append("⚙️ ПАРАМЕТРЫ УСТАНОВКИ (values)")
|
||
lines.append("─" * 40)
|
||
schema = params.get("schema") or params.get("questions") or params.get("schema_details") or params
|
||
self._describe_schema(schema, lines, indent=0, max_depth=3)
|
||
lines.append("")
|
||
lines.append("💡 Подсказка: передай нужные значения в `values_json` метода install_app в виде JSON-строки. Например:")
|
||
lines.append(' values_json = \'{"service": {"web_port": 8080}}\'')
|
||
else:
|
||
lines.append("ℹ️ Параметры установки недоступны (приложение может быть уже установлено или каталог не синхронизирован).")
|
||
return "\n".join(lines)
|
||
|
||
def _describe_schema(self, schema: Any, lines: List[str], indent: int = 0, max_depth: int = 3) -> None:
|
||
pad = " " * indent
|
||
if isinstance(schema, dict):
|
||
for key, val in schema.items():
|
||
if key.startswith("$") or key in ("attrs", "additionalProperties"):
|
||
continue
|
||
if isinstance(val, dict):
|
||
lines.append(f"{pad}• {key}:")
|
||
if indent < max_depth:
|
||
self._describe_schema(val, lines, indent + 1, max_depth)
|
||
elif isinstance(val, list):
|
||
lines.append(f"{pad}• {key}: (список из {len(val)})")
|
||
if val and indent < max_depth:
|
||
sample = val[0]
|
||
if isinstance(sample, dict):
|
||
self._describe_schema(sample, lines, indent + 1, max_depth)
|
||
else:
|
||
lines.append(f"{pad}• {key} = {val}")
|
||
elif isinstance(schema, list):
|
||
for i, item in enumerate(schema[:5]):
|
||
if isinstance(item, dict):
|
||
self._describe_schema(item, lines, indent, max_depth)
|
||
lines.append(" ...")
|
||
|
||
def install_app(self, app_name: str, catalog: Optional[str] = None, train: Optional[str] = None, version: str = "", values_json: str = "{}", confirm: bool = False) -> str:
|
||
if not app_name:
|
||
return "❌ Ошибка: app_name обязателен."
|
||
catalog = catalog or self.valves.default_catalog
|
||
train = train or self.valves.default_train
|
||
try:
|
||
values = json.loads(values_json) if values_json else {}
|
||
if not isinstance(values, dict):
|
||
return "❌ Ошибка: values_json должен быть JSON-объектом (начинаться с '{')."
|
||
except json.JSONDecodeError as e:
|
||
return f"❌ Ошибка парсинга values_json: {e}"
|
||
payload: Dict[str, Any] = {"app_name": app_name, "catalog_name": catalog, "train": train, "values": values}
|
||
if version:
|
||
payload["version"] = version
|
||
if not confirm:
|
||
lines = [
|
||
"🔍 DRY-RUN: установка НЕ будет выполнена",
|
||
"",
|
||
f"📦 Приложение: {app_name}",
|
||
f"📚 Каталог: {catalog}",
|
||
f"🚂 Train: {train}",
|
||
f"🏷 Версия: {version or 'latest'}",
|
||
f"⚙️ Values: {json.dumps(values, ensure_ascii=False, indent=2) if values else '(по умолчанию)'}",
|
||
"",
|
||
"Для реальной установки вызови метод повторно с confirm=True.",
|
||
]
|
||
return "\n".join(lines)
|
||
res = self._post("app/install", payload, timeout=self.valves.install_timeout)
|
||
if res is None:
|
||
return f"❌ Ошибка установки {app_name}.\nПоследняя ошибка: {self._last_error}"
|
||
job_id = res if isinstance(res, str) else (res.get("job_id") if isinstance(res, dict) else None)
|
||
if not job_id:
|
||
job_id = "неизвестно"
|
||
self._cache.pop("apps", None)
|
||
for k in list(self._cache.keys()):
|
||
if k.startswith("apps_"):
|
||
self._cache.pop(k, None)
|
||
return f"✅ OK: установка {app_name} запущена!\n Каталог: {catalog}, train: {train}, версия: {version or 'latest'}\n Job ID: {job_id}\n ⏱ Установка может занять несколько минут (тяжёлые образы — до 15 минут).\n 👉 Проверить состояние: app_status('{app_name}') или get_apps_status()."
|
||
|
||
def install_custom_app(self, app_name: str, compose_yaml: str, env_json: str = "{}", confirm: bool = False) -> str:
|
||
if not app_name:
|
||
return "❌ Ошибка: app_name обязателен."
|
||
if not compose_yaml or "services:" not in compose_yaml:
|
||
return "❌ Ошибка: compose_yaml должен содержать секцию 'services:'."
|
||
try:
|
||
env = json.loads(env_json) if env_json else {}
|
||
if not isinstance(env, dict):
|
||
return "❌ Ошибка: env_json должен быть JSON-объектом."
|
||
except json.JSONDecodeError as e:
|
||
return f"❌ Ошибка парсинга env_json: {e}"
|
||
if not confirm:
|
||
lines = [
|
||
"🔍 DRY-RUN: установка Custom App НЕ будет выполнена",
|
||
"",
|
||
f"📦 Имя: {app_name}",
|
||
f"📝 Compose (первые 800 симв.):",
|
||
compose_yaml[:800] + ("…" if len(compose_yaml) > 800 else ""),
|
||
f"🌍 Env: {json.dumps(env, ensure_ascii=False, indent=2) if env else '(нет)'}",
|
||
"",
|
||
"Для реальной установки вызови метод повторно с confirm=True.",
|
||
]
|
||
return "\n".join(lines)
|
||
payload: Dict[str, Any] = {"app_name": app_name, "compose_string": compose_yaml}
|
||
if env:
|
||
payload["values"] = env
|
||
res = self._post("app/compose_install", payload, timeout=self.valves.install_timeout)
|
||
if res is None:
|
||
try:
|
||
files = {"compose": (f"{app_name}.yml", compose_yaml, "application/x-yaml")}
|
||
r = requests.post(
|
||
f"{self._base}/api/v2.0/app/compose_install",
|
||
headers={"Authorization": f"Bearer {self.valves.TRUENAS_API_KEY}"},
|
||
data={"app_name": app_name, **({"values": json.dumps(env)} if env else {})},
|
||
files=files,
|
||
timeout=self.valves.install_timeout,
|
||
verify=self.valves.verify_ssl,
|
||
)
|
||
if r.status_code in (200, 201, 202):
|
||
try:
|
||
res = r.json()
|
||
except Exception:
|
||
res = {"status": "ok", "text": r.text}
|
||
else:
|
||
self._last_error = f"POST compose_install (form): HTTP {r.status_code}: {r.text[:300]}"
|
||
res = None
|
||
except Exception as e:
|
||
self._last_error = f"compose_install fallback: {type(e).__name__}: {e}"
|
||
res = None
|
||
if res is None:
|
||
return f"❌ Ошибка установки Custom App '{app_name}'.\nПоследняя ошибка: {self._last_error}"
|
||
self._cache.pop("apps", None)
|
||
for k in list(self._cache.keys()):
|
||
if k.startswith("apps_"):
|
||
self._cache.pop(k, None)
|
||
return f"✅ OK: Custom App '{app_name}' поставлен на установку!\n ⏱ Развёртывание может занять 1–5 минут.\n 👉 Проверить состояние: app_status('{app_name}') или get_apps_status()."
|
||
|
||
def app_action(self, app_name: str, action: str, confirm: bool = False) -> str:
|
||
if not app_name:
|
||
return "❌ Ошибка: app_name обязателен."
|
||
action_l = action.lower().strip()
|
||
dangerous = {"delete", "upgrade"}
|
||
if action_l in dangerous and not confirm:
|
||
return f"⚠️ '{action_l}' — потенциально разрушительная операция.\n Для подтверждения вызови с confirm=True."
|
||
if action_l not in {"start","stop","restart","delete","upgrade","redeploy"}:
|
||
return f"❌ Неизвестное действие: {action}. Допустимо: start, stop, restart, delete, upgrade, redeploy."
|
||
endpoints = {
|
||
"start": ("POST", f"app/{app_name}/start"),
|
||
"stop": ("POST", f"app/{app_name}/stop"),
|
||
"restart": ("POST", f"app/{app_name}/restart"),
|
||
"redeploy": ("POST", f"app/{app_name}/redeploy"),
|
||
"delete": ("DELETE", f"app/{app_name}"),
|
||
"upgrade": ("POST", f"app/{app_name}/upgrade"),
|
||
}
|
||
method, ep = endpoints[action_l]
|
||
if method == "DELETE":
|
||
res = self._delete(ep, timeout=self.valves.install_timeout)
|
||
else:
|
||
res = self._post(ep, timeout=self.valves.install_timeout)
|
||
if res is None:
|
||
return f"❌ Ошибка: {action_l} '{app_name}' не выполнено.\n {self._last_error}"
|
||
self._cache.pop("apps", None)
|
||
for k in list(self._cache.keys()):
|
||
if k.startswith("apps_"):
|
||
self._cache.pop(k, None)
|
||
icons = {"start":"▶️","stop":"⏹","restart":"🔄","redeploy":"♻️","delete":"🗑","upgrade":"⬆️"}
|
||
verbs = {"start":"запущено","stop":"остановлено","restart":"перезапущено","redeploy":"переразвёрнуто","delete":"удалено","upgrade":"обновлено"}
|
||
return f"{icons.get(action_l, '✓')} OK: '{app_name}' → {verbs[action_l]}\n Ответ API: {json.dumps(res, default=str)[:300]}"
|
||
|
||
def app_status(self, app_name: str) -> str:
|
||
if not app_name:
|
||
return "❌ Ошибка: app_name обязателен."
|
||
cfg = self._post(f"app/{app_name}/config", {}, timeout=self.valves.request_timeout)
|
||
info = self._post(f"app/{app_name}", {}, timeout=self.valves.request_timeout)
|
||
if cfg is None and info is None:
|
||
return f"❌ Не удалось получить статус '{app_name}'. {self._last_error}"
|
||
lines = [f"📊 СТАТУС ПРИЛОЖЕНИЯ: {app_name}", ""]
|
||
state = "N/A"
|
||
workloads = None
|
||
if isinstance(info, dict):
|
||
state = info.get("state") or info.get("status") or (info.get("status_details", {}) or {}).get("state", "N/A")
|
||
workloads = info.get("workloads") or info.get("used_ports")
|
||
imap = {"RUNNING":"🟢","STOPPED":"⚪","DEPLOYING":"🟡","CRASHED":"🔴","STOPPING":"🟠"}
|
||
lines.append(f"🚦 Состояние: {imap.get(str(state).upper(), '⚪')} {state}")
|
||
version = (info or {}).get("human_version") or (info or {}).get("version") or "N/A"
|
||
lines.append(f"🏷 Версия: {version}")
|
||
if workloads:
|
||
lines.append("🌐 Порты / пробросы:")
|
||
for w in workloads[:10]:
|
||
if isinstance(w, dict):
|
||
port = w.get("port") or w.get("container_port")
|
||
host = w.get("host_port") or w.get("node_port")
|
||
proto = w.get("protocol", "TCP")
|
||
lines.append(f" • {port}/{proto} → {host}")
|
||
if isinstance(cfg, dict):
|
||
lines.append("")
|
||
lines.append("⚙️ Ключевые настройки:")
|
||
for key in ("image","image_tag","namespace","release","service","storage","ingress"):
|
||
if key in cfg:
|
||
lines.append(f" • {key}: {json.dumps(cfg[key], default=str)[:200]}")
|
||
return "\n".join(lines)
|
||
|
||
def wait_for_app(self, app_name: str, desired_state: str = "RUNNING", timeout: int = 600, poll: Optional[int] = None) -> str:
|
||
if not app_name:
|
||
return "❌ Ошибка: app_name обязателен."
|
||
desired = desired_state.upper()
|
||
interval = poll if poll is not None else self.valves.poll_interval
|
||
deadline = time.time() + timeout
|
||
last_state = None
|
||
attempts = 0
|
||
lines = [f"⏳ Ждём, пока '{app_name}' не перейдёт в {desired} (до {timeout}с)…"]
|
||
while time.time() < deadline:
|
||
attempts += 1
|
||
info = self._post(f"app/{app_name}", {}, timeout=self.valves.request_timeout)
|
||
state = "N/A"
|
||
if isinstance(info, dict):
|
||
state = info.get("state") or info.get("status") or (info.get("status_details", {}) or {}).get("state", "N/A")
|
||
last_state = state
|
||
if str(state).upper() == desired:
|
||
lines.append(f"✅ Готово: '{app_name}' = {state} (попытка #{attempts}, {int(time.time() - (deadline - timeout))}с)")
|
||
return "\n".join(lines)
|
||
if str(state).upper() == "CRASHED":
|
||
lines.append(f"❌ Приложение в CRASHED на попытке #{attempts}. Прерываю.")
|
||
return "\n".join(lines)
|
||
time.sleep(interval)
|
||
lines.append(f"⏱ Таймаут: '{app_name}' всё ещё в состоянии '{last_state}' после {timeout}с ({attempts} опросов).")
|
||
return "\n".join(lines)
|
||
|
||
def get_full_report(self) -> str:
|
||
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.list_catalogs()),
|
||
("ПРИЛОЖЕНИЯ", 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) |