From 60bc1ea1b962ca9c76a97bdcf4cbce731100766f Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:08:39 -0600 Subject: [PATCH] =?UTF-8?q?ai:=20system=20endpoints=20=E2=80=94=20aggregat?= =?UTF-8?q?e=20status,=20services,=20logs,=20disks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxy the Controller under /api/ai so the model sees one API: an aggregate /status to start every diagnosis, lean service list, recent service logs (lines clamped), and disks + SMART. One controller_client helper resolves the Controller base URL (docker service name vs localhost). Every proxy degrades to an `errors` entry when the Controller is unreachable — never a 500. Co-Authored-By: Claude Fable 5 --- modules/ai/api.py | 144 ++++++++++++++++++++++++++++- modules/ai/controller_client.py | 27 ++++++ modules/ai/errors.py | 9 ++ modules/ai/test/test_system_api.py | 130 ++++++++++++++++++++++++++ 4 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 modules/ai/controller_client.py create mode 100644 modules/ai/errors.py create mode 100644 modules/ai/test/test_system_api.py diff --git a/modules/ai/api.py b/modules/ai/api.py index 191d5730..957b4ecc 100644 --- a/modules/ai/api.py +++ b/modules/ai/api.py @@ -16,13 +16,19 @@ from modules.inventory.common import get_inventory_configs from modules.inventory.errors import UnknownInventory from modules.videos.video import lib as videos_lib from modules.zim import lib as zim_lib +from wrolpi import flags from wrolpi.api_utils import json_response from wrolpi.collections.lib import search_collections -from wrolpi.common import api_param_limiter, get_media_directory, logger +from wrolpi.common import api_param_limiter, get_media_directory, logger, wrol_mode_enabled +from wrolpi.downloader import download_manager from wrolpi.errors import InvalidFile, SearchEmpty, UnknownFile from wrolpi.files.lib import search_files +from wrolpi.vars import DOCKERIZED +from wrolpi.version import __version__ from wrolpi.schema import JSONErrorResponse from . import lib, schema +from .controller_client import controller_get +from .errors import ControllerUnavailable ai_bp = Blueprint('AI', url_prefix='/api/ai') @@ -328,6 +334,142 @@ async def get_inventory(_: Request, slug: str): return json_response(dict(inventory=inventory)) +# --------------------------------------------------------------------------- +# System endpoints (System mode). The Controller is proxied so the model sees one API; every +# proxy degrades gracefully — a Controller outage becomes an entry in `errors`, never a 500. +# --------------------------------------------------------------------------- + +# The lean subset of the Controller's cached status that is useful for diagnosis; the full blob +# (processes, iostat, disk bandwidth) is too big for small model contexts. +SYSTEM_STATUS_KEYS = ('cpu', 'memory', 'load', 'drives', 'network', 'uptime', 'hotspot', 'throttle', 'is_rpi') + +log_lines_limiter = api_param_limiter(1_000, default=100) + + +@ai_bp.get('/status') +@openapi.definition( + summary='Get WROLPi system status', + description='Get an aggregate of the whole system in one call: WROLPi version, mode flags, download' + ' summary, CPU/memory/load/disk usage, and the state of every WROLPi service. Start' + ' troubleshooting here. Any part that could not be gathered is listed in errors.', +) +@openapi.operation('get_system_status') +async def get_system_status(_: Request): + errors = [] + ret = dict( + version=__version__.strip(), + dockerized=DOCKERIZED, + wrol_mode=wrol_mode_enabled(), + flags=flags.get_flags(), + errors=errors, + ) + + try: + ret['downloads'] = download_manager.get_summary() + except Exception as e: + logger.debug('AI status could not get download summary', exc_info=e) + errors.append('Could not get download summary') + + try: + status, stats = await controller_get('/api/stats') + if status == HTTPStatus.OK: + ret['system'] = {k: v for k, v in stats.items() if k in SYSTEM_STATUS_KEYS} + else: + errors.append(f'Controller stats returned HTTP {status}') + except Exception as e: + logger.debug('AI status could not reach Controller stats', exc_info=e) + errors.append('Could not reach the Controller for system stats') + + try: + ret['services'] = await _lean_services() + except Exception as e: + logger.debug('AI status could not get services', exc_info=e) + errors.append('Could not get service statuses') + + return json_response(ret) + + +async def _lean_services() -> list: + status, services = await controller_get('/api/services') + if status != HTTPStatus.OK or not isinstance(services, list): + raise ControllerUnavailable(f'Controller services returned HTTP {status}') + return [{k: v for k, v in dict( + name=i.get('name'), + status=i.get('status'), + enabled=i.get('enabled'), + port=i.get('port'), + description=i.get('description') or None, + ).items() if v is not None} for i in services] + + +@ai_bp.get('/services') +@openapi.definition( + summary='List WROLPi services', + description='List every WROLPi service with its state (running, stopped, failed) and port. Use the' + ' name with get_service_logs to read a failing service\'s logs.', +) +@openapi.operation('list_services') +@openapi.response(HTTPStatus.BAD_GATEWAY, JSONErrorResponse) +async def ai_list_services(_: Request): + services = await _lean_services() + return json_response(dict(results=services, total=len(services))) + + +@ai_bp.get('/services//logs') +@openapi.definition( + summary='Read service logs', + description='Read the most recent log lines of one WROLPi service by its name from list_services.' + ' Pass lines to read more history (at most 1000).', +) +@openapi.operation('get_service_logs') +@openapi.response(HTTPStatus.BAD_GATEWAY, JSONErrorResponse) +async def ai_get_service_logs(request: Request, name: str): + try: + lines = log_lines_limiter(int(request.args.get('lines', 100))) + except ValueError: + lines = 100 + status, body = await controller_get(f'/api/services/{name}/logs', + params=dict(lines=max(lines, 1), since=request.args.get('since'))) + if status != HTTPStatus.OK: + raise ControllerUnavailable(f'Controller logs returned HTTP {status}: {body.get("detail")}') + return json_response(dict(service=body.get('service', name), lines=body.get('lines'), logs=body.get('logs', ''))) + + +@ai_bp.get('/disks') +@openapi.definition( + summary='Get disks and drive health', + description='List the disks and partitions (size, filesystem, where mounted) and SMART drive-health' + ' status. Use this to diagnose full or failing drives. Parts that could not be gathered' + ' are listed in errors.', +) +@openapi.operation('list_disks') +async def ai_list_disks(_: Request): + errors = [] + ret = dict(errors=errors) + + try: + status, disks = await controller_get('/api/disks') + if status == HTTPStatus.OK: + ret['disks'] = disks + else: + errors.append(f'Controller disks returned HTTP {status}: {disks.get("detail")}') + except Exception as e: + logger.debug('AI disks could not reach Controller', exc_info=e) + errors.append('Could not reach the Controller for disks') + + try: + status, smart = await controller_get('/api/disks/smart') + if status == HTTPStatus.OK: + ret['smart'] = smart + else: + errors.append(f'Controller SMART returned HTTP {status}: {smart.get("detail")}') + except Exception as e: + logger.debug('AI disks could not reach Controller SMART', exc_info=e) + errors.append('Could not reach the Controller for SMART status') + + return json_response(ret) + + @ai_bp.get('/files/read') @openapi.definition( summary='Read a text file', diff --git a/modules/ai/controller_client.py b/modules/ai/controller_client.py new file mode 100644 index 00000000..bef142e3 --- /dev/null +++ b/modules/ai/controller_client.py @@ -0,0 +1,27 @@ +"""One helper for API -> Controller requests. + +Existing call sites disagree on the base URL (wrolpi/common.py uses http://127.0.0.1, the zim +module uses the docker service name); this module resolves it once: the docker service name when +DOCKERIZED, else localhost, overridable with the CONTROLLER_URL environment variable. +""" +import os +from typing import Tuple +from urllib.parse import urlencode + +from wrolpi.common import aiohttp_get, logger +from wrolpi.vars import DOCKERIZED + +logger = logger.getChild(__name__) + +CONTROLLER_URL = os.environ.get('CONTROLLER_URL') or ('http://controller' if DOCKERIZED else 'http://127.0.0.1') + + +async def controller_get(path: str, params: dict = None, timeout: int = 10) -> Tuple[int, dict]: + """GET a Controller endpoint, returning (status, JSON body). Raises on connection errors.""" + url = f'{CONTROLLER_URL}{path}' + if params: + params = {k: v for k, v in params.items() if v is not None} + if params: + url = f'{url}?{urlencode(params)}' + async with aiohttp_get(url, timeout=timeout) as response: + return response.status, await response.json() diff --git a/modules/ai/errors.py b/modules/ai/errors.py new file mode 100644 index 00000000..37ee8840 --- /dev/null +++ b/modules/ai/errors.py @@ -0,0 +1,9 @@ +from http import HTTPStatus + +from wrolpi.errors import APIError + + +class ControllerUnavailable(APIError): + code = 'CONTROLLER_UNAVAILABLE' + summary = 'The Controller did not answer, or answered with an error.' + status_code = HTTPStatus.BAD_GATEWAY diff --git a/modules/ai/test/test_system_api.py b/modules/ai/test/test_system_api.py new file mode 100644 index 00000000..79395853 --- /dev/null +++ b/modules/ai/test/test_system_api.py @@ -0,0 +1,130 @@ +"""Tests for the /api/ai system endpoints (System mode).""" +from http import HTTPStatus +from unittest import mock + +import pytest + +CONTROLLER_STATS = dict( + cpu=dict(percent=12.5), + memory=dict(total=8_000_000_000, used=2_000_000_000), + load=dict(minute_1=0.5), + drives=[dict(mount='/media/wrolpi', percent=41)], + # Keys the lean status must NOT forward (too big for small contexts). + processes=[dict(pid=1, name='huge')], + iostat=dict(huge=True), +) + +CONTROLLER_SERVICES = [ + dict(name='wrolpi-api', status='running', enabled=True, port=8081, description='API', viewable=True), + dict(name='wrolpi-kiwix', status='failed', enabled=True, port=9085, description='', view_path='/'), +] + + +def _mock_controller(responses: dict): + """Patch controller_get to answer from {path: (status, body)}.""" + + async def fake_controller_get(path, params=None, timeout=10): + if path not in responses: + raise ConnectionError(f'no controller response for {path}') + return responses[path] + + return mock.patch('modules.ai.api.controller_get', side_effect=fake_controller_get) + + +@pytest.mark.asyncio +async def test_ai_system_status(async_client, test_session): + """The aggregate status contains API state, lean Controller stats, and services.""" + responses = { + '/api/stats': (200, CONTROLLER_STATS), + '/api/services': (200, CONTROLLER_SERVICES), + } + with _mock_controller(responses): + request, response = await async_client.get('/api/ai/status') + assert response.status_code == HTTPStatus.OK + assert response.json['version'] + assert response.json['wrol_mode'] is False + assert isinstance(response.json['flags'], dict) + # Only the lean subset of Controller stats is forwarded. + assert response.json['system']['cpu'] == dict(percent=12.5) + assert 'processes' not in response.json['system'] + assert 'iostat' not in response.json['system'] + # Services are lean: no view_path/viewable noise, empty description omitted. + services = {i['name']: i for i in response.json['services']} + assert services['wrolpi-kiwix']['status'] == 'failed' + assert 'viewable' not in services['wrolpi-api'] + assert 'description' not in services['wrolpi-kiwix'] + assert response.json['errors'] == [] + + +@pytest.mark.asyncio +async def test_ai_system_status_controller_down(async_client, test_session): + """A Controller outage degrades to errors entries, never a 500.""" + with _mock_controller({}): + request, response = await async_client.get('/api/ai/status') + assert response.status_code == HTTPStatus.OK + assert response.json['version'] + assert any('system stats' in i for i in response.json['errors']) + assert any('service statuses' in i for i in response.json['errors']) + assert 'system' not in response.json + assert 'services' not in response.json + + +@pytest.mark.asyncio +async def test_ai_list_services(async_client, test_session): + """Services are listed lean; a Controller failure is a clear 502.""" + with _mock_controller({'/api/services': (200, CONTROLLER_SERVICES)}): + request, response = await async_client.get('/api/ai/services') + assert response.status_code == HTTPStatus.OK + assert response.json['total'] == 2 + assert response.json['results'][0]['name'] == 'wrolpi-api' + + with _mock_controller({'/api/services': (500, dict(error='boom'))}): + request, response = await async_client.get('/api/ai/services') + assert response.status_code == HTTPStatus.BAD_GATEWAY + + +@pytest.mark.asyncio +async def test_ai_get_service_logs(async_client, test_session): + """Logs are proxied with the lines param clamped.""" + captured = {} + + async def fake_controller_get(path, params=None, timeout=10): + captured['path'] = path + captured['params'] = params + return 200, dict(service='wrolpi-kiwix', lines=params['lines'], logs='line1\nline2') + + with mock.patch('modules.ai.api.controller_get', side_effect=fake_controller_get): + request, response = await async_client.get('/api/ai/services/wrolpi-kiwix/logs?lines=999999') + assert response.status_code == HTTPStatus.OK + assert captured['path'] == '/api/services/wrolpi-kiwix/logs' + assert captured['params']['lines'] == 1_000 # clamped + assert response.json['logs'] == 'line1\nline2' + + # Unknown service: the Controller's error becomes a 502 with its detail. + with _mock_controller({'/api/services/nope/logs': (404, dict(detail='Service not found'))}): + request, response = await async_client.get('/api/ai/services/nope/logs') + assert response.status_code == HTTPStatus.BAD_GATEWAY + + +@pytest.mark.asyncio +async def test_ai_list_disks(async_client, test_session): + """Disks and SMART are aggregated; partial failures land in errors.""" + disks = [dict(name='sda1', path='/dev/sda1', size='500G', fstype='ext4', mountpoint='/media/wrolpi')] + responses = { + '/api/disks': (200, disks), + '/api/disks/smart': (200, dict(available=True, drives=[dict(name='sda', assessment='PASS')])), + } + with _mock_controller(responses): + request, response = await async_client.get('/api/ai/disks') + assert response.status_code == HTTPStatus.OK + assert response.json['disks'] == disks + assert response.json['smart']['available'] is True + assert response.json['errors'] == [] + + # SMART unavailable (e.g. docker) is an error entry, not a failure. + with _mock_controller({'/api/disks': (200, disks)}): + request, response = await async_client.get('/api/ai/disks') + assert response.status_code == HTTPStatus.OK + assert response.json['disks'] == disks + assert 'smart' not in response.json + assert len(response.json['errors']) == 1