Files
open-webui/tool-truenas-export-1785705163085.json
T

1 line
45 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
[{"id":"truenas","user_id":"a9b3bc2c-071d-44d2-b6a3-7188033e0a66","name":"truenas ","meta":{"description":"open-webui-monitoring","manifest":{},"has_user_valves":false},"access_grants":[],"updated_at":1785584532,"created_at":1784063265,"user":null,"write_access":true,"content":"\"\"\"\ntruenas_tools.py — мониторинг TrueNAS через Open WebUI.\n\nПолный набор инструментов для интеграции TrueNAS 25.10 (Goldeye/Goliath)\nв Open WebUI в виде пользовательского Tool.\n\nВозможности:\n • Системная информация (версия, uptime, CPU, load average, RAM, температура CPU)\n • Детальная информация о RAM с перебором нескольких эндпоинтов API\n • Пулы ZFS (статус, занятость, фрагментация)\n • Диски (модель, серийник, объём, температура SMART)\n • Docker-приложения (TrueNAS Apps)\n • Виртуальные машины (libvirt/KVM)\n • Контейнеры (virt/incus)\n • Алерты TrueNAS\n • Статистика ZFS ARC-кэша\n • Диагностика соединения и валидация API-ключа\n • Кэширование ответов с настраиваемым TTL\n\nИстория версий:\n v2.0 — добавлены RAM (multi-endpoint), температуры CPU/дисков, ARC, валидация API\n v1.0 — базовый мониторинг (система, пулы, диски, VM, контейнеры, алерты)\n\"\"\"\n\nimport json\nimport time\nimport requests\nfrom typing import Dict, Optional, List, Any\nfrom pydantic import BaseModel, Field\n\n\nclass Tools:\n \"\"\"\n Мониторинг TrueNAS через Open WebUI.\n REST с перебором альтернативных путей.\n TrueNAS 25.10 (Goldeye/Goliath).\n \"\"\"\n\n # -----------------------------------------------------------------\n # Настройки (Valves) — задаются в UI Open WebUI\n # -----------------------------------------------------------------\n class Valves(BaseModel):\n TRUENAS_URL: str = Field(\n default=\"http://192.168.31.100\",\n description=\"IP-адрес или домен TrueNAS (http://... или https://...)\",\n )\n TRUENAS_API_KEY: str = Field(\n default=\"\",\n description=\"API-ключ для доступа к TrueNAS (Settings → API Keys)\",\n )\n CACHE_TTL: int = Field(\n default=30,\n description=\"Время кэширования ответов в секундах (0 = без кэша)\",\n )\n MEM_CACHE_TTL: int = Field(\n default=60,\n description=\"TTL для данных о RAM в секундах (RAM редко меняется)\",\n )\n verify_ssl: bool = Field(\n default=False,\n description=\"Проверять TLS-сертификат TrueNAS\",\n )\n request_timeout: int = Field(\n default=15,\n description=\"Таймаут HTTP-запроса в секундах\",\n )\n\n # -----------------------------------------------------------------\n # Инициализация\n # -----------------------------------------------------------------\n def __init__(self):\n self.valves = self.Valves()\n self._cache: Dict[str, tuple[float, str]] = {}\n self._last_error = \"нет данных (запросы ещё не выполнялись)\"\n try:\n import urllib3\n\n urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)\n except Exception:\n pass\n\n # -----------------------------------------------------------------\n # Вспомогательные свойства\n # -----------------------------------------------------------------\n @property\n def _base(self) -> str:\n \"\"\"Всегда читаем актуальное значение из Valves — оно может меняться в UI после создания экземпляра Tools.\"\"\"\n return self.valves.TRUENAS_URL.rstrip(\"/\")\n\n @property\n def _headers(self) -> dict:\n return {\n \"Authorization\": f\"Bearer {self.valves.TRUENAS_API_KEY}\",\n \"Content-Type\": \"application/json\",\n }\n\n # -----------------------------------------------------------------\n # Кэш\n # -----------------------------------------------------------------\n def clear_cache(self) -> str:\n \"\"\"Сбрасывает весь кэш запросов. Возвращает количество удалённых записей.\"\"\"\n n = len(self._cache)\n self._cache.clear()\n return f\"🧹 Кэш очищен ({n} записей удалено).\"\n\n def _rest_get(\n self,\n cache_key: str,\n endpoint: str,\n cache_ttl: Optional[int] = None,\n ) -> Optional[str]:\n \"\"\"\n GET-запрос к TrueNAS API с кэшированием.\n Возвращает JSON-строку при успехе или None при ошибке.\n \"\"\"\n ttl = cache_ttl if cache_ttl is not None else self.valves.CACHE_TTL\n now = time.time()\n\n if cache_key in self._cache and ttl > 0:\n t, d = self._cache[cache_key]\n if now - t < ttl:\n return d\n\n url = f\"{self._base}/api/v2.0/{endpoint}\"\n try:\n r = requests.get(\n url,\n headers=self._headers,\n timeout=self.valves.request_timeout,\n verify=self.valves.verify_ssl,\n )\n if r.status_code == 200:\n data = r.json()\n s = json.dumps(data, default=str)\n if ttl > 0:\n self._cache[cache_key] = (now, s)\n return s\n self._last_error = f\"HTTP {r.status_code} на {endpoint}: {r.text[:300]}\"\n return None\n except requests.exceptions.SSLError as e:\n self._last_error = f\"SSL-ошибка на {endpoint}: {e}\"\n return None\n except requests.exceptions.ConnectionError as e:\n self._last_error = f\"Ошибка соединения с {endpoint}: {e}\"\n return None\n except requests.exceptions.Timeout:\n self._last_error = f\"Таймаут на {endpoint}\"\n return None\n except Exception as e:\n self._last_error = f\"Исключение на {endpoint}: {type(e).__name__}: {e}\"\n return None\n\n def _try_endpoints(self, cache_key: str, endpoints: List[str]) -> str:\n \"\"\"\n Перебирает эндпоинты по очереди и возвращает первый удачный ответ.\n \"\"\"\n for ep in endpoints:\n result = self._rest_get(f\"{cache_key}_{ep.replace('/', '_')}\", ep)\n if result is not None:\n return result\n return f\"⚠️ Данные недоступны. Попробованы пути: {', '.join(endpoints)}\"\n\n # -----------------------------------------------------------------\n # Форматирование\n # -----------------------------------------------------------------\n def _format_bytes(self, num_bytes: Any) -> str:\n \"\"\"Форматирует байты в человекочитаемый вид (KiB/MiB/GiB/TiB).\"\"\"\n if num_bytes is None:\n return \"Н/Д\"\n try:\n n = float(num_bytes)\n except (TypeError, ValueError):\n return str(num_bytes)\n if n < 0:\n return \"Н/Д\"\n for unit in [\"B\", \"KiB\", \"MiB\", \"GiB\", \"TiB\"]:\n if abs(n) < 1024.0:\n return f\"{n:.1f} {unit}\"\n n /= 1024.0\n return f\"{n:.1f} PiB\"\n\n # -----------------------------------------------------------------\n # Диагностика и валидация\n # -----------------------------------------------------------------\n def get_debug_info(self) -> str:\n \"\"\"\n Диагностика соединения с TrueNAS: проверяет базовый URL, HTTP/HTTPS,\n код ответа и текст ошибки для system/info. Полезно, когда API\n 'недоступен' и нужно понять, на каком этапе рвётся запрос.\n \"\"\"\n lines = []\n base = self._base\n lines.append(f\"🔧 TRUENAS_URL из настроек: {base}\")\n lines.append(\n \"🔑 API-ключ задан: \"\n + (\n \"да\"\n if self.valves.TRUENAS_API_KEY\n else \"❌ НЕТ — заполни Valves.TRUENAS_API_KEY\"\n )\n )\n\n candidates = [base]\n if base.startswith(\"http://\"):\n candidates.append(\"https://\" + base[len(\"http://\") :])\n elif base.startswith(\"https://\"):\n candidates.append(\"http://\" + base[len(\"https://\") :])\n\n for url in candidates:\n lines.append(f\"\\n▶️ Проверка: {url}/api/v2.0/system/info\")\n try:\n r = requests.get(\n f\"{url}/api/v2.0/system/info\",\n headers=self._headers,\n timeout=self.valves.request_timeout,\n verify=False,\n )\n lines.append(f\" Код ответа: {r.status_code}\")\n if r.status_code == 200:\n lines.append(\n \" ✅ Успех! Обнови TRUENAS_URL на этот вариант, если он отличается от текущего.\"\n )\n elif r.status_code in (401, 403):\n lines.append(\n \" 🔑 Ошибка авторизации — проверь API-ключ (Settings → API Keys) и что он активен.\"\n )\n else:\n lines.append(\n f\" ⚠️ Тело ответа (первые 300 симв.): {r.text[:300]}\"\n )\n except requests.exceptions.SSLError as e:\n lines.append(f\" 🔒 Ошибка SSL: {e}\")\n except requests.exceptions.ConnectionError as e:\n lines.append(\n f\" 🔌 Ошибка соединения (сервер недоступен / порт закрыт / неверный IP): {e}\"\n )\n except requests.exceptions.Timeout:\n lines.append(\n \" ⏱️ Таймаут — сервер не отвечает за 10 секунд (перегружен или сеть медленная).\"\n )\n except Exception as e:\n lines.append(f\" ❓ Прочая ошибка: {type(e).__name__}: {e}\")\n\n lines.append(\n \"\\nℹ️ Последняя зафиксированная ошибка внутренних запросов: \"\n + getattr(self, \"_last_error\", \"нет данных (запросы ещё не выполнялись)\")\n )\n return \"\\n\".join(lines)\n\n def validate_api_key(self) -> str:\n \"\"\"\n Проверяет валидность API-ключа и доступность основных эндпоинтов.\n Возвращает подробный отчёт с результатами проверки.\n \"\"\"\n lines = [\"🔐 ВАЛИДАЦИЯ API-КЛЮЧА TRUENAS\", \"\"]\n\n if not self.valves.TRUENAS_API_KEY:\n lines.append(\"❌ API-ключ не задан в Valves.TRUENAS_API_KEY\")\n return \"\\n\".join(lines)\n\n lines.append(f\"🔑 Ключ: {'*' * 8}{self.valves.TRUENAS_API_KEY[-4:]}\")\n lines.append(f\"🌐 URL: {self._base}\")\n lines.append(\"\")\n\n # Проверяем несколько эндпоинтов — каждый со своим минимальным набором прав\n checks = [\n (\"system/info\", \"базовая информация о системе\"),\n (\"pool/query\", \"список пулов ZFS\"),\n (\"disk/query\", \"список дисков\"),\n (\"app/query\", \"список приложений\"),\n (\"alert/list\", \"список алертов\"),\n ]\n\n ok = 0\n for endpoint, descr in checks:\n url = f\"{self._base}/api/v2.0/{endpoint}\"\n try:\n r = requests.get(\n url,\n headers=self._headers,\n timeout=self.valves.request_timeout,\n verify=self.valves.verify_ssl,\n )\n if r.status_code == 200:\n lines.append(f\"✅ {endpoint:20s} — {descr}\")\n ok += 1\n elif r.status_code in (401, 403):\n lines.append(f\"🔑 {endpoint:20s} — 401/403 (нет прав)\")\n else:\n lines.append(f\"⚠️ {endpoint:20s} — HTTP {r.status_code}\")\n except Exception as e:\n lines.append(f\"🔌 {endpoint:20s} — ошибка: {type(e).__name__}\")\n\n lines.append(\"\")\n lines.append(f\"Итого: {ok}/{len(checks)} эндпоинтов доступны.\")\n\n if ok == len(checks):\n lines.append(\"🎉 API-ключ валиден и имеет все необходимые права.\")\n elif ok == 0:\n lines.append(\"❌ API-ключ не работает или сервер недоступен.\")\n else:\n lines.append(\n \"⚠️ Частичный доступ — проверьте права ключа (Read Only / Full Access).\"\n )\n\n return \"\\n\".join(lines)\n\n # -----------------------------------------------------------------\n # Оперативная память (RAM)\n # -----------------------------------------------------------------\n # Эндпоинты, в которых может быть поле общей RAM (в порядке приоритета)\n _MEM_TOTAL_ENDPOINTS = [\n \"system/get_info\", # старый middleware, часто содержит memory_size\n \"system/info\", # базовый\n \"system/memory\", # отдельный эндпоинт (новые версии)\n \"hardware\", # список оборудования\n \"hardware/info\", # подробно об оборудовании\n \"system/advanced\", # настройки системы\n ]\n\n # Возможные названия поля с общей RAM\n _MEM_TOTAL_KEYS = [\n \"memory_size\", # system/get_info\n \"memory\", # system/info, hardware/info\n \"total_memory\",\n \"memory_total\",\n \"total_ram\",\n \"physmem\", # FreeBSD-style\n \"phys_mem\",\n \"total\", # вложенные объекты\n ]\n\n # Эндпоинты для детальной информации (used/free/available/swap)\n _MEM_DETAIL_ENDPOINTS = [\n \"stats\",\n \"reporting/get_data\",\n \"system/memory\",\n ]\n\n def _get_memory_info(self) -> Dict[str, Any]:\n \"\"\"\n Собирает информацию о RAM через несколько эндпоинтов.\n Возвращает dict с полями:\n total_bytes, total_human,\n used_bytes, used_human,\n free_bytes, free_human,\n available_bytes, available_human,\n swap_total, swap_used,\n source_endpoint, source_field\n \"\"\"\n result: Dict[str, Any] = {\n \"total_bytes\": None,\n \"total_human\": \"Н/Д\",\n \"used_bytes\": None,\n \"used_human\": None,\n \"free_bytes\": None,\n \"free_human\": None,\n \"available_bytes\": None,\n \"available_human\": None,\n \"swap_total\": None,\n \"swap_used\": None,\n \"source_endpoint\": None,\n \"source_field\": None,\n }\n\n # 1. Ищем total в списке эндпоинтов\n for ep in self._MEM_TOTAL_ENDPOINTS:\n raw = self._rest_get(\n f\"mem_total_{ep.replace('/', '_')}\",\n ep,\n cache_ttl=self.valves.MEM_CACHE_TTL,\n )\n if raw is None:\n continue\n try:\n data = json.loads(raw)\n except Exception:\n continue\n\n # Данные могут быть списком или словарём\n items = data if isinstance(data, list) else [data]\n\n found = False\n for item in items:\n if not isinstance(item, dict):\n continue\n for key in self._MEM_TOTAL_KEYS:\n if key in item and item[key] is not None:\n val = item[key]\n bytes_val = self._to_bytes(val)\n if bytes_val and bytes_val > 0:\n result[\"total_bytes\"] = bytes_val\n result[\"source_endpoint\"] = ep\n result[\"source_field\"] = key\n found = True\n break\n if found:\n break\n if found:\n break\n\n # 2. Human-readable total\n if result[\"total_bytes\"]:\n result[\"total_human\"] = self._format_bytes(result[\"total_bytes\"])\n\n # 3. Пробуем получить used/free/swap через stats или memory\n for ep in self._MEM_DETAIL_ENDPOINTS:\n raw = self._rest_get(\n f\"mem_detail_{ep.replace('/', '_')}\",\n ep,\n cache_ttl=self.valves.MEM_CACHE_TTL,\n )\n if raw is None:\n continue\n try:\n data = json.loads(raw)\n except Exception:\n continue\n if not isinstance(data, (dict, list)):\n continue\n\n items = data if isinstance(data, list) else [data]\n for item in items:\n if not isinstance(item, dict):\n continue\n # Некоторые версии кладут всё в один объект\n for src_key, dst_key in [\n (\"used\", \"used_bytes\"),\n (\"free\", \"free_bytes\"),\n (\"available\", \"available_bytes\"),\n (\"swap_total\", \"swap_total\"),\n (\"swap_used\", \"swap_used\"),\n (\"swap_free\", \"swap_used\"),\n ]:\n if src_key in item and item[src_key] is not None:\n val = self._to_bytes(item[src_key])\n if val is not None:\n result[dst_key] = val\n\n # 4. Human-readable для деталей\n for k in [\"used\", \"free\", \"available\", \"swap_total\", \"swap_used\"]:\n bytes_key = f\"{k}_bytes\" if k != \"swap_total\" else \"swap_total\"\n human_key = f\"{k}_human\" if k != \"swap_total\" else \"swap_total_human\"\n if k == \"swap_total\":\n if result[\"swap_total\"]:\n result[\"swap_total_human\"] = self._format_bytes(\n result[\"swap_total\"]\n )\n else:\n if result[f\"{k}_bytes\"]:\n result[f\"{k}_human\"] = self._format_bytes(result[f\"{k}_bytes\"])\n\n return result\n\n def _to_bytes(self, val: Any) -> Optional[int]:\n \"\"\"\n Приводит значение RAM к байтам. Поддерживает:\n • int / float (считаются байтами, если > 1 MB)\n • строку с числом и суффиксом (KiB, MiB, GiB, KB, MB, GB)\n • строку с просто числом\n \"\"\"\n if val is None:\n return None\n if isinstance(val, (int, float)):\n n = float(val)\n # Если значение подозрительно маленькое — возможно это MiB\n if 0 < n < 1024 * 1024:\n # Скорее всего это MiB\n return int(n * 1024 * 1024)\n return int(n)\n if isinstance(val, str):\n s = val.strip().replace(\",\", \".\")\n mult = 1\n for suffix, m in [\n (\"KIB\", 1024),\n (\"MIB\", 1024**2),\n (\"GIB\", 1024**3),\n (\"TIB\", 1024**4),\n (\"KB\", 1000),\n (\"MB\", 1000**2),\n (\"GB\", 1000**3),\n (\"TB\", 1000**4),\n (\"B\", 1),\n ]:\n if s.upper().endswith(suffix):\n s = s[: -len(suffix)].strip()\n mult = m\n break\n try:\n return int(float(s) * mult)\n except ValueError:\n return None\n return None\n\n def get_memory_info(self) -> str:\n \"\"\"Отдельный эндпоинт: детальная информация о RAM.\"\"\"\n m = self._get_memory_info()\n lines = [\"💾 ИНФОРМАЦИЯ О ПАМЯТИ\", \"\"]\n\n lines.append(f\" Всего: {m['total_human']}\")\n if m[\"used_human\"]:\n pct = \"\"\n if m[\"used_bytes\"] and m[\"total_bytes\"]:\n pct = f\" ({m['used_bytes'] / m['total_bytes'] * 100:.1f}%)\"\n lines.append(f\" Использовано: {m['used_human']}{pct}\")\n if m[\"free_human\"]:\n lines.append(f\" Свободно: {m['free_human']}\")\n if m[\"available_human\"]:\n lines.append(f\" Доступно: {m['available_human']}\")\n\n if m.get(\"swap_total_human\"):\n lines.append(f\" Swap всего: {m['swap_total_human']}\")\n if m.get(\"swap_used\") is not None:\n swap_used_h = self._format_bytes(m[\"swap_used\"])\n lines.append(f\" Swap исп.: {swap_used_h}\")\n\n if m[\"source_endpoint\"]:\n lines.append(\n f\"\\n 📡 Источник: эндпоинт `{m['source_endpoint']}` \"\n f\"(поле `{m['source_field']}`)\"\n )\n else:\n lines.append(\n \"\\n ⚠️ RAM не удалось получить ни из одного эндпоинта. \"\n \"Возможно, требуется Full Access API-ключ.\"\n )\n return \"\\n\".join(lines)\n\n # -----------------------------------------------------------------\n # Температура CPU\n # -----------------------------------------------------------------\n def get_cpu_temperature(self) -> str:\n \"\"\"\n Температура CPU и сенсоров.\n TrueNAS Scale может возвращать её в system/info (поле cpu_temperature)\n или в hardware/info.\n \"\"\"\n endpoints = [\"system/info\", \"system/get_info\", \"hardware/info\", \"hardware\"]\n found_any = False\n lines = [\"🌡️ ТЕМПЕРАТУРА CPU И СЕНСОРЫ\", \"\"]\n\n for ep in endpoints:\n raw = self._rest_get(\n f\"cputemp_{ep.replace('/', '_')}\",\n ep,\n cache_ttl=self.valves.MEM_CACHE_TTL,\n )\n if raw is None:\n continue\n try:\n data = json.loads(raw)\n except Exception:\n continue\n\n items = data if isinstance(data, list) else [data]\n\n for item in items:\n if not isinstance(item, dict):\n continue\n\n # Прямое поле cpu_temperature\n cpu_temp = (\n item.get(\"cpu_temperature\")\n or item.get(\"cpu_temp\")\n or item.get(\"temperature\")\n )\n if cpu_temp is not None:\n found_any = True\n try:\n t = float(cpu_temp)\n lines.append(f\" 🌡️ CPU: {t:.1f} °C (эндпоинт {ep})\")\n except (TypeError, ValueError):\n lines.append(f\" 🌡️ CPU: {cpu_temp} (эндпоинт {ep})\")\n\n # Вложенный объект sensors / temperatures\n sensors = item.get(\"sensors\") or item.get(\"temperatures\") or {}\n if isinstance(sensors, dict):\n for name, val in sensors.items():\n if val is None:\n continue\n try:\n t = float(val)\n lines.append(f\" 🌡️ {name}: {t:.1f} °C\")\n found_any = True\n except (TypeError, ValueError):\n lines.append(f\" 🌡️ {name}: {val}\")\n found_any = True\n elif isinstance(sensors, list):\n for s in sensors:\n if not isinstance(s, dict):\n continue\n name = s.get(\"name\") or s.get(\"identifier\") or \"sensor\"\n val = s.get(\"value\") or s.get(\"temperature\") or s.get(\"temp\")\n if val is not None:\n try:\n t = float(val)\n lines.append(f\" 🌡️ {name}: {t:.1f} °C\")\n found_any = True\n except (TypeError, ValueError):\n lines.append(f\" 🌡️ {name}: {val}\")\n found_any = True\n\n if not found_any:\n lines.append(\n \" ⚠️ Температура CPU недоступна через API.\\n\"\n \" Возможные причины: не поддерживается в этой версии TrueNAS,\\n\"\n \" нет прав на чтение сенсоров, или требуется ipmi/sensors модуль.\"\n )\n return \"\\n\".join(lines)\n\n # -----------------------------------------------------------------\n # Статистика ZFS ARC-кэша\n # -----------------------------------------------------------------\n def get_arc_stats(self) -> str:\n \"\"\"\n Статистика ZFS ARC-кэша (если доступна через API).\n ARC — адаптивный кэш чтения ZFS, обычно занимает до 50% RAM.\n \"\"\"\n endpoints = [\"stats\", \"zfs/arc\", \"zfs/pool/stats\", \"pool/statistics\"]\n lines = [\"📊 СТАТИСТИКА ZFS ARC-КЭША\", \"\"]\n\n found = False\n for ep in endpoints:\n raw = self._rest_get(\n f\"arc_{ep.replace('/', '_')}\", ep, cache_ttl=self.valves.CACHE_TTL\n )\n if raw is None:\n continue\n try:\n data = json.loads(raw)\n except Exception:\n continue\n\n items = data if isinstance(data, list) else [data]\n\n for item in items:\n if not isinstance(item, dict):\n continue\n\n # Разные варианты названий\n arc_keys = {\n \"arc_size\": \"Размер ARC\",\n \"arc_max_size\": \"Лимит ARC\",\n \"arc_min_size\": \"Минимум ARC\",\n \"arc_hits\": \"Попадания\",\n \"arc_misses\": \"Промахи\",\n \"arc_miss_percent\": \"Процент промахов\",\n \"arc_c\": \"Целевой размер\",\n \"cache_size\": \"Размер кэша\",\n \"cache_used\": \"Использовано кэша\",\n \"evict_skip\": \"Пропуски вытеснения\",\n \"demand_data_hits\": \"Попадания данных (demand)\",\n \"demand_metadata_hits\": \"Попадания метаданных (demand)\",\n }\n\n for key, descr in arc_keys.items():\n if key in item and item[key] is not None:\n val = item[key]\n if \"size\" in key or \"cache\" in key:\n val_s = self._format_bytes(val)\n elif \"percent\" in key:\n try:\n val_s = f\"{float(val):.2f}%\"\n except (TypeError, ValueError):\n val_s = str(val)\n else:\n val_s = f\"{val}\"\n lines.append(f\" {descr:30s} {val_s}\")\n found = True\n\n # Иногда данные лежат во вложенном объекте\n arc_obj = item.get(\"arc\") or item.get(\"zfs\") or {}\n if isinstance(arc_obj, dict):\n for k, v in arc_obj.items():\n if isinstance(v, (int, float, str)):\n lines.append(f\" arc.{k}: {v}\")\n found = True\n\n if not found:\n lines.append(\n \" ⚠️ ARC-статистика недоступна через эти эндпоинты.\\n\"\n \" Попробуйте: zpool/iostat, arcstat (через SSH) или zfs-stats.\"\n )\n return \"\\n\".join(lines)\n\n # -----------------------------------------------------------------\n # Системная информация (с RAM и температурой CPU)\n # -----------------------------------------------------------------\n def get_system_status(self) -> str:\n raw = self._rest_get(\"info\", \"system/info\")\n if raw is None:\n return \"⚠️ Ошибка получения информации о системе\"\n try:\n d = json.loads(raw)\n except Exception:\n return \"Ошибка парсинга\"\n\n # RAM (через _get_memory_info с перебором эндпоинтов)\n mem = self._get_memory_info()\n ram_s = mem[\"total_human\"]\n if mem[\"used_human\"] and mem[\"total_bytes\"]:\n used_pct = (\n (mem[\"used_bytes\"] / mem[\"total_bytes\"] * 100)\n if mem[\"used_bytes\"]\n else 0\n )\n ram_s += f\" (исп. {mem['used_human']}, {used_pct:.0f}%)\"\n\n # Температура CPU (если есть в этом же ответе)\n cpu_temp_s = \"\"\n cpu_temp = d.get(\"cpu_temperature\") or d.get(\"cpu_temp\") or d.get(\"temperature\")\n if cpu_temp is not None:\n try:\n cpu_temp_s = f\" | 🌡️ {float(cpu_temp):.1f} °C\"\n except (TypeError, ValueError):\n cpu_temp_s = f\" | 🌡️ {cpu_temp}\"\n\n ver = d.get(\"version\", \"N/A\")\n model = d.get(\"model\", \"N/A\")\n load = d.get(\"loadavg\", [])\n load_s = (\n f\"1мин: {load[0]:.2f}, 5мин: {load[1]:.2f}, 15мин: {load[2]:.2f}\"\n if len(load) >= 3\n else str(load) if load else \"Н/Д\"\n )\n up = d.get(\"uptime_seconds\")\n if up:\n dd, r = divmod(int(up), 86400)\n hh, r = divmod(r, 3600)\n mm, _ = divmod(r, 60)\n up_s = f\"{dd}д {hh}ч {mm}м\"\n else:\n up_s = d.get(\"uptime_string\", \"Н/Д\")\n return (\n f\"🖥️ TrueNAS: {ver}\\n\"\n f\"⏱️ Uptime: {up_s}\\n\"\n f\"🔧 Процессор: {model}{cpu_temp_s}\\n\"\n f\"📊 Load Average: {load_s}\\n\"\n f\"💾 RAM: {ram_s}\"\n )\n\n # -----------------------------------------------------------------\n # Пулы ZFS\n # -----------------------------------------------------------------\n def get_pools_status(self) -> str:\n raw = self._try_endpoints(\"pools\", [\"pool/query\", \"pool\", \"storage/pool\"])\n if raw.startswith(\"⚠️\"):\n return raw\n try:\n pools = json.loads(raw)\n except Exception:\n return \"Ошибка парсинга\"\n if not pools:\n return \"Активные пулы не найдены.\"\n rows = []\n for p in pools:\n name = p.get(\"name\", \"?\")\n status = p.get(\"status\", \"?\")\n icon = \"✅\" if p.get(\"healthy\", False) else \"⚠️\"\n total = p.get(\"size\", 0)\n free = p.get(\"free\", 0)\n used_pct = ((total - free) / total * 100) if total > 0 else 0\n frag = p.get(\"fragmentation\")\n frag_s = f\" | фрагм.: {frag}\" if frag not in (None, \"\", \"N/A\") else \"\"\n rows.append(\n f\"{icon} '{name}' | {status} | \"\n f\"{total/1024**4:.2f} TiB, {used_pct:.1f}% занято{frag_s}\"\n )\n return \"\\n\".join(rows)\n\n # -----------------------------------------------------------------\n # Диски (с температурой SMART)\n # -----------------------------------------------------------------\n def get_disks_temp_and_info(self) -> str:\n raw = self._try_endpoints(\"disks\", [\"disk/query\", \"disk\", \"storage/disk\"])\n if raw.startswith(\"⚠️\"):\n return raw\n try:\n disks = json.loads(raw)\n except Exception:\n return \"Ошибка парсинга\"\n if not disks:\n return \"Диски не обнаружены.\"\n rows = []\n for d in disks:\n name = d.get(\"name\", \"N/A\")\n dtype = d.get(\"type\", \"N/A\")\n serial = d.get(\"serial\", \"N/A\")\n size = f\"{int(d['size']) / (1024**3):.1f} GB\" if d.get(\"size\") else \"N/A\"\n model = d.get(\"model\", \"\")\n # Температура SMART\n temp = d.get(\"temperature\")\n temp_s = \"\"\n if temp is not None:\n try:\n t = float(temp)\n icon = (\n \"🟢\" if t < 45 else \"🟡\" if t < 55 else \"🟠\" if t < 65 else \"🔴\"\n )\n temp_s = f\" | {icon} {t:.0f}°C\"\n except (TypeError, ValueError):\n temp_s = f\" | {temp}°C\"\n rows.append(f\"💾 {name} ({dtype}) | {model} | {serial} | {size}{temp_s}\")\n return \"\\n\".join(rows)\n\n # -----------------------------------------------------------------\n # Docker-приложения (TrueNAS Apps)\n # -----------------------------------------------------------------\n def get_apps_status(self) -> str:\n \"\"\"Список установленных приложений (TrueNAS Apps, docker compose).\"\"\"\n raw = self._try_endpoints(\"apps\", [\"app/query\", \"app\", \"chart/release/query\"])\n if raw.startswith(\"⚠️\"):\n return raw\n try:\n apps = json.loads(raw)\n except Exception:\n return \"Ошибка парсинга\"\n if not apps:\n return \"Приложения не установлены.\"\n imap = {\n \"RUNNING\": \"🟢\",\n \"STOPPED\": \"⚪\",\n \"DEPLOYING\": \"🟡\",\n \"CRASHED\": \"🔴\",\n \"STOPPING\": \"🟠\",\n }\n rows = []\n for a in apps:\n name = a.get(\"name\", a.get(\"id\", \"?\"))\n state = a.get(\"state\", a.get(\"status\", \"N/A\"))\n version = a.get(\"human_version\", a.get(\"version\", \"N/A\"))\n icon = imap.get(str(state).upper(), \"⚪\")\n rows.append(f\"{icon} {name} | статус: {state} | версия: {version}\")\n return \"\\n\".join(rows)\n\n # -----------------------------------------------------------------\n # Виртуальные машины (libvirt/KVM)\n # -----------------------------------------------------------------\n def get_vms_status(self) -> str:\n \"\"\"Список виртуальных машин (libvirt/KVM).\"\"\"\n raw = self._try_endpoints(\"vms\", [\"vm/query\", \"vm\"])\n if raw.startswith(\"⚠️\"):\n return raw\n try:\n vms = json.loads(raw)\n except Exception:\n return \"Ошибка парсинга\"\n if not vms:\n return \"Виртуальные машины не найдены.\"\n rows = []\n for vm in vms:\n name = vm.get(\"name\", \"?\")\n status = vm.get(\"status\", {})\n state = status.get(\"state\", \"N/A\") if isinstance(status, dict) else status\n vcpus = vm.get(\"vcpus\", \"N/A\")\n memory = vm.get(\"memory\")\n mem_s = f\"{int(memory) / 1024:.0f} MB\" if memory else \"N/A\"\n autostart = \"✅\" if vm.get(\"autostart\") else \"❌\"\n icon = \"🟢\" if str(state).upper() == \"RUNNING\" else \"⚪\"\n rows.append(\n f\"{icon} {name} | статус: {state} | vCPU: {vcpus} | \"\n f\"RAM: {mem_s} | автозапуск: {autostart}\"\n )\n return \"\\n\".join(rows)\n\n # -----------------------------------------------------------------\n # Контейнеры (virt/incus)\n # -----------------------------------------------------------------\n def get_containers_status(self) -> str:\n \"\"\"Лёгкие контейнеры / инстансы (incus/virt, LXC-подобные).\"\"\"\n raw = self._try_endpoints(\n \"containers\", [\"virt/instance\", \"virt/instance/query\", \"container/query\"]\n )\n if raw.startswith(\"⚠️\"):\n return raw\n try:\n instances = json.loads(raw)\n except Exception:\n return \"Ошибка парсинга\"\n if not instances:\n return \"Контейнеры не найдены.\"\n rows = []\n for c in instances:\n name = c.get(\"name\", c.get(\"id\", \"?\"))\n status = c.get(\"status\", c.get(\"state\", \"N/A\"))\n itype = c.get(\"type\", \"N/A\")\n image = c.get(\"image\", {})\n image_s = image.get(\"name\", \"N/A\") if isinstance(image, dict) else image\n icon = \"🟢\" if str(status).upper() == \"RUNNING\" else \"⚪\"\n rows.append(\n f\"{icon} {name} | тип: {itype} | статус: {status} | образ: {image_s}\"\n )\n return \"\\n\".join(rows)\n\n # -----------------------------------------------------------------\n # Алерты\n # -----------------------------------------------------------------\n def get_active_alerts(self) -> str:\n raw = self._rest_get(\"alerts\", \"alert/list\")\n if raw is None:\n return \"⚠️ Ошибка получения алертов\"\n try:\n alerts = json.loads(raw)\n except Exception:\n return \"Ошибка парсинга\"\n active = [a for a in alerts if not a.get(\"dismissed\", False)]\n if not active:\n return \"✅ Все системы работают штатно.\"\n rows = []\n imap = {\"CRITICAL\": \"🔴\", \"WARNING\": \"🟡\", \"INFO\": \"🔵\"}\n for a in active:\n level = a.get(\"level\", \"INFO\")\n klass = a.get(\"klass\", \"Alert\")\n msg = a.get(\"formatted\", a.get(\"message\", \"?\"))\n rows.append(f\"{imap.get(level, '⚪')} [{level}] {klass}: {msg}\")\n return \"\\n\".join(rows)\n\n # -----------------------------------------------------------------\n # Полный отчёт\n # -----------------------------------------------------------------\n def get_full_report(self) -> str:\n sections = [\n (\"СИСТЕМА\", self.get_system_status()),\n (\"ПАМЯТЬ (RAM)\", self.get_memory_info()),\n (\"ТЕМПЕРАТУРА CPU\", self.get_cpu_temperature()),\n (\"ZFS ARC-КЭШ\", self.get_arc_stats()),\n (\"ПУЛЫ ZFS\", self.get_pools_status()),\n (\"ДИСКИ\", self.get_disks_temp_and_info()),\n (\"ПРИЛОЖЕНИЯ\", self.get_apps_status()),\n (\"ВИРТУАЛЬНЫЕ МАШИНЫ\", self.get_vms_status()),\n (\"КОНТЕЙНЕРЫ (virt/incus)\", self.get_containers_status()),\n (\"АЛЕРТЫ\", self.get_active_alerts()),\n ]\n out = [\n \"══════════════════════════════════════════════════\",\n \"📋 ПОЛНЫЙ ОТЧЁТ TRUENAS\",\n \"(REST перебор путей)\",\n \"══════════════════════════════════════════════════\",\n \"\",\n ]\n for title, content in sections:\n out.append(f\"--- {title} ---\")\n out.append(content)\n out.append(\"\")\n return \"\\n\".join(out)\n","specs":[{"name":"clear_cache","description":"Сбрасывает весь кэш запросов. Возвращает количество удалённых записей.","parameters":{"properties":{},"type":"object"}},{"name":"get_active_alerts","description":"","parameters":{"properties":{},"type":"object"}},{"name":"get_apps_status","description":"Список установленных приложений (TrueNAS Apps, docker compose).","parameters":{"properties":{},"type":"object"}},{"name":"get_arc_stats","description":"Статистика ZFS ARC-кэша (если доступна через API).\nARC — адаптивный кэш чтения ZFS, обычно занимает до 50% RAM.","parameters":{"properties":{},"type":"object"}},{"name":"get_containers_status","description":"Лёгкие контейнеры / инстансы (incus/virt, LXC-подобные).","parameters":{"properties":{},"type":"object"}},{"name":"get_cpu_temperature","description":"Температура CPU и сенсоров.\nTrueNAS Scale может возвращать её в system/info (поле cpu_temperature)\nили в hardware/info.","parameters":{"properties":{},"type":"object"}},{"name":"get_debug_info","description":"Диагностика соединения с TrueNAS: проверяет базовый URL, HTTP/HTTPS,\nкод ответа и текст ошибки для system/info. Полезно, когда API\n'недоступен' и нужно понять, на каком этапе рвётся запрос.","parameters":{"properties":{},"type":"object"}},{"name":"get_disks_temp_and_info","description":"","parameters":{"properties":{},"type":"object"}},{"name":"get_full_report","description":"","parameters":{"properties":{},"type":"object"}},{"name":"get_memory_info","description":"Отдельный эндпоинт: детальная информация о RAM.","parameters":{"properties":{},"type":"object"}},{"name":"get_pools_status","description":"","parameters":{"properties":{},"type":"object"}},{"name":"get_system_status","description":"","parameters":{"properties":{},"type":"object"}},{"name":"get_vms_status","description":"Список виртуальных машин (libvirt/KVM).","parameters":{"properties":{},"type":"object"}},{"name":"validate_api_key","description":"Проверяет валидность API-ключа и доступность основных эндпоинтов.\nВозвращает подробный отчёт с результатами проверки.","parameters":{"properties":{},"type":"object"}}]}]