mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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:
parent
d77eaa9ecd
commit
b07672b976
4 changed files with 309 additions and 1 deletions
|
|
@ -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',
|
||||
|
|
|
|||
27
modules/ai/controller_client.py
Normal file
27
modules/ai/controller_client.py
Normal 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
9
modules/ai/errors.py
Normal 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
|
||||
130
modules/ai/test/test_system_api.py
Normal file
130
modules/ai/test/test_system_api.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue