from __future__ import annotations import re from urllib.parse import urlsplit from fastapi import APIRouter, Depends from sqlmodel import Session, select from ..db.models import App, AppContainerMapping, DiscoveredContainer from ..db.session import get_session from ..core.config import settings from ..services import safety_center_service from ..services.app_registry import category_label, list_active_apps from ..services.audit_logger import log_event from ..services.inventory_discovery import serialize_inventory_app, sync_app_connector_statuses router = APIRouter(prefix='/api/apps') def _published_port(values: list | None) -> int | None: for value in values or []: match = re.match(r'^(\d+):\d+', str(value)) if match: return int(match.group(1)) return None def _credential_free_url(raw: str) -> str | None: raw = str(raw or '').strip() if not raw: return None parsed = urlsplit(raw if '://' in raw else f'http://{raw}') try: hostname = parsed.hostname parsed_port = parsed.port except ValueError: return None if parsed.scheme not in {'http', 'https'} or not hostname: return None host = f'[{hostname}]' if ':' in hostname else hostname port = f':{parsed_port}' if parsed_port else '' path = parsed.path.rstrip('/') return f'{parsed.scheme}://{host}{port}{path}' def _base_runtime_url() -> str | None: safe = _credential_free_url(str(settings.apis.get('UNRAID_URL') or settings.apis.get('PORTAINER_URL') or '')) if not safe: return None parsed = urlsplit(safe) return f'{parsed.scheme}://{parsed.netloc}' def _runtime_hints_by_app(session: Session) -> dict[str, dict]: from ..services.inventory_discovery import _latest_by_container configured_urls = { 'sonarr': 'SONARR_URL', 'radarr': 'RADARR_URL', 'prowlarr': 'PROWLARR_URL', 'deluge': 'DELUGE_URL', 'plex': 'PLEX_URL', 'tautulli': 'TAUTULLI_URL', 'nginx_proxy_manager': 'NPM_URL', 'authentik': 'AUTHENTIK_URL', 'home_assistant': 'HOME_ASSISTANT_URL', 'widefrog': 'WIDEFROG_URL', 'portainer': 'PORTAINER_URL', 'unraid': 'UNRAID_URL', } hints: dict[str, dict] = {} for app_id, env_name in configured_urls.items(): value = _credential_free_url(str(settings.apis.get(env_name) or '')) if value: hints[app_id] = {'url': value} base_url = _base_runtime_url() latest = _latest_by_container(session) mappings = session.exec(select(AppContainerMapping).order_by(AppContainerMapping.is_primary.desc())).all() for mapping in mappings: if mapping.app_id in hints or not base_url: continue item = latest.get(mapping.container_id) port = _published_port(item.ports_json if item else None) if port: hints[mapping.app_id] = {'url': base_url, 'port': port} return hints def _serialize_app(app: App, hints: dict | None = None) -> dict: return { 'id': app.id, 'name': app.name, 'category': app.category, 'status': app.status, 'criticality': app.criticality, 'connectorStatus': app.connector_status, 'containerStatus': app.container_status, 'voiceEnabled': app.voice_enabled, 'lastAction': app.last_action, 'protected': app.protected, 'discoveryStatus': app.discovery_status, 'discoverySource': app.discovery_source, 'needsReview': app.needs_review, **(hints or {}), } def _serialize_discovered_app(item: DiscoveredContainer) -> dict: return serialize_inventory_app(item) @router.get('') def list_apps(include_discovered: bool = False, session: Session = Depends(get_session)): sync_app_connector_statuses(session) runtime_hints = _runtime_hints_by_app(session) apps = [_serialize_app(app, runtime_hints.get(app.id)) for app in list_active_apps(session)] if include_discovered: managed_container_ids = { mapping.container_id for mapping in session.exec(select(AppContainerMapping)).all() } latest_by_container: dict[str, DiscoveredContainer] = {} for item in session.exec(select(DiscoveredContainer)).all(): latest_by_container[item.container_id] = item for item in latest_by_container.values(): if item.container_id in managed_container_ids or item.import_status == 'managed': continue if item.import_status in {'discovered', 'unmapped', 'changed', 'ignored'}: apps.append(_serialize_discovered_app(item)) return apps @router.get('/{app_id}') def get_app(app_id: str, session: Session = Depends(get_session)): sync_app_connector_statuses(session) app = session.get(App, app_id) if app: return _serialize_app(app, _runtime_hints_by_app(session).get(app.id)) if app_id.startswith('discovered-'): discovered_id = app_id.replace('discovered-', '', 1) item = session.get(DiscoveredContainer, discovered_id) if item: return _serialize_discovered_app(item) return None @router.patch('/{app_id}/metadata') def update_app_metadata(app_id: str, payload: dict, session: Session = Depends(get_session)): app = session.get(App, app_id) if not app: return {'success': False, 'status': 'not_found', 'message': 'app_not_found'} name = str(payload.get('name') or '').strip()[:80] category = re.sub(r'[^a-zA-Z0-9_\- ]', '', str(payload.get('category') or '')).strip()[:40] criticality = str(payload.get('criticality') or '').strip().lower() if not name or not category or criticality not in {'low', 'medium', 'high', 'critical'}: return {'success': False, 'status': 'validation_error', 'message': 'invalid_app_metadata'} before = {'name': app.name, 'category': app.category, 'criticality': app.criticality} app.name = name app.category = category_label(category) app.criticality = criticality session.add(app) session.commit() session.refresh(app) safety_center_service.invalidate_cache() log_event( session, source='manual', identity='operator', action_id='app.metadata.update', target=app.id, target_type='app', risk='low', policy_decision='allowed', reason='Operator updated AppOps registry metadata only; no container or external service was mutated.', result='success', before_state=before, after_state={'name': app.name, 'category': app.category, 'criticality': app.criticality}, redacted_metadata={'fields': ['name', 'category', 'criticality']}, ) return {'success': True, 'status': 'success', 'message': 'app_metadata_updated', 'app': _serialize_app(app, _runtime_hints_by_app(session).get(app.id))} @router.patch('/{app_id}/container-mapping') def update_container_mapping(app_id: str, payload: dict, session: Session = Depends(get_session)): container_id = str(payload.get('containerId') or '') container_name = str(payload.get('containerName') or container_id) if not session.get(App, app_id): return {'success': False, 'message': 'app_not_found'} if not container_id: return {'success': False, 'message': 'container_id_required'} mapping = session.exec(select(AppContainerMapping).where(AppContainerMapping.container_id == container_id)).first() if not mapping: mapping = AppContainerMapping(id=f'map-{container_id}', app_id=app_id, container_id=container_id, container_name=container_name) else: mapping.app_id = app_id mapping.container_name = container_name session.add(mapping) session.commit() safety_center_service.invalidate_cache() return {'success': True, 'message': 'mapping_updated', 'mappingId': mapping.id}