truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.0__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.
- truthound_dashboard/api/alerts.py +258 -0
- truthound_dashboard/api/anomaly.py +1302 -0
- truthound_dashboard/api/cross_alerts.py +352 -0
- truthound_dashboard/api/deps.py +143 -0
- truthound_dashboard/api/drift_monitor.py +540 -0
- truthound_dashboard/api/lineage.py +1151 -0
- truthound_dashboard/api/maintenance.py +363 -0
- truthound_dashboard/api/middleware.py +373 -1
- truthound_dashboard/api/model_monitoring.py +805 -0
- truthound_dashboard/api/notifications_advanced.py +2452 -0
- truthound_dashboard/api/plugins.py +2096 -0
- truthound_dashboard/api/profile.py +211 -14
- truthound_dashboard/api/reports.py +853 -0
- truthound_dashboard/api/router.py +147 -0
- truthound_dashboard/api/rule_suggestions.py +310 -0
- truthound_dashboard/api/schema_evolution.py +231 -0
- truthound_dashboard/api/sources.py +47 -3
- truthound_dashboard/api/triggers.py +190 -0
- truthound_dashboard/api/validations.py +13 -0
- truthound_dashboard/api/validators.py +333 -4
- truthound_dashboard/api/versioning.py +309 -0
- truthound_dashboard/api/websocket.py +301 -0
- truthound_dashboard/core/__init__.py +27 -0
- truthound_dashboard/core/anomaly.py +1395 -0
- truthound_dashboard/core/anomaly_explainer.py +633 -0
- truthound_dashboard/core/cache.py +206 -0
- truthound_dashboard/core/cached_services.py +422 -0
- truthound_dashboard/core/charts.py +352 -0
- truthound_dashboard/core/connections.py +1069 -42
- truthound_dashboard/core/cross_alerts.py +837 -0
- truthound_dashboard/core/drift_monitor.py +1477 -0
- truthound_dashboard/core/drift_sampling.py +669 -0
- truthound_dashboard/core/i18n/__init__.py +42 -0
- truthound_dashboard/core/i18n/detector.py +173 -0
- truthound_dashboard/core/i18n/messages.py +564 -0
- truthound_dashboard/core/lineage.py +971 -0
- truthound_dashboard/core/maintenance.py +443 -5
- truthound_dashboard/core/model_monitoring.py +1043 -0
- truthound_dashboard/core/notifications/channels.py +1020 -1
- truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
- truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
- truthound_dashboard/core/notifications/deduplication/service.py +400 -0
- truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
- truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
- truthound_dashboard/core/notifications/dispatcher.py +43 -0
- truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
- truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
- truthound_dashboard/core/notifications/escalation/engine.py +429 -0
- truthound_dashboard/core/notifications/escalation/models.py +336 -0
- truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
- truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
- truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
- truthound_dashboard/core/notifications/events.py +49 -0
- truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
- truthound_dashboard/core/notifications/metrics/base.py +528 -0
- truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
- truthound_dashboard/core/notifications/routing/__init__.py +169 -0
- truthound_dashboard/core/notifications/routing/combinators.py +184 -0
- truthound_dashboard/core/notifications/routing/config.py +375 -0
- truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
- truthound_dashboard/core/notifications/routing/engine.py +382 -0
- truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
- truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
- truthound_dashboard/core/notifications/routing/rules.py +625 -0
- truthound_dashboard/core/notifications/routing/validator.py +678 -0
- truthound_dashboard/core/notifications/service.py +2 -0
- truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
- truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
- truthound_dashboard/core/notifications/throttling/builder.py +311 -0
- truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
- truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
- truthound_dashboard/core/openlineage.py +1028 -0
- truthound_dashboard/core/plugins/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/extractor.py +703 -0
- truthound_dashboard/core/plugins/docs/renderers.py +804 -0
- truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
- truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
- truthound_dashboard/core/plugins/hooks/manager.py +403 -0
- truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
- truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
- truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
- truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
- truthound_dashboard/core/plugins/loader.py +504 -0
- truthound_dashboard/core/plugins/registry.py +810 -0
- truthound_dashboard/core/plugins/reporter_executor.py +588 -0
- truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
- truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
- truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
- truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
- truthound_dashboard/core/plugins/sandbox.py +617 -0
- truthound_dashboard/core/plugins/security/__init__.py +68 -0
- truthound_dashboard/core/plugins/security/analyzer.py +535 -0
- truthound_dashboard/core/plugins/security/policies.py +311 -0
- truthound_dashboard/core/plugins/security/protocols.py +296 -0
- truthound_dashboard/core/plugins/security/signing.py +842 -0
- truthound_dashboard/core/plugins/security.py +446 -0
- truthound_dashboard/core/plugins/validator_executor.py +401 -0
- truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
- truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
- truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
- truthound_dashboard/core/plugins/versioning/semver.py +266 -0
- truthound_dashboard/core/profile_comparison.py +601 -0
- truthound_dashboard/core/report_history.py +570 -0
- truthound_dashboard/core/reporters/__init__.py +57 -0
- truthound_dashboard/core/reporters/base.py +296 -0
- truthound_dashboard/core/reporters/csv_reporter.py +155 -0
- truthound_dashboard/core/reporters/html_reporter.py +598 -0
- truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
- truthound_dashboard/core/reporters/i18n/base.py +494 -0
- truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
- truthound_dashboard/core/reporters/json_reporter.py +160 -0
- truthound_dashboard/core/reporters/junit_reporter.py +233 -0
- truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
- truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
- truthound_dashboard/core/reporters/registry.py +272 -0
- truthound_dashboard/core/rule_generator.py +2088 -0
- truthound_dashboard/core/scheduler.py +822 -12
- truthound_dashboard/core/schema_evolution.py +858 -0
- truthound_dashboard/core/services.py +152 -9
- truthound_dashboard/core/statistics.py +718 -0
- truthound_dashboard/core/streaming_anomaly.py +883 -0
- truthound_dashboard/core/triggers/__init__.py +45 -0
- truthound_dashboard/core/triggers/base.py +226 -0
- truthound_dashboard/core/triggers/evaluators.py +609 -0
- truthound_dashboard/core/triggers/factory.py +363 -0
- truthound_dashboard/core/unified_alerts.py +870 -0
- truthound_dashboard/core/validation_limits.py +509 -0
- truthound_dashboard/core/versioning.py +709 -0
- truthound_dashboard/core/websocket/__init__.py +59 -0
- truthound_dashboard/core/websocket/manager.py +512 -0
- truthound_dashboard/core/websocket/messages.py +130 -0
- truthound_dashboard/db/__init__.py +30 -0
- truthound_dashboard/db/models.py +3375 -3
- truthound_dashboard/main.py +22 -0
- truthound_dashboard/schemas/__init__.py +396 -1
- truthound_dashboard/schemas/anomaly.py +1258 -0
- truthound_dashboard/schemas/base.py +4 -0
- truthound_dashboard/schemas/cross_alerts.py +334 -0
- truthound_dashboard/schemas/drift_monitor.py +890 -0
- truthound_dashboard/schemas/lineage.py +428 -0
- truthound_dashboard/schemas/maintenance.py +154 -0
- truthound_dashboard/schemas/model_monitoring.py +374 -0
- truthound_dashboard/schemas/notifications_advanced.py +1363 -0
- truthound_dashboard/schemas/openlineage.py +704 -0
- truthound_dashboard/schemas/plugins.py +1293 -0
- truthound_dashboard/schemas/profile.py +420 -34
- truthound_dashboard/schemas/profile_comparison.py +242 -0
- truthound_dashboard/schemas/reports.py +285 -0
- truthound_dashboard/schemas/rule_suggestion.py +434 -0
- truthound_dashboard/schemas/schema_evolution.py +164 -0
- truthound_dashboard/schemas/source.py +117 -2
- truthound_dashboard/schemas/triggers.py +511 -0
- truthound_dashboard/schemas/unified_alerts.py +223 -0
- truthound_dashboard/schemas/validation.py +25 -1
- truthound_dashboard/schemas/validators/__init__.py +11 -0
- truthound_dashboard/schemas/validators/base.py +151 -0
- truthound_dashboard/schemas/versioning.py +152 -0
- truthound_dashboard/static/index.html +2 -2
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -22
- truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
- truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
- truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
- truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
- truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Versioning API endpoints.
|
|
2
|
+
|
|
3
|
+
Provides endpoints for validation result version management:
|
|
4
|
+
- List versions for a source
|
|
5
|
+
- Get specific version details
|
|
6
|
+
- Compare two versions
|
|
7
|
+
- Get version history chain
|
|
8
|
+
- Create new versions
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Annotated
|
|
15
|
+
|
|
16
|
+
from fastapi import APIRouter, HTTPException, Path, Query
|
|
17
|
+
|
|
18
|
+
from truthound_dashboard.core.versioning import (
|
|
19
|
+
VersioningStrategy,
|
|
20
|
+
get_version_manager,
|
|
21
|
+
)
|
|
22
|
+
from truthound_dashboard.schemas.versioning import (
|
|
23
|
+
CreateVersionRequest,
|
|
24
|
+
CreateVersionResponse,
|
|
25
|
+
RollbackAvailabilityResponse,
|
|
26
|
+
RollbackRequest,
|
|
27
|
+
RollbackResponse,
|
|
28
|
+
VersionCompareRequest,
|
|
29
|
+
VersionDiffResponse,
|
|
30
|
+
VersionHistoryResponse,
|
|
31
|
+
VersionInfoResponse,
|
|
32
|
+
VersionListResponse,
|
|
33
|
+
)
|
|
34
|
+
from truthound_dashboard.api.deps import ValidationServiceDep
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
router = APIRouter(prefix="/versions", tags=["versioning"])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _version_info_to_response(version_info) -> VersionInfoResponse:
|
|
42
|
+
"""Convert VersionInfo dataclass to response model."""
|
|
43
|
+
return VersionInfoResponse(
|
|
44
|
+
version_id=version_info.version_id,
|
|
45
|
+
version_number=version_info.version_number,
|
|
46
|
+
validation_id=version_info.validation_id,
|
|
47
|
+
source_id=version_info.source_id,
|
|
48
|
+
strategy=version_info.strategy.value,
|
|
49
|
+
created_at=version_info.created_at,
|
|
50
|
+
parent_version_id=version_info.parent_version_id,
|
|
51
|
+
metadata=version_info.metadata,
|
|
52
|
+
content_hash=version_info.content_hash,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.get(
|
|
57
|
+
"/sources/{source_id}",
|
|
58
|
+
response_model=VersionListResponse,
|
|
59
|
+
summary="List versions for a source",
|
|
60
|
+
description="Get all validation result versions for a data source, ordered by creation time (newest first).",
|
|
61
|
+
)
|
|
62
|
+
async def list_source_versions(
|
|
63
|
+
source_id: Annotated[str, Path(description="Source ID")],
|
|
64
|
+
limit: Annotated[int, Query(ge=1, le=100, description="Maximum versions to return")] = 20,
|
|
65
|
+
) -> VersionListResponse:
|
|
66
|
+
"""List all versions for a source."""
|
|
67
|
+
manager = get_version_manager()
|
|
68
|
+
versions = await manager.list_versions(source_id=source_id, limit=limit)
|
|
69
|
+
|
|
70
|
+
return VersionListResponse(
|
|
71
|
+
success=True,
|
|
72
|
+
data=[_version_info_to_response(v) for v in versions],
|
|
73
|
+
total=len(versions),
|
|
74
|
+
source_id=source_id,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.get(
|
|
79
|
+
"/{version_id}",
|
|
80
|
+
response_model=VersionInfoResponse,
|
|
81
|
+
summary="Get version details",
|
|
82
|
+
description="Get detailed information about a specific version.",
|
|
83
|
+
)
|
|
84
|
+
async def get_version(
|
|
85
|
+
version_id: Annotated[str, Path(description="Version ID")],
|
|
86
|
+
) -> VersionInfoResponse:
|
|
87
|
+
"""Get a specific version by ID."""
|
|
88
|
+
manager = get_version_manager()
|
|
89
|
+
version = await manager.get_version(version_id)
|
|
90
|
+
|
|
91
|
+
if not version:
|
|
92
|
+
raise HTTPException(status_code=404, detail=f"Version not found: {version_id}")
|
|
93
|
+
|
|
94
|
+
return _version_info_to_response(version)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.get(
|
|
98
|
+
"/sources/{source_id}/latest",
|
|
99
|
+
response_model=VersionInfoResponse,
|
|
100
|
+
summary="Get latest version",
|
|
101
|
+
description="Get the most recent version for a data source.",
|
|
102
|
+
)
|
|
103
|
+
async def get_latest_version(
|
|
104
|
+
source_id: Annotated[str, Path(description="Source ID")],
|
|
105
|
+
) -> VersionInfoResponse:
|
|
106
|
+
"""Get the latest version for a source."""
|
|
107
|
+
manager = get_version_manager()
|
|
108
|
+
version = await manager.get_latest_version(source_id)
|
|
109
|
+
|
|
110
|
+
if not version:
|
|
111
|
+
raise HTTPException(
|
|
112
|
+
status_code=404,
|
|
113
|
+
detail=f"No versions found for source: {source_id}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return _version_info_to_response(version)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@router.post(
|
|
120
|
+
"/compare",
|
|
121
|
+
response_model=VersionDiffResponse,
|
|
122
|
+
summary="Compare two versions",
|
|
123
|
+
description="Compare two validation result versions and get detailed differences.",
|
|
124
|
+
)
|
|
125
|
+
async def compare_versions(
|
|
126
|
+
request: VersionCompareRequest,
|
|
127
|
+
validation_service: ValidationServiceDep,
|
|
128
|
+
) -> VersionDiffResponse:
|
|
129
|
+
"""Compare two versions and return differences."""
|
|
130
|
+
manager = get_version_manager()
|
|
131
|
+
|
|
132
|
+
# Get versions first to check they exist
|
|
133
|
+
from_version = await manager.get_version(request.from_version_id)
|
|
134
|
+
to_version = await manager.get_version(request.to_version_id)
|
|
135
|
+
|
|
136
|
+
if not from_version:
|
|
137
|
+
raise HTTPException(
|
|
138
|
+
status_code=404,
|
|
139
|
+
detail=f"From version not found: {request.from_version_id}"
|
|
140
|
+
)
|
|
141
|
+
if not to_version:
|
|
142
|
+
raise HTTPException(
|
|
143
|
+
status_code=404,
|
|
144
|
+
detail=f"To version not found: {request.to_version_id}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Try to get validation results for comparison
|
|
148
|
+
from_result = None
|
|
149
|
+
to_result = None
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
from_validation = await validation_service.get_validation(from_version.validation_id)
|
|
153
|
+
if from_validation and from_validation.result_json:
|
|
154
|
+
from_result = from_validation.result_json
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
to_validation = await validation_service.get_validation(to_version.validation_id)
|
|
160
|
+
if to_validation and to_validation.result_json:
|
|
161
|
+
to_result = to_validation.result_json
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
# Compare versions
|
|
166
|
+
diff = await manager.compare_versions(
|
|
167
|
+
from_version_id=request.from_version_id,
|
|
168
|
+
to_version_id=request.to_version_id,
|
|
169
|
+
from_result=from_result,
|
|
170
|
+
to_result=to_result,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return VersionDiffResponse(
|
|
174
|
+
from_version=_version_info_to_response(diff.from_version),
|
|
175
|
+
to_version=_version_info_to_response(diff.to_version),
|
|
176
|
+
issues_added=diff.issues_added,
|
|
177
|
+
issues_removed=diff.issues_removed,
|
|
178
|
+
issues_changed=diff.issues_changed,
|
|
179
|
+
summary_changes=diff.summary_changes,
|
|
180
|
+
has_changes=diff.has_changes,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@router.get(
|
|
185
|
+
"/{version_id}/history",
|
|
186
|
+
response_model=VersionHistoryResponse,
|
|
187
|
+
summary="Get version history",
|
|
188
|
+
description="Get the version history chain starting from a specific version.",
|
|
189
|
+
)
|
|
190
|
+
async def get_version_history(
|
|
191
|
+
version_id: Annotated[str, Path(description="Starting version ID")],
|
|
192
|
+
depth: Annotated[int, Query(ge=1, le=50, description="Maximum history depth")] = 10,
|
|
193
|
+
) -> VersionHistoryResponse:
|
|
194
|
+
"""Get version history chain."""
|
|
195
|
+
manager = get_version_manager()
|
|
196
|
+
|
|
197
|
+
# Check version exists
|
|
198
|
+
version = await manager.get_version(version_id)
|
|
199
|
+
if not version:
|
|
200
|
+
raise HTTPException(status_code=404, detail=f"Version not found: {version_id}")
|
|
201
|
+
|
|
202
|
+
history = await manager.get_version_history(version_id=version_id, depth=depth)
|
|
203
|
+
|
|
204
|
+
return VersionHistoryResponse(
|
|
205
|
+
success=True,
|
|
206
|
+
data=[_version_info_to_response(v) for v in history],
|
|
207
|
+
depth=len(history),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@router.post(
|
|
212
|
+
"/",
|
|
213
|
+
response_model=CreateVersionResponse,
|
|
214
|
+
summary="Create a version",
|
|
215
|
+
description="Create a new version for a validation result.",
|
|
216
|
+
)
|
|
217
|
+
async def create_version(
|
|
218
|
+
request: CreateVersionRequest,
|
|
219
|
+
validation_service: ValidationServiceDep,
|
|
220
|
+
) -> CreateVersionResponse:
|
|
221
|
+
"""Create a new version for a validation."""
|
|
222
|
+
# Get validation to verify it exists
|
|
223
|
+
validation = await validation_service.get_validation(request.validation_id)
|
|
224
|
+
if not validation:
|
|
225
|
+
raise HTTPException(
|
|
226
|
+
status_code=404,
|
|
227
|
+
detail=f"Validation not found: {request.validation_id}"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
manager = get_version_manager()
|
|
231
|
+
|
|
232
|
+
# Parse strategy
|
|
233
|
+
strategy = None
|
|
234
|
+
if request.strategy:
|
|
235
|
+
strategy = VersioningStrategy(request.strategy)
|
|
236
|
+
|
|
237
|
+
# Create version
|
|
238
|
+
version = await manager.create_version(
|
|
239
|
+
validation_id=request.validation_id,
|
|
240
|
+
source_id=validation.source_id,
|
|
241
|
+
result_json=validation.result_json,
|
|
242
|
+
strategy=strategy,
|
|
243
|
+
metadata=request.metadata,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return CreateVersionResponse(
|
|
247
|
+
success=True,
|
|
248
|
+
data=_version_info_to_response(version),
|
|
249
|
+
message=f"Created version {version.version_number} for validation {request.validation_id}",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@router.get(
|
|
254
|
+
"/sources/{source_id}/rollback-availability",
|
|
255
|
+
response_model=RollbackAvailabilityResponse,
|
|
256
|
+
summary="Check rollback availability",
|
|
257
|
+
description="Check if rollback is available for a source and list available targets.",
|
|
258
|
+
)
|
|
259
|
+
async def check_rollback_availability(
|
|
260
|
+
source_id: Annotated[str, Path(description="Source ID")],
|
|
261
|
+
) -> RollbackAvailabilityResponse:
|
|
262
|
+
"""Check if rollback is available for a source."""
|
|
263
|
+
manager = get_version_manager()
|
|
264
|
+
availability = await manager.can_rollback(source_id)
|
|
265
|
+
|
|
266
|
+
return RollbackAvailabilityResponse(
|
|
267
|
+
success=True,
|
|
268
|
+
can_rollback=availability["can_rollback"],
|
|
269
|
+
current_version_id=availability["current_version_id"],
|
|
270
|
+
available_versions=availability["available_versions"],
|
|
271
|
+
rollback_targets=availability["rollback_targets"],
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@router.post(
|
|
276
|
+
"/sources/{source_id}/rollback",
|
|
277
|
+
response_model=RollbackResponse,
|
|
278
|
+
summary="Rollback to a previous version",
|
|
279
|
+
description="Rollback a source to a previous validation result version.",
|
|
280
|
+
)
|
|
281
|
+
async def rollback_to_version(
|
|
282
|
+
source_id: Annotated[str, Path(description="Source ID")],
|
|
283
|
+
request: RollbackRequest,
|
|
284
|
+
) -> RollbackResponse:
|
|
285
|
+
"""Rollback to a previous version."""
|
|
286
|
+
manager = get_version_manager()
|
|
287
|
+
|
|
288
|
+
# Perform rollback
|
|
289
|
+
result = await manager.rollback_to_version(
|
|
290
|
+
source_id=source_id,
|
|
291
|
+
target_version_id=request.target_version_id,
|
|
292
|
+
create_new_validation=request.create_new_validation,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if not result.success:
|
|
296
|
+
raise HTTPException(
|
|
297
|
+
status_code=400,
|
|
298
|
+
detail=result.message,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
return RollbackResponse(
|
|
302
|
+
success=result.success,
|
|
303
|
+
source_id=result.source_id,
|
|
304
|
+
from_version=_version_info_to_response(result.from_version) if result.from_version else None,
|
|
305
|
+
to_version=_version_info_to_response(result.to_version) if result.to_version else None,
|
|
306
|
+
new_validation_id=result.new_validation_id,
|
|
307
|
+
message=result.message,
|
|
308
|
+
rolled_back_at=result.rolled_back_at,
|
|
309
|
+
)
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""WebSocket API endpoints.
|
|
2
|
+
|
|
3
|
+
This module provides WebSocket endpoints for real-time updates.
|
|
4
|
+
|
|
5
|
+
Endpoints:
|
|
6
|
+
WebSocket /ws/notifications/incidents - Real-time escalation incident updates
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
|
|
17
|
+
|
|
18
|
+
from ..core.websocket import (
|
|
19
|
+
WebSocketManager,
|
|
20
|
+
WebSocketMessage,
|
|
21
|
+
WebSocketMessageType,
|
|
22
|
+
get_websocket_manager,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
router = APIRouter()
|
|
28
|
+
|
|
29
|
+
# Room name for incident updates
|
|
30
|
+
INCIDENTS_ROOM = "incidents"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.websocket("/ws/notifications/incidents")
|
|
34
|
+
async def websocket_incidents(
|
|
35
|
+
websocket: WebSocket,
|
|
36
|
+
token: str | None = Query(default=None, description="Optional authentication token"),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""WebSocket endpoint for real-time escalation incident updates.
|
|
39
|
+
|
|
40
|
+
This endpoint allows clients to receive real-time updates about escalation
|
|
41
|
+
incidents, including creation, state changes, and resolution.
|
|
42
|
+
|
|
43
|
+
Query Parameters:
|
|
44
|
+
token: Optional authentication token for secure connections.
|
|
45
|
+
|
|
46
|
+
Message Types Received:
|
|
47
|
+
- ping: Client heartbeat, server responds with pong
|
|
48
|
+
- pong: Response to server ping
|
|
49
|
+
|
|
50
|
+
Message Types Sent:
|
|
51
|
+
- connected: Sent when connection is established
|
|
52
|
+
- ping: Server heartbeat
|
|
53
|
+
- pong: Response to client ping
|
|
54
|
+
- incident_created: New incident created
|
|
55
|
+
- incident_updated: Incident updated
|
|
56
|
+
- incident_state_changed: Incident state changed
|
|
57
|
+
- incident_acknowledged: Incident acknowledged
|
|
58
|
+
- incident_escalated: Incident escalated to next level
|
|
59
|
+
- incident_resolved: Incident resolved
|
|
60
|
+
|
|
61
|
+
Example Client Connection (JavaScript):
|
|
62
|
+
const ws = new WebSocket('ws://localhost:8765/api/v1/ws/notifications/incidents');
|
|
63
|
+
|
|
64
|
+
ws.onmessage = (event) => {
|
|
65
|
+
const message = JSON.parse(event.data);
|
|
66
|
+
switch (message.type) {
|
|
67
|
+
case 'incident_created':
|
|
68
|
+
handleNewIncident(message.data);
|
|
69
|
+
break;
|
|
70
|
+
case 'incident_state_changed':
|
|
71
|
+
handleStateChange(message.data);
|
|
72
|
+
break;
|
|
73
|
+
// ... handle other message types
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Send ping to keep connection alive
|
|
78
|
+
setInterval(() => {
|
|
79
|
+
ws.send(JSON.stringify({ type: 'ping' }));
|
|
80
|
+
}, 30000);
|
|
81
|
+
"""
|
|
82
|
+
manager = get_websocket_manager()
|
|
83
|
+
connection_id = str(uuid.uuid4())
|
|
84
|
+
|
|
85
|
+
# Optional: Validate token if provided
|
|
86
|
+
# In production, implement proper authentication
|
|
87
|
+
if token:
|
|
88
|
+
logger.debug(f"WebSocket connection with token: {token[:8]}...")
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Accept connection and register
|
|
92
|
+
connection = await manager.connect(
|
|
93
|
+
websocket=websocket,
|
|
94
|
+
connection_id=connection_id,
|
|
95
|
+
token=token,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Join the incidents room for broadcast updates
|
|
99
|
+
await manager.join_room(connection, INCIDENTS_ROOM)
|
|
100
|
+
|
|
101
|
+
logger.info(
|
|
102
|
+
f"Client connected to incidents WebSocket: {connection_id}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Handle incoming messages
|
|
106
|
+
while True:
|
|
107
|
+
try:
|
|
108
|
+
data = await websocket.receive_json()
|
|
109
|
+
await manager.handle_client_message(connection, data)
|
|
110
|
+
except WebSocketDisconnect:
|
|
111
|
+
break
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.warning(
|
|
114
|
+
f"Error receiving message from {connection_id}: {e}"
|
|
115
|
+
)
|
|
116
|
+
# Send error message
|
|
117
|
+
await connection.send_message(
|
|
118
|
+
WebSocketMessage(
|
|
119
|
+
type=WebSocketMessageType.ERROR,
|
|
120
|
+
data={"error": str(e)},
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
except WebSocketDisconnect:
|
|
125
|
+
logger.info(f"Client disconnected from incidents WebSocket: {connection_id}")
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.error(f"WebSocket error for {connection_id}: {e}")
|
|
128
|
+
finally:
|
|
129
|
+
await manager.disconnect(connection_id, reason="Connection closed")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def broadcast_incident_event(
|
|
133
|
+
event_type: WebSocketMessageType,
|
|
134
|
+
incident_data: dict[str, Any],
|
|
135
|
+
) -> int:
|
|
136
|
+
"""Broadcast an incident event to all connected clients.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
event_type: Type of the event.
|
|
140
|
+
incident_data: Incident data to broadcast.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Number of clients that received the message.
|
|
144
|
+
"""
|
|
145
|
+
manager = get_websocket_manager()
|
|
146
|
+
|
|
147
|
+
message = WebSocketMessage(
|
|
148
|
+
type=event_type,
|
|
149
|
+
data=incident_data,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return await manager.broadcast_to_room(INCIDENTS_ROOM, message)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def notify_incident_created(
|
|
156
|
+
incident_id: str,
|
|
157
|
+
incident_ref: str,
|
|
158
|
+
policy_id: str,
|
|
159
|
+
state: str,
|
|
160
|
+
current_level: int,
|
|
161
|
+
context: dict[str, Any] | None = None,
|
|
162
|
+
) -> int:
|
|
163
|
+
"""Notify clients about a new incident.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
incident_id: ID of the new incident.
|
|
167
|
+
incident_ref: External reference.
|
|
168
|
+
policy_id: Associated policy ID.
|
|
169
|
+
state: Initial state.
|
|
170
|
+
current_level: Initial escalation level.
|
|
171
|
+
context: Optional incident context.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Number of clients notified.
|
|
175
|
+
"""
|
|
176
|
+
return await broadcast_incident_event(
|
|
177
|
+
WebSocketMessageType.INCIDENT_CREATED,
|
|
178
|
+
{
|
|
179
|
+
"incident_id": incident_id,
|
|
180
|
+
"incident_ref": incident_ref,
|
|
181
|
+
"policy_id": policy_id,
|
|
182
|
+
"state": state,
|
|
183
|
+
"current_level": current_level,
|
|
184
|
+
"context": context or {},
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def notify_incident_state_changed(
|
|
190
|
+
incident_id: str,
|
|
191
|
+
incident_ref: str,
|
|
192
|
+
policy_id: str,
|
|
193
|
+
from_state: str,
|
|
194
|
+
to_state: str,
|
|
195
|
+
current_level: int,
|
|
196
|
+
actor: str | None = None,
|
|
197
|
+
message: str | None = None,
|
|
198
|
+
) -> int:
|
|
199
|
+
"""Notify clients about an incident state change.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
incident_id: ID of the incident.
|
|
203
|
+
incident_ref: External reference.
|
|
204
|
+
policy_id: Associated policy ID.
|
|
205
|
+
from_state: Previous state.
|
|
206
|
+
to_state: New state.
|
|
207
|
+
current_level: Current escalation level.
|
|
208
|
+
actor: Who triggered the change.
|
|
209
|
+
message: Optional message.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Number of clients notified.
|
|
213
|
+
"""
|
|
214
|
+
# Determine specific event type based on new state
|
|
215
|
+
if to_state == "acknowledged":
|
|
216
|
+
event_type = WebSocketMessageType.INCIDENT_ACKNOWLEDGED
|
|
217
|
+
elif to_state == "resolved":
|
|
218
|
+
event_type = WebSocketMessageType.INCIDENT_RESOLVED
|
|
219
|
+
elif to_state == "escalated":
|
|
220
|
+
event_type = WebSocketMessageType.INCIDENT_ESCALATED
|
|
221
|
+
else:
|
|
222
|
+
event_type = WebSocketMessageType.INCIDENT_STATE_CHANGED
|
|
223
|
+
|
|
224
|
+
return await broadcast_incident_event(
|
|
225
|
+
event_type,
|
|
226
|
+
{
|
|
227
|
+
"incident_id": incident_id,
|
|
228
|
+
"incident_ref": incident_ref,
|
|
229
|
+
"policy_id": policy_id,
|
|
230
|
+
"from_state": from_state,
|
|
231
|
+
"to_state": to_state,
|
|
232
|
+
"current_level": current_level,
|
|
233
|
+
"actor": actor,
|
|
234
|
+
"message": message,
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def notify_incident_updated(
|
|
240
|
+
incident_id: str,
|
|
241
|
+
incident_ref: str,
|
|
242
|
+
policy_id: str,
|
|
243
|
+
state: str,
|
|
244
|
+
current_level: int,
|
|
245
|
+
changes: dict[str, Any] | None = None,
|
|
246
|
+
) -> int:
|
|
247
|
+
"""Notify clients about an incident update.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
incident_id: ID of the incident.
|
|
251
|
+
incident_ref: External reference.
|
|
252
|
+
policy_id: Associated policy ID.
|
|
253
|
+
state: Current state.
|
|
254
|
+
current_level: Current escalation level.
|
|
255
|
+
changes: Optional dictionary of changed fields.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
Number of clients notified.
|
|
259
|
+
"""
|
|
260
|
+
return await broadcast_incident_event(
|
|
261
|
+
WebSocketMessageType.INCIDENT_UPDATED,
|
|
262
|
+
{
|
|
263
|
+
"incident_id": incident_id,
|
|
264
|
+
"incident_ref": incident_ref,
|
|
265
|
+
"policy_id": policy_id,
|
|
266
|
+
"state": state,
|
|
267
|
+
"current_level": current_level,
|
|
268
|
+
"changes": changes or {},
|
|
269
|
+
},
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
async def notify_incident_resolved(
|
|
274
|
+
incident_id: str,
|
|
275
|
+
incident_ref: str,
|
|
276
|
+
policy_id: str,
|
|
277
|
+
resolved_by: str | None = None,
|
|
278
|
+
message: str | None = None,
|
|
279
|
+
) -> int:
|
|
280
|
+
"""Notify clients about an incident resolution.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
incident_id: ID of the incident.
|
|
284
|
+
incident_ref: External reference.
|
|
285
|
+
policy_id: Associated policy ID.
|
|
286
|
+
resolved_by: Who resolved it.
|
|
287
|
+
message: Resolution message.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Number of clients notified.
|
|
291
|
+
"""
|
|
292
|
+
return await broadcast_incident_event(
|
|
293
|
+
WebSocketMessageType.INCIDENT_RESOLVED,
|
|
294
|
+
{
|
|
295
|
+
"incident_id": incident_id,
|
|
296
|
+
"incident_ref": incident_ref,
|
|
297
|
+
"policy_id": policy_id,
|
|
298
|
+
"resolved_by": resolved_by,
|
|
299
|
+
"message": message,
|
|
300
|
+
},
|
|
301
|
+
)
|
|
@@ -117,6 +117,20 @@ from .sampling import (
|
|
|
117
117
|
get_sampler,
|
|
118
118
|
reset_sampler,
|
|
119
119
|
)
|
|
120
|
+
from .validation_limits import (
|
|
121
|
+
DeduplicationLimits,
|
|
122
|
+
EscalationLimits,
|
|
123
|
+
ThrottlingLimits,
|
|
124
|
+
TimeWindowLimits,
|
|
125
|
+
ValidationLimitError,
|
|
126
|
+
clear_limits_cache,
|
|
127
|
+
get_deduplication_limits,
|
|
128
|
+
get_escalation_limits,
|
|
129
|
+
get_throttling_limits,
|
|
130
|
+
get_time_window_limits,
|
|
131
|
+
validate_positive_float,
|
|
132
|
+
validate_positive_int,
|
|
133
|
+
)
|
|
120
134
|
from .scheduler import (
|
|
121
135
|
ValidationScheduler,
|
|
122
136
|
get_scheduler,
|
|
@@ -281,4 +295,17 @@ __all__ = [
|
|
|
281
295
|
"CatalogService",
|
|
282
296
|
"CollaborationService",
|
|
283
297
|
"ActivityLogger",
|
|
298
|
+
# Validation Limits (DoS Prevention)
|
|
299
|
+
"ValidationLimitError",
|
|
300
|
+
"DeduplicationLimits",
|
|
301
|
+
"ThrottlingLimits",
|
|
302
|
+
"EscalationLimits",
|
|
303
|
+
"TimeWindowLimits",
|
|
304
|
+
"get_deduplication_limits",
|
|
305
|
+
"get_throttling_limits",
|
|
306
|
+
"get_escalation_limits",
|
|
307
|
+
"get_time_window_limits",
|
|
308
|
+
"clear_limits_cache",
|
|
309
|
+
"validate_positive_int",
|
|
310
|
+
"validate_positive_float",
|
|
284
311
|
]
|