ai: system endpoints — aggregate status, services, logs, disks

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 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-08-25 21:08:39 -06:00
parent d77eaa9ecd
commit b07672b976
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
4 changed files with 309 additions and 1 deletions

View file

@ -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/<name:str>/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',

View file

@ -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()

9
modules/ai/errors.py Normal file
View file

@ -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

View file

@ -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