nat-engine 1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- nat_engine-1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,3567 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""Dashboard routes and WebSocket endpoint for the NAT web dashboard.
|
|
7
|
+
|
|
8
|
+
Provides:
|
|
9
|
+
- ``GET /dashboard`` — serve the single-page dashboard HTML
|
|
10
|
+
- ``GET /api/v1/dashboard/summary`` — aggregated stats for the overview bar
|
|
11
|
+
- ``GET /api/v1/dashboard/settings`` — read-only server configuration
|
|
12
|
+
- ``GET /api/v1/scan/{scan_id}/export`` — export scan results (html|json|csv|pdf)
|
|
13
|
+
- ``WebSocket /ws/live`` — real-time updates during active scans
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import csv
|
|
20
|
+
import io
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import pathlib
|
|
25
|
+
import uuid
|
|
26
|
+
from datetime import datetime, timedelta, timezone
|
|
27
|
+
from typing import Any, Dict, List, Optional, Set
|
|
28
|
+
|
|
29
|
+
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect
|
|
30
|
+
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
|
31
|
+
|
|
32
|
+
from mannf.product.dashboard.models import (
|
|
33
|
+
AnomalyTimelineEntry,
|
|
34
|
+
AutonomousRequest,
|
|
35
|
+
BeliefStateSnapshot,
|
|
36
|
+
BeliefTrend,
|
|
37
|
+
BugFilingConfig,
|
|
38
|
+
CalibrationReport,
|
|
39
|
+
CalibrationBucket,
|
|
40
|
+
DashboardSummary,
|
|
41
|
+
DismissedFinding,
|
|
42
|
+
DiscoveryRequest,
|
|
43
|
+
DiscoveryResult,
|
|
44
|
+
DiscoveryScenario,
|
|
45
|
+
EndpointRiskCard,
|
|
46
|
+
FailureCluster,
|
|
47
|
+
FiledBugEntry,
|
|
48
|
+
FlakinessSummary,
|
|
49
|
+
FunctionalTestResult,
|
|
50
|
+
OnboardingStatus,
|
|
51
|
+
OnboardingStep,
|
|
52
|
+
PendingBugEntry,
|
|
53
|
+
PredictiveRiskScore,
|
|
54
|
+
RootCauseSuggestion,
|
|
55
|
+
UsageSummary,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
logger = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
_STATIC_DIR = pathlib.Path(__file__).parent / "static"
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# In-memory store for discovery results (Phase 3)
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
_discoveries: Dict[str, DiscoveryResult] = {}
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# In-memory store for autonomous run reports (Phase 5)
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
_autonomous_runs: Dict[str, Any] = {}
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# In-memory stores for auto-filed and pending bug tickets (Phase 6.4)
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
_filed_bugs: Dict[str, FiledBugEntry] = {}
|
|
79
|
+
_pending_bugs: Dict[str, PendingBugEntry] = {}
|
|
80
|
+
_bug_filing_config: BugFilingConfig = BugFilingConfig()
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# In-memory store for dismissed findings / FP feedback (Phase 7.4)
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
_dismissed_findings: Dict[str, DismissedFinding] = {}
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# In-memory stores for finding comments and scan share links (Phase 10.4)
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
# finding_id → list of comment dicts: {id, author, text, created_at}
|
|
93
|
+
_finding_comments: Dict[str, List[Dict[str, Any]]] = {}
|
|
94
|
+
|
|
95
|
+
# scan_id → share record dict: {token, scan_id, expires_at, created_at}
|
|
96
|
+
_scan_shares: Dict[str, Dict[str, Any]] = {}
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# In-memory flake detector singleton (Phase 6.5)
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
from mannf.core.diagnostics.flake_detector import FlakeDetector as _FlakeDetector # noqa: E402
|
|
103
|
+
|
|
104
|
+
_flake_detector: _FlakeDetector = _FlakeDetector()
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# WebSocket connection manager for live updates
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _ConnectionManager:
|
|
112
|
+
"""Manages active WebSocket connections and broadcasts messages."""
|
|
113
|
+
|
|
114
|
+
def __init__(self) -> None:
|
|
115
|
+
self._active: Set[WebSocket] = set()
|
|
116
|
+
|
|
117
|
+
async def connect(self, ws: WebSocket) -> None:
|
|
118
|
+
await ws.accept()
|
|
119
|
+
self._active.add(ws)
|
|
120
|
+
logger.debug("WebSocket client connected (%d total)", len(self._active))
|
|
121
|
+
|
|
122
|
+
def disconnect(self, ws: WebSocket) -> None:
|
|
123
|
+
self._active.discard(ws)
|
|
124
|
+
logger.debug("WebSocket client disconnected (%d total)", len(self._active))
|
|
125
|
+
|
|
126
|
+
async def broadcast(self, message: Dict[str, Any]) -> None:
|
|
127
|
+
"""Send a JSON message to all connected clients."""
|
|
128
|
+
if not self._active:
|
|
129
|
+
return
|
|
130
|
+
text = json.dumps(message)
|
|
131
|
+
dead: Set[WebSocket] = set()
|
|
132
|
+
for ws in list(self._active):
|
|
133
|
+
try:
|
|
134
|
+
await ws.send_text(text)
|
|
135
|
+
except Exception: # noqa: BLE001
|
|
136
|
+
dead.add(ws)
|
|
137
|
+
self._active -= dead
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def connection_count(self) -> int:
|
|
141
|
+
return len(self._active)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
manager = _ConnectionManager()
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Router
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
router = APIRouter(tags=["dashboard"])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@router.get("/dashboard", response_class=HTMLResponse)
|
|
154
|
+
async def dashboard_index() -> HTMLResponse:
|
|
155
|
+
"""Serve the single-page dashboard HTML."""
|
|
156
|
+
html_path = _STATIC_DIR / "index.html"
|
|
157
|
+
content = html_path.read_text(encoding="utf-8")
|
|
158
|
+
return HTMLResponse(content=content)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@router.get("/dashboard/status", response_class=HTMLResponse)
|
|
162
|
+
async def dashboard_status() -> HTMLResponse:
|
|
163
|
+
"""Serve a human-readable HTML status page showing component health."""
|
|
164
|
+
import time as _time
|
|
165
|
+
from mannf.product.server import _scans, _server_start_time
|
|
166
|
+
from mannf.product.status import aggregate_status, run_all_checks
|
|
167
|
+
|
|
168
|
+
components = await run_all_checks(_scans, _server_start_time)
|
|
169
|
+
overall = aggregate_status(components)
|
|
170
|
+
uptime_s = _time.monotonic() - _server_start_time
|
|
171
|
+
|
|
172
|
+
def _status_colour(s: str) -> str:
|
|
173
|
+
return {"healthy": "#22c55e", "degraded": "#f59e0b", "unhealthy": "#ef4444", "disabled": "#9ca3af"}.get(s, "#9ca3af")
|
|
174
|
+
|
|
175
|
+
def _badge(s: str) -> str:
|
|
176
|
+
colour = _status_colour(s)
|
|
177
|
+
return f'<span style="background:{colour};color:#fff;padding:2px 8px;border-radius:4px;font-size:0.85em">{s.upper()}</span>'
|
|
178
|
+
|
|
179
|
+
rows = ""
|
|
180
|
+
for comp in components:
|
|
181
|
+
latency = f"{comp.latency_ms:.1f} ms" if comp.latency_ms else "—"
|
|
182
|
+
details_html = ", ".join(f"<b>{k}</b>: {v}" for k, v in comp.details.items())
|
|
183
|
+
rows += f"""
|
|
184
|
+
<tr>
|
|
185
|
+
<td style="padding:8px 12px">{comp.name}</td>
|
|
186
|
+
<td style="padding:8px 12px">{_badge(comp.status)}</td>
|
|
187
|
+
<td style="padding:8px 12px">{latency}</td>
|
|
188
|
+
<td style="padding:8px 12px;font-size:0.85em;color:#6b7280">{details_html}</td>
|
|
189
|
+
</tr>"""
|
|
190
|
+
|
|
191
|
+
overall_colour = _status_colour(overall)
|
|
192
|
+
from mannf import __version__
|
|
193
|
+
|
|
194
|
+
html = f"""<!DOCTYPE html>
|
|
195
|
+
<html lang="en">
|
|
196
|
+
<head>
|
|
197
|
+
<meta charset="UTF-8">
|
|
198
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
199
|
+
<title>NAT Status</title>
|
|
200
|
+
<style>
|
|
201
|
+
body {{ font-family: system-ui, sans-serif; margin: 0; background: #f9fafb; color: #111827; }}
|
|
202
|
+
header {{ background: #1e293b; color: #fff; padding: 16px 24px; display: flex; align-items: center; gap: 12px; }}
|
|
203
|
+
header h1 {{ margin: 0; font-size: 1.25rem; }}
|
|
204
|
+
.container {{ max-width: 960px; margin: 32px auto; padding: 0 16px; }}
|
|
205
|
+
.card {{ background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.1); margin-bottom: 24px; overflow: hidden; }}
|
|
206
|
+
.card-header {{ padding: 16px 20px; border-bottom: 1px solid #e5e7eb; font-weight: 600; }}
|
|
207
|
+
table {{ width: 100%; border-collapse: collapse; }}
|
|
208
|
+
th {{ background: #f3f4f6; text-align: left; padding: 8px 12px; font-size: 0.8rem; text-transform: uppercase; color: #6b7280; }}
|
|
209
|
+
tr:nth-child(even) {{ background: #f9fafb; }}
|
|
210
|
+
.overall {{ display: inline-block; width: 16px; height: 16px; border-radius: 50%; background: {overall_colour}; vertical-align: middle; margin-right: 8px; }}
|
|
211
|
+
.meta {{ font-size: 0.85rem; color: #6b7280; margin-top: 4px; }}
|
|
212
|
+
a {{ color: #3b82f6; text-decoration: none; }}
|
|
213
|
+
a:hover {{ text-decoration: underline; }}
|
|
214
|
+
</style>
|
|
215
|
+
</head>
|
|
216
|
+
<body>
|
|
217
|
+
<header>
|
|
218
|
+
<div style="font-size:1.5rem">🧠</div>
|
|
219
|
+
<h1>NAT — System Status</h1>
|
|
220
|
+
<a href="/dashboard" style="margin-left:auto;color:#93c5fd;font-size:0.9rem">← Dashboard</a>
|
|
221
|
+
</header>
|
|
222
|
+
<div class="container">
|
|
223
|
+
<div class="card">
|
|
224
|
+
<div class="card-header">
|
|
225
|
+
<span class="overall"></span>
|
|
226
|
+
Overall: <strong style="color:{overall_colour}">{overall.upper()}</strong>
|
|
227
|
+
<span class="meta">v{__version__} — uptime {int(uptime_s // 3600)}h {int((uptime_s % 3600) // 60)}m {int(uptime_s % 60)}s</span>
|
|
228
|
+
</div>
|
|
229
|
+
<table>
|
|
230
|
+
<thead>
|
|
231
|
+
<tr>
|
|
232
|
+
<th>Component</th><th>Status</th><th>Latency</th><th>Details</th>
|
|
233
|
+
</tr>
|
|
234
|
+
</thead>
|
|
235
|
+
<tbody>{rows}</tbody>
|
|
236
|
+
</table>
|
|
237
|
+
</div>
|
|
238
|
+
<p style="text-align:center;font-size:0.8rem;color:#9ca3af">
|
|
239
|
+
Machine-readable JSON: <a href="/api/v1/status">/api/v1/status</a>
|
|
240
|
+
</p>
|
|
241
|
+
</div>
|
|
242
|
+
</body>
|
|
243
|
+
</html>"""
|
|
244
|
+
return HTMLResponse(content=html)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@router.get("/manifest.json")
|
|
248
|
+
async def pwa_manifest() -> Response:
|
|
249
|
+
"""Serve the PWA web app manifest."""
|
|
250
|
+
path = _STATIC_DIR / "manifest.json"
|
|
251
|
+
return Response(
|
|
252
|
+
content=path.read_bytes(),
|
|
253
|
+
media_type="application/manifest+json",
|
|
254
|
+
headers={"Cache-Control": "public, max-age=86400"},
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@router.get("/sw.js")
|
|
259
|
+
async def service_worker() -> Response:
|
|
260
|
+
"""Serve the PWA service worker script at the root scope."""
|
|
261
|
+
path = _STATIC_DIR / "sw.js"
|
|
262
|
+
return Response(
|
|
263
|
+
content=path.read_bytes(),
|
|
264
|
+
media_type="application/javascript",
|
|
265
|
+
headers={"Cache-Control": "no-cache", "Service-Worker-Allowed": "/"},
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@router.get("/pwa-icon-192.png")
|
|
270
|
+
async def pwa_icon_192() -> Response:
|
|
271
|
+
"""Serve the 192×192 PWA app icon."""
|
|
272
|
+
path = _STATIC_DIR / "pwa-icon-192.png"
|
|
273
|
+
return Response(
|
|
274
|
+
content=path.read_bytes(),
|
|
275
|
+
media_type="image/png",
|
|
276
|
+
headers={"Cache-Control": "public, max-age=604800"},
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@router.get("/pwa-icon-512.png")
|
|
281
|
+
async def pwa_icon_512() -> Response:
|
|
282
|
+
"""Serve the 512×512 PWA app icon."""
|
|
283
|
+
path = _STATIC_DIR / "pwa-icon-512.png"
|
|
284
|
+
return Response(
|
|
285
|
+
content=path.read_bytes(),
|
|
286
|
+
media_type="image/png",
|
|
287
|
+
headers={"Cache-Control": "public, max-age=604800"},
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@router.get("/api/v1/dashboard/summary", response_model=DashboardSummary)
|
|
292
|
+
async def dashboard_summary() -> DashboardSummary:
|
|
293
|
+
"""Return aggregated statistics for the dashboard overview bar."""
|
|
294
|
+
# Import here to avoid circular imports with server.py
|
|
295
|
+
from mannf.product.server import _scans, _get_anomaly_detector, _get_risk_scorer
|
|
296
|
+
|
|
297
|
+
# --- scan stats ---------------------------------------------------------
|
|
298
|
+
total_scans = len(_scans)
|
|
299
|
+
total_tests_run = 0
|
|
300
|
+
total_passed = 0
|
|
301
|
+
last_scan_at: str | None = None
|
|
302
|
+
|
|
303
|
+
for entry in _scans.values():
|
|
304
|
+
summary = entry.get("summary") or {}
|
|
305
|
+
total_tests_run += summary.get("total", 0)
|
|
306
|
+
total_passed += summary.get("passed", 0)
|
|
307
|
+
completed_at = entry.get("completed_at")
|
|
308
|
+
if completed_at:
|
|
309
|
+
if last_scan_at is None or completed_at > last_scan_at:
|
|
310
|
+
last_scan_at = completed_at
|
|
311
|
+
|
|
312
|
+
pass_rate = total_passed / total_tests_run if total_tests_run > 0 else 0.0
|
|
313
|
+
|
|
314
|
+
# --- risk stats ---------------------------------------------------------
|
|
315
|
+
scorer = _get_risk_scorer()
|
|
316
|
+
risk_report: List[Dict[str, Any]] = scorer.get_risk_report()
|
|
317
|
+
endpoints_discovered = len(risk_report)
|
|
318
|
+
avg_risk_score = (
|
|
319
|
+
sum(e["risk_score"] for e in risk_report) / endpoints_discovered
|
|
320
|
+
if endpoints_discovered > 0
|
|
321
|
+
else 0.0
|
|
322
|
+
)
|
|
323
|
+
# Health score is inverse of avg risk
|
|
324
|
+
health_score = max(0.0, 1.0 - avg_risk_score)
|
|
325
|
+
|
|
326
|
+
# --- anomaly stats ------------------------------------------------------
|
|
327
|
+
detector = _get_anomaly_detector()
|
|
328
|
+
all_alerts: List[Dict[str, Any]] = detector.get_alerts_dict()
|
|
329
|
+
anomalies_found = len(all_alerts)
|
|
330
|
+
active_anomalies = sum(
|
|
331
|
+
1 for a in all_alerts if a.get("severity") in ("warning", "critical")
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
return DashboardSummary(
|
|
335
|
+
total_scans=total_scans,
|
|
336
|
+
endpoints_discovered=endpoints_discovered,
|
|
337
|
+
anomalies_found=anomalies_found,
|
|
338
|
+
avg_risk_score=round(avg_risk_score, 4),
|
|
339
|
+
total_tests_run=total_tests_run,
|
|
340
|
+
pass_rate=round(pass_rate, 4),
|
|
341
|
+
active_anomalies=active_anomalies,
|
|
342
|
+
last_scan_at=last_scan_at,
|
|
343
|
+
health_score=round(health_score, 4),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@router.get("/api/v1/dashboard/endpoints", response_model=List[EndpointRiskCard])
|
|
348
|
+
async def dashboard_endpoints() -> List[EndpointRiskCard]:
|
|
349
|
+
"""Return enriched per-endpoint cards for the risk heatmap."""
|
|
350
|
+
from mannf.product.server import _get_anomaly_detector, _get_risk_scorer
|
|
351
|
+
|
|
352
|
+
scorer = _get_risk_scorer()
|
|
353
|
+
detector = _get_anomaly_detector()
|
|
354
|
+
risk_report: List[Dict[str, Any]] = scorer.get_risk_report()
|
|
355
|
+
all_alerts: List[Dict[str, Any]] = detector.get_alerts_dict()
|
|
356
|
+
|
|
357
|
+
# Count anomalies per endpoint
|
|
358
|
+
anomaly_counts: Dict[str, int] = {}
|
|
359
|
+
for alert in all_alerts:
|
|
360
|
+
ep = alert.get("endpoint", "")
|
|
361
|
+
anomaly_counts[ep] = anomaly_counts.get(ep, 0) + 1
|
|
362
|
+
|
|
363
|
+
cards: List[EndpointRiskCard] = []
|
|
364
|
+
for entry in risk_report:
|
|
365
|
+
ep = entry["endpoint"]
|
|
366
|
+
cards.append(
|
|
367
|
+
EndpointRiskCard(
|
|
368
|
+
endpoint=ep,
|
|
369
|
+
risk_score=entry["risk_score"],
|
|
370
|
+
fault_rate=entry["fault_rate"],
|
|
371
|
+
belief_signal=entry["belief_signal"],
|
|
372
|
+
latency_signal=entry["latency_signal"],
|
|
373
|
+
diversity_signal=entry["diversity_signal"],
|
|
374
|
+
trend=entry["trend"],
|
|
375
|
+
run_count=entry["run_count"],
|
|
376
|
+
fault_count=entry["fault_count"],
|
|
377
|
+
anomaly_count=anomaly_counts.get(ep, 0),
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
return cards
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@router.get("/api/v1/dashboard/anomalies", response_model=List[AnomalyTimelineEntry])
|
|
384
|
+
async def dashboard_anomalies() -> List[AnomalyTimelineEntry]:
|
|
385
|
+
"""Return anomaly timeline entries sorted newest-first."""
|
|
386
|
+
from mannf.product.server import _get_anomaly_detector
|
|
387
|
+
|
|
388
|
+
detector = _get_anomaly_detector()
|
|
389
|
+
alerts: List[Dict[str, Any]] = detector.get_alerts_dict()
|
|
390
|
+
entries = [AnomalyTimelineEntry(**a) for a in alerts]
|
|
391
|
+
# Sort newest-first
|
|
392
|
+
entries.sort(key=lambda e: e.detected_at, reverse=True)
|
|
393
|
+
return entries
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@router.get("/api/v1/dashboard/agents", response_model=List[BeliefStateSnapshot])
|
|
397
|
+
async def dashboard_agents() -> List[BeliefStateSnapshot]:
|
|
398
|
+
"""Return the most recent telemetry snapshot for each active agent.
|
|
399
|
+
|
|
400
|
+
Intended for the initial page load before the WebSocket connects.
|
|
401
|
+
Each entry is the last ``agent_state`` snapshot emitted after a
|
|
402
|
+
deliberation cycle.
|
|
403
|
+
"""
|
|
404
|
+
from mannf.product.dashboard.telemetry import get_agent_snapshots
|
|
405
|
+
|
|
406
|
+
snapshots = get_agent_snapshots()
|
|
407
|
+
result: List[BeliefStateSnapshot] = []
|
|
408
|
+
for snap in snapshots:
|
|
409
|
+
confidence_map: Dict[str, Any] = snap.get("confidence", {})
|
|
410
|
+
avg_confidence = (
|
|
411
|
+
sum(confidence_map.values()) / len(confidence_map)
|
|
412
|
+
if confidence_map
|
|
413
|
+
else 0.5
|
|
414
|
+
)
|
|
415
|
+
result.append(
|
|
416
|
+
BeliefStateSnapshot(
|
|
417
|
+
agent_id=snap.get("agent_id", ""),
|
|
418
|
+
agent_role=snap.get("agent_role", ""),
|
|
419
|
+
beliefs=snap.get("beliefs", {}),
|
|
420
|
+
active=True,
|
|
421
|
+
confidence=round(avg_confidence, 4),
|
|
422
|
+
confidence_per_service=confidence_map,
|
|
423
|
+
intentions=snap.get("intentions", []),
|
|
424
|
+
desires=snap.get("desires", {}),
|
|
425
|
+
entropy=snap.get("entropy", 0.0),
|
|
426
|
+
timestamp=snap.get("timestamp"),
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
return result
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@router.get("/api/v1/dashboard/settings")
|
|
433
|
+
async def dashboard_settings() -> Dict[str, Any]:
|
|
434
|
+
"""Return read-only server configuration for the Settings tab."""
|
|
435
|
+
api_key_raw = os.environ.get("NAT_API_KEY", "")
|
|
436
|
+
if len(api_key_raw) > 4:
|
|
437
|
+
api_key_preview = "*" * (len(api_key_raw) - 4) + api_key_raw[-4:]
|
|
438
|
+
elif api_key_raw:
|
|
439
|
+
api_key_preview = "***"
|
|
440
|
+
else:
|
|
441
|
+
api_key_preview = ""
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
"server": {
|
|
445
|
+
"host": os.environ.get("NAT_HOST", "0.0.0.0"),
|
|
446
|
+
"port": int(os.environ.get("NAT_PORT", "8080")),
|
|
447
|
+
"workers": int(os.environ.get("WEB_CONCURRENCY", "1")),
|
|
448
|
+
"log_level": os.environ.get("NAT_LOG_LEVEL", "info"),
|
|
449
|
+
},
|
|
450
|
+
"auth": {
|
|
451
|
+
"api_key_configured": bool(api_key_raw),
|
|
452
|
+
"api_key_preview": api_key_preview,
|
|
453
|
+
},
|
|
454
|
+
"queue": {
|
|
455
|
+
"max_concurrent_scans": int(os.environ.get("NAT_MAX_CONCURRENT_SCANS", "4")),
|
|
456
|
+
"max_queue_depth": int(os.environ.get("NAT_MAX_QUEUE_DEPTH", "20")),
|
|
457
|
+
"scan_timeout_seconds": int(os.environ.get("NAT_SCAN_TIMEOUT", "300")),
|
|
458
|
+
},
|
|
459
|
+
"rate_limit": os.environ.get("NAT_RATE_LIMIT", ""),
|
|
460
|
+
"webhook": {
|
|
461
|
+
"default_url": os.environ.get("NAT_WEBHOOK_URL", ""),
|
|
462
|
+
"hmac_configured": bool(os.environ.get("NAT_WEBHOOK_SECRET", "")),
|
|
463
|
+
},
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _render_export_html(
|
|
468
|
+
scan_id: str,
|
|
469
|
+
entry: Dict[str, Any],
|
|
470
|
+
summary: Dict[str, Any],
|
|
471
|
+
results: List[Any],
|
|
472
|
+
) -> str:
|
|
473
|
+
"""Generate a standalone HTML report for a scan export."""
|
|
474
|
+
status = entry.get("status", "unknown")
|
|
475
|
+
spec_name = entry.get("spec_name", "—")
|
|
476
|
+
base_url = entry.get("base_url", "—")
|
|
477
|
+
created_at = entry.get("created_at", "—")
|
|
478
|
+
completed_at = entry.get("completed_at", "—")
|
|
479
|
+
total = summary.get("total", 0)
|
|
480
|
+
passed = summary.get("passed", 0)
|
|
481
|
+
failed = summary.get("failed", 0)
|
|
482
|
+
pass_rate = summary.get("pass_rate", 0.0)
|
|
483
|
+
|
|
484
|
+
status_color = "#3fb950" if status == "completed" else ("#f85149" if status == "failed" else "#8b949e")
|
|
485
|
+
|
|
486
|
+
rows_html = ""
|
|
487
|
+
for r in results:
|
|
488
|
+
if hasattr(r, "__dict__"):
|
|
489
|
+
r = r.__dict__
|
|
490
|
+
tc_id = r.get("test_case_id", "")
|
|
491
|
+
ep = r.get("endpoint", "")
|
|
492
|
+
ok = r.get("passed", False)
|
|
493
|
+
sc = r.get("status_code", "")
|
|
494
|
+
lat = r.get("execution_time_ms", "")
|
|
495
|
+
err = r.get("error") or ""
|
|
496
|
+
row_bg = "#1a2e1a" if ok else "#2e1a1a"
|
|
497
|
+
badge = '<span style="color:#3fb950;font-weight:700;">PASS</span>' if ok else '<span style="color:#f85149;font-weight:700;">FAIL</span>'
|
|
498
|
+
rows_html += f"""<tr style="background:{row_bg};">
|
|
499
|
+
<td style="padding:6px 10px;font-family:monospace;font-size:12px;max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{tc_id}</td>
|
|
500
|
+
<td style="padding:6px 10px;font-family:monospace;font-size:12px;">{ep}</td>
|
|
501
|
+
<td style="padding:6px 10px;text-align:center;">{badge}</td>
|
|
502
|
+
<td style="padding:6px 10px;text-align:center;">{sc}</td>
|
|
503
|
+
<td style="padding:6px 10px;text-align:right;">{lat}</td>
|
|
504
|
+
<td style="padding:6px 10px;font-size:11px;color:#8b949e;">{err}</td>
|
|
505
|
+
</tr>"""
|
|
506
|
+
|
|
507
|
+
report_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
508
|
+
return f"""<!DOCTYPE html>
|
|
509
|
+
<html lang="en">
|
|
510
|
+
<head>
|
|
511
|
+
<meta charset="UTF-8" />
|
|
512
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
513
|
+
<title>NAT Scan Report — {scan_id}</title>
|
|
514
|
+
<style>
|
|
515
|
+
body {{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:#0d1117;color:#e6edf3;margin:0;padding:24px;font-size:14px;}}
|
|
516
|
+
h1 {{font-size:20px;margin-bottom:4px;}}
|
|
517
|
+
.meta {{color:#8b949e;font-size:12px;margin-bottom:24px;}}
|
|
518
|
+
.cards {{display:flex;gap:16px;flex-wrap:wrap;margin-bottom:24px;}}
|
|
519
|
+
.card {{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px 20px;min-width:120px;}}
|
|
520
|
+
.card-label {{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b949e;margin-bottom:4px;}}
|
|
521
|
+
.card-value {{font-size:28px;font-weight:700;line-height:1;}}
|
|
522
|
+
table {{width:100%;border-collapse:collapse;font-size:13px;}}
|
|
523
|
+
th {{text-align:left;padding:8px 10px;color:#8b949e;border-bottom:1px solid #30363d;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.05em;}}
|
|
524
|
+
td {{border-bottom:1px solid rgba(48,54,61,.5);}}
|
|
525
|
+
.footer {{margin-top:24px;color:#484f58;font-size:11px;}}
|
|
526
|
+
</style>
|
|
527
|
+
</head>
|
|
528
|
+
<body>
|
|
529
|
+
<h1>🔬 NAT Scan Report</h1>
|
|
530
|
+
<div class="meta">Generated {report_timestamp} · Scan ID: {scan_id}</div>
|
|
531
|
+
<div class="cards">
|
|
532
|
+
<div class="card">
|
|
533
|
+
<div class="card-label">Status</div>
|
|
534
|
+
<div class="card-value" style="font-size:18px;color:{status_color};">{status}</div>
|
|
535
|
+
</div>
|
|
536
|
+
<div class="card">
|
|
537
|
+
<div class="card-label">Total Tests</div>
|
|
538
|
+
<div class="card-value">{total}</div>
|
|
539
|
+
</div>
|
|
540
|
+
<div class="card">
|
|
541
|
+
<div class="card-label">Passed</div>
|
|
542
|
+
<div class="card-value" style="color:#3fb950;">{passed}</div>
|
|
543
|
+
</div>
|
|
544
|
+
<div class="card">
|
|
545
|
+
<div class="card-label">Failed</div>
|
|
546
|
+
<div class="card-value" style="color:#f85149;">{failed}</div>
|
|
547
|
+
</div>
|
|
548
|
+
<div class="card">
|
|
549
|
+
<div class="card-label">Pass Rate</div>
|
|
550
|
+
<div class="card-value">{pass_rate * 100:.1f}%</div>
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
<table style="background:#161b22;border:1px solid #30363d;border-radius:8px;overflow:hidden;">
|
|
554
|
+
<thead>
|
|
555
|
+
<tr>
|
|
556
|
+
<th>Test Case ID</th>
|
|
557
|
+
<th>Endpoint</th>
|
|
558
|
+
<th>Result</th>
|
|
559
|
+
<th>Status Code</th>
|
|
560
|
+
<th>Latency (ms)</th>
|
|
561
|
+
<th>Error</th>
|
|
562
|
+
</tr>
|
|
563
|
+
</thead>
|
|
564
|
+
<tbody>
|
|
565
|
+
{rows_html if rows_html else '<tr><td colspan="6" style="padding:16px;text-align:center;color:#484f58;">No test results recorded</td></tr>'}
|
|
566
|
+
</tbody>
|
|
567
|
+
</table>
|
|
568
|
+
<div class="footer">
|
|
569
|
+
Spec: {spec_name} · Base URL: {base_url} · Started: {created_at} · Completed: {completed_at}
|
|
570
|
+
</div>
|
|
571
|
+
</body>
|
|
572
|
+
</html>"""
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
@router.get("/api/v1/scan/{scan_id}/export")
|
|
576
|
+
async def export_scan(
|
|
577
|
+
scan_id: str,
|
|
578
|
+
format: str = Query("json", description="Export format: html, json, csv, or pdf"),
|
|
579
|
+
request: "Any" = None,
|
|
580
|
+
) -> Response:
|
|
581
|
+
"""Export scan results in html, json, csv, or pdf format.
|
|
582
|
+
|
|
583
|
+
- ``html`` — standalone printable HTML report with embedded styles
|
|
584
|
+
- ``json`` — full machine-readable scan data
|
|
585
|
+
- ``csv`` — tabular test results for spreadsheet analysis
|
|
586
|
+
- ``pdf`` — PDF report (Pro+ plans only; Enterprise supports white-label)
|
|
587
|
+
"""
|
|
588
|
+
if format not in ("html", "json", "csv", "pdf"):
|
|
589
|
+
raise HTTPException(status_code=400, detail="format must be one of: html, json, csv, pdf")
|
|
590
|
+
|
|
591
|
+
from mannf.product.server import _scans
|
|
592
|
+
|
|
593
|
+
entry = _scans.get(scan_id)
|
|
594
|
+
if entry is None:
|
|
595
|
+
raise HTTPException(status_code=404, detail=f"Scan '{scan_id}' not found")
|
|
596
|
+
|
|
597
|
+
summary: Dict[str, Any] = entry.get("summary") or {}
|
|
598
|
+
results: List[Any] = entry.get("results") or []
|
|
599
|
+
|
|
600
|
+
if format == "json":
|
|
601
|
+
content = json.dumps(entry, indent=2, default=str)
|
|
602
|
+
return Response(
|
|
603
|
+
content=content,
|
|
604
|
+
media_type="application/json",
|
|
605
|
+
headers={"Content-Disposition": f'attachment; filename="scan-{scan_id}.json"'},
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
if format == "csv":
|
|
609
|
+
output = io.StringIO()
|
|
610
|
+
writer = csv.writer(output)
|
|
611
|
+
writer.writerow(["test_case_id", "endpoint", "passed", "status_code", "execution_time_ms", "error"])
|
|
612
|
+
for r in results:
|
|
613
|
+
if hasattr(r, "__dict__"):
|
|
614
|
+
r = r.__dict__
|
|
615
|
+
writer.writerow([
|
|
616
|
+
r.get("test_case_id", ""),
|
|
617
|
+
r.get("endpoint", ""),
|
|
618
|
+
r.get("passed", ""),
|
|
619
|
+
r.get("status_code", ""),
|
|
620
|
+
r.get("execution_time_ms", ""),
|
|
621
|
+
r.get("error") or "",
|
|
622
|
+
])
|
|
623
|
+
return Response(
|
|
624
|
+
content=output.getvalue(),
|
|
625
|
+
media_type="text/csv",
|
|
626
|
+
headers={"Content-Disposition": f'attachment; filename="scan-{scan_id}.csv"'},
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
if format == "pdf":
|
|
630
|
+
from mannf.product.billing.feature_gates import check_feature, get_features # noqa: PLC0415
|
|
631
|
+
|
|
632
|
+
tenant = None
|
|
633
|
+
if request is not None:
|
|
634
|
+
tenant = getattr(getattr(request, "state", None), "tenant", None)
|
|
635
|
+
plan_tier: str = tenant.get("plan_tier", "free") if tenant else "free"
|
|
636
|
+
|
|
637
|
+
if not check_feature(plan_tier, "export_pdf"):
|
|
638
|
+
raise HTTPException(
|
|
639
|
+
status_code=403,
|
|
640
|
+
detail={
|
|
641
|
+
"code": "FEATURE_NOT_AVAILABLE",
|
|
642
|
+
"message": (
|
|
643
|
+
"PDF export is not available on the free plan. "
|
|
644
|
+
"Upgrade to Pro or higher to download PDF reports."
|
|
645
|
+
),
|
|
646
|
+
"feature": "export_pdf",
|
|
647
|
+
"plan": plan_tier,
|
|
648
|
+
"upgrade_url": "https://app.nat-testing.io/dashboard/upgrade?to=pro",
|
|
649
|
+
},
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
whitelabel: bool = bool(get_features(plan_tier).get("pdf_whitelabel", False))
|
|
653
|
+
|
|
654
|
+
from mannf.product.reports.pdf import PDFReportGenerator # noqa: PLC0415
|
|
655
|
+
|
|
656
|
+
html = _render_export_html(scan_id, entry, summary, results)
|
|
657
|
+
generator = PDFReportGenerator()
|
|
658
|
+
pdf_bytes = await generator.generate(html, whitelabel=whitelabel)
|
|
659
|
+
return Response(
|
|
660
|
+
content=pdf_bytes,
|
|
661
|
+
media_type="application/pdf",
|
|
662
|
+
headers={"Content-Disposition": f'attachment; filename="scan-{scan_id}.pdf"'},
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
# html
|
|
666
|
+
html = _render_export_html(scan_id, entry, summary, results)
|
|
667
|
+
return Response(
|
|
668
|
+
content=html,
|
|
669
|
+
media_type="text/html",
|
|
670
|
+
headers={"Content-Disposition": f'attachment; filename="scan-{scan_id}.html"'},
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
@router.get("/api/v1/dashboard/functional-results", response_model=FunctionalTestResult)
|
|
675
|
+
async def dashboard_functional_results() -> FunctionalTestResult:
|
|
676
|
+
"""Return the latest FunctionalTestOrchestrator run results for the dashboard.
|
|
677
|
+
|
|
678
|
+
Pulls data from the server's ``_functional_results`` store when available.
|
|
679
|
+
Returns an empty result with helpful defaults when no runs exist yet.
|
|
680
|
+
"""
|
|
681
|
+
try:
|
|
682
|
+
from mannf.product.server import _functional_results # noqa: PLC0415
|
|
683
|
+
except ImportError:
|
|
684
|
+
_functional_results: List[Dict[str, Any]] = []
|
|
685
|
+
|
|
686
|
+
if not _functional_results:
|
|
687
|
+
return FunctionalTestResult()
|
|
688
|
+
|
|
689
|
+
latest = _functional_results[-1]
|
|
690
|
+
from mannf.product.dashboard.models import ( # noqa: PLC0415
|
|
691
|
+
A11yViolationSummary,
|
|
692
|
+
FunctionalTestItem,
|
|
693
|
+
PerfMetrics,
|
|
694
|
+
VisualDiffEntry,
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
total = latest.get("total", 0)
|
|
698
|
+
passed = latest.get("passed", 0)
|
|
699
|
+
failed = latest.get("failed", total - passed)
|
|
700
|
+
pass_rate = round(passed / total, 4) if total > 0 else 0.0
|
|
701
|
+
|
|
702
|
+
items = [
|
|
703
|
+
FunctionalTestItem(
|
|
704
|
+
task_id=item.get("task_id", ""),
|
|
705
|
+
task_name=item.get("task_name", item.get("task_id", "")),
|
|
706
|
+
passed=item.get("passed", False),
|
|
707
|
+
duration_ms=item.get("duration_ms"),
|
|
708
|
+
error=item.get("error"),
|
|
709
|
+
artifact_url=item.get("artifact_url"),
|
|
710
|
+
)
|
|
711
|
+
for item in latest.get("items", [])
|
|
712
|
+
]
|
|
713
|
+
|
|
714
|
+
visual_diffs = [
|
|
715
|
+
VisualDiffEntry(
|
|
716
|
+
task_id=vd.get("task_id", ""),
|
|
717
|
+
baseline_url=vd.get("baseline_url"),
|
|
718
|
+
current_url=vd.get("current_url"),
|
|
719
|
+
diff_url=vd.get("diff_url"),
|
|
720
|
+
diff_percent=vd.get("diff_percent", 0.0),
|
|
721
|
+
passed=vd.get("passed", True),
|
|
722
|
+
)
|
|
723
|
+
for vd in latest.get("visual_diffs", [])
|
|
724
|
+
]
|
|
725
|
+
|
|
726
|
+
a11y_data = latest.get("a11y")
|
|
727
|
+
a11y = (
|
|
728
|
+
A11yViolationSummary(
|
|
729
|
+
critical=a11y_data.get("critical", 0),
|
|
730
|
+
serious=a11y_data.get("serious", 0),
|
|
731
|
+
moderate=a11y_data.get("moderate", 0),
|
|
732
|
+
minor=a11y_data.get("minor", 0),
|
|
733
|
+
total=a11y_data.get("total", 0),
|
|
734
|
+
sample_violations=a11y_data.get("sample_violations", []),
|
|
735
|
+
)
|
|
736
|
+
if a11y_data
|
|
737
|
+
else None
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
perf_data = latest.get("perf")
|
|
741
|
+
perf = (
|
|
742
|
+
PerfMetrics(
|
|
743
|
+
lcp_ms=perf_data.get("lcp_ms"),
|
|
744
|
+
fid_ms=perf_data.get("fid_ms"),
|
|
745
|
+
cls=perf_data.get("cls"),
|
|
746
|
+
ttfb_ms=perf_data.get("ttfb_ms"),
|
|
747
|
+
)
|
|
748
|
+
if perf_data
|
|
749
|
+
else None
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
return FunctionalTestResult(
|
|
753
|
+
run_id=latest.get("run_id"),
|
|
754
|
+
started_at=latest.get("started_at"),
|
|
755
|
+
completed_at=latest.get("completed_at"),
|
|
756
|
+
total=total,
|
|
757
|
+
passed=passed,
|
|
758
|
+
failed=failed,
|
|
759
|
+
pass_rate=pass_rate,
|
|
760
|
+
items=items,
|
|
761
|
+
visual_diffs=visual_diffs,
|
|
762
|
+
a11y=a11y,
|
|
763
|
+
perf=perf,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
@router.get("/api/v1/dashboard/browser-security-findings")
|
|
768
|
+
async def dashboard_browser_security_findings() -> Dict[str, Any]:
|
|
769
|
+
"""Return browser-level security findings from autonomous run(s).
|
|
770
|
+
|
|
771
|
+
Aggregates findings collected by the
|
|
772
|
+
:class:`~mannf.core.browser.reflection_analyzer.ReflectionAnalyzer`
|
|
773
|
+
across all autonomous run reports in the in-memory store.
|
|
774
|
+
|
|
775
|
+
Returns a JSON object with:
|
|
776
|
+
|
|
777
|
+
* ``findings`` — list of finding dicts (matching the
|
|
778
|
+
:class:`~mannf.product.security.models.SecurityFinding` field layout).
|
|
779
|
+
* ``summary`` — counts by severity.
|
|
780
|
+
* ``total_findings`` — total number of findings.
|
|
781
|
+
"""
|
|
782
|
+
all_findings: list[Dict[str, Any]] = []
|
|
783
|
+
|
|
784
|
+
for entry in _autonomous_runs.values():
|
|
785
|
+
# findings may be attached at the top level or inside the report
|
|
786
|
+
raw_report = entry if isinstance(entry, dict) else {}
|
|
787
|
+
coverage = raw_report.get("coverage_summary", {})
|
|
788
|
+
# Collected findings are stored on the agent but we surface them
|
|
789
|
+
# from the in-memory demo data and any live run data.
|
|
790
|
+
findings_in_entry = raw_report.get("browser_security_findings", [])
|
|
791
|
+
all_findings.extend(findings_in_entry)
|
|
792
|
+
|
|
793
|
+
# Include demo findings when no live data exists
|
|
794
|
+
if not all_findings:
|
|
795
|
+
from mannf.product.demo import _DEMO_BROWSER_SECURITY_FINDINGS # noqa: PLC0415
|
|
796
|
+
all_findings = list(_DEMO_BROWSER_SECURITY_FINDINGS)
|
|
797
|
+
|
|
798
|
+
severity_order = ["critical", "high", "medium", "low", "info"]
|
|
799
|
+
summary: Dict[str, int] = {s: 0 for s in severity_order}
|
|
800
|
+
for f in all_findings:
|
|
801
|
+
sev = f.get("severity", "info")
|
|
802
|
+
if sev in summary:
|
|
803
|
+
summary[sev] += 1
|
|
804
|
+
|
|
805
|
+
return {
|
|
806
|
+
"findings": all_findings,
|
|
807
|
+
"summary": summary,
|
|
808
|
+
"total_findings": len(all_findings),
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
@router.get("/api/v1/dashboard/usage-summary", response_model=UsageSummary)
|
|
813
|
+
async def dashboard_usage_summary(request: "Any" = None) -> UsageSummary:
|
|
814
|
+
"""Return usage gauge data (wraps /api/v1/usage) for the billing tab.
|
|
815
|
+
|
|
816
|
+
When a tenant is authenticated the data is tenant-scoped; otherwise
|
|
817
|
+
returns quota data from the server's environment configuration.
|
|
818
|
+
"""
|
|
819
|
+
from mannf.product.billing.plans import get_plan # noqa: PLC0415
|
|
820
|
+
from mannf.product.dashboard.models import InvoiceEntry # noqa: PLC0415
|
|
821
|
+
|
|
822
|
+
# Attempt to resolve the tenant from the request state (may be None in
|
|
823
|
+
# environments without full auth middleware)
|
|
824
|
+
tenant = None
|
|
825
|
+
if request is not None:
|
|
826
|
+
tenant = getattr(getattr(request, "state", None), "tenant", None)
|
|
827
|
+
|
|
828
|
+
if tenant is not None:
|
|
829
|
+
from mannf.product.billing.tenant_auth import get_monthly_usage # noqa: PLC0415
|
|
830
|
+
import uuid as _uuid # noqa: PLC0415
|
|
831
|
+
|
|
832
|
+
tenant_id = _uuid.UUID(tenant["id"])
|
|
833
|
+
plan_tier = tenant.get("plan_tier", "free")
|
|
834
|
+
plan = get_plan(plan_tier)
|
|
835
|
+
func_used = await get_monthly_usage(tenant_id, "functional")
|
|
836
|
+
sec_used = await get_monthly_usage(tenant_id, "security")
|
|
837
|
+
else:
|
|
838
|
+
plan_tier = "free"
|
|
839
|
+
plan = get_plan(plan_tier)
|
|
840
|
+
func_used = 0
|
|
841
|
+
sec_used = 0
|
|
842
|
+
|
|
843
|
+
func_quota = plan.monthly_scan_quota
|
|
844
|
+
sec_quota = plan.monthly_security_scan_quota
|
|
845
|
+
func_pct = round(func_used / func_quota, 4) if func_quota > 0 else 0.0
|
|
846
|
+
sec_pct = round(sec_used / sec_quota, 4) if sec_quota > 0 else 0.0
|
|
847
|
+
approaching = func_pct >= 0.7 or sec_pct >= 0.7
|
|
848
|
+
|
|
849
|
+
# Quota resets on the 1st of the next month
|
|
850
|
+
now = datetime.now(timezone.utc)
|
|
851
|
+
if now.month == 12:
|
|
852
|
+
reset_dt = now.replace(year=now.year + 1, month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
853
|
+
else:
|
|
854
|
+
reset_dt = now.replace(month=now.month + 1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
855
|
+
quota_reset_date = reset_dt.strftime("%Y-%m-%d")
|
|
856
|
+
|
|
857
|
+
return UsageSummary(
|
|
858
|
+
plan_name=plan.display_name,
|
|
859
|
+
plan_tier=plan_tier,
|
|
860
|
+
functional_used=func_used,
|
|
861
|
+
functional_quota=func_quota,
|
|
862
|
+
security_used=sec_used,
|
|
863
|
+
security_quota=sec_quota,
|
|
864
|
+
functional_percent=func_pct,
|
|
865
|
+
security_percent=sec_pct,
|
|
866
|
+
approaching_limit=approaching,
|
|
867
|
+
quota_reset_date=quota_reset_date,
|
|
868
|
+
stripe_portal_url=None,
|
|
869
|
+
invoices=[],
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
@router.get("/api/v1/dashboard/onboarding-status", response_model=OnboardingStatus)
|
|
874
|
+
async def dashboard_onboarding_status(request: "Any" = None) -> OnboardingStatus:
|
|
875
|
+
"""Return onboarding checklist completion state for the getting-started tab.
|
|
876
|
+
|
|
877
|
+
Steps are derived from the tenant's activity: whether they have an API
|
|
878
|
+
key configured, have run at least one scan, and have viewed results.
|
|
879
|
+
"""
|
|
880
|
+
from mannf.product.server import _scans # noqa: PLC0415
|
|
881
|
+
|
|
882
|
+
tenant = None
|
|
883
|
+
if request is not None:
|
|
884
|
+
tenant = getattr(getattr(request, "state", None), "tenant", None)
|
|
885
|
+
|
|
886
|
+
api_key_raw = os.environ.get("NAT_API_KEY", "")
|
|
887
|
+
has_api_key = bool(api_key_raw) or (tenant is not None)
|
|
888
|
+
has_run_scan = len(_scans) > 0
|
|
889
|
+
has_viewed_results = has_run_scan # proxy: if they ran a scan they've seen results
|
|
890
|
+
|
|
891
|
+
# Build masked API key for display
|
|
892
|
+
api_key_masked: Optional[str] = None
|
|
893
|
+
if api_key_raw:
|
|
894
|
+
visible = api_key_raw[-4:] if len(api_key_raw) >= 4 else api_key_raw
|
|
895
|
+
api_key_masked = "nat_pk_" + "•" * 16 + visible
|
|
896
|
+
elif tenant:
|
|
897
|
+
api_key_masked = "nat_pk_" + "•" * 20
|
|
898
|
+
|
|
899
|
+
steps: List[OnboardingStep] = [
|
|
900
|
+
OnboardingStep(
|
|
901
|
+
step=1,
|
|
902
|
+
title="Account created",
|
|
903
|
+
description="Your NAT account is set up and ready to use.",
|
|
904
|
+
completed=True,
|
|
905
|
+
),
|
|
906
|
+
OnboardingStep(
|
|
907
|
+
step=2,
|
|
908
|
+
title="Copy your API key",
|
|
909
|
+
description="Use this key to authenticate CLI and API requests.",
|
|
910
|
+
completed=has_api_key,
|
|
911
|
+
action_label="View API key",
|
|
912
|
+
action_url="/dashboard#settings",
|
|
913
|
+
),
|
|
914
|
+
OnboardingStep(
|
|
915
|
+
step=3,
|
|
916
|
+
title="Install NAT CLI",
|
|
917
|
+
description="Install the NAT testing toolkit with pip.",
|
|
918
|
+
completed=False,
|
|
919
|
+
code_snippet="pip install nat-testing",
|
|
920
|
+
),
|
|
921
|
+
OnboardingStep(
|
|
922
|
+
step=4,
|
|
923
|
+
title="Run your first scan",
|
|
924
|
+
description="Point NAT at your OpenAPI spec and base URL.",
|
|
925
|
+
completed=has_run_scan,
|
|
926
|
+
code_snippet="nat scan --spec openapi.yaml --url https://api.example.com",
|
|
927
|
+
),
|
|
928
|
+
OnboardingStep(
|
|
929
|
+
step=5,
|
|
930
|
+
title="View results in dashboard",
|
|
931
|
+
description="Explore your scan results, risk heatmap, and agent activity.",
|
|
932
|
+
completed=has_viewed_results,
|
|
933
|
+
action_label="Go to Overview",
|
|
934
|
+
action_url="/dashboard#overview",
|
|
935
|
+
),
|
|
936
|
+
]
|
|
937
|
+
|
|
938
|
+
completed_count = sum(1 for s in steps if s.completed)
|
|
939
|
+
return OnboardingStatus(
|
|
940
|
+
steps=steps,
|
|
941
|
+
completed_count=completed_count,
|
|
942
|
+
total_count=len(steps),
|
|
943
|
+
api_key_masked=api_key_masked,
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
@router.get(
|
|
948
|
+
"/api/v1/dashboard/onboarding",
|
|
949
|
+
tags=["dashboard", "onboarding"],
|
|
950
|
+
summary="Onboarding progress for the authenticated tenant (dashboard view)",
|
|
951
|
+
)
|
|
952
|
+
async def dashboard_onboarding(request: "Any" = None) -> dict:
|
|
953
|
+
"""Return the onboarding progress for the current tenant using the tracker.
|
|
954
|
+
|
|
955
|
+
Delegates to :mod:`mannf.product.onboarding` for per-step state, with a
|
|
956
|
+
fallback to the static checklist when no tenant is authenticated.
|
|
957
|
+
"""
|
|
958
|
+
from mannf.product.onboarding import get_onboarding_status # noqa: PLC0415
|
|
959
|
+
|
|
960
|
+
tenant = None
|
|
961
|
+
if request is not None:
|
|
962
|
+
tenant = getattr(getattr(request, "state", None), "tenant", None)
|
|
963
|
+
|
|
964
|
+
if tenant is None:
|
|
965
|
+
# No authenticated tenant — return an empty/default status
|
|
966
|
+
return {
|
|
967
|
+
"tenant_id": None,
|
|
968
|
+
"completed_steps": [],
|
|
969
|
+
"next_step": "ACCOUNT_CREATED",
|
|
970
|
+
"progress_percent": 0.0,
|
|
971
|
+
"all_steps": [
|
|
972
|
+
{"step": s, "completed": False, "completed_at": None}
|
|
973
|
+
for s in [
|
|
974
|
+
"ACCOUNT_CREATED",
|
|
975
|
+
"API_KEY_PROVISIONED",
|
|
976
|
+
"FIRST_SCAN_STARTED",
|
|
977
|
+
"FIRST_SCAN_COMPLETED",
|
|
978
|
+
"RESULTS_VIEWED",
|
|
979
|
+
"BILLING_CONFIGURED",
|
|
980
|
+
]
|
|
981
|
+
],
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
status = get_onboarding_status(tenant["id"])
|
|
985
|
+
return status.model_dump()
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
# ---------------------------------------------------------------------------
|
|
989
|
+
# Go-live indicators — plan badge, usage bars, upgrade CTA, demo banner
|
|
990
|
+
# (Phase 8.5 — Go-to-Market Readiness)
|
|
991
|
+
# ---------------------------------------------------------------------------
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
@router.get(
|
|
995
|
+
"/api/v1/dashboard/go-live-status",
|
|
996
|
+
tags=["dashboard"],
|
|
997
|
+
summary="Go-live readiness indicators for the current tenant",
|
|
998
|
+
)
|
|
999
|
+
async def dashboard_go_live_status(request: "Any" = None) -> JSONResponse:
|
|
1000
|
+
"""Return plan badge, usage progress, upgrade CTA, and demo-mode info.
|
|
1001
|
+
|
|
1002
|
+
Used by the dashboard to render plan tier badge, usage progress bars,
|
|
1003
|
+
"Upgrade" CTA for free/pro users, trial countdown (if applicable), and
|
|
1004
|
+
the demo environment banner when ``NAT_DEMO_MODE=true``.
|
|
1005
|
+
|
|
1006
|
+
Degrades gracefully when billing is not configured.
|
|
1007
|
+
"""
|
|
1008
|
+
from mannf.product.billing.plans import get_plan # noqa: PLC0415
|
|
1009
|
+
|
|
1010
|
+
tenant = None
|
|
1011
|
+
if request is not None:
|
|
1012
|
+
tenant = getattr(getattr(request, "state", None), "tenant", None)
|
|
1013
|
+
|
|
1014
|
+
# Demo mode
|
|
1015
|
+
raw_demo = os.environ.get("NAT_DEMO_MODE", "").lower().strip()
|
|
1016
|
+
is_demo = raw_demo in ("true", "1", "yes")
|
|
1017
|
+
|
|
1018
|
+
# Billing availability (degrades gracefully when Stripe not configured)
|
|
1019
|
+
stripe_key_set = bool(os.environ.get("STRIPE_SECRET_KEY", "").strip())
|
|
1020
|
+
billing_enabled = stripe_key_set
|
|
1021
|
+
|
|
1022
|
+
if tenant is not None:
|
|
1023
|
+
plan_tier = tenant.get("plan_tier", "free")
|
|
1024
|
+
tenant_name = tenant.get("name", "")
|
|
1025
|
+
monthly_scan_quota = tenant.get("monthly_scan_quota", 50)
|
|
1026
|
+
monthly_security_scan_quota = tenant.get("monthly_security_scan_quota", 10)
|
|
1027
|
+
trial_ends_at = tenant.get("trial_ends_at")
|
|
1028
|
+
else:
|
|
1029
|
+
plan_tier = "free"
|
|
1030
|
+
tenant_name = ""
|
|
1031
|
+
plan_obj = get_plan("free")
|
|
1032
|
+
monthly_scan_quota = plan_obj.monthly_scan_quota
|
|
1033
|
+
monthly_security_scan_quota = plan_obj.monthly_security_scan_quota
|
|
1034
|
+
trial_ends_at = None
|
|
1035
|
+
|
|
1036
|
+
plan_obj = get_plan(plan_tier)
|
|
1037
|
+
|
|
1038
|
+
# Fetch usage if possible
|
|
1039
|
+
func_used = 0
|
|
1040
|
+
sec_used = 0
|
|
1041
|
+
if tenant is not None:
|
|
1042
|
+
try:
|
|
1043
|
+
from mannf.product.billing.tenant_auth import get_monthly_usage # noqa: PLC0415
|
|
1044
|
+
import uuid as _uuid # noqa: PLC0415
|
|
1045
|
+
|
|
1046
|
+
tenant_id = _uuid.UUID(tenant["id"])
|
|
1047
|
+
func_used = await get_monthly_usage(tenant_id, "functional")
|
|
1048
|
+
sec_used = await get_monthly_usage(tenant_id, "security")
|
|
1049
|
+
except Exception: # noqa: BLE001
|
|
1050
|
+
pass
|
|
1051
|
+
|
|
1052
|
+
func_pct = round(func_used / monthly_scan_quota, 4) if monthly_scan_quota > 0 else 0.0
|
|
1053
|
+
sec_pct = round(sec_used / monthly_security_scan_quota, 4) if monthly_security_scan_quota > 0 else 0.0
|
|
1054
|
+
|
|
1055
|
+
# Upgrade CTA: show for free/pro users
|
|
1056
|
+
show_upgrade_cta = plan_tier in ("free", "pro") and billing_enabled
|
|
1057
|
+
upgrade_tier = "pro" if plan_tier == "free" else "team"
|
|
1058
|
+
upgrade_url = f"https://app.nat-testing.io/dashboard/upgrade?to={upgrade_tier}" if show_upgrade_cta else None
|
|
1059
|
+
|
|
1060
|
+
# Trial countdown
|
|
1061
|
+
trial_countdown_days: Optional[int] = None
|
|
1062
|
+
if trial_ends_at:
|
|
1063
|
+
try:
|
|
1064
|
+
from datetime import datetime, timezone # noqa: PLC0415
|
|
1065
|
+
if isinstance(trial_ends_at, str):
|
|
1066
|
+
ends = datetime.fromisoformat(trial_ends_at)
|
|
1067
|
+
else:
|
|
1068
|
+
ends = trial_ends_at
|
|
1069
|
+
if ends.tzinfo is None:
|
|
1070
|
+
ends = ends.replace(tzinfo=timezone.utc)
|
|
1071
|
+
delta = (ends - datetime.now(timezone.utc)).days
|
|
1072
|
+
trial_countdown_days = max(0, delta)
|
|
1073
|
+
except Exception: # noqa: BLE001
|
|
1074
|
+
pass
|
|
1075
|
+
|
|
1076
|
+
return JSONResponse(content={
|
|
1077
|
+
"plan": {
|
|
1078
|
+
"tier": plan_tier,
|
|
1079
|
+
"display_name": plan_obj.display_name,
|
|
1080
|
+
"badge_label": plan_obj.display_name.upper(),
|
|
1081
|
+
},
|
|
1082
|
+
"usage": {
|
|
1083
|
+
"functional_scans": {
|
|
1084
|
+
"used": func_used,
|
|
1085
|
+
"quota": monthly_scan_quota,
|
|
1086
|
+
"percent": func_pct,
|
|
1087
|
+
"approaching_limit": func_pct >= 0.8,
|
|
1088
|
+
},
|
|
1089
|
+
"security_scans": {
|
|
1090
|
+
"used": sec_used,
|
|
1091
|
+
"quota": monthly_security_scan_quota,
|
|
1092
|
+
"percent": sec_pct,
|
|
1093
|
+
"approaching_limit": sec_pct >= 0.8,
|
|
1094
|
+
},
|
|
1095
|
+
},
|
|
1096
|
+
"upgrade": {
|
|
1097
|
+
"show_cta": show_upgrade_cta,
|
|
1098
|
+
"upgrade_tier": upgrade_tier if show_upgrade_cta else None,
|
|
1099
|
+
"upgrade_url": upgrade_url,
|
|
1100
|
+
},
|
|
1101
|
+
"trial": {
|
|
1102
|
+
"is_trial": trial_countdown_days is not None,
|
|
1103
|
+
"days_remaining": trial_countdown_days,
|
|
1104
|
+
},
|
|
1105
|
+
"demo_mode": {
|
|
1106
|
+
"active": is_demo,
|
|
1107
|
+
"banner_message": "🎯 Demo Environment — data is synthetic and resets periodically" if is_demo else None,
|
|
1108
|
+
},
|
|
1109
|
+
"billing": {
|
|
1110
|
+
"enabled": billing_enabled,
|
|
1111
|
+
},
|
|
1112
|
+
})
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
# ---------------------------------------------------------------------------
|
|
1116
|
+
# Discovery / Scenario Suggestion endpoints (Phase 3)
|
|
1117
|
+
# ---------------------------------------------------------------------------
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _assign_confidence(scenario: Dict[str, Any]) -> float:
|
|
1121
|
+
"""Heuristic confidence score based on scenario type."""
|
|
1122
|
+
type_confidence = {
|
|
1123
|
+
"happy_path": 0.9,
|
|
1124
|
+
"flow": 0.8,
|
|
1125
|
+
"boundary": 0.6,
|
|
1126
|
+
"negative": 0.5,
|
|
1127
|
+
"exploratory": 0.4,
|
|
1128
|
+
}
|
|
1129
|
+
scenario_type = scenario.get("metadata", {}).get("scenario_type", "exploratory")
|
|
1130
|
+
return type_confidence.get(scenario_type, 0.5)
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _assign_risk_score(scenario: Dict[str, Any]) -> float:
|
|
1134
|
+
"""Heuristic risk score based on scenario type and step count."""
|
|
1135
|
+
type_risk = {
|
|
1136
|
+
"happy_path": 0.3,
|
|
1137
|
+
"flow": 0.5,
|
|
1138
|
+
"boundary": 0.7,
|
|
1139
|
+
"negative": 0.8,
|
|
1140
|
+
"exploratory": 0.4,
|
|
1141
|
+
}
|
|
1142
|
+
scenario_type = scenario.get("metadata", {}).get("scenario_type", "exploratory")
|
|
1143
|
+
return type_risk.get(scenario_type, 0.5)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
async def _run_discovery(discovery_id: str, req: DiscoveryRequest) -> None:
|
|
1147
|
+
"""Background task: crawl target URL and generate scenario suggestions."""
|
|
1148
|
+
from mannf.product.dashboard.telemetry import ( # noqa: PLC0415
|
|
1149
|
+
broadcast_discovery_complete,
|
|
1150
|
+
broadcast_discovery_failed,
|
|
1151
|
+
broadcast_discovery_progress,
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
result = _discoveries[discovery_id]
|
|
1155
|
+
try:
|
|
1156
|
+
# Import Phase 1 / Phase 2 components — gracefully degrade when
|
|
1157
|
+
# Playwright or the crawler aren't installed.
|
|
1158
|
+
try:
|
|
1159
|
+
from mannf.core.agents.web_crawler_agent import WebCrawlerAgent # noqa: PLC0415
|
|
1160
|
+
from mannf.core.browser.scenario_generator import ScenarioConfigGenerator # noqa: PLC0415
|
|
1161
|
+
from mannf.core.messaging.bus import MessageBus # noqa: PLC0415
|
|
1162
|
+
except ImportError as exc:
|
|
1163
|
+
result.status = "failed"
|
|
1164
|
+
result.error = (
|
|
1165
|
+
f"Required components not available (Playwright may not be installed): {exc}"
|
|
1166
|
+
)
|
|
1167
|
+
await broadcast_discovery_failed(discovery_id, result.error)
|
|
1168
|
+
return
|
|
1169
|
+
|
|
1170
|
+
bus = MessageBus()
|
|
1171
|
+
crawler = WebCrawlerAgent(
|
|
1172
|
+
agent_id=f"crawler-{discovery_id[:8]}",
|
|
1173
|
+
bus=bus,
|
|
1174
|
+
service_names=[req.url],
|
|
1175
|
+
base_url=req.url,
|
|
1176
|
+
headless=True,
|
|
1177
|
+
max_pages=req.max_pages,
|
|
1178
|
+
max_depth=req.max_depth,
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
# Broadcast initial progress
|
|
1182
|
+
await broadcast_discovery_progress(
|
|
1183
|
+
discovery_id=discovery_id,
|
|
1184
|
+
pages_visited=0,
|
|
1185
|
+
total_pages=req.max_pages,
|
|
1186
|
+
forms_found=0,
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
await crawler.start_browser()
|
|
1190
|
+
try:
|
|
1191
|
+
await crawler._run_crawl()
|
|
1192
|
+
finally:
|
|
1193
|
+
await crawler.stop_browser()
|
|
1194
|
+
|
|
1195
|
+
discovery_model = crawler._discovery_model
|
|
1196
|
+
if discovery_model is None:
|
|
1197
|
+
result.status = "failed"
|
|
1198
|
+
result.error = "Crawler produced no discovery model"
|
|
1199
|
+
await broadcast_discovery_failed(discovery_id, result.error)
|
|
1200
|
+
return
|
|
1201
|
+
|
|
1202
|
+
# Update stats
|
|
1203
|
+
pages_count = len(discovery_model.pages)
|
|
1204
|
+
forms_count = sum(len(p.forms) for p in discovery_model.pages)
|
|
1205
|
+
edges_count = len(discovery_model.edges)
|
|
1206
|
+
|
|
1207
|
+
await broadcast_discovery_progress(
|
|
1208
|
+
discovery_id=discovery_id,
|
|
1209
|
+
pages_visited=pages_count,
|
|
1210
|
+
total_pages=req.max_pages,
|
|
1211
|
+
forms_found=forms_count,
|
|
1212
|
+
)
|
|
1213
|
+
|
|
1214
|
+
# Generate scenarios via Phase 2
|
|
1215
|
+
llm_provider = None
|
|
1216
|
+
if req.llm_provider:
|
|
1217
|
+
try:
|
|
1218
|
+
from mannf.product.llm.config import LLMConfig # noqa: PLC0415
|
|
1219
|
+
from mannf.product.llm.factory import get_provider # noqa: PLC0415
|
|
1220
|
+
|
|
1221
|
+
llm_cfg = LLMConfig(
|
|
1222
|
+
provider=req.llm_provider,
|
|
1223
|
+
api_key=req.llm_api_key,
|
|
1224
|
+
model=req.llm_model or "",
|
|
1225
|
+
)
|
|
1226
|
+
llm_provider = get_provider(llm_cfg)
|
|
1227
|
+
except Exception as llm_exc: # noqa: BLE001
|
|
1228
|
+
logger.warning("Failed to create LLM provider for discovery: %s", llm_exc)
|
|
1229
|
+
|
|
1230
|
+
generator = ScenarioConfigGenerator(
|
|
1231
|
+
discovery_model,
|
|
1232
|
+
include_negative=req.include_negative,
|
|
1233
|
+
include_boundary=req.include_boundary,
|
|
1234
|
+
llm_provider=llm_provider,
|
|
1235
|
+
)
|
|
1236
|
+
if llm_provider is not None and llm_provider.is_available():
|
|
1237
|
+
raw_scenarios = await generator.generate_all_async()
|
|
1238
|
+
else:
|
|
1239
|
+
raw_scenarios = generator.generate_all()
|
|
1240
|
+
|
|
1241
|
+
scenarios = [
|
|
1242
|
+
DiscoveryScenario(
|
|
1243
|
+
scenario_id=str(uuid.uuid4()),
|
|
1244
|
+
url=s.get("url", req.url),
|
|
1245
|
+
steps=s.get("steps", []),
|
|
1246
|
+
metadata=s.get("metadata", {}),
|
|
1247
|
+
selected=True,
|
|
1248
|
+
confidence=_assign_confidence(s),
|
|
1249
|
+
risk_score=_assign_risk_score(s),
|
|
1250
|
+
)
|
|
1251
|
+
for s in raw_scenarios
|
|
1252
|
+
]
|
|
1253
|
+
|
|
1254
|
+
result.scenarios = scenarios
|
|
1255
|
+
result.stats = {
|
|
1256
|
+
"pages_discovered": pages_count,
|
|
1257
|
+
"forms_found": forms_count,
|
|
1258
|
+
"flows_detected": edges_count,
|
|
1259
|
+
"total_scenarios": len(scenarios),
|
|
1260
|
+
}
|
|
1261
|
+
result.status = "complete"
|
|
1262
|
+
await broadcast_discovery_complete(discovery_id, len(scenarios))
|
|
1263
|
+
await _persist_discovery(discovery_id)
|
|
1264
|
+
|
|
1265
|
+
except Exception as exc: # noqa: BLE001
|
|
1266
|
+
logger.exception("Discovery %s failed", discovery_id)
|
|
1267
|
+
result.status = "failed"
|
|
1268
|
+
result.error = str(exc)
|
|
1269
|
+
await broadcast_discovery_failed(discovery_id, str(exc))
|
|
1270
|
+
await _persist_discovery(discovery_id)
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
@router.post("/api/v1/discover")
|
|
1274
|
+
async def start_discovery(body: DiscoveryRequest) -> Dict[str, Any]:
|
|
1275
|
+
"""Trigger a discovery crawl and scenario generation.
|
|
1276
|
+
|
|
1277
|
+
Returns immediately with a ``discovery_id``; progress is broadcast via
|
|
1278
|
+
the ``/ws/live`` WebSocket connection.
|
|
1279
|
+
"""
|
|
1280
|
+
discovery_id = str(uuid.uuid4())
|
|
1281
|
+
result = DiscoveryResult(
|
|
1282
|
+
discovery_id=discovery_id,
|
|
1283
|
+
status="running",
|
|
1284
|
+
base_url=body.url,
|
|
1285
|
+
scenarios=[],
|
|
1286
|
+
stats={},
|
|
1287
|
+
created_at=datetime.now(timezone.utc).isoformat(),
|
|
1288
|
+
)
|
|
1289
|
+
_discoveries[discovery_id] = result
|
|
1290
|
+
asyncio.create_task(_persist_discovery(discovery_id))
|
|
1291
|
+
asyncio.create_task(_run_discovery(discovery_id, body))
|
|
1292
|
+
return {"discovery_id": discovery_id, "status": "running"}
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
@router.post("/api/v1/discover/import")
|
|
1296
|
+
async def import_artifact(
|
|
1297
|
+
file: Optional[UploadFile] = File(None),
|
|
1298
|
+
content: Optional[str] = Form(None),
|
|
1299
|
+
format: Optional[str] = Form(None),
|
|
1300
|
+
base_url: Optional[str] = Form(None),
|
|
1301
|
+
) -> Dict[str, Any]:
|
|
1302
|
+
"""Import an artifact (HAR, Postman, Playwright, cURL, etc.) as a discovery.
|
|
1303
|
+
|
|
1304
|
+
Accepts either:
|
|
1305
|
+
|
|
1306
|
+
* A **file upload** (multipart form data) — ``file`` field.
|
|
1307
|
+
* **Raw content** passed as a form field — ``content`` field (optionally
|
|
1308
|
+
with ``format`` to help format detection).
|
|
1309
|
+
|
|
1310
|
+
The format is auto-detected via
|
|
1311
|
+
:func:`~mannf.product.ingestors.loader.get_ingestor_for_source` when not
|
|
1312
|
+
supplied. The imported artifact is converted into a
|
|
1313
|
+
:class:`~mannf.product.dashboard.models.DiscoveryResult` via the
|
|
1314
|
+
:class:`~mannf.core.browser.ingestor_bridge.IngestorBridge` and stored in
|
|
1315
|
+
``_discoveries`` so the standard ``GET /api/v1/discover/{id}`` endpoints
|
|
1316
|
+
work without modification.
|
|
1317
|
+
|
|
1318
|
+
Returns a ``discovery_id`` that can be polled with the existing
|
|
1319
|
+
``/api/v1/discover/{id}`` endpoints.
|
|
1320
|
+
"""
|
|
1321
|
+
from mannf.product.ingestors.loader import ( # noqa: PLC0415
|
|
1322
|
+
get_ingestor_for_source,
|
|
1323
|
+
load_all_ingestors,
|
|
1324
|
+
)
|
|
1325
|
+
from mannf.product.ingestors.models import IngestorSource # noqa: PLC0415
|
|
1326
|
+
from mannf.core.browser.ingestor_bridge import IngestorBridge # noqa: PLC0415
|
|
1327
|
+
from mannf.product.dashboard.telemetry import ( # noqa: PLC0415
|
|
1328
|
+
broadcast_discovery_complete,
|
|
1329
|
+
)
|
|
1330
|
+
|
|
1331
|
+
# ---- 1. Resolve raw content ----------------------------------------
|
|
1332
|
+
raw_content: Optional[str] = None
|
|
1333
|
+
filename: str = "import"
|
|
1334
|
+
|
|
1335
|
+
if file is not None:
|
|
1336
|
+
raw_bytes = await file.read()
|
|
1337
|
+
try:
|
|
1338
|
+
raw_content = raw_bytes.decode("utf-8")
|
|
1339
|
+
except UnicodeDecodeError:
|
|
1340
|
+
raw_content = raw_bytes.decode("latin-1")
|
|
1341
|
+
filename = file.filename or "import"
|
|
1342
|
+
elif content is not None:
|
|
1343
|
+
raw_content = content
|
|
1344
|
+
else:
|
|
1345
|
+
raise HTTPException(
|
|
1346
|
+
status_code=422,
|
|
1347
|
+
detail="Provide either a 'file' upload or a 'content' form field.",
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
if not raw_content or not raw_content.strip():
|
|
1351
|
+
raise HTTPException(status_code=422, detail="Artifact content is empty.")
|
|
1352
|
+
|
|
1353
|
+
# ---- 2. Build IngestorSource and auto-detect format ----------------
|
|
1354
|
+
fmt = (format or "").strip().lower() or None
|
|
1355
|
+
source = IngestorSource(
|
|
1356
|
+
format=fmt or "auto",
|
|
1357
|
+
raw_content=raw_content,
|
|
1358
|
+
path=filename,
|
|
1359
|
+
metadata={"filename": filename},
|
|
1360
|
+
)
|
|
1361
|
+
|
|
1362
|
+
# Load all built-ins so get_ingestor_for_source has access to them
|
|
1363
|
+
all_ingestors = load_all_ingestors()
|
|
1364
|
+
ingestor = get_ingestor_for_source(source, all_ingestors)
|
|
1365
|
+
|
|
1366
|
+
if ingestor is None:
|
|
1367
|
+
raise HTTPException(
|
|
1368
|
+
status_code=422,
|
|
1369
|
+
detail=(
|
|
1370
|
+
f"Could not detect format for artifact '{filename}'. "
|
|
1371
|
+
"Supply a 'format' field (e.g. 'har', 'postman', 'playwright', 'curl')."
|
|
1372
|
+
),
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
# ---- 3. Run the ingestor -------------------------------------------
|
|
1376
|
+
try:
|
|
1377
|
+
ingest_result = await ingestor.ingest(source, config={})
|
|
1378
|
+
except Exception as exc: # noqa: BLE001
|
|
1379
|
+
logger.exception("Ingestor %r failed for import", ingestor.name)
|
|
1380
|
+
raise HTTPException(
|
|
1381
|
+
status_code=422,
|
|
1382
|
+
detail=f"Ingestor '{ingestor.name}' failed: {exc}",
|
|
1383
|
+
) from exc
|
|
1384
|
+
|
|
1385
|
+
if not ingest_result.success and not ingest_result.test_cases and not ingest_result.endpoints:
|
|
1386
|
+
errors = "; ".join(ingest_result.errors) if ingest_result.errors else "unknown error"
|
|
1387
|
+
raise HTTPException(
|
|
1388
|
+
status_code=422,
|
|
1389
|
+
detail=f"Ingestion produced no results: {errors}",
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
# ---- 4. Convert via IngestorBridge → scenario dicts ----------------
|
|
1393
|
+
effective_base_url = (base_url or "").strip() or "https://example.com"
|
|
1394
|
+
bridge = IngestorBridge(
|
|
1395
|
+
base_url=effective_base_url,
|
|
1396
|
+
source_tag=ingestor.name,
|
|
1397
|
+
)
|
|
1398
|
+
raw_scenarios = bridge.convert(ingest_result)
|
|
1399
|
+
|
|
1400
|
+
# ---- 5. Build DiscoveryResult and store ----------------------------
|
|
1401
|
+
discovery_id = str(uuid.uuid4())
|
|
1402
|
+
scenarios = [
|
|
1403
|
+
DiscoveryScenario(
|
|
1404
|
+
scenario_id=str(uuid.uuid4()),
|
|
1405
|
+
url=s.get("url", effective_base_url),
|
|
1406
|
+
steps=s.get("steps", []),
|
|
1407
|
+
metadata=s.get("metadata", {}),
|
|
1408
|
+
selected=True,
|
|
1409
|
+
confidence=_assign_confidence(s),
|
|
1410
|
+
risk_score=_assign_risk_score(s),
|
|
1411
|
+
)
|
|
1412
|
+
for s in raw_scenarios
|
|
1413
|
+
]
|
|
1414
|
+
|
|
1415
|
+
result = DiscoveryResult(
|
|
1416
|
+
discovery_id=discovery_id,
|
|
1417
|
+
status="complete",
|
|
1418
|
+
base_url=effective_base_url,
|
|
1419
|
+
scenarios=scenarios,
|
|
1420
|
+
stats={
|
|
1421
|
+
"pages_discovered": 0,
|
|
1422
|
+
"forms_found": 0,
|
|
1423
|
+
"flows_detected": 0,
|
|
1424
|
+
"total_scenarios": len(scenarios),
|
|
1425
|
+
"endpoints_ingested": len(ingest_result.endpoints),
|
|
1426
|
+
"test_cases_ingested": len(ingest_result.test_cases),
|
|
1427
|
+
},
|
|
1428
|
+
created_at=datetime.now(timezone.utc).isoformat(),
|
|
1429
|
+
)
|
|
1430
|
+
_discoveries[discovery_id] = result
|
|
1431
|
+
|
|
1432
|
+
# ---- 6. Broadcast completion and persist ----------------------------------------
|
|
1433
|
+
await broadcast_discovery_complete(discovery_id, len(scenarios))
|
|
1434
|
+
asyncio.create_task(_persist_discovery(discovery_id))
|
|
1435
|
+
|
|
1436
|
+
return {
|
|
1437
|
+
"discovery_id": discovery_id,
|
|
1438
|
+
"status": "complete",
|
|
1439
|
+
"ingestor": ingestor.name,
|
|
1440
|
+
"scenarios_imported": len(scenarios),
|
|
1441
|
+
"scenario_preview": [
|
|
1442
|
+
{
|
|
1443
|
+
"scenario_id": s.scenario_id,
|
|
1444
|
+
"url": s.url,
|
|
1445
|
+
"description": s.metadata.get("description", ""),
|
|
1446
|
+
"scenario_type": s.metadata.get("scenario_type", ""),
|
|
1447
|
+
"has_auth": s.metadata.get("has_auth", False),
|
|
1448
|
+
"nav_flow": s.metadata.get("nav_flow", []),
|
|
1449
|
+
"assertions_count": len(s.metadata.get("assertions", [])),
|
|
1450
|
+
"steps_count": len(s.steps),
|
|
1451
|
+
"confidence": s.confidence,
|
|
1452
|
+
"risk_score": s.risk_score,
|
|
1453
|
+
}
|
|
1454
|
+
for s in scenarios[:5]
|
|
1455
|
+
],
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
@router.post("/api/v1/discover/import/run")
|
|
1460
|
+
async def import_and_run(
|
|
1461
|
+
file: Optional[UploadFile] = File(None),
|
|
1462
|
+
content: Optional[str] = Form(None),
|
|
1463
|
+
format: Optional[str] = Form(None),
|
|
1464
|
+
base_url: Optional[str] = Form(None),
|
|
1465
|
+
target_url: Optional[str] = Form(None),
|
|
1466
|
+
max_iterations: int = Form(3),
|
|
1467
|
+
strategy: str = Form("all"),
|
|
1468
|
+
) -> Dict[str, Any]:
|
|
1469
|
+
"""Import an artifact and immediately launch an autonomous run seeded with the imported scenarios.
|
|
1470
|
+
|
|
1471
|
+
This is the one-click "Run autonomous with these seeds" trigger. It:
|
|
1472
|
+
|
|
1473
|
+
1. Ingests the artifact (same as ``POST /api/v1/discover/import``).
|
|
1474
|
+
2. Converts the ingested scenarios via :class:`~mannf.core.browser.ingestor_bridge.IngestorBridge`.
|
|
1475
|
+
3. Starts an :class:`~mannf.core.agents.autonomous_loop_agent.AutonomousTestLoopAgent`
|
|
1476
|
+
with the converted scenarios pre-loaded as ``seeded_scenarios``.
|
|
1477
|
+
4. Stores the agent's run ID and the seeded discovery in ``_autonomous_runs`` /
|
|
1478
|
+
``_discoveries`` so they can be polled via the standard endpoints.
|
|
1479
|
+
|
|
1480
|
+
Returns a ``run_id`` and ``discovery_id`` that can be polled for progress.
|
|
1481
|
+
"""
|
|
1482
|
+
from mannf.product.ingestors.loader import ( # noqa: PLC0415
|
|
1483
|
+
get_ingestor_for_source,
|
|
1484
|
+
load_all_ingestors,
|
|
1485
|
+
)
|
|
1486
|
+
from mannf.product.ingestors.models import IngestorSource # noqa: PLC0415
|
|
1487
|
+
from mannf.core.browser.ingestor_bridge import IngestorBridge # noqa: PLC0415
|
|
1488
|
+
from mannf.core.agents.autonomous_loop_agent import AutonomousTestLoopAgent # noqa: PLC0415
|
|
1489
|
+
from mannf.product.dashboard.telemetry import broadcast_discovery_complete # noqa: PLC0415
|
|
1490
|
+
|
|
1491
|
+
# ---- 1. Resolve content (reuse import logic) -------------------------
|
|
1492
|
+
raw_content: Optional[str] = None
|
|
1493
|
+
filename: str = "import"
|
|
1494
|
+
|
|
1495
|
+
if file is not None:
|
|
1496
|
+
raw_bytes = await file.read()
|
|
1497
|
+
try:
|
|
1498
|
+
raw_content = raw_bytes.decode("utf-8")
|
|
1499
|
+
except UnicodeDecodeError:
|
|
1500
|
+
raw_content = raw_bytes.decode("latin-1")
|
|
1501
|
+
filename = file.filename or "import"
|
|
1502
|
+
elif content is not None:
|
|
1503
|
+
raw_content = content
|
|
1504
|
+
else:
|
|
1505
|
+
raise HTTPException(
|
|
1506
|
+
status_code=422,
|
|
1507
|
+
detail="Provide either a 'file' upload or a 'content' form field.",
|
|
1508
|
+
)
|
|
1509
|
+
|
|
1510
|
+
if not raw_content or not raw_content.strip():
|
|
1511
|
+
raise HTTPException(status_code=422, detail="Artifact content is empty.")
|
|
1512
|
+
|
|
1513
|
+
# ---- 2. Ingest -------------------------------------------------------
|
|
1514
|
+
fmt = (format or "").strip().lower() or None
|
|
1515
|
+
source = IngestorSource(
|
|
1516
|
+
format=fmt or "auto",
|
|
1517
|
+
raw_content=raw_content,
|
|
1518
|
+
path=filename,
|
|
1519
|
+
metadata={"filename": filename},
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
all_ingestors = load_all_ingestors()
|
|
1523
|
+
ingestor = get_ingestor_for_source(source, all_ingestors)
|
|
1524
|
+
|
|
1525
|
+
if ingestor is None:
|
|
1526
|
+
raise HTTPException(
|
|
1527
|
+
status_code=422,
|
|
1528
|
+
detail=(
|
|
1529
|
+
f"Could not detect format for artifact '{filename}'. "
|
|
1530
|
+
"Supply a 'format' field."
|
|
1531
|
+
),
|
|
1532
|
+
)
|
|
1533
|
+
|
|
1534
|
+
try:
|
|
1535
|
+
ingest_result = await ingestor.ingest(source, config={})
|
|
1536
|
+
except Exception as exc: # noqa: BLE001
|
|
1537
|
+
logger.exception("Ingestor %r failed for import/run", ingestor.name)
|
|
1538
|
+
raise HTTPException(
|
|
1539
|
+
status_code=422,
|
|
1540
|
+
detail=f"Ingestor '{ingestor.name}' failed: {exc}",
|
|
1541
|
+
) from exc
|
|
1542
|
+
|
|
1543
|
+
if not ingest_result.success and not ingest_result.test_cases and not ingest_result.endpoints:
|
|
1544
|
+
errors = "; ".join(ingest_result.errors) if ingest_result.errors else "unknown error"
|
|
1545
|
+
raise HTTPException(status_code=422, detail=f"Ingestion produced no results: {errors}")
|
|
1546
|
+
|
|
1547
|
+
# ---- 3. Convert → scenario dicts ------------------------------------
|
|
1548
|
+
effective_base_url = (base_url or target_url or "").strip() or "https://example.com"
|
|
1549
|
+
effective_target_url = (target_url or base_url or "").strip() or "https://example.com"
|
|
1550
|
+
|
|
1551
|
+
bridge = IngestorBridge(base_url=effective_base_url, source_tag=ingestor.name)
|
|
1552
|
+
raw_scenarios = bridge.convert(ingest_result)
|
|
1553
|
+
|
|
1554
|
+
# ---- 4. Store as a discovery ----------------------------------------
|
|
1555
|
+
discovery_id = str(uuid.uuid4())
|
|
1556
|
+
scenarios = [
|
|
1557
|
+
DiscoveryScenario(
|
|
1558
|
+
scenario_id=str(uuid.uuid4()),
|
|
1559
|
+
url=s.get("url", effective_base_url),
|
|
1560
|
+
steps=s.get("steps", []),
|
|
1561
|
+
metadata=s.get("metadata", {}),
|
|
1562
|
+
selected=True,
|
|
1563
|
+
confidence=_assign_confidence(s),
|
|
1564
|
+
risk_score=_assign_risk_score(s),
|
|
1565
|
+
)
|
|
1566
|
+
for s in raw_scenarios
|
|
1567
|
+
]
|
|
1568
|
+
|
|
1569
|
+
discovery_result = DiscoveryResult(
|
|
1570
|
+
discovery_id=discovery_id,
|
|
1571
|
+
status="complete",
|
|
1572
|
+
base_url=effective_base_url,
|
|
1573
|
+
scenarios=scenarios,
|
|
1574
|
+
stats={
|
|
1575
|
+
"pages_discovered": 0,
|
|
1576
|
+
"forms_found": 0,
|
|
1577
|
+
"flows_detected": 0,
|
|
1578
|
+
"total_scenarios": len(scenarios),
|
|
1579
|
+
"endpoints_ingested": len(ingest_result.endpoints),
|
|
1580
|
+
"test_cases_ingested": len(ingest_result.test_cases),
|
|
1581
|
+
},
|
|
1582
|
+
created_at=datetime.now(timezone.utc).isoformat(),
|
|
1583
|
+
)
|
|
1584
|
+
_discoveries[discovery_id] = discovery_result
|
|
1585
|
+
await broadcast_discovery_complete(discovery_id, len(scenarios))
|
|
1586
|
+
asyncio.create_task(_persist_discovery(discovery_id))
|
|
1587
|
+
|
|
1588
|
+
# ---- 5. Spawn autonomous run with seeds -----------------------------
|
|
1589
|
+
agent = AutonomousTestLoopAgent(
|
|
1590
|
+
target_url=effective_target_url,
|
|
1591
|
+
max_iterations=max(1, min(max_iterations, 20)),
|
|
1592
|
+
strategy=strategy,
|
|
1593
|
+
seeded_scenarios=raw_scenarios,
|
|
1594
|
+
)
|
|
1595
|
+
run_id = agent.run_id
|
|
1596
|
+
|
|
1597
|
+
# Store a placeholder autonomous run record (dict-based, same as standard runs)
|
|
1598
|
+
_autonomous_runs[run_id] = {
|
|
1599
|
+
"run_id": run_id,
|
|
1600
|
+
"target_url": effective_target_url,
|
|
1601
|
+
"strategy": strategy,
|
|
1602
|
+
"status": "running",
|
|
1603
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
1604
|
+
"total_iterations": 0,
|
|
1605
|
+
"total_scenarios_executed": 0,
|
|
1606
|
+
"total_passed": 0,
|
|
1607
|
+
"total_failed": 0,
|
|
1608
|
+
"total_flaky": 0,
|
|
1609
|
+
"iterations": [],
|
|
1610
|
+
"unique_failures": [],
|
|
1611
|
+
"coverage_summary": {},
|
|
1612
|
+
"seeded_scenarios_count": len(raw_scenarios),
|
|
1613
|
+
}
|
|
1614
|
+
asyncio.create_task(_persist_autonomous_run(run_id))
|
|
1615
|
+
|
|
1616
|
+
# Launch the autonomous loop in the background
|
|
1617
|
+
async def _run_seeded_agent() -> None:
|
|
1618
|
+
try:
|
|
1619
|
+
result = await agent.run_loop()
|
|
1620
|
+
_autonomous_runs[run_id].update(result.model_dump())
|
|
1621
|
+
_autonomous_runs[run_id]["status"] = result.status
|
|
1622
|
+
asyncio.create_task(_persist_autonomous_run(run_id))
|
|
1623
|
+
await _compute_and_dispatch_delta(run_id, result)
|
|
1624
|
+
except Exception: # noqa: BLE001
|
|
1625
|
+
logger.exception("Seeded autonomous run %s failed", run_id[:8])
|
|
1626
|
+
_autonomous_runs[run_id]["status"] = "failed"
|
|
1627
|
+
finally:
|
|
1628
|
+
_autonomous_runs[run_id].pop("_agent", None)
|
|
1629
|
+
|
|
1630
|
+
asyncio.create_task(_run_seeded_agent())
|
|
1631
|
+
|
|
1632
|
+
return {
|
|
1633
|
+
"run_id": run_id,
|
|
1634
|
+
"discovery_id": discovery_id,
|
|
1635
|
+
"status": "running",
|
|
1636
|
+
"ingestor": ingestor.name,
|
|
1637
|
+
"scenarios_seeded": len(raw_scenarios),
|
|
1638
|
+
"target_url": effective_target_url,
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
|
|
1642
|
+
@router.get("/api/v1/discover/{discovery_id}")
|
|
1643
|
+
async def get_discovery(discovery_id: str) -> Dict[str, Any]:
|
|
1644
|
+
"""Return the current status and scenarios for a discovery run."""
|
|
1645
|
+
result = _discoveries.get(discovery_id)
|
|
1646
|
+
if result is None:
|
|
1647
|
+
raise HTTPException(status_code=404, detail=f"Discovery '{discovery_id}' not found")
|
|
1648
|
+
return result.model_dump()
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
@router.put("/api/v1/discover/{discovery_id}/scenarios/{scenario_id}")
|
|
1652
|
+
async def update_scenario(
|
|
1653
|
+
discovery_id: str,
|
|
1654
|
+
scenario_id: str,
|
|
1655
|
+
body: Dict[str, Any],
|
|
1656
|
+
) -> Dict[str, Any]:
|
|
1657
|
+
"""Apply a partial update to a scenario (steps, values, selection state)."""
|
|
1658
|
+
result = _discoveries.get(discovery_id)
|
|
1659
|
+
if result is None:
|
|
1660
|
+
raise HTTPException(status_code=404, detail=f"Discovery '{discovery_id}' not found")
|
|
1661
|
+
scenario = next((s for s in result.scenarios if s.scenario_id == scenario_id), None)
|
|
1662
|
+
if scenario is None:
|
|
1663
|
+
raise HTTPException(status_code=404, detail=f"Scenario '{scenario_id}' not found")
|
|
1664
|
+
|
|
1665
|
+
allowed_fields = {"url", "steps", "metadata", "selected", "confidence", "risk_score"}
|
|
1666
|
+
for field, value in body.items():
|
|
1667
|
+
if field in allowed_fields:
|
|
1668
|
+
setattr(scenario, field, value)
|
|
1669
|
+
|
|
1670
|
+
return scenario.model_dump()
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
@router.delete("/api/v1/discover/{discovery_id}/scenarios/{scenario_id}")
|
|
1674
|
+
async def delete_scenario(discovery_id: str, scenario_id: str) -> Dict[str, Any]:
|
|
1675
|
+
"""Remove a scenario from the suggestion list."""
|
|
1676
|
+
result = _discoveries.get(discovery_id)
|
|
1677
|
+
if result is None:
|
|
1678
|
+
raise HTTPException(status_code=404, detail=f"Discovery '{discovery_id}' not found")
|
|
1679
|
+
original_count = len(result.scenarios)
|
|
1680
|
+
result.scenarios = [s for s in result.scenarios if s.scenario_id != scenario_id]
|
|
1681
|
+
if len(result.scenarios) == original_count:
|
|
1682
|
+
raise HTTPException(status_code=404, detail=f"Scenario '{scenario_id}' not found")
|
|
1683
|
+
return {"deleted": scenario_id}
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
@router.post("/api/v1/discover/{discovery_id}/run")
|
|
1687
|
+
async def run_scenarios(
|
|
1688
|
+
discovery_id: str,
|
|
1689
|
+
body: Optional[Dict[str, Any]] = None,
|
|
1690
|
+
) -> Dict[str, Any]:
|
|
1691
|
+
"""Submit selected (or specified) scenarios to FunctionalTestOrchestrator."""
|
|
1692
|
+
result = _discoveries.get(discovery_id)
|
|
1693
|
+
if result is None:
|
|
1694
|
+
raise HTTPException(status_code=404, detail=f"Discovery '{discovery_id}' not found")
|
|
1695
|
+
|
|
1696
|
+
scenario_ids: Optional[List[str]] = (body or {}).get("scenario_ids")
|
|
1697
|
+
if scenario_ids is not None:
|
|
1698
|
+
selected = [s for s in result.scenarios if s.scenario_id in scenario_ids]
|
|
1699
|
+
else:
|
|
1700
|
+
selected = [s for s in result.scenarios if s.selected]
|
|
1701
|
+
|
|
1702
|
+
if not selected:
|
|
1703
|
+
raise HTTPException(status_code=422, detail="No scenarios selected for execution")
|
|
1704
|
+
|
|
1705
|
+
run_id = str(uuid.uuid4())
|
|
1706
|
+
tasks = [
|
|
1707
|
+
{"url": s.url, "steps": s.steps, "metadata": s.metadata}
|
|
1708
|
+
for s in selected
|
|
1709
|
+
]
|
|
1710
|
+
|
|
1711
|
+
async def _run() -> None:
|
|
1712
|
+
try:
|
|
1713
|
+
from mannf.core.functional_orchestrator import FunctionalTestOrchestrator # noqa: PLC0415
|
|
1714
|
+
orch = FunctionalTestOrchestrator()
|
|
1715
|
+
await orch.run(tasks)
|
|
1716
|
+
except Exception: # noqa: BLE001
|
|
1717
|
+
logger.exception("Scenario run %s failed", run_id)
|
|
1718
|
+
|
|
1719
|
+
asyncio.create_task(_run())
|
|
1720
|
+
return {"run_id": run_id, "scenarios_submitted": len(selected)}
|
|
1721
|
+
|
|
1722
|
+
|
|
1723
|
+
@router.websocket("/ws/live")
|
|
1724
|
+
async def websocket_live(ws: WebSocket) -> None:
|
|
1725
|
+
"""WebSocket endpoint for real-time dashboard updates during scans.
|
|
1726
|
+
|
|
1727
|
+
The client receives JSON messages with ``type`` and ``data`` fields:
|
|
1728
|
+
|
|
1729
|
+
- ``{"type": "ping"}`` — heartbeat every 5 s
|
|
1730
|
+
- ``{"type": "scan_update", "data": {...}}`` — scan progress
|
|
1731
|
+
- ``{"type": "anomaly", "data": {...}}`` — new anomaly detected
|
|
1732
|
+
- ``{"type": "summary", "data": {...}}`` — updated summary stats
|
|
1733
|
+
"""
|
|
1734
|
+
await manager.connect(ws)
|
|
1735
|
+
try:
|
|
1736
|
+
# Send an initial ping so the client knows the connection is alive
|
|
1737
|
+
await ws.send_text(json.dumps({"type": "ping"}))
|
|
1738
|
+
while True:
|
|
1739
|
+
# Keep the connection alive with periodic pings; the client can
|
|
1740
|
+
# also send messages (currently ignored but allows future use).
|
|
1741
|
+
try:
|
|
1742
|
+
await asyncio.wait_for(ws.receive_text(), timeout=5.0)
|
|
1743
|
+
except asyncio.TimeoutError:
|
|
1744
|
+
await ws.send_text(json.dumps({"type": "ping"}))
|
|
1745
|
+
except WebSocketDisconnect:
|
|
1746
|
+
pass
|
|
1747
|
+
finally:
|
|
1748
|
+
manager.disconnect(ws)
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
# ---------------------------------------------------------------------------
|
|
1752
|
+
# Autonomous Test Loop endpoints (Phase 5)
|
|
1753
|
+
# ---------------------------------------------------------------------------
|
|
1754
|
+
|
|
1755
|
+
|
|
1756
|
+
async def _run_autonomous(run_id: str, req: AutonomousRequest) -> None:
|
|
1757
|
+
"""Background task: run the full autonomous test loop."""
|
|
1758
|
+
from mannf.core.agents.autonomous_loop_agent import AutonomousTestLoopAgent # noqa: PLC0415
|
|
1759
|
+
|
|
1760
|
+
report = _autonomous_runs[run_id]
|
|
1761
|
+
|
|
1762
|
+
# Build optional LLM provider
|
|
1763
|
+
llm_provider = None
|
|
1764
|
+
if req.llm_provider and req.llm_api_key:
|
|
1765
|
+
try:
|
|
1766
|
+
from mannf.product.llm.config import LLMConfig # noqa: PLC0415
|
|
1767
|
+
from mannf.product.llm.factory import get_provider # noqa: PLC0415
|
|
1768
|
+
config = LLMConfig(
|
|
1769
|
+
provider=req.llm_provider,
|
|
1770
|
+
model=req.llm_model or "",
|
|
1771
|
+
api_key=req.llm_api_key,
|
|
1772
|
+
)
|
|
1773
|
+
candidate = get_provider(config)
|
|
1774
|
+
if candidate.is_available():
|
|
1775
|
+
llm_provider = candidate
|
|
1776
|
+
except Exception: # noqa: BLE001
|
|
1777
|
+
logger.warning("Autonomous run %s: could not initialise LLM provider", run_id)
|
|
1778
|
+
|
|
1779
|
+
agent = AutonomousTestLoopAgent(
|
|
1780
|
+
target_url=req.url,
|
|
1781
|
+
max_iterations=req.max_iterations,
|
|
1782
|
+
max_duration_s=req.max_duration_s,
|
|
1783
|
+
strategy=req.strategy,
|
|
1784
|
+
risk_threshold=req.risk_threshold,
|
|
1785
|
+
max_pages=req.max_pages,
|
|
1786
|
+
max_depth=req.max_depth,
|
|
1787
|
+
headless=req.headless,
|
|
1788
|
+
llm_provider=llm_provider,
|
|
1789
|
+
)
|
|
1790
|
+
|
|
1791
|
+
# Store agent reference for stop support
|
|
1792
|
+
_autonomous_runs[run_id]["_agent"] = agent
|
|
1793
|
+
|
|
1794
|
+
try:
|
|
1795
|
+
result = await agent.run_loop()
|
|
1796
|
+
# Propagate attribution fields from request into the report
|
|
1797
|
+
if req.build_id:
|
|
1798
|
+
result.build_id = req.build_id
|
|
1799
|
+
if req.commit_sha:
|
|
1800
|
+
result.commit_sha = req.commit_sha
|
|
1801
|
+
if req.deploy_tag:
|
|
1802
|
+
result.deploy_tag = req.deploy_tag
|
|
1803
|
+
_autonomous_runs[run_id].update(result.model_dump())
|
|
1804
|
+
_autonomous_runs[run_id]["status"] = result.status
|
|
1805
|
+
if result.status == "paused":
|
|
1806
|
+
_autonomous_runs[run_id]["_pause_state"] = agent.get_pause_state()
|
|
1807
|
+
# Update flake detector with task outcomes from this run (Phase 6.5)
|
|
1808
|
+
_update_flake_detector_from_run(run_id, result)
|
|
1809
|
+
# Compute run-over-run diff and dispatch delta notifications
|
|
1810
|
+
await _compute_and_dispatch_delta(run_id, result)
|
|
1811
|
+
# File bug tickets for unique failures (fire-and-forget, non-blocking)
|
|
1812
|
+
asyncio.create_task(_auto_file_bugs(run_id, result))
|
|
1813
|
+
except Exception as exc: # noqa: BLE001
|
|
1814
|
+
logger.exception("Autonomous run %s failed", run_id)
|
|
1815
|
+
_autonomous_runs[run_id]["status"] = "failed"
|
|
1816
|
+
_autonomous_runs[run_id]["error"] = str(exc)
|
|
1817
|
+
finally:
|
|
1818
|
+
_autonomous_runs[run_id].pop("_agent", None)
|
|
1819
|
+
await _persist_autonomous_run(run_id)
|
|
1820
|
+
|
|
1821
|
+
|
|
1822
|
+
@router.post("/api/v1/autonomous/start")
|
|
1823
|
+
async def start_autonomous(body: AutonomousRequest) -> Dict[str, Any]:
|
|
1824
|
+
"""Start an autonomous test loop run.
|
|
1825
|
+
|
|
1826
|
+
Returns immediately with a ``run_id``; progress is broadcast via
|
|
1827
|
+
the ``/ws/live`` WebSocket connection.
|
|
1828
|
+
"""
|
|
1829
|
+
run_id = str(uuid.uuid4())
|
|
1830
|
+
_autonomous_runs[run_id] = {
|
|
1831
|
+
"run_id": run_id,
|
|
1832
|
+
"target_url": body.url,
|
|
1833
|
+
"strategy": body.strategy,
|
|
1834
|
+
"status": "running",
|
|
1835
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
1836
|
+
"total_iterations": 0,
|
|
1837
|
+
"total_scenarios_executed": 0,
|
|
1838
|
+
"total_passed": 0,
|
|
1839
|
+
"total_failed": 0,
|
|
1840
|
+
"total_flaky": 0,
|
|
1841
|
+
"iterations": [],
|
|
1842
|
+
"unique_failures": [],
|
|
1843
|
+
"coverage_summary": {},
|
|
1844
|
+
# Deploy attribution (Phase 6.3)
|
|
1845
|
+
"build_id": body.build_id,
|
|
1846
|
+
"commit_sha": body.commit_sha,
|
|
1847
|
+
"deploy_tag": body.deploy_tag,
|
|
1848
|
+
}
|
|
1849
|
+
asyncio.create_task(_persist_autonomous_run(run_id))
|
|
1850
|
+
asyncio.create_task(_run_autonomous(run_id, body))
|
|
1851
|
+
return {"run_id": run_id, "status": "running"}
|
|
1852
|
+
|
|
1853
|
+
|
|
1854
|
+
@router.get("/api/v1/autonomous/history")
|
|
1855
|
+
async def list_autonomous_runs() -> Dict[str, Any]:
|
|
1856
|
+
"""Return summaries of all past autonomous runs."""
|
|
1857
|
+
summaries = []
|
|
1858
|
+
for run_id, run in _autonomous_runs.items():
|
|
1859
|
+
summaries.append(
|
|
1860
|
+
{
|
|
1861
|
+
"run_id": run_id,
|
|
1862
|
+
"target_url": run.get("target_url", ""),
|
|
1863
|
+
"strategy": run.get("strategy", ""),
|
|
1864
|
+
"status": run.get("status", "unknown"),
|
|
1865
|
+
"started_at": run.get("started_at", ""),
|
|
1866
|
+
"completed_at": run.get("completed_at", ""),
|
|
1867
|
+
"total_iterations": run.get("total_iterations", 0),
|
|
1868
|
+
"total_passed": run.get("total_passed", 0),
|
|
1869
|
+
"total_failed": run.get("total_failed", 0),
|
|
1870
|
+
}
|
|
1871
|
+
)
|
|
1872
|
+
return {"runs": summaries}
|
|
1873
|
+
|
|
1874
|
+
|
|
1875
|
+
@router.get("/api/v1/autonomous/{run_id}")
|
|
1876
|
+
async def get_autonomous_run(run_id: str) -> Dict[str, Any]:
|
|
1877
|
+
"""Return the current status and partial results of an autonomous run."""
|
|
1878
|
+
run = _autonomous_runs.get(run_id)
|
|
1879
|
+
if run is None:
|
|
1880
|
+
raise HTTPException(status_code=404, detail=f"Autonomous run '{run_id}' not found")
|
|
1881
|
+
# Return a copy without the internal _agent reference
|
|
1882
|
+
return {k: v for k, v in run.items() if not k.startswith("_")}
|
|
1883
|
+
|
|
1884
|
+
|
|
1885
|
+
@router.post("/api/v1/autonomous/{run_id}/stop")
|
|
1886
|
+
async def stop_autonomous_run(run_id: str) -> Dict[str, Any]:
|
|
1887
|
+
"""Gracefully stop a running autonomous loop."""
|
|
1888
|
+
run = _autonomous_runs.get(run_id)
|
|
1889
|
+
if run is None:
|
|
1890
|
+
raise HTTPException(status_code=404, detail=f"Autonomous run '{run_id}' not found")
|
|
1891
|
+
|
|
1892
|
+
agent = run.get("_agent")
|
|
1893
|
+
if agent is not None:
|
|
1894
|
+
agent.request_stop()
|
|
1895
|
+
return {"run_id": run_id, "status": "stopping"}
|
|
1896
|
+
|
|
1897
|
+
return {"run_id": run_id, "status": run.get("status", "unknown")}
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
@router.post("/api/v1/autonomous/{run_id}/pause")
|
|
1901
|
+
async def pause_autonomous_run(run_id: str) -> Dict[str, Any]:
|
|
1902
|
+
"""Pause a running autonomous loop after the current iteration.
|
|
1903
|
+
|
|
1904
|
+
The run state is serialised so it can be resumed later via the
|
|
1905
|
+
``POST /api/v1/autonomous/{run_id}/resume`` endpoint.
|
|
1906
|
+
"""
|
|
1907
|
+
run = _autonomous_runs.get(run_id)
|
|
1908
|
+
if run is None:
|
|
1909
|
+
raise HTTPException(status_code=404, detail=f"Autonomous run '{run_id}' not found")
|
|
1910
|
+
|
|
1911
|
+
agent = run.get("_agent")
|
|
1912
|
+
if agent is None:
|
|
1913
|
+
return {"run_id": run_id, "status": run.get("status", "unknown")}
|
|
1914
|
+
|
|
1915
|
+
agent.request_pause()
|
|
1916
|
+
return {"run_id": run_id, "status": "pausing"}
|
|
1917
|
+
|
|
1918
|
+
|
|
1919
|
+
@router.post("/api/v1/autonomous/{run_id}/resume")
|
|
1920
|
+
async def resume_autonomous_run(run_id: str) -> Dict[str, Any]:
|
|
1921
|
+
"""Resume a previously paused autonomous run.
|
|
1922
|
+
|
|
1923
|
+
Reconstructs the agent from the saved pause state stored in the run
|
|
1924
|
+
record and restarts the loop from the paused iteration.
|
|
1925
|
+
"""
|
|
1926
|
+
from mannf.core.agents.autonomous_loop_agent import AutonomousTestLoopAgent # noqa: PLC0415
|
|
1927
|
+
|
|
1928
|
+
run = _autonomous_runs.get(run_id)
|
|
1929
|
+
if run is None:
|
|
1930
|
+
raise HTTPException(status_code=404, detail=f"Autonomous run '{run_id}' not found")
|
|
1931
|
+
|
|
1932
|
+
if run.get("status") != "paused":
|
|
1933
|
+
raise HTTPException(
|
|
1934
|
+
status_code=422,
|
|
1935
|
+
detail=f"Autonomous run '{run_id}' is not paused (status={run.get('status')})",
|
|
1936
|
+
)
|
|
1937
|
+
|
|
1938
|
+
pause_state = run.get("_pause_state")
|
|
1939
|
+
if not pause_state:
|
|
1940
|
+
raise HTTPException(
|
|
1941
|
+
status_code=422,
|
|
1942
|
+
detail=f"No pause state available for run '{run_id}'",
|
|
1943
|
+
)
|
|
1944
|
+
|
|
1945
|
+
# Reconstruct and restart the agent
|
|
1946
|
+
agent = AutonomousTestLoopAgent.resume(pause_state)
|
|
1947
|
+
_autonomous_runs[run_id]["status"] = "running"
|
|
1948
|
+
_autonomous_runs[run_id]["_agent"] = agent
|
|
1949
|
+
|
|
1950
|
+
async def _continue() -> None:
|
|
1951
|
+
try:
|
|
1952
|
+
result = await agent.run_loop()
|
|
1953
|
+
_autonomous_runs[run_id].update(result.model_dump())
|
|
1954
|
+
_autonomous_runs[run_id]["status"] = result.status
|
|
1955
|
+
# Update flake detector with task outcomes (Phase 6.5)
|
|
1956
|
+
_update_flake_detector_from_run(run_id, result)
|
|
1957
|
+
await _persist_autonomous_run(run_id)
|
|
1958
|
+
except Exception as exc: # noqa: BLE001
|
|
1959
|
+
logger.exception("Resumed autonomous run %s failed", run_id)
|
|
1960
|
+
_autonomous_runs[run_id]["status"] = "failed"
|
|
1961
|
+
_autonomous_runs[run_id]["error"] = str(exc)
|
|
1962
|
+
await _persist_autonomous_run(run_id)
|
|
1963
|
+
finally:
|
|
1964
|
+
_autonomous_runs[run_id].pop("_agent", None)
|
|
1965
|
+
_autonomous_runs[run_id].pop("_pause_state", None)
|
|
1966
|
+
|
|
1967
|
+
asyncio.create_task(_continue())
|
|
1968
|
+
return {"run_id": run_id, "status": "running"}
|
|
1969
|
+
|
|
1970
|
+
|
|
1971
|
+
# ---------------------------------------------------------------------------
|
|
1972
|
+
# Delta diff helpers (Phase 6.3)
|
|
1973
|
+
# ---------------------------------------------------------------------------
|
|
1974
|
+
|
|
1975
|
+
|
|
1976
|
+
def _update_flake_detector_from_run(
|
|
1977
|
+
run_id: str,
|
|
1978
|
+
result: "AutonomousRunReport",
|
|
1979
|
+
) -> None:
|
|
1980
|
+
"""Feed autonomous run outcomes into the flake detector (Phase 6.5)."""
|
|
1981
|
+
try:
|
|
1982
|
+
for failure in result.unique_failures:
|
|
1983
|
+
page_key = failure.get("page", failure.get("url", ""))
|
|
1984
|
+
scenario_id = failure.get("task_id", "")
|
|
1985
|
+
if page_key and scenario_id:
|
|
1986
|
+
_flake_detector.record(page_key, scenario_id, passed=False)
|
|
1987
|
+
|
|
1988
|
+
# Also record pages that were covered without failure as passing
|
|
1989
|
+
cs = result.coverage_summary or {}
|
|
1990
|
+
covered_pages = cs.get("covered_pages", [])
|
|
1991
|
+
failed_pages = {f.get("page", "") for f in result.unique_failures}
|
|
1992
|
+
for page_key in covered_pages:
|
|
1993
|
+
if page_key not in failed_pages:
|
|
1994
|
+
# We don't have scenario-level pass data here; record as a
|
|
1995
|
+
# synthetic "run-level" pass for the page using run_id as scenario
|
|
1996
|
+
_flake_detector.record(page_key, run_id, passed=True)
|
|
1997
|
+
except Exception as exc: # noqa: BLE001
|
|
1998
|
+
logger.debug("_update_flake_detector_from_run failed (non-fatal): %s", exc)
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
|
|
2002
|
+
def _find_previous_run(current_run_id: str, target_url: str) -> Optional[Dict[str, Any]]:
|
|
2003
|
+
"""Return the most recent completed run for *target_url* other than *current_run_id*."""
|
|
2004
|
+
candidates = [
|
|
2005
|
+
run
|
|
2006
|
+
for rid, run in _autonomous_runs.items()
|
|
2007
|
+
if rid != current_run_id
|
|
2008
|
+
and run.get("target_url") == target_url
|
|
2009
|
+
and run.get("status") in {"complete", "stopped"}
|
|
2010
|
+
and not run.get("error")
|
|
2011
|
+
]
|
|
2012
|
+
if not candidates:
|
|
2013
|
+
return None
|
|
2014
|
+
# Sort by started_at descending
|
|
2015
|
+
candidates.sort(key=lambda r: r.get("started_at", ""), reverse=True)
|
|
2016
|
+
return candidates[0]
|
|
2017
|
+
|
|
2018
|
+
|
|
2019
|
+
def _run_dict_to_report(run: Dict[str, Any]) -> "AutonomousRunReport":
|
|
2020
|
+
"""Reconstruct an AutonomousRunReport from an in-memory run dict."""
|
|
2021
|
+
from mannf.core.agents.autonomous_loop_models import AutonomousRunReport # noqa: PLC0415
|
|
2022
|
+
|
|
2023
|
+
return AutonomousRunReport(
|
|
2024
|
+
run_id=run.get("run_id", ""),
|
|
2025
|
+
target_url=run.get("target_url", ""),
|
|
2026
|
+
strategy=run.get("strategy", "all"),
|
|
2027
|
+
total_iterations=run.get("total_iterations", 0),
|
|
2028
|
+
total_scenarios_executed=run.get("total_scenarios_executed", 0),
|
|
2029
|
+
total_passed=run.get("total_passed", 0),
|
|
2030
|
+
total_failed=run.get("total_failed", 0),
|
|
2031
|
+
total_flaky=run.get("total_flaky", 0),
|
|
2032
|
+
unique_failures=run.get("unique_failures", []),
|
|
2033
|
+
coverage_summary=run.get("coverage_summary", {}),
|
|
2034
|
+
iterations=run.get("iterations", []),
|
|
2035
|
+
started_at=run.get("started_at", ""),
|
|
2036
|
+
completed_at=run.get("completed_at", ""),
|
|
2037
|
+
duration_s=run.get("duration_s", 0.0),
|
|
2038
|
+
stop_reason=run.get("stop_reason", ""),
|
|
2039
|
+
status=run.get("status", ""),
|
|
2040
|
+
error=run.get("error", ""),
|
|
2041
|
+
build_id=run.get("build_id"),
|
|
2042
|
+
commit_sha=run.get("commit_sha"),
|
|
2043
|
+
deploy_tag=run.get("deploy_tag"),
|
|
2044
|
+
)
|
|
2045
|
+
|
|
2046
|
+
|
|
2047
|
+
async def _compute_and_dispatch_delta(
|
|
2048
|
+
run_id: str,
|
|
2049
|
+
result: "AutonomousRunReport",
|
|
2050
|
+
) -> None:
|
|
2051
|
+
"""Compute the run-over-run diff and dispatch delta notifications (best-effort)."""
|
|
2052
|
+
try:
|
|
2053
|
+
from mannf.core.agents.autonomous_run_differ import AutonomousRunDiffer # noqa: PLC0415
|
|
2054
|
+
from mannf.product.notifications.dispatcher import dispatch_scan_notification # noqa: PLC0415
|
|
2055
|
+
from mannf.product.dashboard.telemetry import broadcast_autonomous_complete # noqa: PLC0415
|
|
2056
|
+
|
|
2057
|
+
previous_run = _find_previous_run(run_id, result.target_url)
|
|
2058
|
+
if previous_run is None:
|
|
2059
|
+
return
|
|
2060
|
+
|
|
2061
|
+
baseline = _run_dict_to_report(previous_run)
|
|
2062
|
+
differ = AutonomousRunDiffer()
|
|
2063
|
+
run_diff = differ.diff(baseline, result)
|
|
2064
|
+
|
|
2065
|
+
# Store diff summary on the current run for retrieval
|
|
2066
|
+
_autonomous_runs[run_id]["_last_diff"] = {
|
|
2067
|
+
"baseline_run_id": run_diff.baseline_run_id,
|
|
2068
|
+
"current_run_id": run_diff.current_run_id,
|
|
2069
|
+
"baseline_commit": run_diff.baseline_commit,
|
|
2070
|
+
"current_commit": run_diff.current_commit,
|
|
2071
|
+
"summary": run_diff.summary,
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
# Re-broadcast with delta enrichment
|
|
2075
|
+
await broadcast_autonomous_complete(
|
|
2076
|
+
run_id=run_id,
|
|
2077
|
+
stop_reason=result.stop_reason,
|
|
2078
|
+
total_iterations=result.total_iterations,
|
|
2079
|
+
total_passed=result.total_passed,
|
|
2080
|
+
total_failed=result.total_failed,
|
|
2081
|
+
delta=run_diff.summary,
|
|
2082
|
+
)
|
|
2083
|
+
|
|
2084
|
+
commit_label = result.commit_sha or result.build_id or "unknown"
|
|
2085
|
+
summary = run_diff.summary
|
|
2086
|
+
|
|
2087
|
+
# Dispatch regression notification if there are new failures
|
|
2088
|
+
if summary.get("new_failure_count", 0) > 0:
|
|
2089
|
+
await dispatch_scan_notification(
|
|
2090
|
+
"autonomous_run.regression",
|
|
2091
|
+
scan_id=run_id,
|
|
2092
|
+
details={
|
|
2093
|
+
"new_failures": summary["new_failure_count"],
|
|
2094
|
+
"commit": commit_label,
|
|
2095
|
+
"target_url": result.target_url,
|
|
2096
|
+
"total_failed": result.total_failed,
|
|
2097
|
+
},
|
|
2098
|
+
)
|
|
2099
|
+
|
|
2100
|
+
# Dispatch recovery notification if failures were resolved
|
|
2101
|
+
elif summary.get("resolved_failure_count", 0) > 0 and summary.get("new_failure_count", 0) == 0:
|
|
2102
|
+
await dispatch_scan_notification(
|
|
2103
|
+
"autonomous_run.recovery",
|
|
2104
|
+
scan_id=run_id,
|
|
2105
|
+
details={
|
|
2106
|
+
"resolved_failures": summary["resolved_failure_count"],
|
|
2107
|
+
"commit": commit_label,
|
|
2108
|
+
"target_url": result.target_url,
|
|
2109
|
+
"total_passed": result.total_passed,
|
|
2110
|
+
},
|
|
2111
|
+
)
|
|
2112
|
+
|
|
2113
|
+
# Dispatch flakiness spike notification
|
|
2114
|
+
if summary.get("flakiness_spike_pages", 0) > 0:
|
|
2115
|
+
await dispatch_scan_notification(
|
|
2116
|
+
"autonomous_run.flake_spike",
|
|
2117
|
+
scan_id=run_id,
|
|
2118
|
+
details={
|
|
2119
|
+
"flakiness_spike_pages": summary["flakiness_spike_pages"],
|
|
2120
|
+
"commit": commit_label,
|
|
2121
|
+
"target_url": result.target_url,
|
|
2122
|
+
},
|
|
2123
|
+
)
|
|
2124
|
+
|
|
2125
|
+
except Exception as exc: # noqa: BLE001
|
|
2126
|
+
logger.debug("Delta diff/alerting for run %s failed (non-fatal): %s", run_id, exc)
|
|
2127
|
+
|
|
2128
|
+
|
|
2129
|
+
# ---------------------------------------------------------------------------
|
|
2130
|
+
# Auto-bug-filing pipeline (Phase 6.4)
|
|
2131
|
+
# ---------------------------------------------------------------------------
|
|
2132
|
+
|
|
2133
|
+
|
|
2134
|
+
_SEVERITY_ORDER: dict[str, int] = {
|
|
2135
|
+
"critical": 0,
|
|
2136
|
+
"high": 1,
|
|
2137
|
+
"medium": 2,
|
|
2138
|
+
"low": 3,
|
|
2139
|
+
"info": 4,
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
|
|
2143
|
+
async def _auto_file_bugs(run_id: str, result: Any) -> None:
|
|
2144
|
+
"""Best-effort: file bug tickets for unique failures in *result*.
|
|
2145
|
+
|
|
2146
|
+
This runs as a background task and *never* raises — any error is logged
|
|
2147
|
+
and silently swallowed to ensure the autonomous loop is never delayed.
|
|
2148
|
+
"""
|
|
2149
|
+
try:
|
|
2150
|
+
from mannf.product.exporters.finding_adapter import FunctionalFindingAdapter # noqa: PLC0415
|
|
2151
|
+
from mannf.product.exporters.dedup import ( # noqa: PLC0415
|
|
2152
|
+
make_failure_fingerprint,
|
|
2153
|
+
fingerprint_label,
|
|
2154
|
+
)
|
|
2155
|
+
|
|
2156
|
+
cfg = _bug_filing_config
|
|
2157
|
+
if not cfg.enabled:
|
|
2158
|
+
return
|
|
2159
|
+
|
|
2160
|
+
unique_failures = getattr(result, "unique_failures", []) or []
|
|
2161
|
+
if not unique_failures:
|
|
2162
|
+
return
|
|
2163
|
+
|
|
2164
|
+
target_url = getattr(result, "target_url", "")
|
|
2165
|
+
threshold_order = _SEVERITY_ORDER.get(cfg.severity_threshold.lower(), _SEVERITY_ORDER["high"])
|
|
2166
|
+
|
|
2167
|
+
for failure in unique_failures:
|
|
2168
|
+
try:
|
|
2169
|
+
finding = FunctionalFindingAdapter.from_unique_failure(
|
|
2170
|
+
failure, run_id=run_id, target_url=target_url
|
|
2171
|
+
)
|
|
2172
|
+
finding_order = _SEVERITY_ORDER.get(finding.severity.lower(), _SEVERITY_ORDER["info"])
|
|
2173
|
+
if finding_order > threshold_order:
|
|
2174
|
+
# Below threshold — skip
|
|
2175
|
+
continue
|
|
2176
|
+
|
|
2177
|
+
fingerprint = make_failure_fingerprint(failure)
|
|
2178
|
+
fp_label = fingerprint_label(fingerprint)
|
|
2179
|
+
|
|
2180
|
+
if not cfg.auto_file:
|
|
2181
|
+
# Queue for manual approval
|
|
2182
|
+
bug_id = str(uuid.uuid4())
|
|
2183
|
+
pending = PendingBugEntry(
|
|
2184
|
+
bug_id=bug_id,
|
|
2185
|
+
run_id=run_id,
|
|
2186
|
+
fingerprint=fingerprint,
|
|
2187
|
+
failure=failure,
|
|
2188
|
+
severity=finding.severity,
|
|
2189
|
+
queued_at=datetime.now(timezone.utc).isoformat(),
|
|
2190
|
+
)
|
|
2191
|
+
_pending_bugs[bug_id] = pending
|
|
2192
|
+
logger.info(
|
|
2193
|
+
"Auto-filing queued for approval: bug_id=%s fingerprint=%s",
|
|
2194
|
+
bug_id,
|
|
2195
|
+
fingerprint[:16],
|
|
2196
|
+
)
|
|
2197
|
+
continue
|
|
2198
|
+
|
|
2199
|
+
# Auto-file the ticket
|
|
2200
|
+
report = FunctionalFindingAdapter.make_report(
|
|
2201
|
+
run_id, target_url, len(unique_failures)
|
|
2202
|
+
)
|
|
2203
|
+
# Add fingerprint label so dedup can find it later
|
|
2204
|
+
exporter_cfg = dict(cfg.exporter_config)
|
|
2205
|
+
existing_labels = list(exporter_cfg.get("labels") or [])
|
|
2206
|
+
exporter_cfg["labels"] = list({*existing_labels, fp_label, "nat-auto-filed"})
|
|
2207
|
+
|
|
2208
|
+
was_duplicate = False
|
|
2209
|
+
external_id: Optional[str] = None
|
|
2210
|
+
external_url: Optional[str] = None
|
|
2211
|
+
|
|
2212
|
+
if cfg.deduplicate:
|
|
2213
|
+
# Check for existing ticket
|
|
2214
|
+
existing = await _find_existing_bug(fingerprint, cfg)
|
|
2215
|
+
if existing:
|
|
2216
|
+
was_duplicate = True
|
|
2217
|
+
external_id = existing.get("id")
|
|
2218
|
+
external_url = existing.get("url")
|
|
2219
|
+
# Add a new-occurrence comment
|
|
2220
|
+
await _add_duplicate_comment(
|
|
2221
|
+
existing, cfg, run_id, fingerprint, failure
|
|
2222
|
+
)
|
|
2223
|
+
|
|
2224
|
+
if not was_duplicate:
|
|
2225
|
+
result_obj = await _call_exporter(finding, report, cfg.target_system, exporter_cfg)
|
|
2226
|
+
if result_obj:
|
|
2227
|
+
external_id = result_obj.external_id
|
|
2228
|
+
external_url = result_obj.external_url
|
|
2229
|
+
|
|
2230
|
+
bug_id = str(uuid.uuid4())
|
|
2231
|
+
filed = FiledBugEntry(
|
|
2232
|
+
bug_id=bug_id,
|
|
2233
|
+
run_id=run_id,
|
|
2234
|
+
fingerprint=fingerprint,
|
|
2235
|
+
failure=failure,
|
|
2236
|
+
target_system=cfg.target_system,
|
|
2237
|
+
external_id=external_id,
|
|
2238
|
+
external_url=external_url,
|
|
2239
|
+
severity=finding.severity,
|
|
2240
|
+
filed_at=datetime.now(timezone.utc).isoformat(),
|
|
2241
|
+
was_duplicate=was_duplicate,
|
|
2242
|
+
)
|
|
2243
|
+
_filed_bugs[bug_id] = filed
|
|
2244
|
+
logger.info(
|
|
2245
|
+
"Auto-filed bug: bug_id=%s target=%s external_id=%s duplicate=%s",
|
|
2246
|
+
bug_id,
|
|
2247
|
+
cfg.target_system,
|
|
2248
|
+
external_id,
|
|
2249
|
+
was_duplicate,
|
|
2250
|
+
)
|
|
2251
|
+
|
|
2252
|
+
except Exception as inner_exc: # noqa: BLE001
|
|
2253
|
+
logger.warning(
|
|
2254
|
+
"Auto-filing failed for failure in run %s (non-fatal): %s",
|
|
2255
|
+
run_id,
|
|
2256
|
+
inner_exc,
|
|
2257
|
+
)
|
|
2258
|
+
|
|
2259
|
+
except Exception as exc: # noqa: BLE001
|
|
2260
|
+
logger.warning("_auto_file_bugs for run %s failed (non-fatal): %s", run_id, exc)
|
|
2261
|
+
|
|
2262
|
+
|
|
2263
|
+
async def _find_existing_bug(fingerprint: str, cfg: BugFilingConfig) -> Optional[Dict[str, Any]]:
|
|
2264
|
+
"""Check the external system for an existing open ticket with this fingerprint."""
|
|
2265
|
+
try:
|
|
2266
|
+
from mannf.product.exporters.dedup import ( # noqa: PLC0415
|
|
2267
|
+
find_existing_jira_issue,
|
|
2268
|
+
find_existing_github_issue,
|
|
2269
|
+
)
|
|
2270
|
+
|
|
2271
|
+
if cfg.target_system == "jira":
|
|
2272
|
+
ec = cfg.exporter_config
|
|
2273
|
+
key = await find_existing_jira_issue(
|
|
2274
|
+
fingerprint,
|
|
2275
|
+
base_url=ec.get("base_url", ""),
|
|
2276
|
+
project_key=ec.get("project_key", ""),
|
|
2277
|
+
email=ec.get("email", ""),
|
|
2278
|
+
api_token=ec.get("api_token", ""),
|
|
2279
|
+
)
|
|
2280
|
+
if key:
|
|
2281
|
+
base_url = ec.get("base_url", "").rstrip("/")
|
|
2282
|
+
return {"id": key, "url": f"{base_url}/browse/{key}"}
|
|
2283
|
+
|
|
2284
|
+
elif cfg.target_system == "github":
|
|
2285
|
+
ec = cfg.exporter_config
|
|
2286
|
+
number = await find_existing_github_issue(
|
|
2287
|
+
fingerprint,
|
|
2288
|
+
token=ec.get("token", ""),
|
|
2289
|
+
repo=ec.get("repo", ""),
|
|
2290
|
+
)
|
|
2291
|
+
if number is not None:
|
|
2292
|
+
repo = ec.get("repo", "")
|
|
2293
|
+
return {
|
|
2294
|
+
"id": str(number),
|
|
2295
|
+
"url": f"https://github.com/{repo}/issues/{number}",
|
|
2296
|
+
}
|
|
2297
|
+
except Exception as exc: # noqa: BLE001
|
|
2298
|
+
logger.debug("Dedup check failed (non-fatal): %s", exc)
|
|
2299
|
+
return None
|
|
2300
|
+
|
|
2301
|
+
|
|
2302
|
+
async def _add_duplicate_comment(
|
|
2303
|
+
existing: Dict[str, Any],
|
|
2304
|
+
cfg: BugFilingConfig,
|
|
2305
|
+
run_id: str,
|
|
2306
|
+
fingerprint: str,
|
|
2307
|
+
failure: Dict[str, Any],
|
|
2308
|
+
) -> None:
|
|
2309
|
+
"""Append a new-occurrence comment to an existing ticket."""
|
|
2310
|
+
try:
|
|
2311
|
+
from mannf.product.exporters.dedup import ( # noqa: PLC0415
|
|
2312
|
+
add_jira_comment,
|
|
2313
|
+
add_github_comment,
|
|
2314
|
+
)
|
|
2315
|
+
|
|
2316
|
+
comment = (
|
|
2317
|
+
f"**New occurrence detected** (run `{run_id}`)\n\n"
|
|
2318
|
+
f"- Page: `{failure.get('page', '?')}`\n"
|
|
2319
|
+
f"- Error: {failure.get('error', '?')[:200]}\n"
|
|
2320
|
+
f"- Fingerprint: `{fingerprint[:16]}…`\n"
|
|
2321
|
+
)
|
|
2322
|
+
ec = cfg.exporter_config
|
|
2323
|
+
|
|
2324
|
+
if cfg.target_system == "jira":
|
|
2325
|
+
await add_jira_comment(
|
|
2326
|
+
existing["id"],
|
|
2327
|
+
comment,
|
|
2328
|
+
base_url=ec.get("base_url", ""),
|
|
2329
|
+
email=ec.get("email", ""),
|
|
2330
|
+
api_token=ec.get("api_token", ""),
|
|
2331
|
+
)
|
|
2332
|
+
|
|
2333
|
+
elif cfg.target_system == "github":
|
|
2334
|
+
await add_github_comment(
|
|
2335
|
+
int(existing["id"]),
|
|
2336
|
+
comment,
|
|
2337
|
+
token=ec.get("token", ""),
|
|
2338
|
+
repo=ec.get("repo", ""),
|
|
2339
|
+
)
|
|
2340
|
+
except Exception as exc: # noqa: BLE001
|
|
2341
|
+
logger.debug("Duplicate comment failed (non-fatal): %s", exc)
|
|
2342
|
+
|
|
2343
|
+
|
|
2344
|
+
async def _call_exporter(
|
|
2345
|
+
finding: Any,
|
|
2346
|
+
report: Any,
|
|
2347
|
+
target_system: str,
|
|
2348
|
+
exporter_config: dict,
|
|
2349
|
+
) -> Optional[Any]:
|
|
2350
|
+
"""Instantiate the correct exporter and call export_finding."""
|
|
2351
|
+
try:
|
|
2352
|
+
if target_system == "jira":
|
|
2353
|
+
from mannf.product.exporters.jira_exporter import JiraExporter # noqa: PLC0415
|
|
2354
|
+
return await JiraExporter().export_finding(finding, report, exporter_config)
|
|
2355
|
+
|
|
2356
|
+
elif target_system == "github":
|
|
2357
|
+
from mannf.product.exporters.github_exporter import GitHubIssuesExporter # noqa: PLC0415
|
|
2358
|
+
return await GitHubIssuesExporter().export_finding(finding, report, exporter_config)
|
|
2359
|
+
|
|
2360
|
+
elif target_system == "webhook":
|
|
2361
|
+
from mannf.product.exporters.webhook_exporter import WebhookExporter # noqa: PLC0415
|
|
2362
|
+
return await WebhookExporter().export_finding(finding, report, exporter_config)
|
|
2363
|
+
|
|
2364
|
+
else:
|
|
2365
|
+
logger.warning("Unknown target_system '%s' — skipping export", target_system)
|
|
2366
|
+
except Exception as exc: # noqa: BLE001
|
|
2367
|
+
logger.warning("_call_exporter(%s) failed (non-fatal): %s", target_system, exc)
|
|
2368
|
+
return None
|
|
2369
|
+
|
|
2370
|
+
|
|
2371
|
+
# ---------------------------------------------------------------------------
|
|
2372
|
+
# Bug filing REST API (Phase 6.4)
|
|
2373
|
+
# ---------------------------------------------------------------------------
|
|
2374
|
+
|
|
2375
|
+
|
|
2376
|
+
@router.get("/api/v1/bugs/config")
|
|
2377
|
+
async def get_bug_filing_config() -> Dict[str, Any]:
|
|
2378
|
+
"""Return the current auto-bug-filing configuration."""
|
|
2379
|
+
return _bug_filing_config.model_dump()
|
|
2380
|
+
|
|
2381
|
+
|
|
2382
|
+
@router.put("/api/v1/bugs/config")
|
|
2383
|
+
async def update_bug_filing_config(body: BugFilingConfig) -> Dict[str, Any]:
|
|
2384
|
+
"""Update the auto-bug-filing configuration."""
|
|
2385
|
+
global _bug_filing_config
|
|
2386
|
+
_bug_filing_config = body
|
|
2387
|
+
return _bug_filing_config.model_dump()
|
|
2388
|
+
|
|
2389
|
+
|
|
2390
|
+
@router.get("/api/v1/bugs/filed")
|
|
2391
|
+
async def list_filed_bugs() -> Dict[str, Any]:
|
|
2392
|
+
"""Return all auto-filed bug tickets with external links."""
|
|
2393
|
+
sorted_bugs = sorted(_filed_bugs.values(), key=lambda b: b.filed_at, reverse=True)
|
|
2394
|
+
return {
|
|
2395
|
+
"total": len(_filed_bugs),
|
|
2396
|
+
"bugs": [b.model_dump() for b in sorted_bugs],
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
|
|
2400
|
+
@router.get("/api/v1/bugs/pending")
|
|
2401
|
+
async def list_pending_bugs() -> Dict[str, Any]:
|
|
2402
|
+
"""Return the queue of failures awaiting manual approval."""
|
|
2403
|
+
pending = [
|
|
2404
|
+
b.model_dump()
|
|
2405
|
+
for b in sorted(
|
|
2406
|
+
_pending_bugs.values(),
|
|
2407
|
+
key=lambda b: b.queued_at,
|
|
2408
|
+
reverse=True,
|
|
2409
|
+
)
|
|
2410
|
+
if b.status == "pending"
|
|
2411
|
+
]
|
|
2412
|
+
return {"total": len(pending), "bugs": pending}
|
|
2413
|
+
|
|
2414
|
+
|
|
2415
|
+
@router.post("/api/v1/bugs/{bug_id}/approve")
|
|
2416
|
+
async def approve_pending_bug(bug_id: str) -> Dict[str, Any]:
|
|
2417
|
+
"""Approve a pending bug and immediately file a ticket in the configured system.
|
|
2418
|
+
|
|
2419
|
+
Moves the entry from the pending queue to the filed list.
|
|
2420
|
+
"""
|
|
2421
|
+
entry = _pending_bugs.get(bug_id)
|
|
2422
|
+
if entry is None:
|
|
2423
|
+
raise HTTPException(status_code=404, detail=f"Pending bug '{bug_id}' not found")
|
|
2424
|
+
if entry.status != "pending":
|
|
2425
|
+
raise HTTPException(
|
|
2426
|
+
status_code=409,
|
|
2427
|
+
detail=f"Bug '{bug_id}' has already been {entry.status}",
|
|
2428
|
+
)
|
|
2429
|
+
|
|
2430
|
+
entry.status = "approved"
|
|
2431
|
+
|
|
2432
|
+
# File the ticket now
|
|
2433
|
+
try:
|
|
2434
|
+
from mannf.product.exporters.finding_adapter import FunctionalFindingAdapter # noqa: PLC0415
|
|
2435
|
+
from mannf.product.exporters.dedup import fingerprint_label # noqa: PLC0415
|
|
2436
|
+
|
|
2437
|
+
cfg = _bug_filing_config
|
|
2438
|
+
finding = FunctionalFindingAdapter.from_unique_failure(
|
|
2439
|
+
entry.failure, run_id=entry.run_id
|
|
2440
|
+
)
|
|
2441
|
+
report = FunctionalFindingAdapter.make_report(
|
|
2442
|
+
entry.run_id, entry.failure.get("url", ""), 1
|
|
2443
|
+
)
|
|
2444
|
+
fp_label = fingerprint_label(entry.fingerprint)
|
|
2445
|
+
exporter_cfg = dict(cfg.exporter_config)
|
|
2446
|
+
existing_labels = list(exporter_cfg.get("labels") or [])
|
|
2447
|
+
exporter_cfg["labels"] = list({*existing_labels, fp_label, "nat-auto-filed"})
|
|
2448
|
+
|
|
2449
|
+
result_obj = await _call_exporter(finding, report, cfg.target_system, exporter_cfg)
|
|
2450
|
+
|
|
2451
|
+
filed = FiledBugEntry(
|
|
2452
|
+
bug_id=str(uuid.uuid4()),
|
|
2453
|
+
run_id=entry.run_id,
|
|
2454
|
+
fingerprint=entry.fingerprint,
|
|
2455
|
+
failure=entry.failure,
|
|
2456
|
+
target_system=cfg.target_system,
|
|
2457
|
+
external_id=result_obj.external_id if result_obj else None,
|
|
2458
|
+
external_url=result_obj.external_url if result_obj else None,
|
|
2459
|
+
severity=entry.severity,
|
|
2460
|
+
filed_at=datetime.now(timezone.utc).isoformat(),
|
|
2461
|
+
)
|
|
2462
|
+
_filed_bugs[filed.bug_id] = filed
|
|
2463
|
+
|
|
2464
|
+
return {
|
|
2465
|
+
"bug_id": bug_id,
|
|
2466
|
+
"status": "approved",
|
|
2467
|
+
"filed_bug_id": filed.bug_id,
|
|
2468
|
+
"external_id": filed.external_id,
|
|
2469
|
+
"external_url": filed.external_url,
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
except Exception as exc: # noqa: BLE001
|
|
2473
|
+
logger.warning("approve_pending_bug %s: filing failed: %s", bug_id, exc)
|
|
2474
|
+
return {"bug_id": bug_id, "status": "approved", "error": "Filing failed — see server logs for details."}
|
|
2475
|
+
|
|
2476
|
+
|
|
2477
|
+
@router.post("/api/v1/bugs/{bug_id}/dismiss")
|
|
2478
|
+
async def dismiss_pending_bug(bug_id: str) -> Dict[str, Any]:
|
|
2479
|
+
"""Dismiss a pending bug — no ticket will be filed."""
|
|
2480
|
+
entry = _pending_bugs.get(bug_id)
|
|
2481
|
+
if entry is None:
|
|
2482
|
+
raise HTTPException(status_code=404, detail=f"Pending bug '{bug_id}' not found")
|
|
2483
|
+
if entry.status != "pending":
|
|
2484
|
+
raise HTTPException(
|
|
2485
|
+
status_code=409,
|
|
2486
|
+
detail=f"Bug '{bug_id}' has already been {entry.status}",
|
|
2487
|
+
)
|
|
2488
|
+
|
|
2489
|
+
entry.status = "dismissed"
|
|
2490
|
+
return {"bug_id": bug_id, "status": "dismissed"}
|
|
2491
|
+
|
|
2492
|
+
|
|
2493
|
+
# ---------------------------------------------------------------------------
|
|
2494
|
+
# Analytics endpoints (Phase 6.3)
|
|
2495
|
+
# ---------------------------------------------------------------------------
|
|
2496
|
+
|
|
2497
|
+
|
|
2498
|
+
@router.get("/api/v1/analytics/trends")
|
|
2499
|
+
async def get_analytics_trends(
|
|
2500
|
+
page: Optional[str] = Query(None, description="Filter by page path (e.g. /checkout)"),
|
|
2501
|
+
window: str = Query("7d", description="Time window: 1d, 7d, 30d"),
|
|
2502
|
+
target_url: Optional[str] = Query(None, description="Filter by target URL"),
|
|
2503
|
+
) -> Dict[str, Any]:
|
|
2504
|
+
"""Return pass rate, failure rate, and flakiness trends over completed autonomous runs.
|
|
2505
|
+
|
|
2506
|
+
Query parameters
|
|
2507
|
+
----------------
|
|
2508
|
+
page:
|
|
2509
|
+
Optional page path filter (e.g. ``/checkout``).
|
|
2510
|
+
window:
|
|
2511
|
+
Time window: ``1d``, ``7d`` (default), or ``30d``.
|
|
2512
|
+
target_url:
|
|
2513
|
+
Optional target URL to filter runs.
|
|
2514
|
+
"""
|
|
2515
|
+
from datetime import timedelta # noqa: PLC0415
|
|
2516
|
+
|
|
2517
|
+
window_map = {"1d": 1, "7d": 7, "30d": 30}
|
|
2518
|
+
days = window_map.get(window, 7)
|
|
2519
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
|
2520
|
+
|
|
2521
|
+
data_points = []
|
|
2522
|
+
for run in sorted(
|
|
2523
|
+
_autonomous_runs.values(),
|
|
2524
|
+
key=lambda r: r.get("started_at", ""),
|
|
2525
|
+
):
|
|
2526
|
+
if run.get("status") not in {"complete", "stopped"}:
|
|
2527
|
+
continue
|
|
2528
|
+
if target_url and run.get("target_url") != target_url:
|
|
2529
|
+
continue
|
|
2530
|
+
|
|
2531
|
+
started_at_raw = run.get("started_at", "")
|
|
2532
|
+
try:
|
|
2533
|
+
started_dt = datetime.fromisoformat(started_at_raw)
|
|
2534
|
+
if started_dt.tzinfo is None:
|
|
2535
|
+
from datetime import timezone as _tz # noqa: PLC0415
|
|
2536
|
+
started_dt = started_dt.replace(tzinfo=_tz.utc)
|
|
2537
|
+
except (ValueError, TypeError):
|
|
2538
|
+
continue
|
|
2539
|
+
|
|
2540
|
+
if started_dt < cutoff:
|
|
2541
|
+
continue
|
|
2542
|
+
|
|
2543
|
+
total_executed = run.get("total_scenarios_executed", 0)
|
|
2544
|
+
total_passed = run.get("total_passed", 0)
|
|
2545
|
+
total_failed = run.get("total_failed", 0)
|
|
2546
|
+
total_flaky = run.get("total_flaky", 0)
|
|
2547
|
+
|
|
2548
|
+
# Apply page filter if supplied
|
|
2549
|
+
if page:
|
|
2550
|
+
cs = run.get("coverage_summary") or {}
|
|
2551
|
+
covered = cs.get("covered_pages", [])
|
|
2552
|
+
if page not in covered:
|
|
2553
|
+
continue
|
|
2554
|
+
|
|
2555
|
+
pass_rate = round(total_passed / total_executed, 4) if total_executed > 0 else None
|
|
2556
|
+
fail_rate = round(total_failed / total_executed, 4) if total_executed > 0 else None
|
|
2557
|
+
flaky_rate = round(total_flaky / total_executed, 4) if total_executed > 0 else None
|
|
2558
|
+
|
|
2559
|
+
data_points.append(
|
|
2560
|
+
{
|
|
2561
|
+
"run_id": run.get("run_id", ""),
|
|
2562
|
+
"started_at": started_at_raw,
|
|
2563
|
+
"target_url": run.get("target_url", ""),
|
|
2564
|
+
"total_passed": total_passed,
|
|
2565
|
+
"total_failed": total_failed,
|
|
2566
|
+
"total_flaky": total_flaky,
|
|
2567
|
+
"total_scenarios_executed": total_executed,
|
|
2568
|
+
"pass_rate": pass_rate,
|
|
2569
|
+
"fail_rate": fail_rate,
|
|
2570
|
+
"flaky_rate": flaky_rate,
|
|
2571
|
+
"commit_sha": run.get("commit_sha"),
|
|
2572
|
+
"build_id": run.get("build_id"),
|
|
2573
|
+
"deploy_tag": run.get("deploy_tag"),
|
|
2574
|
+
}
|
|
2575
|
+
)
|
|
2576
|
+
|
|
2577
|
+
return {
|
|
2578
|
+
"window": window,
|
|
2579
|
+
"page_filter": page,
|
|
2580
|
+
"target_url_filter": target_url,
|
|
2581
|
+
"data_points": data_points,
|
|
2582
|
+
"count": len(data_points),
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
|
|
2586
|
+
@router.get("/api/v1/analytics/coverage")
|
|
2587
|
+
async def get_analytics_coverage(
|
|
2588
|
+
run_id: Optional[str] = Query(None, description="Run ID to inspect coverage for"),
|
|
2589
|
+
) -> Dict[str, Any]:
|
|
2590
|
+
"""Return coverage breakdown for an autonomous run.
|
|
2591
|
+
|
|
2592
|
+
If ``run_id`` is omitted, returns coverage for the most recent completed run.
|
|
2593
|
+
|
|
2594
|
+
Coverage includes:
|
|
2595
|
+
- Pages covered
|
|
2596
|
+
- Forms tested
|
|
2597
|
+
- Flows executed
|
|
2598
|
+
- Security payloads attempted (if security scenarios were enabled)
|
|
2599
|
+
"""
|
|
2600
|
+
if run_id:
|
|
2601
|
+
run = _autonomous_runs.get(run_id)
|
|
2602
|
+
if run is None:
|
|
2603
|
+
raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found")
|
|
2604
|
+
else:
|
|
2605
|
+
# Pick most recent completed run
|
|
2606
|
+
completed = [
|
|
2607
|
+
r
|
|
2608
|
+
for r in _autonomous_runs.values()
|
|
2609
|
+
if r.get("status") in {"complete", "stopped"}
|
|
2610
|
+
]
|
|
2611
|
+
if not completed:
|
|
2612
|
+
raise HTTPException(status_code=404, detail="No completed autonomous runs found")
|
|
2613
|
+
completed.sort(key=lambda r: r.get("started_at", ""), reverse=True)
|
|
2614
|
+
run = completed[0]
|
|
2615
|
+
|
|
2616
|
+
cs = run.get("coverage_summary") or {}
|
|
2617
|
+
|
|
2618
|
+
return {
|
|
2619
|
+
"run_id": run.get("run_id", ""),
|
|
2620
|
+
"target_url": run.get("target_url", ""),
|
|
2621
|
+
"status": run.get("status", ""),
|
|
2622
|
+
"started_at": run.get("started_at", ""),
|
|
2623
|
+
"completed_at": run.get("completed_at", ""),
|
|
2624
|
+
"pages_covered": cs.get("covered_pages", []),
|
|
2625
|
+
"page_count": len(cs.get("covered_pages", [])),
|
|
2626
|
+
"forms_tested": cs.get("forms_tested", 0),
|
|
2627
|
+
"flows_executed": cs.get("flows_executed", 0),
|
|
2628
|
+
"security_fields_tested": cs.get("security_fields_tested", 0),
|
|
2629
|
+
"security_payloads_attempted": cs.get("security_payloads_attempted", 0),
|
|
2630
|
+
"security_exploits_found": cs.get("security_exploits_found", 0),
|
|
2631
|
+
"owasp_coverage": cs.get("owasp_coverage", []),
|
|
2632
|
+
"coverage_summary": cs,
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
|
|
2636
|
+
@router.post("/api/v1/autonomous/diff")
|
|
2637
|
+
async def diff_autonomous_runs(body: Dict[str, Any]) -> Dict[str, Any]:
|
|
2638
|
+
"""Compare two autonomous runs and return a structured diff.
|
|
2639
|
+
|
|
2640
|
+
Request body
|
|
2641
|
+
------------
|
|
2642
|
+
``baseline_run_id`` : str
|
|
2643
|
+
Run ID of the baseline (earlier) run.
|
|
2644
|
+
``current_run_id`` : str
|
|
2645
|
+
Run ID of the current (later) run to compare against the baseline.
|
|
2646
|
+
|
|
2647
|
+
Returns
|
|
2648
|
+
-------
|
|
2649
|
+
Structured diff including new failures, resolved failures, per-page deltas,
|
|
2650
|
+
flakiness changes, and coverage delta.
|
|
2651
|
+
"""
|
|
2652
|
+
from mannf.core.agents.autonomous_run_differ import AutonomousRunDiffer # noqa: PLC0415
|
|
2653
|
+
from dataclasses import asdict # noqa: PLC0415
|
|
2654
|
+
|
|
2655
|
+
baseline_id = body.get("baseline_run_id")
|
|
2656
|
+
current_id = body.get("current_run_id")
|
|
2657
|
+
|
|
2658
|
+
if not baseline_id or not current_id:
|
|
2659
|
+
raise HTTPException(
|
|
2660
|
+
status_code=422,
|
|
2661
|
+
detail="Both 'baseline_run_id' and 'current_run_id' are required",
|
|
2662
|
+
)
|
|
2663
|
+
|
|
2664
|
+
baseline_run = _autonomous_runs.get(baseline_id)
|
|
2665
|
+
if baseline_run is None:
|
|
2666
|
+
raise HTTPException(status_code=404, detail=f"Baseline run '{baseline_id}' not found")
|
|
2667
|
+
|
|
2668
|
+
current_run = _autonomous_runs.get(current_id)
|
|
2669
|
+
if current_run is None:
|
|
2670
|
+
raise HTTPException(status_code=404, detail=f"Current run '{current_id}' not found")
|
|
2671
|
+
|
|
2672
|
+
baseline_report = _run_dict_to_report(baseline_run)
|
|
2673
|
+
current_report = _run_dict_to_report(current_run)
|
|
2674
|
+
|
|
2675
|
+
differ = AutonomousRunDiffer()
|
|
2676
|
+
run_diff = differ.diff(baseline_report, current_report)
|
|
2677
|
+
|
|
2678
|
+
return {
|
|
2679
|
+
"baseline_run_id": run_diff.baseline_run_id,
|
|
2680
|
+
"current_run_id": run_diff.current_run_id,
|
|
2681
|
+
"baseline_commit": run_diff.baseline_commit,
|
|
2682
|
+
"current_commit": run_diff.current_commit,
|
|
2683
|
+
"baseline_build": run_diff.baseline_build,
|
|
2684
|
+
"current_build": run_diff.current_build,
|
|
2685
|
+
"summary": run_diff.summary,
|
|
2686
|
+
"new_failures": [asdict(f) for f in run_diff.new_failures],
|
|
2687
|
+
"resolved_failures": [asdict(f) for f in run_diff.resolved_failures],
|
|
2688
|
+
"page_deltas": [asdict(d) for d in run_diff.page_deltas],
|
|
2689
|
+
"flakiness_changes": [asdict(c) for c in run_diff.flakiness_changes],
|
|
2690
|
+
"coverage_delta": asdict(run_diff.coverage_delta),
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
|
|
2694
|
+
async def _persist_autonomous_run(run_id: str) -> None:
|
|
2695
|
+
"""Upsert the current in-memory autonomous run entry to PostgreSQL (no-op if no DB)."""
|
|
2696
|
+
try:
|
|
2697
|
+
from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
|
|
2698
|
+
|
|
2699
|
+
if _build_engine() is None or _async_session_factory is None:
|
|
2700
|
+
return
|
|
2701
|
+
|
|
2702
|
+
from mannf.product.models import AutonomousRun # noqa: PLC0415
|
|
2703
|
+
|
|
2704
|
+
entry = _autonomous_runs.get(run_id)
|
|
2705
|
+
if entry is None:
|
|
2706
|
+
return
|
|
2707
|
+
|
|
2708
|
+
async with _async_session_factory() as session:
|
|
2709
|
+
obj = await session.get(AutonomousRun, uuid.UUID(run_id))
|
|
2710
|
+
if obj is None:
|
|
2711
|
+
obj = AutonomousRun(id=uuid.UUID(run_id))
|
|
2712
|
+
session.add(obj)
|
|
2713
|
+
|
|
2714
|
+
obj.status = str(entry.get("status", ""))
|
|
2715
|
+
obj.target_url = entry.get("target_url")
|
|
2716
|
+
obj.strategy = entry.get("strategy")
|
|
2717
|
+
# Store full run data minus internal keys
|
|
2718
|
+
obj.report = {k: v for k, v in entry.items() if not k.startswith("_")}
|
|
2719
|
+
obj.error = entry.get("error")
|
|
2720
|
+
|
|
2721
|
+
raw_created = entry.get("created_at")
|
|
2722
|
+
if raw_created:
|
|
2723
|
+
try:
|
|
2724
|
+
obj.created_at = datetime.fromisoformat(raw_created)
|
|
2725
|
+
except (ValueError, TypeError):
|
|
2726
|
+
pass
|
|
2727
|
+
|
|
2728
|
+
raw_completed = entry.get("completed_at")
|
|
2729
|
+
if raw_completed:
|
|
2730
|
+
try:
|
|
2731
|
+
obj.completed_at = datetime.fromisoformat(raw_completed)
|
|
2732
|
+
except (ValueError, TypeError):
|
|
2733
|
+
pass
|
|
2734
|
+
|
|
2735
|
+
raw_tid = entry.get("tenant_id")
|
|
2736
|
+
obj.tenant_id = uuid.UUID(raw_tid) if raw_tid else None
|
|
2737
|
+
|
|
2738
|
+
await session.commit()
|
|
2739
|
+
except Exception as exc: # noqa: BLE001
|
|
2740
|
+
logger.warning("DB persist_autonomous_run %s failed: %s", run_id, exc)
|
|
2741
|
+
|
|
2742
|
+
|
|
2743
|
+
async def _persist_discovery(discovery_id: str) -> None:
|
|
2744
|
+
"""Upsert the current in-memory discovery result to PostgreSQL (no-op if no DB)."""
|
|
2745
|
+
try:
|
|
2746
|
+
from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
|
|
2747
|
+
|
|
2748
|
+
if _build_engine() is None or _async_session_factory is None:
|
|
2749
|
+
return
|
|
2750
|
+
|
|
2751
|
+
from mannf.product.models import DiscoveryRun # noqa: PLC0415
|
|
2752
|
+
|
|
2753
|
+
result = _discoveries.get(discovery_id)
|
|
2754
|
+
if result is None:
|
|
2755
|
+
return
|
|
2756
|
+
|
|
2757
|
+
async with _async_session_factory() as session:
|
|
2758
|
+
obj = await session.get(DiscoveryRun, uuid.UUID(discovery_id))
|
|
2759
|
+
if obj is None:
|
|
2760
|
+
obj = DiscoveryRun(id=uuid.UUID(discovery_id))
|
|
2761
|
+
session.add(obj)
|
|
2762
|
+
|
|
2763
|
+
obj.status = result.status
|
|
2764
|
+
obj.base_url = result.base_url
|
|
2765
|
+
obj.scenarios = [s.model_dump() for s in result.scenarios]
|
|
2766
|
+
obj.stats = result.stats
|
|
2767
|
+
obj.error = result.error
|
|
2768
|
+
|
|
2769
|
+
raw_created = result.created_at
|
|
2770
|
+
if raw_created:
|
|
2771
|
+
try:
|
|
2772
|
+
obj.created_at = datetime.fromisoformat(raw_created)
|
|
2773
|
+
except (ValueError, TypeError):
|
|
2774
|
+
pass
|
|
2775
|
+
|
|
2776
|
+
await session.commit()
|
|
2777
|
+
except Exception as exc: # noqa: BLE001
|
|
2778
|
+
logger.warning("DB persist_discovery %s failed: %s", discovery_id, exc)
|
|
2779
|
+
|
|
2780
|
+
|
|
2781
|
+
async def _preload_autonomous_runs() -> None:
|
|
2782
|
+
"""Pre-populate ``_autonomous_runs`` from PostgreSQL on startup."""
|
|
2783
|
+
try:
|
|
2784
|
+
from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
|
|
2785
|
+
|
|
2786
|
+
if _build_engine() is None or _async_session_factory is None:
|
|
2787
|
+
return
|
|
2788
|
+
|
|
2789
|
+
from sqlalchemy import select, desc # noqa: PLC0415
|
|
2790
|
+
from mannf.product.models import AutonomousRun # noqa: PLC0415
|
|
2791
|
+
|
|
2792
|
+
async with _async_session_factory() as session:
|
|
2793
|
+
result = await session.execute(
|
|
2794
|
+
select(AutonomousRun)
|
|
2795
|
+
.order_by(desc(AutonomousRun.created_at))
|
|
2796
|
+
.limit(200)
|
|
2797
|
+
)
|
|
2798
|
+
count = 0
|
|
2799
|
+
for row in result.scalars():
|
|
2800
|
+
run_id = str(row.id)
|
|
2801
|
+
if run_id not in _autonomous_runs:
|
|
2802
|
+
entry = row.report or {}
|
|
2803
|
+
entry["run_id"] = run_id
|
|
2804
|
+
entry["status"] = row.status
|
|
2805
|
+
entry["target_url"] = row.target_url
|
|
2806
|
+
entry["strategy"] = row.strategy
|
|
2807
|
+
entry["error"] = row.error
|
|
2808
|
+
entry["created_at"] = (
|
|
2809
|
+
row.created_at.isoformat() if row.created_at else None
|
|
2810
|
+
)
|
|
2811
|
+
entry["completed_at"] = (
|
|
2812
|
+
row.completed_at.isoformat() if row.completed_at else None
|
|
2813
|
+
)
|
|
2814
|
+
_autonomous_runs[run_id] = entry
|
|
2815
|
+
count += 1
|
|
2816
|
+
logger.info("Preloaded %d autonomous runs from DB", count)
|
|
2817
|
+
except Exception as exc: # noqa: BLE001
|
|
2818
|
+
logger.warning("DB preload_autonomous_runs failed (continuing in-memory only): %s", exc)
|
|
2819
|
+
|
|
2820
|
+
|
|
2821
|
+
async def _preload_discoveries() -> None:
|
|
2822
|
+
"""Pre-populate ``_discoveries`` from PostgreSQL on startup."""
|
|
2823
|
+
try:
|
|
2824
|
+
from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
|
|
2825
|
+
|
|
2826
|
+
if _build_engine() is None or _async_session_factory is None:
|
|
2827
|
+
return
|
|
2828
|
+
|
|
2829
|
+
from sqlalchemy import select, desc # noqa: PLC0415
|
|
2830
|
+
from mannf.product.models import DiscoveryRun # noqa: PLC0415
|
|
2831
|
+
|
|
2832
|
+
async with _async_session_factory() as session:
|
|
2833
|
+
result = await session.execute(
|
|
2834
|
+
select(DiscoveryRun)
|
|
2835
|
+
.order_by(desc(DiscoveryRun.created_at))
|
|
2836
|
+
.limit(200)
|
|
2837
|
+
)
|
|
2838
|
+
count = 0
|
|
2839
|
+
for row in result.scalars():
|
|
2840
|
+
discovery_id = str(row.id)
|
|
2841
|
+
if discovery_id not in _discoveries:
|
|
2842
|
+
raw_scenarios = row.scenarios or []
|
|
2843
|
+
scenarios = []
|
|
2844
|
+
for s in raw_scenarios:
|
|
2845
|
+
try:
|
|
2846
|
+
scenarios.append(DiscoveryScenario(**s))
|
|
2847
|
+
except Exception: # noqa: BLE001
|
|
2848
|
+
pass
|
|
2849
|
+
_discoveries[discovery_id] = DiscoveryResult(
|
|
2850
|
+
discovery_id=discovery_id,
|
|
2851
|
+
status=row.status,
|
|
2852
|
+
base_url=row.base_url or "",
|
|
2853
|
+
scenarios=scenarios,
|
|
2854
|
+
stats=row.stats or {},
|
|
2855
|
+
error=row.error,
|
|
2856
|
+
created_at=(
|
|
2857
|
+
row.created_at.isoformat() if row.created_at else ""
|
|
2858
|
+
),
|
|
2859
|
+
)
|
|
2860
|
+
count += 1
|
|
2861
|
+
logger.info("Preloaded %d discovery runs from DB", count)
|
|
2862
|
+
except Exception as exc: # noqa: BLE001
|
|
2863
|
+
logger.warning("DB preload_discoveries failed (continuing in-memory only): %s", exc)
|
|
2864
|
+
|
|
2865
|
+
|
|
2866
|
+
# ---------------------------------------------------------------------------
|
|
2867
|
+
# ML/LLM Diagnostics analytics endpoints (Phase 6.5)
|
|
2868
|
+
# ---------------------------------------------------------------------------
|
|
2869
|
+
|
|
2870
|
+
|
|
2871
|
+
@router.get("/api/v1/analytics/beliefs", response_model=List[BeliefTrend])
|
|
2872
|
+
async def get_belief_trends(
|
|
2873
|
+
page: Optional[str] = Query(None, description="Filter by page path (e.g. /checkout)"),
|
|
2874
|
+
window: str = Query("30d", description="Time window: 1d, 7d, 30d"),
|
|
2875
|
+
run_id: Optional[str] = Query(None, description="Filter by specific run ID"),
|
|
2876
|
+
) -> List[BeliefTrend]:
|
|
2877
|
+
"""Return time-series fault_likelihood belief snapshots per page.
|
|
2878
|
+
|
|
2879
|
+
Query parameters
|
|
2880
|
+
----------------
|
|
2881
|
+
page:
|
|
2882
|
+
Optional page path filter (e.g. ``/checkout``).
|
|
2883
|
+
window:
|
|
2884
|
+
Time window: ``1d``, ``7d`` (default), or ``30d``.
|
|
2885
|
+
run_id:
|
|
2886
|
+
When supplied, return snapshots from that run only.
|
|
2887
|
+
"""
|
|
2888
|
+
from datetime import timedelta # noqa: PLC0415
|
|
2889
|
+
|
|
2890
|
+
window_map = {"1d": 1, "7d": 7, "30d": 30}
|
|
2891
|
+
days = window_map.get(window, 30)
|
|
2892
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
|
2893
|
+
|
|
2894
|
+
results: List[BeliefTrend] = []
|
|
2895
|
+
|
|
2896
|
+
runs_to_scan = (
|
|
2897
|
+
{run_id: _autonomous_runs[run_id]}
|
|
2898
|
+
if run_id and run_id in _autonomous_runs
|
|
2899
|
+
else _autonomous_runs
|
|
2900
|
+
)
|
|
2901
|
+
|
|
2902
|
+
for rid, run in runs_to_scan.items():
|
|
2903
|
+
snapshots = run.get("belief_snapshots") or []
|
|
2904
|
+
for snap in snapshots:
|
|
2905
|
+
ts_str = snap.get("timestamp", "")
|
|
2906
|
+
try:
|
|
2907
|
+
ts = datetime.fromisoformat(ts_str)
|
|
2908
|
+
if ts.tzinfo is None:
|
|
2909
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
2910
|
+
if ts < cutoff:
|
|
2911
|
+
continue
|
|
2912
|
+
except (ValueError, TypeError):
|
|
2913
|
+
continue
|
|
2914
|
+
|
|
2915
|
+
iteration = snap.get("iteration", 0)
|
|
2916
|
+
beliefs = snap.get("beliefs") or {}
|
|
2917
|
+
|
|
2918
|
+
for page_key, fault_likelihood in beliefs.items():
|
|
2919
|
+
if page and page_key != page:
|
|
2920
|
+
continue
|
|
2921
|
+
results.append(
|
|
2922
|
+
BeliefTrend(
|
|
2923
|
+
run_id=rid,
|
|
2924
|
+
iteration=iteration,
|
|
2925
|
+
page_key=page_key,
|
|
2926
|
+
fault_likelihood=float(fault_likelihood),
|
|
2927
|
+
timestamp=ts_str,
|
|
2928
|
+
)
|
|
2929
|
+
)
|
|
2930
|
+
|
|
2931
|
+
# Sort by timestamp then iteration
|
|
2932
|
+
results.sort(key=lambda r: (r.timestamp, r.iteration))
|
|
2933
|
+
return results
|
|
2934
|
+
|
|
2935
|
+
|
|
2936
|
+
@router.get("/api/v1/analytics/flakiness", response_model=List[FlakinessSummary])
|
|
2937
|
+
async def get_flakiness_summary(
|
|
2938
|
+
page: Optional[str] = Query(None, description="Filter by page path"),
|
|
2939
|
+
flaky_only: bool = Query(False, description="Return only flaky scenarios"),
|
|
2940
|
+
) -> List[FlakinessSummary]:
|
|
2941
|
+
"""Return flakiness statistics per scenario.
|
|
2942
|
+
|
|
2943
|
+
Aggregates pass/fail history from all persisted autonomous runs using the
|
|
2944
|
+
in-process :class:`~mannf.core.diagnostics.flake_detector.FlakeDetector`.
|
|
2945
|
+
|
|
2946
|
+
Query parameters
|
|
2947
|
+
----------------
|
|
2948
|
+
page:
|
|
2949
|
+
Optional page path filter.
|
|
2950
|
+
flaky_only:
|
|
2951
|
+
When ``True``, return only scenarios labelled flaky.
|
|
2952
|
+
"""
|
|
2953
|
+
summaries = _flake_detector.all_summaries()
|
|
2954
|
+
|
|
2955
|
+
results: List[FlakinessSummary] = []
|
|
2956
|
+
for s in summaries:
|
|
2957
|
+
if page and s.page_key != page:
|
|
2958
|
+
continue
|
|
2959
|
+
if flaky_only and not s.is_flaky:
|
|
2960
|
+
continue
|
|
2961
|
+
results.append(
|
|
2962
|
+
FlakinessSummary(
|
|
2963
|
+
page_key=s.page_key,
|
|
2964
|
+
scenario_id=s.scenario_id,
|
|
2965
|
+
is_flaky=s.is_flaky,
|
|
2966
|
+
flake_rate=s.flake_rate,
|
|
2967
|
+
total_runs=s.total_runs,
|
|
2968
|
+
pass_count=s.pass_count,
|
|
2969
|
+
fail_count=s.fail_count,
|
|
2970
|
+
alternations=s.alternations,
|
|
2971
|
+
)
|
|
2972
|
+
)
|
|
2973
|
+
|
|
2974
|
+
# Sort by flake_rate descending so worst offenders come first
|
|
2975
|
+
results.sort(key=lambda s: s.flake_rate, reverse=True)
|
|
2976
|
+
return results
|
|
2977
|
+
|
|
2978
|
+
|
|
2979
|
+
@router.get("/api/v1/analytics/clusters", response_model=List[FailureCluster])
|
|
2980
|
+
async def get_failure_clusters(
|
|
2981
|
+
run_id: Optional[str] = Query(None, description="Run ID to cluster failures from"),
|
|
2982
|
+
threshold: float = Query(0.4, description="Cosine similarity threshold (0.0–1.0)"),
|
|
2983
|
+
) -> List[FailureCluster]:
|
|
2984
|
+
"""Cluster failures by error-message similarity using TF-IDF cosine distance.
|
|
2985
|
+
|
|
2986
|
+
Query parameters
|
|
2987
|
+
----------------
|
|
2988
|
+
run_id:
|
|
2989
|
+
When supplied, only cluster failures from that run. Otherwise,
|
|
2990
|
+
aggregates failures from the 10 most recent completed runs.
|
|
2991
|
+
threshold:
|
|
2992
|
+
Cosine similarity threshold in (0, 1]. Higher values produce
|
|
2993
|
+
tighter (more homogeneous) clusters.
|
|
2994
|
+
"""
|
|
2995
|
+
from mannf.core.diagnostics.failure_clusterer import FailureClusterer # noqa: PLC0415
|
|
2996
|
+
|
|
2997
|
+
failures: List[Dict[str, Any]] = []
|
|
2998
|
+
|
|
2999
|
+
if run_id:
|
|
3000
|
+
run = _autonomous_runs.get(run_id)
|
|
3001
|
+
if run is None:
|
|
3002
|
+
raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found")
|
|
3003
|
+
failures = list(run.get("unique_failures") or [])
|
|
3004
|
+
else:
|
|
3005
|
+
# Aggregate from the 10 most recent completed runs
|
|
3006
|
+
completed = [
|
|
3007
|
+
r
|
|
3008
|
+
for r in _autonomous_runs.values()
|
|
3009
|
+
if r.get("status") in {"complete", "stopped"}
|
|
3010
|
+
]
|
|
3011
|
+
completed.sort(key=lambda r: r.get("started_at", ""), reverse=True)
|
|
3012
|
+
for run in completed[:10]:
|
|
3013
|
+
failures.extend(run.get("unique_failures") or [])
|
|
3014
|
+
|
|
3015
|
+
if not failures:
|
|
3016
|
+
return []
|
|
3017
|
+
|
|
3018
|
+
try:
|
|
3019
|
+
clusterer = FailureClusterer(threshold=threshold)
|
|
3020
|
+
except ValueError:
|
|
3021
|
+
raise HTTPException(status_code=422, detail="threshold must be in (0, 1]")
|
|
3022
|
+
|
|
3023
|
+
raw_clusters = clusterer.cluster(failures)
|
|
3024
|
+
return [
|
|
3025
|
+
FailureCluster(
|
|
3026
|
+
cluster_id=c.cluster_id,
|
|
3027
|
+
label=c.label,
|
|
3028
|
+
representative_error=c.representative_error,
|
|
3029
|
+
pages=c.pages,
|
|
3030
|
+
size=c.size,
|
|
3031
|
+
failures=c.failures,
|
|
3032
|
+
)
|
|
3033
|
+
for c in raw_clusters
|
|
3034
|
+
]
|
|
3035
|
+
|
|
3036
|
+
|
|
3037
|
+
@router.get("/api/v1/analytics/predictions", response_model=List[PredictiveRiskScore])
|
|
3038
|
+
async def get_predictive_risk(
|
|
3039
|
+
target_url: Optional[str] = Query(None, description="Filter by target URL"),
|
|
3040
|
+
top_k: int = Query(10, description="Maximum number of pages to return", ge=1, le=100),
|
|
3041
|
+
) -> List[PredictiveRiskScore]:
|
|
3042
|
+
"""Return predicted failure probability per page for the next run.
|
|
3043
|
+
|
|
3044
|
+
Predictive score is computed from:
|
|
3045
|
+
- **Belief momentum** — rate of change in ``fault_likelihood`` across the
|
|
3046
|
+
last 5 belief snapshots for the page.
|
|
3047
|
+
- **Recent failure density** — fraction of failures on the page across the
|
|
3048
|
+
last 5 completed runs.
|
|
3049
|
+
|
|
3050
|
+
Query parameters
|
|
3051
|
+
----------------
|
|
3052
|
+
target_url:
|
|
3053
|
+
Optional target URL to filter runs.
|
|
3054
|
+
top_k:
|
|
3055
|
+
Maximum number of pages to return (highest predicted risk first).
|
|
3056
|
+
"""
|
|
3057
|
+
# Gather the most recent completed runs
|
|
3058
|
+
completed = [
|
|
3059
|
+
r
|
|
3060
|
+
for r in _autonomous_runs.values()
|
|
3061
|
+
if r.get("status") in {"complete", "stopped"}
|
|
3062
|
+
and (not target_url or r.get("target_url") == target_url)
|
|
3063
|
+
]
|
|
3064
|
+
completed.sort(key=lambda r: r.get("started_at", ""), reverse=True)
|
|
3065
|
+
recent_runs = completed[:5]
|
|
3066
|
+
|
|
3067
|
+
if not recent_runs:
|
|
3068
|
+
return []
|
|
3069
|
+
|
|
3070
|
+
# Per-page: collect the last N fault_likelihood values from snapshots
|
|
3071
|
+
page_belief_history: Dict[str, List[float]] = {}
|
|
3072
|
+
# Per-page: failure density
|
|
3073
|
+
page_failure_counts: Dict[str, int] = {}
|
|
3074
|
+
total_failure_scenarios = 0
|
|
3075
|
+
|
|
3076
|
+
for run in recent_runs:
|
|
3077
|
+
snapshots = run.get("belief_snapshots") or []
|
|
3078
|
+
for snap in snapshots:
|
|
3079
|
+
beliefs = snap.get("beliefs") or {}
|
|
3080
|
+
for pk, fl in beliefs.items():
|
|
3081
|
+
page_belief_history.setdefault(pk, []).append(float(fl))
|
|
3082
|
+
|
|
3083
|
+
for failure in run.get("unique_failures") or []:
|
|
3084
|
+
pk = failure.get("page", failure.get("url", ""))
|
|
3085
|
+
if pk:
|
|
3086
|
+
page_failure_counts[pk] = page_failure_counts.get(pk, 0) + 1
|
|
3087
|
+
total_failure_scenarios += 1
|
|
3088
|
+
|
|
3089
|
+
all_pages = set(page_belief_history) | set(page_failure_counts)
|
|
3090
|
+
scores: List[PredictiveRiskScore] = []
|
|
3091
|
+
|
|
3092
|
+
for pk in all_pages:
|
|
3093
|
+
belief_vals = page_belief_history.get(pk, [])
|
|
3094
|
+
# Belief momentum: average delta between consecutive snapshots
|
|
3095
|
+
if len(belief_vals) >= 2:
|
|
3096
|
+
deltas = [belief_vals[i] - belief_vals[i - 1] for i in range(1, len(belief_vals))]
|
|
3097
|
+
belief_momentum = sum(deltas) / len(deltas)
|
|
3098
|
+
else:
|
|
3099
|
+
belief_momentum = 0.0
|
|
3100
|
+
|
|
3101
|
+
latest_belief = belief_vals[-1] if belief_vals else 0.5
|
|
3102
|
+
|
|
3103
|
+
failure_density = (
|
|
3104
|
+
page_failure_counts.get(pk, 0) / total_failure_scenarios
|
|
3105
|
+
if total_failure_scenarios > 0
|
|
3106
|
+
else 0.0
|
|
3107
|
+
)
|
|
3108
|
+
|
|
3109
|
+
# Combine signals: weighted average of latest belief, momentum push, and failure density
|
|
3110
|
+
raw_prob = (
|
|
3111
|
+
0.5 * latest_belief
|
|
3112
|
+
+ 0.3 * max(0.0, belief_momentum) # positive momentum = degradation
|
|
3113
|
+
+ 0.2 * failure_density
|
|
3114
|
+
)
|
|
3115
|
+
predicted_prob = round(max(0.0, min(1.0, raw_prob)), 4)
|
|
3116
|
+
|
|
3117
|
+
# Confidence: how many data points we have
|
|
3118
|
+
n_obs = len(belief_vals) + page_failure_counts.get(pk, 0)
|
|
3119
|
+
confidence = round(min(0.95, n_obs / (n_obs + 5)), 4)
|
|
3120
|
+
|
|
3121
|
+
scores.append(
|
|
3122
|
+
PredictiveRiskScore(
|
|
3123
|
+
page_key=pk,
|
|
3124
|
+
predicted_failure_probability=predicted_prob,
|
|
3125
|
+
belief_momentum=round(belief_momentum, 4),
|
|
3126
|
+
recent_failure_density=round(failure_density, 4),
|
|
3127
|
+
confidence=confidence,
|
|
3128
|
+
)
|
|
3129
|
+
)
|
|
3130
|
+
|
|
3131
|
+
scores.sort(key=lambda s: s.predicted_failure_probability, reverse=True)
|
|
3132
|
+
return scores[:top_k]
|
|
3133
|
+
|
|
3134
|
+
|
|
3135
|
+
# ---------------------------------------------------------------------------
|
|
3136
|
+
# Phase 7.4 — Confidence Calibration & Self-Validation endpoints
|
|
3137
|
+
# ---------------------------------------------------------------------------
|
|
3138
|
+
|
|
3139
|
+
|
|
3140
|
+
@router.post("/api/v1/findings/{finding_id}/dismiss", response_model=DismissedFinding)
|
|
3141
|
+
async def dismiss_finding(
|
|
3142
|
+
finding_id: str,
|
|
3143
|
+
dismissed_by: str = "",
|
|
3144
|
+
run_id: str = "",
|
|
3145
|
+
reason: str = "",
|
|
3146
|
+
) -> DismissedFinding:
|
|
3147
|
+
"""Dismiss a finding as a false positive (FP) and recalibrate belief states.
|
|
3148
|
+
|
|
3149
|
+
When a finding is dismissed:
|
|
3150
|
+
|
|
3151
|
+
1. A ``DismissedFinding`` record is stored in-memory (and would be
|
|
3152
|
+
persisted to the ``dismissed_findings`` table in production via
|
|
3153
|
+
Alembic migration 007).
|
|
3154
|
+
2. ``evidence=0.0`` is injected into every ``BeliefState`` that has
|
|
3155
|
+
knowledge of the endpoint, recalibrating fault-likelihood downward.
|
|
3156
|
+
|
|
3157
|
+
Parameters
|
|
3158
|
+
----------
|
|
3159
|
+
finding_id:
|
|
3160
|
+
Unique identifier for the finding (from ``unique_failures[].task_id``
|
|
3161
|
+
or ``browser_security_findings[].id``).
|
|
3162
|
+
dismissed_by:
|
|
3163
|
+
Optional identifier for who is dismissing (user name / API key).
|
|
3164
|
+
run_id:
|
|
3165
|
+
Optional autonomous run ID the finding originated from.
|
|
3166
|
+
reason:
|
|
3167
|
+
Optional free-text reason for dismissal.
|
|
3168
|
+
"""
|
|
3169
|
+
if finding_id in _dismissed_findings:
|
|
3170
|
+
raise HTTPException(
|
|
3171
|
+
status_code=409,
|
|
3172
|
+
detail=f"Finding '{finding_id}' has already been dismissed.",
|
|
3173
|
+
)
|
|
3174
|
+
|
|
3175
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
3176
|
+
|
|
3177
|
+
# Resolve the endpoint key from the finding
|
|
3178
|
+
endpoint = ""
|
|
3179
|
+
raw_run = _autonomous_runs.get(run_id) if run_id else None
|
|
3180
|
+
if raw_run:
|
|
3181
|
+
for failure in raw_run.get("unique_failures", []):
|
|
3182
|
+
if failure.get("task_id") == finding_id or failure.get("id") == finding_id:
|
|
3183
|
+
endpoint = failure.get("page") or failure.get("url", "")
|
|
3184
|
+
break
|
|
3185
|
+
if not endpoint:
|
|
3186
|
+
for f in raw_run.get("browser_security_findings", []):
|
|
3187
|
+
if f.get("id") == finding_id:
|
|
3188
|
+
endpoint = f.get("endpoint", "")
|
|
3189
|
+
break
|
|
3190
|
+
|
|
3191
|
+
# Persist the dismissal record
|
|
3192
|
+
record = DismissedFinding(
|
|
3193
|
+
finding_id=finding_id,
|
|
3194
|
+
endpoint=endpoint,
|
|
3195
|
+
dismissed_by=dismissed_by or "user",
|
|
3196
|
+
dismissed_at=now_iso,
|
|
3197
|
+
run_id=run_id,
|
|
3198
|
+
reason=reason,
|
|
3199
|
+
)
|
|
3200
|
+
_dismissed_findings[finding_id] = record
|
|
3201
|
+
|
|
3202
|
+
# Propagate FP feedback: inject evidence=0.0 into all known belief states
|
|
3203
|
+
if endpoint:
|
|
3204
|
+
_propagate_fp_feedback(endpoint)
|
|
3205
|
+
|
|
3206
|
+
logger.info(
|
|
3207
|
+
"dismiss_finding: finding_id=%s endpoint=%r run_id=%s",
|
|
3208
|
+
finding_id, endpoint, run_id,
|
|
3209
|
+
)
|
|
3210
|
+
return record
|
|
3211
|
+
|
|
3212
|
+
|
|
3213
|
+
def _propagate_fp_feedback(endpoint: str) -> None:
|
|
3214
|
+
"""Inject ``evidence=0.0`` into active autonomous-run belief states for *endpoint*.
|
|
3215
|
+
|
|
3216
|
+
Iterates over all in-progress autonomous runs and updates their attached
|
|
3217
|
+
belief state so that the FP signal propagates through to the next
|
|
3218
|
+
iteration's scenario generation via the AdaptiveController.
|
|
3219
|
+
|
|
3220
|
+
Parameters
|
|
3221
|
+
----------
|
|
3222
|
+
endpoint:
|
|
3223
|
+
Endpoint key (page path or full key) for which a finding was dismissed.
|
|
3224
|
+
"""
|
|
3225
|
+
# Running agents may store a reference to themselves in the _autonomous_runs
|
|
3226
|
+
# record under the ``"_agent"`` key (set during background task execution).
|
|
3227
|
+
# Fall back gracefully if no live agent reference is found.
|
|
3228
|
+
updated = 0
|
|
3229
|
+
for run_record in _autonomous_runs.values():
|
|
3230
|
+
if run_record.get("status") != "running":
|
|
3231
|
+
continue
|
|
3232
|
+
agent = run_record.get("_agent")
|
|
3233
|
+
if agent is None:
|
|
3234
|
+
continue
|
|
3235
|
+
bs = getattr(agent, "_belief_state", None)
|
|
3236
|
+
if bs is None:
|
|
3237
|
+
continue
|
|
3238
|
+
if endpoint in bs.fault_likelihood or endpoint in bs.confidence:
|
|
3239
|
+
bs.update(endpoint, 0.0)
|
|
3240
|
+
updated += 1
|
|
3241
|
+
if updated:
|
|
3242
|
+
logger.debug(
|
|
3243
|
+
"_propagate_fp_feedback: updated %d belief state(s) for endpoint %r",
|
|
3244
|
+
updated, endpoint,
|
|
3245
|
+
)
|
|
3246
|
+
|
|
3247
|
+
|
|
3248
|
+
@router.get("/api/v1/findings/dismissed", response_model=List[DismissedFinding])
|
|
3249
|
+
async def list_dismissed_findings(
|
|
3250
|
+
run_id: Optional[str] = Query(None, description="Filter by run ID"),
|
|
3251
|
+
endpoint: Optional[str] = Query(None, description="Filter by endpoint path"),
|
|
3252
|
+
) -> List[DismissedFinding]:
|
|
3253
|
+
"""Return the list of findings dismissed as false positives.
|
|
3254
|
+
|
|
3255
|
+
Query parameters
|
|
3256
|
+
----------------
|
|
3257
|
+
run_id:
|
|
3258
|
+
Optional filter — only return dismissals from the specified run.
|
|
3259
|
+
endpoint:
|
|
3260
|
+
Optional filter — only return dismissals for the specified endpoint.
|
|
3261
|
+
"""
|
|
3262
|
+
results = list(_dismissed_findings.values())
|
|
3263
|
+
if run_id:
|
|
3264
|
+
results = [r for r in results if r.run_id == run_id]
|
|
3265
|
+
if endpoint:
|
|
3266
|
+
results = [r for r in results if r.endpoint == endpoint]
|
|
3267
|
+
results.sort(key=lambda r: r.dismissed_at, reverse=True)
|
|
3268
|
+
return results
|
|
3269
|
+
|
|
3270
|
+
|
|
3271
|
+
# ---------------------------------------------------------------------------
|
|
3272
|
+
# Finding annotations (comments) — Phase 10.4
|
|
3273
|
+
# ---------------------------------------------------------------------------
|
|
3274
|
+
|
|
3275
|
+
|
|
3276
|
+
@router.post("/api/v1/findings/{finding_id}/comments", status_code=201, tags=["findings"])
|
|
3277
|
+
async def add_finding_comment(
|
|
3278
|
+
finding_id: str,
|
|
3279
|
+
author: Optional[str] = None,
|
|
3280
|
+
text: str = Query(..., min_length=1, description="Comment body text (required, non-empty)"),
|
|
3281
|
+
) -> Dict[str, Any]:
|
|
3282
|
+
"""Add a comment/annotation to a finding.
|
|
3283
|
+
|
|
3284
|
+
Comments are stored in-memory keyed by *finding_id*. Each comment
|
|
3285
|
+
records its author, text body, and creation timestamp.
|
|
3286
|
+
|
|
3287
|
+
Parameters
|
|
3288
|
+
----------
|
|
3289
|
+
finding_id:
|
|
3290
|
+
Unique identifier of the finding to annotate.
|
|
3291
|
+
author:
|
|
3292
|
+
Display name of the commenter (defaults to ``"anonymous"`` when omitted).
|
|
3293
|
+
text:
|
|
3294
|
+
Comment body text (required, must be non-empty).
|
|
3295
|
+
"""
|
|
3296
|
+
comment: Dict[str, Any] = {
|
|
3297
|
+
"id": str(uuid.uuid4()),
|
|
3298
|
+
"finding_id": finding_id,
|
|
3299
|
+
"author": author if author is not None else "anonymous",
|
|
3300
|
+
"text": text,
|
|
3301
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
3302
|
+
}
|
|
3303
|
+
_finding_comments.setdefault(finding_id, []).append(comment)
|
|
3304
|
+
|
|
3305
|
+
# Broadcast team_annotation event to all WS clients (non-blocking)
|
|
3306
|
+
try:
|
|
3307
|
+
from mannf.product.dashboard import telemetry as _tel # noqa: PLC0415
|
|
3308
|
+
import asyncio # noqa: PLC0415
|
|
3309
|
+
loop = asyncio.get_running_loop()
|
|
3310
|
+
loop.create_task(_tel.broadcast_team_annotation(
|
|
3311
|
+
finding_id=finding_id,
|
|
3312
|
+
author=comment["author"],
|
|
3313
|
+
text=text,
|
|
3314
|
+
))
|
|
3315
|
+
except Exception: # noqa: BLE001
|
|
3316
|
+
pass
|
|
3317
|
+
|
|
3318
|
+
return comment
|
|
3319
|
+
|
|
3320
|
+
|
|
3321
|
+
@router.get("/api/v1/findings/{finding_id}/comments", tags=["findings"])
|
|
3322
|
+
async def get_finding_comments(finding_id: str) -> List[Dict[str, Any]]:
|
|
3323
|
+
"""Return all comments for a given finding, oldest first.
|
|
3324
|
+
|
|
3325
|
+
Parameters
|
|
3326
|
+
----------
|
|
3327
|
+
finding_id:
|
|
3328
|
+
Unique identifier of the finding whose comments to retrieve.
|
|
3329
|
+
"""
|
|
3330
|
+
return list(_finding_comments.get(finding_id, []))
|
|
3331
|
+
|
|
3332
|
+
|
|
3333
|
+
# ---------------------------------------------------------------------------
|
|
3334
|
+
# Share scan link — Phase 10.4
|
|
3335
|
+
# ---------------------------------------------------------------------------
|
|
3336
|
+
|
|
3337
|
+
|
|
3338
|
+
@router.post("/api/v1/scan/{scan_id}/share", status_code=201, tags=["scans"])
|
|
3339
|
+
async def share_scan(
|
|
3340
|
+
scan_id: str,
|
|
3341
|
+
expiry_hours: int = 24,
|
|
3342
|
+
) -> Dict[str, Any]:
|
|
3343
|
+
"""Generate a time-limited public share link for a scan.
|
|
3344
|
+
|
|
3345
|
+
The returned ``token`` can be embedded in a URL for read-only, auth-free
|
|
3346
|
+
access to the scan results. The link expires after *expiry_hours* hours
|
|
3347
|
+
(default 24 h; max 720 h / 30 days).
|
|
3348
|
+
|
|
3349
|
+
Parameters
|
|
3350
|
+
----------
|
|
3351
|
+
scan_id:
|
|
3352
|
+
Unique identifier of the scan to share.
|
|
3353
|
+
expiry_hours:
|
|
3354
|
+
Hours until the link expires (1–720).
|
|
3355
|
+
"""
|
|
3356
|
+
expiry_hours = max(1, min(expiry_hours, 720))
|
|
3357
|
+
token = str(uuid.uuid4())
|
|
3358
|
+
expires_at = datetime.now(timezone.utc) + timedelta(hours=expiry_hours)
|
|
3359
|
+
record: Dict[str, Any] = {
|
|
3360
|
+
"token": token,
|
|
3361
|
+
"scan_id": scan_id,
|
|
3362
|
+
"expires_at": expires_at.isoformat(),
|
|
3363
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
3364
|
+
}
|
|
3365
|
+
_scan_shares[token] = record
|
|
3366
|
+
return record
|
|
3367
|
+
|
|
3368
|
+
|
|
3369
|
+
@router.get("/api/v1/analytics/calibration", response_model=CalibrationReport)
|
|
3370
|
+
async def get_calibration(
|
|
3371
|
+
target_url: Optional[str] = Query(None, description="Filter by target URL"),
|
|
3372
|
+
buckets: int = Query(10, description="Number of equal-width buckets (1–20)", ge=1, le=20),
|
|
3373
|
+
) -> CalibrationReport:
|
|
3374
|
+
"""Return a predicted-vs-actual reliability curve for the calibration dashboard view.
|
|
3375
|
+
|
|
3376
|
+
Buckets predictions into ``buckets`` equal-width ranges from 0.0 to 1.0
|
|
3377
|
+
and shows:
|
|
3378
|
+
- How many predictions fell in each bucket.
|
|
3379
|
+
- The actual failure rate observed in that bucket (from ``unique_failures``).
|
|
3380
|
+
- The mean predicted probability within the bucket.
|
|
3381
|
+
|
|
3382
|
+
Also computes the **Brier score** (mean squared error between predicted
|
|
3383
|
+
probability and actual outcome) when actual outcomes are available.
|
|
3384
|
+
|
|
3385
|
+
Handles sparse data gracefully: ``actual_failure_rate`` is ``None`` when
|
|
3386
|
+
no outcomes are available for a bucket.
|
|
3387
|
+
|
|
3388
|
+
Query parameters
|
|
3389
|
+
----------------
|
|
3390
|
+
target_url:
|
|
3391
|
+
Optional target URL to filter runs.
|
|
3392
|
+
buckets:
|
|
3393
|
+
Number of equal-width buckets. Defaults to 10 (0.0–0.1, 0.1–0.2, …).
|
|
3394
|
+
"""
|
|
3395
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
3396
|
+
|
|
3397
|
+
completed = [
|
|
3398
|
+
r
|
|
3399
|
+
for r in _autonomous_runs.values()
|
|
3400
|
+
if r.get("status") in {"complete", "stopped"}
|
|
3401
|
+
and (not target_url or r.get("target_url") == target_url)
|
|
3402
|
+
]
|
|
3403
|
+
completed.sort(key=lambda r: r.get("started_at", ""), reverse=True)
|
|
3404
|
+
recent_runs = completed[:10] # use up to last 10 runs
|
|
3405
|
+
|
|
3406
|
+
if not recent_runs:
|
|
3407
|
+
return CalibrationReport(
|
|
3408
|
+
buckets=[],
|
|
3409
|
+
total_predictions=0,
|
|
3410
|
+
total_with_outcomes=0,
|
|
3411
|
+
brier_score=None,
|
|
3412
|
+
generated_at=now_iso,
|
|
3413
|
+
)
|
|
3414
|
+
|
|
3415
|
+
# Gather per-page: (predicted_score, actual_outcome) pairs
|
|
3416
|
+
# Use the same belief-momentum formula as /analytics/predictions to be consistent
|
|
3417
|
+
page_belief_history: Dict[str, List[float]] = {}
|
|
3418
|
+
page_failure_counts: Dict[str, int] = {}
|
|
3419
|
+
page_pass_counts: Dict[str, int] = {}
|
|
3420
|
+
total_failure_scenarios = 0
|
|
3421
|
+
|
|
3422
|
+
for run in recent_runs:
|
|
3423
|
+
for snap in run.get("belief_snapshots") or []:
|
|
3424
|
+
beliefs = snap.get("beliefs") or {}
|
|
3425
|
+
for pk, fl in beliefs.items():
|
|
3426
|
+
page_belief_history.setdefault(pk, []).append(float(fl))
|
|
3427
|
+
|
|
3428
|
+
for failure in run.get("unique_failures") or []:
|
|
3429
|
+
pk = failure.get("page", failure.get("url", ""))
|
|
3430
|
+
if pk:
|
|
3431
|
+
page_failure_counts[pk] = page_failure_counts.get(pk, 0) + 1
|
|
3432
|
+
total_failure_scenarios += 1
|
|
3433
|
+
|
|
3434
|
+
# Use iteration-level pass/fail info where available
|
|
3435
|
+
for iteration in run.get("iterations") or []:
|
|
3436
|
+
pass_count = iteration.get("passed", 0)
|
|
3437
|
+
fail_count = iteration.get("failed", 0)
|
|
3438
|
+
# We can't attribute these to individual pages from iteration-level data,
|
|
3439
|
+
# so we just use the per-page failure counts from unique_failures
|
|
3440
|
+
|
|
3441
|
+
all_pages = set(page_belief_history) | set(page_failure_counts)
|
|
3442
|
+
|
|
3443
|
+
# Build (predicted, actual) pairs for each page with enough data
|
|
3444
|
+
data_points: List[tuple[float, int]] = [] # (predicted, 0|1)
|
|
3445
|
+
|
|
3446
|
+
for pk in all_pages:
|
|
3447
|
+
belief_vals = page_belief_history.get(pk, [])
|
|
3448
|
+
if len(belief_vals) >= 2:
|
|
3449
|
+
deltas = [belief_vals[i] - belief_vals[i - 1] for i in range(1, len(belief_vals))]
|
|
3450
|
+
belief_momentum = sum(deltas) / len(deltas)
|
|
3451
|
+
else:
|
|
3452
|
+
belief_momentum = 0.0
|
|
3453
|
+
|
|
3454
|
+
latest_belief = belief_vals[-1] if belief_vals else 0.5
|
|
3455
|
+
failure_density = (
|
|
3456
|
+
page_failure_counts.get(pk, 0) / total_failure_scenarios
|
|
3457
|
+
if total_failure_scenarios > 0
|
|
3458
|
+
else 0.0
|
|
3459
|
+
)
|
|
3460
|
+
raw_prob = (
|
|
3461
|
+
0.5 * latest_belief
|
|
3462
|
+
+ 0.3 * max(0.0, belief_momentum)
|
|
3463
|
+
+ 0.2 * failure_density
|
|
3464
|
+
)
|
|
3465
|
+
predicted_prob = max(0.0, min(1.0, raw_prob))
|
|
3466
|
+
|
|
3467
|
+
# Actual outcome: 1 if failed at least once across runs, 0 otherwise
|
|
3468
|
+
actual = 1 if page_failure_counts.get(pk, 0) > 0 else 0
|
|
3469
|
+
data_points.append((predicted_prob, actual))
|
|
3470
|
+
|
|
3471
|
+
# Build reliability curve buckets
|
|
3472
|
+
bucket_width = 1.0 / buckets
|
|
3473
|
+
bucket_data: List[tuple[float, float, List[tuple[float, int]]]] = []
|
|
3474
|
+
for i in range(buckets):
|
|
3475
|
+
low = round(i * bucket_width, 10)
|
|
3476
|
+
high = round((i + 1) * bucket_width, 10)
|
|
3477
|
+
bucket_data.append((low, high, []))
|
|
3478
|
+
|
|
3479
|
+
for pred, actual in data_points:
|
|
3480
|
+
idx = min(int(pred / bucket_width), buckets - 1)
|
|
3481
|
+
bucket_data[idx][2].append((pred, actual))
|
|
3482
|
+
|
|
3483
|
+
calibration_buckets: List[CalibrationBucket] = []
|
|
3484
|
+
brier_sum = 0.0
|
|
3485
|
+
total_with_outcomes = 0
|
|
3486
|
+
|
|
3487
|
+
for low, high, points in bucket_data:
|
|
3488
|
+
n = len(points)
|
|
3489
|
+
if n == 0:
|
|
3490
|
+
calibration_buckets.append(
|
|
3491
|
+
CalibrationBucket(
|
|
3492
|
+
bucket_low=round(low, 4),
|
|
3493
|
+
bucket_high=round(high, 4),
|
|
3494
|
+
predicted_count=0,
|
|
3495
|
+
actual_failure_rate=None,
|
|
3496
|
+
mean_predicted=None,
|
|
3497
|
+
)
|
|
3498
|
+
)
|
|
3499
|
+
else:
|
|
3500
|
+
actuals = [p[1] for p in points]
|
|
3501
|
+
preds = [p[0] for p in points]
|
|
3502
|
+
actual_rate = sum(actuals) / n
|
|
3503
|
+
mean_pred = sum(preds) / n
|
|
3504
|
+
for pred_v, act_v in points:
|
|
3505
|
+
brier_sum += (pred_v - act_v) ** 2
|
|
3506
|
+
total_with_outcomes += n
|
|
3507
|
+
calibration_buckets.append(
|
|
3508
|
+
CalibrationBucket(
|
|
3509
|
+
bucket_low=round(low, 4),
|
|
3510
|
+
bucket_high=round(high, 4),
|
|
3511
|
+
predicted_count=n,
|
|
3512
|
+
actual_failure_rate=round(actual_rate, 4),
|
|
3513
|
+
mean_predicted=round(mean_pred, 4),
|
|
3514
|
+
)
|
|
3515
|
+
)
|
|
3516
|
+
|
|
3517
|
+
brier_score = round(brier_sum / total_with_outcomes, 4) if total_with_outcomes > 0 else None
|
|
3518
|
+
|
|
3519
|
+
return CalibrationReport(
|
|
3520
|
+
buckets=calibration_buckets,
|
|
3521
|
+
total_predictions=len(data_points),
|
|
3522
|
+
total_with_outcomes=total_with_outcomes,
|
|
3523
|
+
brier_score=brier_score,
|
|
3524
|
+
generated_at=now_iso,
|
|
3525
|
+
)
|
|
3526
|
+
|
|
3527
|
+
|
|
3528
|
+
# ---------------------------------------------------------------------------
|
|
3529
|
+
# Worker pool status endpoint
|
|
3530
|
+
# ---------------------------------------------------------------------------
|
|
3531
|
+
|
|
3532
|
+
|
|
3533
|
+
@router.get(
|
|
3534
|
+
"/api/v1/worker-pool/status",
|
|
3535
|
+
tags=["worker-pool"],
|
|
3536
|
+
summary="Get worker pool and queue status",
|
|
3537
|
+
)
|
|
3538
|
+
async def get_worker_pool_status() -> Dict[str, Any]:
|
|
3539
|
+
"""Return the current state of the process-wide worker pool and job queue.
|
|
3540
|
+
|
|
3541
|
+
When no worker pool is configured (default single-process mode) the
|
|
3542
|
+
response contains null pool data with the current in-memory queue stats.
|
|
3543
|
+
"""
|
|
3544
|
+
from mannf.core.agents.worker_pool import get_default_pool # noqa: PLC0415
|
|
3545
|
+
from mannf.product.scheduling.queue import get_default_queue # noqa: PLC0415
|
|
3546
|
+
|
|
3547
|
+
pool = get_default_pool()
|
|
3548
|
+
queue = get_default_queue()
|
|
3549
|
+
|
|
3550
|
+
pool_info: Dict[str, Any] = {
|
|
3551
|
+
"configured": pool is not None,
|
|
3552
|
+
}
|
|
3553
|
+
if pool is not None:
|
|
3554
|
+
pool_info.update(pool.info())
|
|
3555
|
+
|
|
3556
|
+
queue_stats: Dict[str, Any] = {}
|
|
3557
|
+
if queue is not None:
|
|
3558
|
+
try:
|
|
3559
|
+
stats = await queue.status()
|
|
3560
|
+
queue_stats = stats.to_dict()
|
|
3561
|
+
except Exception: # noqa: BLE001
|
|
3562
|
+
queue_stats = {}
|
|
3563
|
+
|
|
3564
|
+
return {
|
|
3565
|
+
"pool": pool_info,
|
|
3566
|
+
"queue": queue_stats,
|
|
3567
|
+
}
|