746 lines
30 KiB
Python
746 lines
30 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from ..core.config import settings
|
|
from ..core.redaction import redact_message
|
|
from .base import BaseConnector
|
|
|
|
_UNRAID_DEFAULT_TIMEOUT_SECONDS = 8.0
|
|
_UNRAID_REDIRECT_STATUSES = {301, 302, 303, 307, 308}
|
|
|
|
|
|
@dataclass
|
|
class _ConnectorConfig:
|
|
url: str
|
|
api_key: str
|
|
verify_tls: bool
|
|
|
|
|
|
def _as_bool(value: Any) -> bool | None:
|
|
if isinstance(value, bool):
|
|
return value
|
|
|
|
if value is None:
|
|
return None
|
|
|
|
if isinstance(value, str):
|
|
normalized = value.strip().lower()
|
|
if normalized in {'true', '1', 'yes', 'on', 'running', 'started'}:
|
|
return True
|
|
if normalized in {'false', '0', 'off', 'stopped'}:
|
|
return False
|
|
|
|
return None
|
|
|
|
|
|
def _as_int(value: Any, default: int = 0) -> int:
|
|
if value is None:
|
|
return default
|
|
|
|
if isinstance(value, bool):
|
|
return int(value)
|
|
|
|
if isinstance(value, int | float):
|
|
return int(value)
|
|
|
|
if isinstance(value, str):
|
|
cleaned = value.strip().strip('%')
|
|
if not cleaned:
|
|
return default
|
|
numeric = re.sub(r'[^0-9.+-]', '', cleaned)
|
|
if not numeric:
|
|
return default
|
|
try:
|
|
return int(float(numeric))
|
|
except ValueError:
|
|
return default
|
|
|
|
return default
|
|
|
|
|
|
def _as_float(value: Any) -> float | None:
|
|
if value is None or isinstance(value, bool):
|
|
return None
|
|
|
|
if isinstance(value, int | float):
|
|
number = float(value)
|
|
return number if math.isfinite(number) else None
|
|
|
|
if isinstance(value, str):
|
|
cleaned = value.strip().strip('%')
|
|
if not cleaned:
|
|
return None
|
|
numeric = re.sub(r'[^0-9.+-]', '', cleaned)
|
|
if not numeric:
|
|
return None
|
|
try:
|
|
number = float(numeric)
|
|
return number if math.isfinite(number) else None
|
|
except ValueError:
|
|
return None
|
|
|
|
return None
|
|
|
|
|
|
def _clamp_percent(value: float) -> int:
|
|
return int(round(max(0.0, min(100.0, value))))
|
|
|
|
|
|
def _percent_from_values(used: Any, total: Any, *, precise: bool = False) -> float | int | None:
|
|
used_value = _as_float(used)
|
|
total_value = _as_float(total)
|
|
if used_value is None or total_value is None or total_value <= 0:
|
|
return None
|
|
percent = max(0.0, min(100.0, (used_value / total_value) * 100))
|
|
return round(percent, 2) if precise else _clamp_percent(percent)
|
|
|
|
|
|
def _average_cpu_usage(cpus: Any) -> float | None:
|
|
if not isinstance(cpus, list) or not cpus:
|
|
return None
|
|
|
|
idle_values: list[float] = []
|
|
active_values: list[float] = []
|
|
active_fields = ('percentUser', 'percentSystem', 'percentNice', 'percentIrq')
|
|
|
|
for raw_cpu in cpus:
|
|
if not isinstance(raw_cpu, dict):
|
|
continue
|
|
idle = _as_float(raw_cpu.get('percentIdle'))
|
|
if idle is not None:
|
|
idle_values.append(max(0.0, min(100.0, idle)))
|
|
continue
|
|
|
|
active_total = 0.0
|
|
has_active = False
|
|
for field in active_fields:
|
|
value = _as_float(raw_cpu.get(field))
|
|
if value is None:
|
|
continue
|
|
active_total += value
|
|
has_active = True
|
|
if has_active:
|
|
active_values.append(active_total)
|
|
|
|
if idle_values:
|
|
return round(max(0.0, min(100.0, 100 - (sum(idle_values) / len(idle_values)))), 2)
|
|
if active_values:
|
|
return round(max(0.0, min(100.0, sum(active_values) / len(active_values))), 2)
|
|
return None
|
|
|
|
|
|
def _storage_value_to_gib(value: Any) -> int:
|
|
raw = _as_int(value, 0)
|
|
if raw <= 0:
|
|
return 0
|
|
if raw >= 1_048_576:
|
|
return int(raw / 1024 / 1024)
|
|
return raw
|
|
|
|
|
|
def _get_nested(data: dict[str, Any], path: str) -> Any:
|
|
cursor: Any = data
|
|
for part in path.split('.'):
|
|
if not isinstance(cursor, dict):
|
|
return None
|
|
cursor = cursor.get(part)
|
|
if cursor is None:
|
|
return None
|
|
return cursor
|
|
|
|
|
|
def _first_value(data: dict[str, Any], paths: tuple[str, ...]) -> Any:
|
|
for path in paths:
|
|
value = _get_nested(data, path)
|
|
if value is not None:
|
|
return value
|
|
return None
|
|
|
|
|
|
def _normalize_status(value: Any, allowed: tuple[str, ...], default: str = "stopped") -> str:
|
|
if value is None:
|
|
return default
|
|
|
|
candidate = str(value).strip().lower()
|
|
if not candidate:
|
|
return default
|
|
|
|
if candidate in {"1", "true", "started", "running", "online", "healthy"}:
|
|
return allowed[0]
|
|
if candidate in {"0", "false", "stopped", "offline", "unhealthy", "critical", "invalid"}:
|
|
return allowed[1] if len(allowed) > 1 else default
|
|
return candidate if candidate in allowed else default
|
|
|
|
|
|
def _safe_disk_warning_count(disks: list[dict[str, Any]]) -> int:
|
|
total = 0
|
|
for raw_disk in disks:
|
|
if not isinstance(raw_disk, dict):
|
|
continue
|
|
warning = raw_disk.get('warning')
|
|
if warning is True:
|
|
total += 1
|
|
continue
|
|
|
|
if raw_disk.get('critical') is not None:
|
|
total += 1
|
|
continue
|
|
|
|
status = str(raw_disk.get('status', raw_disk.get('state', ''))).strip().lower()
|
|
if status in {'warning', 'degraded', 'failed', 'offline', 'faulty', 'disk_dsbl', 'disk_invalid'}:
|
|
total += 1
|
|
|
|
if _as_bool(raw_disk.get('healthy')) is False:
|
|
total += 1
|
|
|
|
return total
|
|
|
|
|
|
def _trusted_https_redirect(response: httpx.Response) -> httpx.URL | None:
|
|
if response.status_code not in _UNRAID_REDIRECT_STATUSES:
|
|
return None
|
|
|
|
location = response.headers.get('location')
|
|
if not location:
|
|
return None
|
|
|
|
redirected = response.url.join(location)
|
|
if redirected.scheme != 'https' or redirected.host != response.url.host:
|
|
return None
|
|
return redirected
|
|
|
|
|
|
class UnraidConnector(BaseConnector):
|
|
name = 'Unraid'
|
|
default_headers = {
|
|
'Content-Type': 'application/json',
|
|
'x-api-key': '',
|
|
}
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
def is_configured(self):
|
|
return bool(settings.apis.get('UNRAID_URL') and settings.apis.get('UNRAID_API_KEY'))
|
|
|
|
def _config(self) -> _ConnectorConfig | None:
|
|
if not self.is_configured():
|
|
return None
|
|
|
|
return _ConnectorConfig(
|
|
url=str(settings.apis['UNRAID_URL']).rstrip('/'),
|
|
api_key=str(settings.apis['UNRAID_API_KEY']),
|
|
verify_tls=bool(settings.apis.get('UNRAID_VERIFY_TLS', settings.verify_tls)),
|
|
)
|
|
|
|
def _request(self, query: str) -> dict[str, Any]:
|
|
config = self._config()
|
|
if not config:
|
|
raise RuntimeError('missing_config')
|
|
|
|
headers = dict(self.default_headers)
|
|
headers['x-api-key'] = config.api_key
|
|
url = f'{config.url}/graphql'
|
|
|
|
try:
|
|
self.clear_error()
|
|
with httpx.Client(timeout=_UNRAID_DEFAULT_TIMEOUT_SECONDS, verify=config.verify_tls) as client:
|
|
response = client.post(url, headers=headers, json={'query': query})
|
|
if response.status_code in _UNRAID_REDIRECT_STATUSES:
|
|
redirected = _trusted_https_redirect(response)
|
|
if redirected is None:
|
|
raise RuntimeError('Unraid API refused an untrusted redirect')
|
|
response = client.post(str(redirected), headers=headers, json={'query': query})
|
|
except (httpx.RequestError, ValueError) as exc:
|
|
message = redact_message(str(exc))
|
|
self.set_error(message)
|
|
raise RuntimeError(message)
|
|
|
|
if response.status_code in {401, 403}:
|
|
message = 'unauthorized'
|
|
self.set_error(message)
|
|
raise RuntimeError(message)
|
|
|
|
if response.status_code >= 500:
|
|
message = f'Unraid server error: HTTP {response.status_code}'
|
|
self.set_error(message)
|
|
raise RuntimeError(message)
|
|
|
|
if response.status_code >= 400:
|
|
payload = response.text or ''
|
|
message = f'Unraid API error: HTTP {response.status_code} {payload[:256]}'
|
|
redacted = redact_message(message)
|
|
self.set_error(redacted)
|
|
raise RuntimeError(redacted)
|
|
|
|
try:
|
|
payload = response.json()
|
|
except ValueError as exc:
|
|
message = redact_message(str(exc))
|
|
self.set_error(message)
|
|
raise RuntimeError(message)
|
|
|
|
if not isinstance(payload, dict):
|
|
message = 'Invalid GraphQL payload'
|
|
self.set_error(message)
|
|
raise RuntimeError(message)
|
|
|
|
if payload.get('errors'):
|
|
error_messages = [
|
|
str(err.get('message', 'unknown'))
|
|
for err in payload.get('errors') if isinstance(err, dict)
|
|
]
|
|
message = f'GraphQL error: {", ".join(error_messages[:2])}'
|
|
redacted = redact_message(message)
|
|
self.set_error(redacted)
|
|
raise RuntimeError(redacted)
|
|
|
|
data = payload.get('data')
|
|
if data is None:
|
|
message = 'No GraphQL data field'
|
|
self.set_error(message)
|
|
raise RuntimeError(message)
|
|
|
|
if not isinstance(data, dict):
|
|
message = 'Invalid GraphQL data'
|
|
self.set_error(message)
|
|
raise RuntimeError(message)
|
|
|
|
return data
|
|
|
|
def _query(self, queries: list[str]) -> tuple[dict[str, Any], str]:
|
|
last_error = ''
|
|
for query in queries:
|
|
try:
|
|
return self._request(query), 'ok'
|
|
except RuntimeError as exc:
|
|
last_error = str(exc)
|
|
if last_error == 'missing_config':
|
|
return {'error': 'missing_config'}, 'missing_config'
|
|
if 'VMs are not available' in last_error:
|
|
return {'error': last_error}, 'unavailable'
|
|
continue
|
|
|
|
return {'error': last_error or 'query_failed'}, 'error'
|
|
|
|
def test_connection(self):
|
|
data, status = self._query(['query { __typename }'])
|
|
if status == 'ok':
|
|
return {'status': 'connected'}
|
|
if status == 'missing_config':
|
|
return {'status': 'missing_config', 'reason': 'missing_config'}
|
|
reason = str(data.get('error', 'connection_failed'))
|
|
return {'status': 'error', 'reason': reason}
|
|
|
|
def get_server_info(self):
|
|
payload, status = self._query([
|
|
'query { info { os { platform distro release uptime } cpu { manufacturer brand cores threads } } }',
|
|
'query { serverInfo { hostname version uptime cpuPercent ramPercent } }',
|
|
'query { system { name version uptime cpuPercent ramPercent } }',
|
|
])
|
|
if status != 'ok':
|
|
return {'status': status, 'reason': str(payload.get('error', 'connection_failed'))}
|
|
|
|
uptime = _first_value(payload, ('info.os.uptime', 'serverInfo.uptime', 'system.uptime', 'info.uptime'))
|
|
hostname = _first_value(payload, ('serverInfo.hostname', 'system.hostname', 'info.hostname', 'serverInfo.name'))
|
|
version = _first_value(payload, ('info.os.release', 'serverInfo.version', 'system.version', 'info.version'))
|
|
cpu_brand = _first_value(payload, ('info.cpu.brand',))
|
|
cpu_cores = _first_value(payload, ('info.cpu.cores',))
|
|
cpu_threads = _first_value(payload, ('info.cpu.threads',))
|
|
|
|
return {
|
|
'status': 'ok',
|
|
'info': {
|
|
'hostname': str(hostname or 'unknown'),
|
|
'version': str(version or 'unknown'),
|
|
'uptime': str(uptime or 'unknown'),
|
|
'name': str(hostname or 'unraid'),
|
|
'cpu': {
|
|
'brand': cpu_brand,
|
|
'cores': _as_int(cpu_cores, 0) if cpu_cores is not None else None,
|
|
'threads': _as_int(cpu_threads, 0) if cpu_threads is not None else None,
|
|
},
|
|
},
|
|
}
|
|
|
|
def get_system_metrics(self):
|
|
payload, status = self._query([
|
|
(
|
|
'query { metrics { '
|
|
'cpu { cpus { percentIdle percentUser percentSystem percentNice percentIrq } } '
|
|
'memory { total used free available active } '
|
|
'} }'
|
|
),
|
|
])
|
|
if status != 'ok':
|
|
return {
|
|
'status': status,
|
|
'reason': str(payload.get('error', 'metrics_unavailable')),
|
|
'cpuUsage': None,
|
|
'ramUsage': None,
|
|
}
|
|
|
|
cpu_usage = _average_cpu_usage(_first_value(payload, ('metrics.cpu.cpus',)))
|
|
memory_total = _first_value(payload, ('metrics.memory.total',))
|
|
memory_available = _first_value(payload, ('metrics.memory.available',))
|
|
memory_used = _first_value(payload, ('metrics.memory.used',))
|
|
# Linux includes reclaimable cache (and often ZFS ARC) in ``used``.
|
|
# Prefer total - available so the dashboard reflects actual memory
|
|
# pressure rather than useful filesystem cache.
|
|
ram_uses_available = (
|
|
isinstance(memory_total, (int, float))
|
|
and isinstance(memory_available, (int, float))
|
|
and 0 <= memory_available <= memory_total
|
|
)
|
|
if ram_uses_available:
|
|
memory_used = memory_total - memory_available
|
|
ram_usage = _percent_from_values(memory_used, memory_total, precise=True)
|
|
missing = []
|
|
if cpu_usage is None:
|
|
missing.append('cpu_unavailable')
|
|
if ram_usage is None:
|
|
missing.append('ram_unavailable')
|
|
|
|
memory = _first_value(payload, ('metrics.memory',))
|
|
return {
|
|
'status': 'ok' if not missing else 'partial',
|
|
'reason': ','.join(missing) if missing else None,
|
|
'cpuUsage': cpu_usage,
|
|
'ramUsage': ram_usage,
|
|
'sources': {
|
|
'cpu': 'metrics.cpu.cpus.percentIdle' if cpu_usage is not None else 'unavailable',
|
|
'ram': 'metrics.memory.available_total' if ram_usage is not None and ram_uses_available else ('metrics.memory.used_total' if ram_usage is not None else 'unavailable'),
|
|
},
|
|
'memory': memory if isinstance(memory, dict) else {},
|
|
}
|
|
|
|
def get_array_status(self):
|
|
payload, status = self._query([
|
|
'query { array { state } }',
|
|
'query { array { status started } }',
|
|
'query { system { array { status started } } }',
|
|
'query { arrayStatus started }',
|
|
'query { system { arrayStatus started } }',
|
|
])
|
|
if status != 'ok':
|
|
return {'status': status, 'reason': str(payload.get('error', 'connection_failed'))}
|
|
|
|
raw_status = _first_value(payload, ('array.state', 'array.status', 'arrayStatus', 'system.array.status', 'system.arrayStatus', 'state', 'status'))
|
|
started = _first_value(payload, ('array.started', 'system.array.started', 'started'))
|
|
if isinstance(started, bool):
|
|
raw_status = 'started' if started else 'stopped'
|
|
elif isinstance(started, str):
|
|
if started.lower() in {'true', '1', 'yes', 'running', 'started', 'online'}:
|
|
raw_status = 'started'
|
|
elif started.lower() in {'false', '0', 'no', 'stopped', 'offline'}:
|
|
raw_status = 'stopped'
|
|
|
|
normalized = _normalize_status(raw_status, ('started', 'stopped', 'maintenance'), default='maintenance')
|
|
return {'status': 'ok', 'arrayStatus': normalized}
|
|
|
|
def get_disk_warnings(self):
|
|
payload, status = self._query([
|
|
'query { array { disks { name status warning critical fsSize fsUsed fsFree type } } }',
|
|
'query { disks { name smartStatus temperature isSpinning size } }',
|
|
'query { array { disks { name status healthy warning } } }',
|
|
'query { system { disks { name status healthy warning } } }',
|
|
'query { disks { name status healthy warning } }',
|
|
])
|
|
if status != 'ok':
|
|
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'disks': [], 'diskWarnings': 0}
|
|
|
|
disks_raw = _first_value(payload, ('array.disks', 'system.disks', 'disks')) or []
|
|
if not isinstance(disks_raw, list):
|
|
disks_raw = []
|
|
disks: list[dict[str, Any]] = []
|
|
for raw_disk in disks_raw:
|
|
if not isinstance(raw_disk, dict):
|
|
continue
|
|
status_value = str(raw_disk.get('status') or raw_disk.get('smartStatus') or 'unknown')
|
|
warning = raw_disk.get('warning') is not None or raw_disk.get('critical') is not None
|
|
healthy = _as_bool(raw_disk.get('healthy', None))
|
|
if healthy is None:
|
|
healthy = not warning and status_value.lower() in {'ok', 'disk_ok', 'unknown'}
|
|
used_gib = _storage_value_to_gib(raw_disk.get('fsUsed', raw_disk.get('used')))
|
|
size_gib = _storage_value_to_gib(raw_disk.get('fsSize', raw_disk.get('size')))
|
|
free_gib = _storage_value_to_gib(raw_disk.get('fsFree', raw_disk.get('free')))
|
|
if size_gib <= 0 and used_gib > 0 and free_gib > 0:
|
|
size_gib = used_gib + free_gib
|
|
disks.append({
|
|
'name': str(raw_disk.get('name', 'unknown')),
|
|
'status': status_value,
|
|
'healthy': healthy,
|
|
'type': raw_disk.get('type'),
|
|
'usedGiB': used_gib if used_gib > 0 else None,
|
|
'sizeGiB': size_gib if size_gib > 0 else None,
|
|
'freeGiB': free_gib if free_gib > 0 else None,
|
|
'temperature': raw_disk.get('temperature'),
|
|
})
|
|
return {'status': 'ok', 'disks': disks, 'diskWarnings': _safe_disk_warning_count(disks_raw)}
|
|
|
|
def get_cache_status(self):
|
|
payload, status = self._query([
|
|
'query { array { caches { name fsSize fsUsed fsFree size } } }',
|
|
'query { cache { usagePercent usedGiB sizeGiB } }',
|
|
'query { cache { used size } }',
|
|
'query { system { cache { usagePercent usedGiB sizeGiB used size } } }',
|
|
'query { cacheUsage }',
|
|
'query { cachePercent }',
|
|
])
|
|
if status != 'ok':
|
|
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'cacheUsage': 0, 'pools': []}
|
|
|
|
cache_percent = _first_value(payload, ('cache.usagePercent', 'system.cache.usagePercent', 'cacheUsage', 'cachePercent'))
|
|
pools: list[dict[str, Any]] = []
|
|
caches = _first_value(payload, ('array.caches',))
|
|
if isinstance(caches, list):
|
|
for cache in caches:
|
|
if not isinstance(cache, dict):
|
|
continue
|
|
used_gib = _storage_value_to_gib(cache.get('fsUsed', cache.get('used', cache.get('usedGiB'))))
|
|
size_gib = _storage_value_to_gib(cache.get('fsSize', cache.get('size', cache.get('sizeGiB'))))
|
|
free_gib = _storage_value_to_gib(cache.get('fsFree', cache.get('free', cache.get('freeGiB'))))
|
|
if size_gib <= 0 and used_gib > 0 and free_gib > 0:
|
|
size_gib = used_gib + free_gib
|
|
status_value = str(cache.get('status') or cache.get('smartStatus') or 'unknown')
|
|
warning = cache.get('warning') is not None or cache.get('critical') is not None
|
|
healthy = _as_bool(cache.get('healthy', None))
|
|
if healthy is None:
|
|
healthy = not warning and status_value.lower() in {'ok', 'disk_ok', 'healthy', 'unknown'}
|
|
usage_percent = _as_int(cache.get('usagePercent'), -1)
|
|
if usage_percent < 0:
|
|
usage_percent = _percent_from_values(used_gib, size_gib)
|
|
pools.append({
|
|
'name': str(cache.get('name', 'cache')),
|
|
'status': status_value,
|
|
'healthy': healthy,
|
|
'type': cache.get('type'),
|
|
'usedGiB': used_gib if used_gib > 0 else None,
|
|
'sizeGiB': size_gib if size_gib > 0 else None,
|
|
'freeGiB': free_gib if free_gib > 0 else None,
|
|
'usagePercent': usage_percent,
|
|
})
|
|
if cache_percent is None:
|
|
if isinstance(caches, list):
|
|
used_total = 0
|
|
size_total = 0
|
|
for cache in caches:
|
|
if not isinstance(cache, dict):
|
|
continue
|
|
used_total += _as_int(cache.get('fsUsed', cache.get('used')), 0)
|
|
size_total += _as_int(cache.get('fsSize', cache.get('size')), 0)
|
|
if size_total > 0:
|
|
cache_percent = int((used_total / size_total) * 100)
|
|
if cache_percent is None:
|
|
used = _first_value(payload, ('cache.used', 'cache.usedGiB', 'system.cache.used', 'system.cache.usedGiB'))
|
|
total = _first_value(payload, ('cache.size', 'cache.sizeGiB', 'system.cache.size', 'system.cache.sizeGiB'))
|
|
if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
|
|
cache_percent = int((float(used) / float(total)) * 100)
|
|
|
|
if isinstance(cache_percent, bool):
|
|
cache_percent = 100 if cache_percent else 0
|
|
usage = _as_int(cache_percent, 0)
|
|
return {'status': 'ok', 'cacheUsage': usage, 'pools': pools}
|
|
|
|
def get_parity_status(self):
|
|
payload, status = self._query([
|
|
(
|
|
'query { array { '
|
|
'parityCheckStatus { progress speed errors status paused running correcting } '
|
|
'parities { name status warning critical } '
|
|
'} }'
|
|
),
|
|
'query { array { parities { name status warning critical } } }',
|
|
])
|
|
if status != 'ok':
|
|
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'parityStatus': 'invalid'}
|
|
|
|
check = _first_value(payload, ('array.parityCheckStatus',))
|
|
check = check if isinstance(check, dict) else {}
|
|
check_status = str(check.get('status') or '').strip().lower()
|
|
paused = _as_bool(check.get('paused')) is True
|
|
running = (
|
|
not paused
|
|
and (
|
|
_as_bool(check.get('running')) is True
|
|
or check_status in {'running', 'checking', 'resyncing'}
|
|
)
|
|
)
|
|
if running or paused or check_status in {'running', 'checking', 'paused', 'resyncing'}:
|
|
parity_status = 'checking'
|
|
else:
|
|
parities = _first_value(payload, ('array.parities',))
|
|
parities = parities if isinstance(parities, list) else []
|
|
healthy_statuses = {'disk_ok', 'ok', 'valid', 'healthy', 'online'}
|
|
parity_status = 'valid' if parities and all(
|
|
isinstance(disk, dict)
|
|
and not disk.get('warning')
|
|
and not disk.get('critical')
|
|
and str(disk.get('status') or '').strip().lower() in healthy_statuses
|
|
for disk in parities
|
|
) else 'invalid'
|
|
|
|
return {
|
|
'status': 'ok',
|
|
'parityStatus': parity_status,
|
|
'checkStatus': check_status or 'idle',
|
|
'running': running,
|
|
'paused': paused,
|
|
'correcting': _as_bool(check.get('correcting')) is True,
|
|
'progress': check.get('progress'),
|
|
'speed': check.get('speed'),
|
|
'errors': _as_int(check.get('errors'), 0),
|
|
}
|
|
|
|
def get_docker_service_status(self):
|
|
payload, status = self._query([
|
|
'query { docker { containers { id names image state status } networks { id name } } }',
|
|
'query { docker { running status } }',
|
|
'query { dockerService { running status } }',
|
|
'query { system { docker { running status } } }',
|
|
'query { dockerStatus }',
|
|
])
|
|
if status != 'ok':
|
|
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'running': False}
|
|
|
|
docker_containers = _first_value(payload, ('docker.containers',))
|
|
raw_running = _first_value(payload, ('docker.running', 'dockerService.running', 'system.docker.running'))
|
|
if raw_running is None and isinstance(docker_containers, list):
|
|
raw_running = True
|
|
if isinstance(raw_running, str) and raw_running.lower() in {'running', 'started', 'online'}:
|
|
raw_running = True
|
|
running = bool(_as_bool(raw_running) or str(raw_running).lower() in {'running', 'started', 'online'})
|
|
return {
|
|
'status': 'ok',
|
|
'running': running,
|
|
'serviceStatus': str(_first_value(payload, ('docker.status', 'dockerService.status', 'system.docker.status', 'status')) or ('running' if running else 'stopped')),
|
|
'containers': len(docker_containers) if isinstance(docker_containers, list) else None,
|
|
}
|
|
|
|
def get_vm_summary(self):
|
|
payload, status = self._query([
|
|
'query { vms { domains { id uuid name state } } }',
|
|
'query { vms { id name state status } }',
|
|
'query { virtualMachines { id name state status } }',
|
|
'query { system { vms { id name state status } } }',
|
|
])
|
|
if status != 'ok':
|
|
reason = str(payload.get('error', 'connection_failed'))
|
|
if 'VMs are not available' in reason:
|
|
return {
|
|
'status': 'api_unavailable',
|
|
'reason': (
|
|
'De Unraid API gaf geen VM-toegang terug. VM Manager kan nog steeds actief zijn; '
|
|
'controleer of de ingestelde API-sleutel vm:read (of vm:*) heeft en of deze '
|
|
'Unraid API-versie het VM-schema beschikbaar stelt.'
|
|
),
|
|
'vms': [],
|
|
}
|
|
return {'status': status, 'reason': reason, 'vms': []}
|
|
|
|
raw_vms = _first_value(payload, ('vms.domains', 'vms', 'virtualMachines', 'system.vms'))
|
|
if not isinstance(raw_vms, list):
|
|
raw_vms = []
|
|
|
|
vms = []
|
|
for raw_vm in raw_vms:
|
|
if not isinstance(raw_vm, dict):
|
|
continue
|
|
vms.append({
|
|
'id': str(raw_vm.get('id', raw_vm.get('uuid', raw_vm.get('name', '')))),
|
|
'name': str(raw_vm.get('name', 'unknown')),
|
|
'state': str(raw_vm.get('state', raw_vm.get('status', 'unknown'))),
|
|
})
|
|
return {'status': 'ok', 'vms': vms}
|
|
|
|
def mutate_vm(self, vm_id: str, action: str) -> dict[str, Any]:
|
|
supported_actions = {'start', 'pause', 'resume'}
|
|
normalized_action = str(action).strip().lower()
|
|
normalized_vm_id = str(vm_id).strip()
|
|
if normalized_action not in supported_actions:
|
|
return {
|
|
'success': False,
|
|
'status': 'blocked',
|
|
'message': 'unsupported_or_blocked_vm_action',
|
|
'action': normalized_action,
|
|
}
|
|
if not normalized_vm_id:
|
|
return {
|
|
'success': False,
|
|
'status': 'invalid_target',
|
|
'message': 'vm_id_required',
|
|
'action': normalized_action,
|
|
}
|
|
if not self.is_configured():
|
|
return {
|
|
'success': False,
|
|
'status': 'missing_config',
|
|
'message': 'missing_config',
|
|
'action': normalized_action,
|
|
}
|
|
|
|
# json.dumps produces a valid escaped GraphQL string literal without
|
|
# interpolating an operator-controlled identifier as GraphQL syntax.
|
|
vm_id_literal = json.dumps(normalized_vm_id)
|
|
query = f'mutation AppOpsVmAction {{ vm {{ {normalized_action}(id: {vm_id_literal}) }} }}'
|
|
try:
|
|
payload = self._request(query)
|
|
except RuntimeError as exc:
|
|
reason = str(exc)
|
|
if reason == 'unauthorized':
|
|
reason = 'Unraid API key needs vm:update (or vm:*) permission.'
|
|
return {
|
|
'success': False,
|
|
'status': 'permission_denied' if 'unauthorized' in reason.lower() else 'error',
|
|
'message': reason,
|
|
'action': normalized_action,
|
|
}
|
|
|
|
changed = _first_value(payload, (f'vm.{normalized_action}',))
|
|
return {
|
|
'success': changed is True,
|
|
'status': 'ok' if changed is True else 'failed',
|
|
'message': f'vm_{normalized_action}_accepted' if changed is True else f'vm_{normalized_action}_rejected',
|
|
'action': normalized_action,
|
|
}
|
|
|
|
def get_share_summary(self):
|
|
payload, status = self._query([
|
|
'query { shares { name free used size } }',
|
|
'query { shares { name usedGiB sizeGiB critical } }',
|
|
'query { system { shares { name usedGiB sizeGiB critical } } }',
|
|
'query { shareSummary { shares { name usedGiB sizeGiB critical } } }',
|
|
])
|
|
if status != 'ok':
|
|
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'shares': []}
|
|
|
|
raw_shares = _first_value(payload, ('shares', 'system.shares', 'shareSummary.shares'))
|
|
if not isinstance(raw_shares, list):
|
|
raw_shares = []
|
|
shares = []
|
|
for share in raw_shares:
|
|
if not isinstance(share, dict):
|
|
continue
|
|
used = _as_int(share.get('usedGiB'), -1)
|
|
size = _as_int(share.get('sizeGiB'), -1)
|
|
if used < 0:
|
|
used = _storage_value_to_gib(share.get('used'))
|
|
if size < 0:
|
|
size = _storage_value_to_gib(share.get('size'))
|
|
if size <= 0 and share.get('free') is not None:
|
|
size = used + _storage_value_to_gib(share.get('free'))
|
|
shares.append({
|
|
'name': str(share.get('name', 'unknown')),
|
|
'usedGiB': used,
|
|
'sizeGiB': size,
|
|
'critical': bool(_as_bool(share.get('critical', False))),
|
|
})
|
|
return {'status': 'ok', 'shares': shares}
|