diff --git a/tool-truenas-export-1785705163085.json b/tool-truenas-export-1785705163085.json deleted file mode 100644 index 5bfa823..0000000 --- a/tool-truenas-export-1785705163085.json +++ /dev/null @@ -1 +0,0 @@ -[{"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"}}]}] \ No newline at end of file