from __future__ import annotations import json 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): return float(value) 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: return float(numeric) 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) -> 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 return _clamp_percent((used_value / total_value) * 100) def _average_cpu_usage(cpus: Any) -> int | 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 _clamp_percent(100 - (sum(idle_values) / len(idle_values))) if active_values: return _clamp_percent(sum(active_values) / len(active_values)) 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) 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}