from __future__ import annotations import asyncio import os import sqlite3 from contextlib import asynccontextmanager, suppress from datetime import timedelta from pathlib import Path from fastapi import FastAPI, HTTPException, Query, Response, status from fastapi.responses import JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from .auth import AuthConfig, AuthorizationError, authorize_request from .config_io import export_document, import_document, parse_document, serialize_document from .demo import DEMO_ROOT_CAUSE_NAME, DEMO_SCENARIO_NAME, build_demo_records from .discovery import DiscoveryManager from .discovery_store import DiscoveryStore from .docker_discovery import discover_containers from .docker_provider import docker_provider_mode from .events import event_bus, next_event, sse_payload from .health import dependency_status, effective_status, status_maps, validate_dependencies from .incidents import incident_overview, sync_incidents from .models import ( AvailabilitySummary, BulkProbeResult, ConfigImportRequest, ConfigImportResult, DemoMutationResult, DemoState, DiscoveryObservation, DiscoveryReconcileRequest, DiscoveryResult, DiscoveryRunRequest, DiscoveryStatus, DockerDiscoveryRequest, DockerDiscoveryResult, EdgeActionRequest, EdgeMutationRequest, EventRecord, HealthResolution, IncidentOverview, LogicalRelation, MaintenanceRequest, OverviewSummary, ProbeRepairReport, ProbeResult, ProviderStatus, ReconcileResult, RelationEvidence, SemanticTopology, Service, ServiceCreate, ServiceSnapshot, ServiceUpdate, SuggestionStatus, TopologyDiff, TopologySnapshot, TopologySuggestion, ) from .observation import ObservationManager from .probes import probe_many, probe_service from .scheduler import scheduled_probe_loop from .semantic import build_semantic_topology from .store import Store store = Store() discovery_store = DiscoveryStore(store) discovery_manager = DiscoveryManager(store, discovery_store) def _env_int(name: str, default: int, minimum: int = 0) -> int: try: return max(minimum, int(os.getenv(name, str(default)))) except ValueError: return default def _env_float(name: str, default: float, minimum: float = 0.1) -> float: try: return max(minimum, float(os.getenv(name, str(default)))) except ValueError: return default PROBE_INTERVAL = _env_int("SERVICEMAP_PROBE_INTERVAL", 0) PROBE_TIMEOUT = _env_float("SERVICEMAP_PROBE_TIMEOUT", 5.0) PROBE_RETRIES = _env_int("SERVICEMAP_PROBE_RETRIES", 1) PROBE_CONCURRENCY = _env_int("SERVICEMAP_PROBE_CONCURRENCY", 10, minimum=1) RETENTION_DAYS = _env_int("SERVICEMAP_RETENTION_DAYS", 30, minimum=1) EVENT_RETENTION_DAYS = _env_int("SERVICEMAP_EVENT_RETENTION_DAYS", 90, minimum=1) DISCOVERY_INTERVAL = _env_int("SERVICEMAP_DISCOVERY_INTERVAL_SECONDS", 0, minimum=0) OBSERVATION_INTERVAL = _env_int("SERVICEMAP_OBSERVATION_INTERVAL_SECONDS", 60, minimum=10) observation_manager = ObservationManager(store, interval_seconds=OBSERVATION_INTERVAL) def _require_configured_discovery_targets( docker_host: str | None = None, appops_url: str | None = None ) -> None: """Request bodies cannot redirect an unauthenticated LAN deployment's inventory reads.""" configured_docker = os.getenv("SERVICEMAP_DOCKER_HOST", "unix:///var/run/docker.sock") configured_appops = os.getenv("SERVICEMAP_APPOPS_URL", "") if docker_host and docker_host != configured_docker: raise HTTPException(status_code=403, detail="Docker discovery target is not configured") if appops_url and appops_url.rstrip("/") != configured_appops.rstrip("/"): raise HTTPException(status_code=403, detail="AppOps discovery target is not configured") @asynccontextmanager async def lifespan(_: FastAPI): task: asyncio.Task[None] | None = None discovery_task: asyncio.Task[None] | None = None observation_task: asyncio.Task[None] | None = None if PROBE_INTERVAL > 0: task = asyncio.create_task( scheduled_probe_loop( store, interval_seconds=PROBE_INTERVAL, timeout_seconds=PROBE_TIMEOUT, retries=PROBE_RETRIES, concurrency=PROBE_CONCURRENCY, retention_days=RETENTION_DAYS, ) ) if DISCOVERY_INTERVAL > 0: async def discovery_loop() -> None: while True: await asyncio.sleep(DISCOVERY_INTERVAL) if store.demo_active(): continue try: request = DiscoveryRunRequest() result = await asyncio.to_thread(discovery_manager.run, request) await asyncio.to_thread( discovery_manager.reconcile, DiscoveryReconcileRequest(run_id=result.run.id, apply_high_confidence=False), ) except (OSError, RuntimeError, ValueError): # Discovery is review-first; a failed background run is # persisted and must not take down the monitoring loop. continue discovery_task = asyncio.create_task(discovery_loop()) async def observation_loop() -> None: while True: if not store.demo_active(): try: await asyncio.to_thread(observation_manager.refresh) except (OSError, RuntimeError, ValueError): # Provider availability is persisted by the observer; an # unavailable provider must not stop scheduled probing. pass await asyncio.sleep(OBSERVATION_INTERVAL) observation_task = asyncio.create_task(observation_loop()) try: yield finally: if task: task.cancel() with suppress(asyncio.CancelledError): await task if discovery_task: discovery_task.cancel() with suppress(asyncio.CancelledError): await discovery_task if observation_task: observation_task.cancel() with suppress(asyncio.CancelledError): await observation_task app = FastAPI(title="ServiceMap API", version="1.6.2", lifespan=lifespan) @app.middleware("http") async def authorization_boundary(request, call_next): try: authorize_request(request) except HTTPException as exc: return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) return await call_next(request) def _snapshot(service: Service) -> ServiceSnapshot: services = store.list_services() dependents = [item.name for item in services if service.id in item.depends_on] return ServiceSnapshot( service=service, latest_probe=store.latest_probe(service.id), availability_24h=store.availability_summary(service.id, 24), dependency_status=dependency_status(service, store), effective_status=effective_status(service, store), direct_status=store.service_health(service.id), flapping=service.flapping, flap_count=service.flap_count, next_probe_at=store.next_probe_at(service, PROBE_INTERVAL), dependents=dependents, sources=discovery_store.sources_for_service(service.id), evidence=discovery_store.evidence_for_service(service.id), health_evidence=store.health_evidence_for_service(service.id), health_resolution=store.health_resolution(service.id), ) def _name_conflict(name: str, *, exclude_id: str | None = None) -> bool: existing = store.get_service_by_name(name) return existing is not None and existing.id != exclude_id @app.get("/health") def health() -> dict[str, str]: return {"status": "ok"} @app.get("/api/v1/meta") def meta() -> dict[str, str | int | float | bool]: try: auth_mode = AuthConfig.from_environment().mode except AuthorizationError: auth_mode = "misconfigured" return { "name": "ServiceMap", "version": "1.6.2", "stage": "v1", "scheduled_probing": PROBE_INTERVAL > 0, "probe_interval_seconds": PROBE_INTERVAL, "probe_timeout_seconds": PROBE_TIMEOUT, "probe_retries": PROBE_RETRIES, "probe_concurrency": PROBE_CONCURRENCY, "retention_days": RETENTION_DAYS, "event_retention_days": EVENT_RETENTION_DAYS, "sse": True, "discovery": True, "discovery_interval_seconds": DISCOVERY_INTERVAL, "observation_interval_seconds": OBSERVATION_INTERVAL, "docker_configured": bool(os.getenv("SERVICEMAP_DOCKER_HOST")), "docker_provider": docker_provider_mode(), "appops_configured": bool(os.getenv("SERVICEMAP_APPOPS_URL")), "auth_mode": auth_mode, } def _service_snapshots( *, include_hidden: bool = False, include_context: bool = True ) -> list[ServiceSnapshot]: all_services = store.list_services() services = ( all_services if include_hidden else [ service for service in all_services if service.visibility.value == "visible" and service.lifecycle.value not in {"retired", "ignored"} ] ) latest = store.latest_probes([service.id for service in services]) availability = store.availability_summaries([service.id for service in services], 24) evidence_by_service = store.health_evidence_for_services([service.id for service in all_services]) # Calculate propagation over the complete canonical graph first. Hidden # infrastructure nodes may still sit between a visible service and its # dependency; filtering before this calculation would turn that impact # into a misleading unknown state. direct_all, resolutions = store.service_healths( [service.id for service in all_services], evidence_by_service=evidence_by_service ) effective_all, dependency_all = status_maps(all_services, direct_statuses=direct_all) dependent_names = { service.id: [item.name for item in services if service.id in item.depends_on] for service in services } return [ ServiceSnapshot( service=service, latest_probe=latest.get(service.id), availability_24h=availability.get(service.id), dependency_status=dependency_all.get(service.id, service.state), effective_status=effective_all.get(service.id, service.state), direct_status=direct_all.get(service.id, service.state), flapping=service.flapping, flap_count=service.flap_count, next_probe_at=( latest[service.id].checked_at + timedelta(seconds=service.interval_seconds or PROBE_INTERVAL) if service.id in latest and (service.interval_seconds or PROBE_INTERVAL) > 0 else None ), dependents=dependent_names.get(service.id, []), sources=discovery_store.sources_for_service(service.id) if include_context else [], evidence=discovery_store.evidence_for_service(service.id) if include_context else [], health_evidence=evidence_by_service.get(service.id, []), health_resolution=resolutions.get(service.id, HealthResolution()), ) for service in services ] @app.get("/api/v1/services", response_model=list[ServiceSnapshot]) def list_services(include_hidden: bool = Query(default=False)) -> list[ServiceSnapshot]: return _service_snapshots(include_hidden=include_hidden) @app.post("/api/v1/services", response_model=Service, status_code=status.HTTP_201_CREATED) def create_service(payload: ServiceCreate) -> Service: if _name_conflict(payload.name): raise HTTPException(status_code=409, detail="Service name already exists") try: validate_dependencies(payload.depends_on, store) return store.create_service(payload) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc except sqlite3.IntegrityError as exc: raise HTTPException(status_code=409, detail="Service name already exists") from exc @app.get("/api/v1/services/{service_id}", response_model=ServiceSnapshot) def get_service(service_id: str) -> ServiceSnapshot: service = store.get_service(service_id) if service is None: raise HTTPException(status_code=404, detail="Service not found") return _snapshot(service) @app.put("/api/v1/services/{service_id}", response_model=Service) def update_service(service_id: str, payload: ServiceUpdate) -> Service: before = store.get_service(service_id) if before is None: raise HTTPException(status_code=404, detail="Service not found") if _name_conflict(payload.name, exclude_id=service_id): raise HTTPException(status_code=409, detail="Service name already exists") try: validate_dependencies(payload.depends_on, store, service_id=service_id) updated = store.update_service(service_id, payload) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc except sqlite3.IntegrityError as exc: raise HTTPException(status_code=409, detail="Service name already exists") from exc if updated is None: raise HTTPException(status_code=404, detail="Service not found") if before.source_type: discovery_manager.record_service_override(before, updated) return updated @app.delete("/api/v1/services/{service_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_service(service_id: str) -> None: if not store.delete_service(service_id): raise HTTPException(status_code=404, detail="Service not found") @app.post("/api/v1/services/{service_id}/probe", response_model=ProbeResult) async def run_probe( service_id: str, timeout_seconds: float | None = Query(default=None, ge=0.1, le=30), retries: int | None = Query(default=None, ge=0, le=5), ) -> ProbeResult: if store.demo_active(): raise HTTPException(status_code=409, detail="Explore demo uses synthetic probe data; live probing is paused") service = store.get_service(service_id) if service is None: raise HTTPException(status_code=404, detail="Service not found") result = await probe_service( service, timeout_seconds=timeout_seconds, retries=retries, trigger="manual", default_timeout_seconds=PROBE_TIMEOUT, default_retries=PROBE_RETRIES, ) return store.save_probe(result) @app.post("/api/v1/probe-all", response_model=BulkProbeResult) async def run_all_probes( timeout_seconds: float | None = Query(default=None, ge=0.1, le=30), retries: int | None = Query(default=None, ge=0, le=5), concurrency: int = Query(default=PROBE_CONCURRENCY, ge=1, le=50), ) -> BulkProbeResult: if store.demo_active(): return BulkProbeResult(requested=0, completed=0, results=[]) services = [service for service in store.list_services() if service.enabled and service.monitoring_enabled] results = await probe_many( services, timeout_seconds=timeout_seconds, retries=retries, concurrency=concurrency, trigger="manual", default_timeout_seconds=PROBE_TIMEOUT, default_retries=PROBE_RETRIES, ) for result in results: store.save_probe(result) return BulkProbeResult(requested=len(services), completed=len(results), results=results) @app.get("/api/v1/services/{service_id}/history", response_model=list[ProbeResult]) def probe_history(service_id: str, limit: int = Query(default=50, ge=1, le=500)) -> list[ProbeResult]: if store.get_service(service_id) is None: raise HTTPException(status_code=404, detail="Service not found") return store.probe_history(service_id, limit) @app.get("/api/v1/services/{service_id}/availability", response_model=AvailabilitySummary) def availability(service_id: str, hours: int = Query(default=24, ge=1, le=24 * 90)) -> AvailabilitySummary: if store.get_service(service_id) is None: raise HTTPException(status_code=404, detail="Service not found") return store.availability_summary(service_id, hours) @app.get("/api/v1/services/{service_id}/evidence", response_model=HealthResolution) def service_evidence(service_id: str) -> HealthResolution: if store.get_service(service_id) is None: raise HTTPException(status_code=404, detail="Service not found") return store.health_resolution(service_id) @app.get("/api/v1/providers", response_model=list[ProviderStatus]) def providers() -> list[ProviderStatus]: return store.provider_statuses() @app.post("/api/v1/services/{service_id}/maintenance", response_model=Service) def maintenance(service_id: str, payload: MaintenanceRequest) -> Service: updated = store.set_maintenance(service_id, payload.enabled, payload.reason) if updated is None: raise HTTPException(status_code=404, detail="Service not found") return updated @app.get("/api/v1/incidents", response_model=IncidentOverview) def incidents() -> IncidentOverview: sync_incidents(store) overview = incident_overview(store) overview.incidents = store.list_incidents(active_only=False) return overview @app.get("/api/v1/incidents/{incident_id}") def get_incident(incident_id: str) -> dict[str, object]: incident = store.get_incident(incident_id) if incident is None: raise HTTPException(status_code=404, detail="Incident not found") return {"incident": incident.model_dump(mode="json"), "timeline": [item.model_dump(mode="json") for item in store.incident_timeline(incident_id)]} @app.post("/api/v1/incidents/{incident_id}/acknowledge") def acknowledge_incident(incident_id: str) -> dict[str, object]: try: incident = store.acknowledge_incident(incident_id) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc if incident is None: raise HTTPException(status_code=404, detail="Incident not found") return {"incident": incident.model_dump(mode="json"), "timeline": [item.model_dump(mode="json") for item in store.incident_timeline(incident_id)]} @app.get("/api/v1/events", response_model=list[EventRecord]) def events(limit: int = Query(default=50, ge=1, le=500), service_id: str | None = None) -> list[EventRecord]: return store.list_events(limit=limit, service_id=service_id) @app.get("/api/v1/events/stream") async def event_stream() -> StreamingResponse: queue = event_bus.subscribe() async def stream(): try: yield ": connected\n\n" while True: payload = await next_event(queue) if payload is None: yield ": keepalive\n\n" else: yield sse_payload(payload) finally: event_bus.unsubscribe(queue) return StreamingResponse( stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @app.get("/api/v1/overview", response_model=OverviewSummary) def overview() -> OverviewSummary: sync_incidents(store) snapshots = _service_snapshots(include_hidden=False) overview_state = incident_overview(store) counts: dict[str, int] = {} coverage: dict[str, int] = {} for snapshot in snapshots: counts[snapshot.effective_status.value] = counts.get(snapshot.effective_status.value, 0) + 1 state = snapshot.service.monitoring_state.value coverage[state] = coverage.get(state, 0) + 1 return OverviewSummary( generated_at=overview_state.generated_at, total_services=len(snapshots), health_counts=counts, active_incidents=len(store.list_incidents(active_only=True)), likely_root_causes=[candidate.service_name for candidate in overview_state.candidates], flapping_services=[snapshot.service.name for snapshot in snapshots if snapshot.flapping], recent_events=store.list_events(limit=10), monitoring_coverage=coverage, ) @app.get("/api/v1/config") def export_config(format: str = Query(default="yaml", pattern="^(yaml|json)$")) -> Response: document = export_document(store) content = serialize_document(document, format) media_type = "application/json" if format == "json" else "application/yaml" extension = "json" if format == "json" else "yaml" return Response(content=content, media_type=media_type, headers={"Content-Disposition": f'attachment; filename="servicemap.{extension}"'}) @app.post("/api/v1/config/import", response_model=ConfigImportResult) def import_config(payload: ConfigImportRequest) -> ConfigImportResult: try: document = parse_document(payload.content, payload.format) return import_document(store, document, payload.mode) except (ValueError, sqlite3.IntegrityError) as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc def _demo_state() -> DemoState: if not store.demo_active(): return DemoState(active=False) sync_incidents(store) overview_state = incident_overview(store) candidate = overview_state.candidates[0] if overview_state.candidates else None return DemoState( active=True, synthetic=True, scenario_name=DEMO_SCENARIO_NAME, service_count=len(store.list_services()), root_cause_service_id=candidate.service_id if candidate else None, root_cause_service_name=candidate.service_name if candidate else DEMO_ROOT_CAUSE_NAME, affected_service_ids=candidate.affected_service_ids if candidate else [], affected_service_names=candidate.affected_service_names if candidate else [], affected_count=len(candidate.affected_service_ids) if candidate else 0, ) def _demo_result(result: ConfigImportResult | None = None, *, restored: bool = False) -> DemoMutationResult: state = _demo_state() return DemoMutationResult( **state.model_dump(), created=result.created if result else 0, updated=result.updated if result else 0, total=result.total if result else state.service_count, restored=restored, ) @app.get("/api/v1/demo", response_model=DemoState) def demo_state() -> DemoState: return _demo_state() @app.post("/api/v1/demo", response_model=DemoMutationResult) def load_demo() -> DemoMutationResult: services, probes = build_demo_records() result = store.activate_demo(services, probes) sync_incidents(store) return _demo_result(result) @app.post("/api/v1/demo/reset", response_model=DemoMutationResult) def reset_demo() -> DemoMutationResult: services, probes = build_demo_records() result = store.activate_demo(services, probes) sync_incidents(store) return _demo_result(result) @app.post("/api/v1/demo/exit", response_model=DemoMutationResult) async def exit_demo() -> DemoMutationResult: restored = store.exit_demo() # Refresh live provider evidence immediately after restoring the saved # production topology. Test stores intentionally do not inherit the # process-global observer, but production always does. if restored and observation_manager.store is store: await asyncio.to_thread(observation_manager.refresh) return _demo_result(restored=restored) @app.post("/api/v1/discovery/run", response_model=DiscoveryResult) def run_discovery(payload: DiscoveryRunRequest | None = None) -> DiscoveryResult: if store.demo_active(): raise HTTPException(status_code=409, detail="Exit Explore demo before discovering production infrastructure") request = payload or DiscoveryRunRequest() _require_configured_discovery_targets(request.docker_host, request.appops_url) try: return discovery_manager.run(request) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.get("/api/v1/discovery/status", response_model=DiscoveryStatus) def discovery_status() -> DiscoveryStatus: return DiscoveryStatus(**discovery_manager.status()) @app.get("/api/v1/discovery/items", response_model=list[DiscoveryObservation]) def discovery_items(run_id: str | None = None) -> list[DiscoveryObservation]: selected = discovery_store.get_run(run_id) if run_id else discovery_store.last_completed() return discovery_store.observations_for_run(selected.id) if selected else [] @app.get("/api/v1/discovery/suggestions", response_model=list[TopologySuggestion]) def discovery_suggestions( status_filter: str | None = Query(default=None, alias="status"), ) -> list[TopologySuggestion]: try: suggestion_status = SuggestionStatus(status_filter) if status_filter else None except ValueError as exc: raise HTTPException(status_code=422, detail="Unknown suggestion status") from exc return discovery_store.list_suggestions(status=suggestion_status) @app.post("/api/v1/discovery/suggestions/{suggestion_id}/accept", response_model=TopologySuggestion) def accept_discovery_suggestion(suggestion_id: str) -> TopologySuggestion: try: return discovery_manager.suggestion_action(suggestion_id, "confirm") except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.post("/api/v1/discovery/suggestions/{suggestion_id}/reject", response_model=TopologySuggestion) def reject_discovery_suggestion(suggestion_id: str) -> TopologySuggestion: try: return discovery_manager.suggestion_action(suggestion_id, "reject") except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.post("/api/v1/discovery/reconcile", response_model=ReconcileResult) def reconcile_discovery(payload: DiscoveryReconcileRequest | None = None) -> ReconcileResult: if store.demo_active(): raise HTTPException(status_code=409, detail="Exit Explore demo before reconciling production infrastructure") try: return discovery_manager.reconcile(payload or DiscoveryReconcileRequest()) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc @app.post("/api/v1/discovery/probes/repair", response_model=ProbeRepairReport) def repair_discovered_probes( dry_run: bool = Query(default=True), run_id: str | None = Query(default=None), ) -> ProbeRepairReport: if store.demo_active(): raise HTTPException(status_code=409, detail="Exit Explore demo before repairing production probe endpoints") try: return discovery_manager.repair_probe_reachability(run_id=run_id, dry_run=dry_run) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc @app.get("/api/v1/topology/edges", response_model=list[RelationEvidence]) def topology_edges() -> list[RelationEvidence]: return discovery_store.list_evidence() @app.get("/api/v1/topology/relations", response_model=list[LogicalRelation]) def topology_relations() -> list[LogicalRelation]: """Canonical relationships: one graph edge, all supporting evidence retained.""" return discovery_store.list_logical_relations() @app.get("/api/v1/topology/snapshots", response_model=list[TopologySnapshot]) def topology_snapshots(limit: int = Query(default=30, ge=1, le=200)) -> list[TopologySnapshot]: return discovery_store.list_snapshots(limit=limit) @app.get("/api/v1/topology/diff", response_model=TopologyDiff) def topology_diff( after_snapshot_id: str = Query(...), before_snapshot_id: str | None = Query(default=None), ) -> TopologyDiff: try: return discovery_store.topology_diff( after_snapshot_id=after_snapshot_id, before_snapshot_id=before_snapshot_id, ) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.get("/api/v1/topology/semantic", response_model=SemanticTopology) def semantic_topology(include_hidden: bool = Query(default=False)) -> SemanticTopology: snapshots = _service_snapshots(include_hidden=include_hidden, include_context=False) incident_state = incident_overview(store) root_ids = {candidate.service_id for candidate in incident_state.candidates} affected_ids = { service_id for candidate in incident_state.candidates for service_id in candidate.affected_service_ids } return build_semantic_topology( snapshots, discovery_store.list_logical_relations(), root_service_ids=root_ids, affected_service_ids=affected_ids, ) @app.post("/api/v1/topology/edges", response_model=RelationEvidence, status_code=status.HTTP_201_CREATED) def create_topology_edge(payload: EdgeMutationRequest) -> RelationEvidence: try: return discovery_manager.apply_edge(payload) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc @app.post("/api/v1/topology/edges/{evidence_id}/action", response_model=RelationEvidence) def topology_edge_action(evidence_id: str, payload: EdgeActionRequest) -> RelationEvidence: try: return discovery_manager.edge_action(evidence_id, payload.action) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc @app.post("/api/v1/docker/discover", response_model=DockerDiscoveryResult) def docker_discover(payload: DockerDiscoveryRequest) -> DockerDiscoveryResult: _require_configured_discovery_targets(payload.docker_host) try: return discover_containers( payload.docker_host, store.list_services(), appops_url=os.getenv("SERVICEMAP_APPOPS_URL"), ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc @app.post("/api/v1/maintenance/prune") def prune_history(retention_days: int = Query(default=RETENTION_DAYS, ge=1, le=3650)) -> dict[str, int]: return {"deleted_probe_results": store.prune_probes(retention_days, EVENT_RETENTION_DAYS)} static_dir = Path(os.getenv("SERVICEMAP_STATIC_DIR", "/app/static")) if static_dir.is_dir(): app.mount("/", StaticFiles(directory=static_dir, html=True), name="frontend")