diff --git a/truenas_tools.py b/truenas_tools.py index 3a45d5d..7d3c971 100644 --- a/truenas_tools.py +++ b/truenas_tools.py @@ -1,39 +1,180 @@ - # v2.1.2: проверяем реально работающие эндпоинты TrueNAS 25.10 - # (многие получили новые имена — перебираем варианты) - checks = [ - ("system/info", "GET", None, "базовая информация о системе"), - ("alert/list", "GET", None, "список алертов"), - ("catalog", "GET", None, "список каталогов приложений"), - ("pool/query", "GET", None, "список пулов ZFS (v2)"), - ("pool", "GET", None, "список пулов ZFS (v1)"), - ("disk/query", "GET", None, "список дисков (v2)"), - ("disk", "GET", None, "список дисков (v1)"), - ("app/query", "GET", None, "список приложений (v2)"), - ("app", "GET", None, "список приложений (v1)"), - ("chart/release/query", "GET", None, "helm-релизы"), - ] - ok = 0 - checked: set = set() - for endpoint, method, payload, descr in checks: - if endpoint in checked: - continue - checked.add(endpoint) - url = f"{self._base}/api/v2.0/{endpoint}" - try: - if method == "GET": - r = requests.get(url, headers=self._headers, params=payload, timeout=self.valves.request_timeout, verify=self.valves.verify_ssl) - else: - r = requests.post(url, headers=self._headers, json=payload or {}, timeout=self.valves.request_timeout, verify=self.valves.verify_ssl) - if r.status_code == 200: - lines.append(f"✅ {endpoint:30s} — {descr}") - ok += 1 - elif r.status_code in (401, 403): - lines.append(f"🔑 {endpoint:30s} — 401/403 (нет прав)") - elif r.status_code == 404: - lines.append(f"❌ {endpoint:30s} — HTTP 404 (нет такого пути)") - elif r.status_code == 405: - lines.append(f"⚠️ {endpoint:30s} — HTTP 405 (другой метод)") - else: - lines.append(f"⚠️ {endpoint:30s} — HTTP {r.status_code}") - except Exception as e: - lines.append(f"💥 {endpoint:30s} — ошибка: {type(e).__name__}") \ No newline at end of file +"""Read-only инструменты мониторинга TrueNAS v2.0.""" +import json +import time +import requests +from typing import Any, Optional +from pydantic import BaseModel, Field + + +class Tools: + """Мониторинг TrueNAS через REST API. Изменяющие операции отсутствуют.""" + + class Valves(BaseModel): + TRUENAS_URL: str = Field(default="http://192.168.31.100", description="URL TrueNAS") + TRUENAS_API_KEY: str = Field(default="", description="API-ключ TrueNAS") + CACHE_TTL: int = Field(default=30, description="TTL кэша GET-запросов") + MEM_CACHE_TTL: int = Field(default=60, description="TTL кэша RAM") + verify_ssl: bool = Field(default=False, description="Проверять TLS") + request_timeout: int = Field(default=15, description="Таймаут запроса") + + def __init__(self): + self.valves = self.Valves() + self._cache = {} + self._last_error = "нет данных" + + @property + def _base(self): + return self.valves.TRUENAS_URL.rstrip("/") + + @property + def _headers(self): + 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 _get(self, key: str, endpoint: str, ttl: Optional[int] = None): + """Выполняет кэшируемый GET-запрос.""" + ttl = self.valves.CACHE_TTL if ttl is None else ttl + now = time.time() + if key in self._cache and ttl > 0 and now - self._cache[key][0] < ttl: + return self._cache[key][1] + try: + r = requests.get(f"{self._base}/api/v2.0/{endpoint}", headers=self._headers, timeout=self.valves.request_timeout, verify=self.valves.verify_ssl) + if r.status_code == 200: + value = r.json() + if ttl > 0: + self._cache[key] = (now, value) + return value + self._last_error = f"HTTP {r.status_code} на {endpoint}: {r.text[:300]}" + except Exception as e: + self._last_error = f"{type(e).__name__}: {e}" + return None + + def _first(self, name, endpoints): + for ep in endpoints: + value = self._get(f"{name}_{ep.replace('/', '_')}", ep) + if value is not None: + return value + return None + + @staticmethod + def _fmt(value: Any) -> str: + if value is None: + return "Н/Д" + try: + n = float(value) + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if abs(n) < 1024: + return f"{n:.1f} {unit}" + n /= 1024 + return f"{n:.1f} PiB" + except (TypeError, ValueError): + return str(value) + + def get_debug_info(self) -> str: + """Проверяет соединение с TrueNAS.""" + try: + r = requests.get(f"{self._base}/api/v2.0/system/info", headers=self._headers, timeout=self.valves.request_timeout, verify=False) + return f"URL: {self._base}\nКод ответа: {r.status_code}\nПоследняя ошибка: {self._last_error}" + except Exception as e: + return f"URL: {self._base}\nОшибка: {type(e).__name__}: {e}" + + def validate_api_key(self) -> str: + """Проверяет read-only доступ API-ключа.""" + if not self.valves.TRUENAS_API_KEY: + return "❌ API-ключ не задан" + checks = [("system/info", "система"), ("pool/query", "пулы"), ("disk/query", "диски"), ("app/query", "приложения"), ("alert/list", "алерты")] + out, ok = ["🔐 ВАЛИДАЦИЯ API-КЛЮЧА TRUENAS", ""], 0 + for ep, title in checks: + value = self._get(f"validate_{ep}", ep, 0) + if value is not None: + out.append(f"✅ {ep:15s} — {title}") + ok += 1 + else: + out.append(f"⚠️ {ep:15s} — недоступен") + out.append(f"\nИтого: {ok}/{len(checks)}") + return "\n".join(out) + + def get_memory_info(self) -> str: + """Показывает RAM через доступные API-эндпоинты.""" + data = self._first("memory", ["system/memory", "system/info", "system/get_info"]) + if not isinstance(data, dict): + return "💾 RAM: Н/Д (TrueNAS REST API не возвращает память в этой версии)" + total = data.get("total_memory") or data.get("memory_size") or data.get("memory") or data.get("total") + used = data.get("used") + return f"💾 RAM\n Всего: {self._fmt(total)}\n Использовано: {self._fmt(used)}" + + def get_system_status(self) -> str: + """Показывает системную информацию.""" + d = self._get("system", "system/info") + if not isinstance(d, dict): + return f"⚠️ Ошибка получения системы: {self._last_error}" + load = d.get("loadavg") or [] + return "\n".join([f"🖥️ TrueNAS: {d.get('version', 'N/A')}", f"🔧 Процессор: {d.get('model', 'N/A')}", f"📊 Load Average: {load}", self.get_memory_info()]) + + def get_pools_status(self) -> str: + """Показывает пулы ZFS.""" + pools = self._first("pools", ["pool/query", "pool", "storage/pool"]) + if not isinstance(pools, list): + return f"⚠️ Пулы недоступны: {self._last_error}" + rows = [] + for p in pools: + size, free = p.get("size", 0), p.get("free", 0) + used = (size - free) / size * 100 if size else 0 + rows.append(f"{'✅' if p.get('healthy') else '⚠️'} '{p.get('name', '?')}' | {p.get('status', '?')} | {used:.1f}% занято") + return "\n".join(rows) or "Пулы не найдены." + + def get_disks_temp_and_info(self) -> str: + """Показывает диски и температуры.""" + disks = self._first("disks", ["disk/query", "disk", "storage/disk"]) + if not isinstance(disks, list): + return f"⚠️ Диски недоступны: {self._last_error}" + return "\n".join(f"💾 {d.get('name', 'N/A')} | {d.get('model', '')} | {self._fmt(d.get('size'))} | {d.get('temperature', 'N/A')}°C" for d in disks) + + def get_apps_status(self) -> str: + """Показывает установленные приложения. Установка отсутствует.""" + apps = self._first("apps", ["app/query", "app", "chart/release/query"]) + if not isinstance(apps, list): + return f"⚠️ Приложения недоступны: {self._last_error}" + icons = {"RUNNING": "🟢", "STOPPED": "⚪", "DEPLOYING": "🟡", "CRASHED": "🔴"} + return "\n".join(f"{icons.get(str(a.get('state', a.get('status', 'N/A'))).upper(), '⚪')} {a.get('name', a.get('id', '?'))} | статус: {a.get('state', a.get('status', 'N/A'))} | версия: {a.get('human_version', a.get('version', 'N/A'))}" for a in apps) or "Приложения не установлены." + + def get_cpu_temperature(self) -> str: + """Показывает температуру CPU, если она есть в API.""" + d = self._get("temperature", "system/info") or {} + value = d.get("cpu_temperature") or d.get("cpu_temp") or d.get("temperature") + return f"🌡️ CPU: {value} °C" if value is not None else "🌡️ Температура CPU недоступна через API." + + def get_arc_stats(self) -> str: + """Показывает ZFS ARC, если он доступен через API.""" + d = self._first("arc", ["stats", "zfs/arc", "zfs/pool/stats", "pool/statistics"]) + return json.dumps(d, ensure_ascii=False, indent=2, default=str) if d else "📊 ARC-статистика недоступна через API." + + def get_vms_status(self) -> str: + """Показывает виртуальные машины.""" + vms = self._first("vms", ["vm/query", "vm"]) + if not isinstance(vms, list): + return f"⚠️ VM недоступны: {self._last_error}" + return "\n".join(f"{v.get('name', '?')} | {v.get('status', 'N/A')}" for v in vms) or "Виртуальные машины не найдены." + + def get_containers_status(self) -> str: + """Показывает контейнеры virt/incus.""" + items = self._first("containers", ["virt/instance", "virt/instance/query", "container/query"]) + if not isinstance(items, list): + return f"⚠️ Контейнеры недоступны: {self._last_error}" + return "\n".join(f"{c.get('name', c.get('id', '?'))} | {c.get('status', c.get('state', 'N/A'))}" for c in items) or "Контейнеры не найдены." + + def get_active_alerts(self) -> str: + """Показывает активные алерты.""" + alerts = self._get("alerts", "alert/list") or [] + active = [a for a in alerts if not a.get("dismissed", False)] + return "✅ Все системы работают штатно." if not active else "\n".join(f"[{a.get('level', 'INFO')}] {a.get('formatted', a.get('message', '?'))}" for a in active) + + def get_full_report(self) -> str: + """Формирует полный read-only отчёт.""" + sections = [("СИСТЕМА", self.get_system_status()), ("RAM", self.get_memory_info()), ("ТЕМПЕРАТУРА", self.get_cpu_temperature()), ("ARC", self.get_arc_stats()), ("ПУЛЫ", self.get_pools_status()), ("ДИСКИ", self.get_disks_temp_and_info()), ("ПРИЛОЖЕНИЯ", self.get_apps_status()), ("VM", self.get_vms_status()), ("КОНТЕЙНЕРЫ", self.get_containers_status()), ("АЛЕРТЫ", self.get_active_alerts())] + return "\n\n".join(f"--- {title} ---\n{content}" for title, content in sections)