truthound-dashboard 1.4.3__py3-none-any.whl → 1.5.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 +75 -86
- truthound_dashboard/api/anomaly.py +7 -13
- truthound_dashboard/api/cross_alerts.py +38 -52
- truthound_dashboard/api/drift.py +49 -59
- truthound_dashboard/api/drift_monitor.py +234 -79
- truthound_dashboard/api/enterprise_sampling.py +498 -0
- truthound_dashboard/api/history.py +57 -5
- truthound_dashboard/api/lineage.py +3 -48
- truthound_dashboard/api/maintenance.py +104 -49
- truthound_dashboard/api/mask.py +1 -2
- truthound_dashboard/api/middleware.py +2 -1
- truthound_dashboard/api/model_monitoring.py +435 -311
- truthound_dashboard/api/notifications.py +227 -191
- truthound_dashboard/api/notifications_advanced.py +21 -20
- truthound_dashboard/api/observability.py +586 -0
- truthound_dashboard/api/plugins.py +2 -433
- truthound_dashboard/api/profile.py +199 -37
- truthound_dashboard/api/quality_reporter.py +701 -0
- truthound_dashboard/api/reports.py +7 -16
- truthound_dashboard/api/router.py +66 -0
- truthound_dashboard/api/rule_suggestions.py +5 -5
- truthound_dashboard/api/scan.py +17 -19
- truthound_dashboard/api/schedules.py +85 -50
- truthound_dashboard/api/schema_evolution.py +6 -6
- truthound_dashboard/api/schema_watcher.py +667 -0
- truthound_dashboard/api/sources.py +98 -27
- truthound_dashboard/api/tiering.py +1323 -0
- truthound_dashboard/api/triggers.py +14 -11
- truthound_dashboard/api/validations.py +12 -11
- truthound_dashboard/api/versioning.py +1 -6
- truthound_dashboard/core/__init__.py +129 -3
- truthound_dashboard/core/actions/__init__.py +62 -0
- truthound_dashboard/core/actions/custom.py +426 -0
- truthound_dashboard/core/actions/notifications.py +910 -0
- truthound_dashboard/core/actions/storage.py +472 -0
- truthound_dashboard/core/actions/webhook.py +281 -0
- truthound_dashboard/core/anomaly.py +262 -67
- truthound_dashboard/core/anomaly_explainer.py +4 -3
- truthound_dashboard/core/backends/__init__.py +67 -0
- truthound_dashboard/core/backends/base.py +299 -0
- truthound_dashboard/core/backends/errors.py +191 -0
- truthound_dashboard/core/backends/factory.py +423 -0
- truthound_dashboard/core/backends/mock_backend.py +451 -0
- truthound_dashboard/core/backends/truthound_backend.py +718 -0
- truthound_dashboard/core/checkpoint/__init__.py +87 -0
- truthound_dashboard/core/checkpoint/adapters.py +814 -0
- truthound_dashboard/core/checkpoint/checkpoint.py +491 -0
- truthound_dashboard/core/checkpoint/runner.py +270 -0
- truthound_dashboard/core/connections.py +437 -10
- truthound_dashboard/core/converters/__init__.py +14 -0
- truthound_dashboard/core/converters/truthound.py +620 -0
- truthound_dashboard/core/cross_alerts.py +540 -320
- truthound_dashboard/core/datasource_factory.py +1672 -0
- truthound_dashboard/core/drift_monitor.py +216 -20
- truthound_dashboard/core/enterprise_sampling.py +1291 -0
- truthound_dashboard/core/interfaces/__init__.py +225 -0
- truthound_dashboard/core/interfaces/actions.py +652 -0
- truthound_dashboard/core/interfaces/base.py +247 -0
- truthound_dashboard/core/interfaces/checkpoint.py +676 -0
- truthound_dashboard/core/interfaces/protocols.py +664 -0
- truthound_dashboard/core/interfaces/reporters.py +650 -0
- truthound_dashboard/core/interfaces/routing.py +646 -0
- truthound_dashboard/core/interfaces/triggers.py +619 -0
- truthound_dashboard/core/lineage.py +407 -71
- truthound_dashboard/core/model_monitoring.py +431 -3
- truthound_dashboard/core/notifications/base.py +4 -0
- truthound_dashboard/core/notifications/channels.py +501 -1203
- truthound_dashboard/core/notifications/deduplication/__init__.py +81 -115
- truthound_dashboard/core/notifications/deduplication/service.py +131 -348
- truthound_dashboard/core/notifications/dispatcher.py +202 -11
- truthound_dashboard/core/notifications/escalation/__init__.py +119 -106
- truthound_dashboard/core/notifications/escalation/engine.py +168 -358
- truthound_dashboard/core/notifications/routing/__init__.py +88 -128
- truthound_dashboard/core/notifications/routing/engine.py +90 -317
- truthound_dashboard/core/notifications/stats_aggregator.py +246 -1
- truthound_dashboard/core/notifications/throttling/__init__.py +67 -50
- truthound_dashboard/core/notifications/throttling/builder.py +117 -255
- truthound_dashboard/core/notifications/truthound_adapter.py +842 -0
- truthound_dashboard/core/phase5/collaboration.py +1 -1
- truthound_dashboard/core/plugins/lifecycle/__init__.py +0 -13
- truthound_dashboard/core/quality_reporter.py +1359 -0
- truthound_dashboard/core/report_history.py +0 -6
- truthound_dashboard/core/reporters/__init__.py +175 -14
- truthound_dashboard/core/reporters/adapters.py +943 -0
- truthound_dashboard/core/reporters/base.py +0 -3
- truthound_dashboard/core/reporters/builtin/__init__.py +18 -0
- truthound_dashboard/core/reporters/builtin/csv_reporter.py +111 -0
- truthound_dashboard/core/reporters/builtin/html_reporter.py +270 -0
- truthound_dashboard/core/reporters/builtin/json_reporter.py +127 -0
- truthound_dashboard/core/reporters/compat.py +266 -0
- truthound_dashboard/core/reporters/csv_reporter.py +2 -35
- truthound_dashboard/core/reporters/factory.py +526 -0
- truthound_dashboard/core/reporters/interfaces.py +745 -0
- truthound_dashboard/core/reporters/registry.py +1 -10
- truthound_dashboard/core/scheduler.py +165 -0
- truthound_dashboard/core/schema_evolution.py +3 -3
- truthound_dashboard/core/schema_watcher.py +1528 -0
- truthound_dashboard/core/services.py +595 -76
- truthound_dashboard/core/store_manager.py +810 -0
- truthound_dashboard/core/streaming_anomaly.py +169 -4
- truthound_dashboard/core/tiering.py +1309 -0
- truthound_dashboard/core/triggers/evaluators.py +178 -8
- truthound_dashboard/core/truthound_adapter.py +2620 -197
- truthound_dashboard/core/unified_alerts.py +23 -20
- truthound_dashboard/db/__init__.py +8 -0
- truthound_dashboard/db/database.py +8 -2
- truthound_dashboard/db/models.py +944 -25
- truthound_dashboard/db/repository.py +2 -0
- truthound_dashboard/main.py +11 -0
- truthound_dashboard/schemas/__init__.py +177 -16
- truthound_dashboard/schemas/base.py +44 -23
- truthound_dashboard/schemas/collaboration.py +19 -6
- truthound_dashboard/schemas/cross_alerts.py +19 -3
- truthound_dashboard/schemas/drift.py +61 -55
- truthound_dashboard/schemas/drift_monitor.py +67 -23
- truthound_dashboard/schemas/enterprise_sampling.py +653 -0
- truthound_dashboard/schemas/lineage.py +0 -33
- truthound_dashboard/schemas/mask.py +10 -8
- truthound_dashboard/schemas/model_monitoring.py +89 -10
- truthound_dashboard/schemas/notifications_advanced.py +13 -0
- truthound_dashboard/schemas/observability.py +453 -0
- truthound_dashboard/schemas/plugins.py +0 -280
- truthound_dashboard/schemas/profile.py +154 -247
- truthound_dashboard/schemas/quality_reporter.py +403 -0
- truthound_dashboard/schemas/reports.py +2 -2
- truthound_dashboard/schemas/rule_suggestion.py +8 -1
- truthound_dashboard/schemas/scan.py +4 -24
- truthound_dashboard/schemas/schedule.py +11 -3
- truthound_dashboard/schemas/schema_watcher.py +727 -0
- truthound_dashboard/schemas/source.py +17 -2
- truthound_dashboard/schemas/tiering.py +822 -0
- truthound_dashboard/schemas/triggers.py +16 -0
- truthound_dashboard/schemas/unified_alerts.py +7 -0
- truthound_dashboard/schemas/validation.py +0 -13
- truthound_dashboard/schemas/validators/base.py +41 -21
- truthound_dashboard/schemas/validators/business_rule_validators.py +244 -0
- truthound_dashboard/schemas/validators/localization_validators.py +273 -0
- truthound_dashboard/schemas/validators/ml_feature_validators.py +308 -0
- truthound_dashboard/schemas/validators/profiling_validators.py +275 -0
- truthound_dashboard/schemas/validators/referential_validators.py +312 -0
- truthound_dashboard/schemas/validators/registry.py +93 -8
- truthound_dashboard/schemas/validators/timeseries_validators.py +389 -0
- truthound_dashboard/schemas/versioning.py +1 -6
- truthound_dashboard/static/index.html +2 -2
- truthound_dashboard-1.5.0.dist-info/METADATA +309 -0
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/RECORD +149 -148
- truthound_dashboard/core/plugins/hooks/__init__.py +0 -63
- truthound_dashboard/core/plugins/hooks/decorators.py +0 -367
- truthound_dashboard/core/plugins/hooks/manager.py +0 -403
- truthound_dashboard/core/plugins/hooks/protocols.py +0 -265
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +0 -584
- truthound_dashboard/core/reporters/junit_reporter.py +0 -233
- truthound_dashboard/core/reporters/markdown_reporter.py +0 -207
- truthound_dashboard/core/reporters/pdf_reporter.py +0 -209
- truthound_dashboard/static/assets/_baseUniq-BcrSP13d.js +0 -1
- truthound_dashboard/static/assets/arc-DlYjKwIL.js +0 -1
- truthound_dashboard/static/assets/architectureDiagram-VXUJARFQ-Bb2drbQM.js +0 -36
- truthound_dashboard/static/assets/blockDiagram-VD42YOAC-BlsPG1CH.js +0 -122
- truthound_dashboard/static/assets/c4Diagram-YG6GDRKO-B9JdUoaC.js +0 -10
- truthound_dashboard/static/assets/channel-Q6mHF1Hd.js +0 -1
- truthound_dashboard/static/assets/chunk-4BX2VUAB-DmyoPVuJ.js +0 -1
- truthound_dashboard/static/assets/chunk-55IACEB6-Bcz6Siv8.js +0 -1
- truthound_dashboard/static/assets/chunk-B4BG7PRW-Br3G5Rum.js +0 -165
- truthound_dashboard/static/assets/chunk-DI55MBZ5-DuM9c23u.js +0 -220
- truthound_dashboard/static/assets/chunk-FMBD7UC4-DNU-5mvT.js +0 -15
- truthound_dashboard/static/assets/chunk-QN33PNHL-Im2yNcmS.js +0 -1
- truthound_dashboard/static/assets/chunk-QZHKN3VN-kZr8XFm1.js +0 -1
- truthound_dashboard/static/assets/chunk-TZMSLE5B-Q__360q_.js +0 -1
- truthound_dashboard/static/assets/classDiagram-2ON5EDUG-vtixxUyK.js +0 -1
- truthound_dashboard/static/assets/classDiagram-v2-WZHVMYZB-vtixxUyK.js +0 -1
- truthound_dashboard/static/assets/clone-BOt2LwD0.js +0 -1
- truthound_dashboard/static/assets/cose-bilkent-S5V4N54A-CBDw6iac.js +0 -1
- truthound_dashboard/static/assets/dagre-6UL2VRFP-XdKqmmY9.js +0 -4
- truthound_dashboard/static/assets/diagram-PSM6KHXK-DAZ8nx9V.js +0 -24
- truthound_dashboard/static/assets/diagram-QEK2KX5R-BRvDTbGD.js +0 -43
- truthound_dashboard/static/assets/diagram-S2PKOQOG-bQcczUkl.js +0 -24
- truthound_dashboard/static/assets/erDiagram-Q2GNP2WA-DPje7VMN.js +0 -60
- truthound_dashboard/static/assets/flowDiagram-NV44I4VS-B7BVtFVS.js +0 -162
- truthound_dashboard/static/assets/ganttDiagram-JELNMOA3-D6WKSS7U.js +0 -267
- truthound_dashboard/static/assets/gitGraphDiagram-NY62KEGX-D3vtVd3y.js +0 -65
- truthound_dashboard/static/assets/graph-BKgNKZVp.js +0 -1
- truthound_dashboard/static/assets/index-C6JSrkHo.css +0 -1
- truthound_dashboard/static/assets/index-DkU82VsU.js +0 -1800
- truthound_dashboard/static/assets/infoDiagram-WHAUD3N6-DnNCT429.js +0 -2
- truthound_dashboard/static/assets/journeyDiagram-XKPGCS4Q-DGiMozqS.js +0 -139
- truthound_dashboard/static/assets/kanban-definition-3W4ZIXB7-BV2gUgli.js +0 -89
- truthound_dashboard/static/assets/katex-Cu_Erd72.js +0 -261
- truthound_dashboard/static/assets/layout-DI2MfQ5G.js +0 -1
- truthound_dashboard/static/assets/min-DYdgXVcT.js +0 -1
- truthound_dashboard/static/assets/mindmap-definition-VGOIOE7T-C7x4ruxz.js +0 -68
- truthound_dashboard/static/assets/pieDiagram-ADFJNKIX-CAJaAB9f.js +0 -30
- truthound_dashboard/static/assets/quadrantDiagram-AYHSOK5B-DeqwDI46.js +0 -7
- truthound_dashboard/static/assets/requirementDiagram-UZGBJVZJ-e3XDpZIM.js +0 -64
- truthound_dashboard/static/assets/sankeyDiagram-TZEHDZUN-CNnAv5Ux.js +0 -10
- truthound_dashboard/static/assets/sequenceDiagram-WL72ISMW-Dsne-Of3.js +0 -145
- truthound_dashboard/static/assets/stateDiagram-FKZM4ZOC-Ee0sQXyb.js +0 -1
- truthound_dashboard/static/assets/stateDiagram-v2-4FDKWEC3-B26KqW_W.js +0 -1
- truthound_dashboard/static/assets/timeline-definition-IT6M3QCI-DZYi2yl3.js +0 -61
- truthound_dashboard/static/assets/treemap-KMMF4GRG-CY3f8In2.js +0 -128
- truthound_dashboard/static/assets/unmerged_dictionaries-Dd7xcPWG.js +0 -1
- truthound_dashboard/static/assets/xychartDiagram-PRI3JC2R-CS7fydZZ.js +0 -7
- truthound_dashboard-1.4.3.dist-info/METADATA +0 -505
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"""Schema Watcher API endpoints.
|
|
2
|
+
|
|
3
|
+
This module provides REST API endpoints for schema watcher management,
|
|
4
|
+
including continuous schema monitoring, alerts, and run history.
|
|
5
|
+
|
|
6
|
+
All schema watcher features use truthound's schema evolution module:
|
|
7
|
+
- SchemaEvolutionDetector for change detection
|
|
8
|
+
- SchemaHistory for version management
|
|
9
|
+
- ColumnRenameDetector for rename detection
|
|
10
|
+
- BreakingChangeAlertManager for alerts
|
|
11
|
+
- ImpactAnalyzer for impact analysis
|
|
12
|
+
|
|
13
|
+
API Design: Direct Response Style
|
|
14
|
+
- Single resources return the resource directly
|
|
15
|
+
- List endpoints return PaginatedResponse with data, total, offset, limit
|
|
16
|
+
- Errors are handled via HTTPException
|
|
17
|
+
- Success is indicated by HTTP status codes (200, 201, 204)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Annotated, Any
|
|
23
|
+
|
|
24
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
25
|
+
|
|
26
|
+
from truthound_dashboard.core.schema_watcher import SchemaWatcherService
|
|
27
|
+
from truthound_dashboard.schemas.schema_watcher import (
|
|
28
|
+
SchemaWatcherCreate,
|
|
29
|
+
SchemaWatcherUpdate,
|
|
30
|
+
SchemaWatcherResponse,
|
|
31
|
+
SchemaWatcherSummary,
|
|
32
|
+
SchemaWatcherStatistics,
|
|
33
|
+
SchemaWatcherStatus,
|
|
34
|
+
SchemaWatcherStatusAction,
|
|
35
|
+
SchemaWatcherCheckNowResponse,
|
|
36
|
+
SchemaWatcherAlertResponse,
|
|
37
|
+
SchemaWatcherAlertSummary,
|
|
38
|
+
SchemaWatcherAlertAcknowledge,
|
|
39
|
+
SchemaWatcherAlertResolve,
|
|
40
|
+
SchemaWatcherAlertStatus,
|
|
41
|
+
SchemaWatcherAlertSeverity,
|
|
42
|
+
SchemaWatcherRunResponse,
|
|
43
|
+
SchemaWatcherRunSummary,
|
|
44
|
+
SchemaWatcherRunStatus,
|
|
45
|
+
SchemaWatcherSchedulerStatus,
|
|
46
|
+
# Schema Detection types
|
|
47
|
+
SchemaDetectionRequest,
|
|
48
|
+
SchemaDetectionResponse,
|
|
49
|
+
RenameDetectionRequest,
|
|
50
|
+
RenameDetectionResponse,
|
|
51
|
+
# Version History types
|
|
52
|
+
SchemaVersionCreate,
|
|
53
|
+
SchemaVersionResponse,
|
|
54
|
+
SchemaVersionSummary,
|
|
55
|
+
SchemaDiffRequest,
|
|
56
|
+
SchemaDiffResponse,
|
|
57
|
+
SchemaRollbackRequest,
|
|
58
|
+
)
|
|
59
|
+
from truthound_dashboard.schemas.base import MessageResponse, PaginatedResponse
|
|
60
|
+
from .deps import SessionDep
|
|
61
|
+
|
|
62
|
+
router = APIRouter(prefix="/schema-watchers", tags=["Schema Watchers"])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# =============================================================================
|
|
66
|
+
# Dependencies
|
|
67
|
+
# =============================================================================
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def get_schema_watcher_service(session: SessionDep) -> SchemaWatcherService:
|
|
71
|
+
"""Get schema watcher service dependency."""
|
|
72
|
+
return SchemaWatcherService(session)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
SchemaWatcherServiceDep = Annotated[
|
|
76
|
+
SchemaWatcherService, Depends(get_schema_watcher_service)
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# =============================================================================
|
|
81
|
+
# Watcher CRUD Endpoints
|
|
82
|
+
# =============================================================================
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@router.post(
|
|
86
|
+
"",
|
|
87
|
+
response_model=SchemaWatcherResponse,
|
|
88
|
+
status_code=201,
|
|
89
|
+
summary="Create schema watcher",
|
|
90
|
+
description="""
|
|
91
|
+
Create a new schema watcher for continuous schema monitoring.
|
|
92
|
+
|
|
93
|
+
Uses truthound's SchemaWatcher with:
|
|
94
|
+
- SchemaEvolutionDetector for change detection
|
|
95
|
+
- SchemaHistory for version management
|
|
96
|
+
- ColumnRenameDetector for rename detection (configurable)
|
|
97
|
+
""",
|
|
98
|
+
)
|
|
99
|
+
async def create_watcher(
|
|
100
|
+
request: SchemaWatcherCreate,
|
|
101
|
+
service: SchemaWatcherServiceDep,
|
|
102
|
+
) -> SchemaWatcherResponse:
|
|
103
|
+
"""Create a new schema watcher."""
|
|
104
|
+
try:
|
|
105
|
+
return await service.create_watcher(request)
|
|
106
|
+
except ValueError as e:
|
|
107
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.get(
|
|
111
|
+
"",
|
|
112
|
+
response_model=PaginatedResponse[SchemaWatcherSummary],
|
|
113
|
+
summary="List schema watchers",
|
|
114
|
+
description="List all schema watchers with optional filtering.",
|
|
115
|
+
)
|
|
116
|
+
async def list_watchers(
|
|
117
|
+
service: SchemaWatcherServiceDep,
|
|
118
|
+
status: SchemaWatcherStatus | None = Query(None, description="Filter by status"),
|
|
119
|
+
source_id: str | None = Query(None, description="Filter by source ID"),
|
|
120
|
+
limit: int = Query(50, ge=1, le=100),
|
|
121
|
+
offset: int = Query(0, ge=0),
|
|
122
|
+
) -> PaginatedResponse[SchemaWatcherSummary]:
|
|
123
|
+
"""List schema watchers."""
|
|
124
|
+
watchers, total = await service.list_watchers(
|
|
125
|
+
status=status,
|
|
126
|
+
source_id=source_id,
|
|
127
|
+
limit=limit,
|
|
128
|
+
offset=offset,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return PaginatedResponse(
|
|
132
|
+
data=watchers,
|
|
133
|
+
total=total,
|
|
134
|
+
offset=offset,
|
|
135
|
+
limit=limit,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@router.get(
|
|
140
|
+
"/statistics",
|
|
141
|
+
response_model=SchemaWatcherStatistics,
|
|
142
|
+
summary="Get watcher statistics",
|
|
143
|
+
description="Get aggregate statistics for all schema watchers.",
|
|
144
|
+
)
|
|
145
|
+
async def get_statistics(
|
|
146
|
+
service: SchemaWatcherServiceDep,
|
|
147
|
+
) -> SchemaWatcherStatistics:
|
|
148
|
+
"""Get schema watcher statistics."""
|
|
149
|
+
return await service.get_statistics()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@router.get(
|
|
153
|
+
"/scheduler/status",
|
|
154
|
+
response_model=SchemaWatcherSchedulerStatus,
|
|
155
|
+
summary="Get scheduler status",
|
|
156
|
+
description="Get the status of the schema watcher scheduler job.",
|
|
157
|
+
)
|
|
158
|
+
async def get_scheduler_status(
|
|
159
|
+
service: SchemaWatcherServiceDep,
|
|
160
|
+
) -> SchemaWatcherSchedulerStatus:
|
|
161
|
+
"""Get schema watcher scheduler status."""
|
|
162
|
+
return await service.get_scheduler_status()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# =============================================================================
|
|
166
|
+
# Alert Endpoints
|
|
167
|
+
# =============================================================================
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@router.get(
|
|
171
|
+
"/alerts",
|
|
172
|
+
response_model=PaginatedResponse[SchemaWatcherAlertSummary],
|
|
173
|
+
summary="List alerts",
|
|
174
|
+
description="List all schema watcher alerts with optional filtering.",
|
|
175
|
+
)
|
|
176
|
+
async def list_alerts(
|
|
177
|
+
service: SchemaWatcherServiceDep,
|
|
178
|
+
watcher_id: str | None = Query(None, description="Filter by watcher ID"),
|
|
179
|
+
status: SchemaWatcherAlertStatus | None = Query(None, description="Filter by status"),
|
|
180
|
+
severity: SchemaWatcherAlertSeverity | None = Query(None, description="Filter by severity"),
|
|
181
|
+
limit: int = Query(50, ge=1, le=100),
|
|
182
|
+
offset: int = Query(0, ge=0),
|
|
183
|
+
) -> PaginatedResponse[SchemaWatcherAlertSummary]:
|
|
184
|
+
"""List schema watcher alerts."""
|
|
185
|
+
alerts, total = await service.list_alerts(
|
|
186
|
+
watcher_id=watcher_id,
|
|
187
|
+
status=status,
|
|
188
|
+
severity=severity,
|
|
189
|
+
limit=limit,
|
|
190
|
+
offset=offset,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return PaginatedResponse(
|
|
194
|
+
data=alerts,
|
|
195
|
+
total=total,
|
|
196
|
+
offset=offset,
|
|
197
|
+
limit=limit,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@router.get(
|
|
202
|
+
"/alerts/{alert_id}",
|
|
203
|
+
response_model=SchemaWatcherAlertResponse,
|
|
204
|
+
summary="Get alert",
|
|
205
|
+
description="Get a specific schema watcher alert by ID.",
|
|
206
|
+
)
|
|
207
|
+
async def get_alert(
|
|
208
|
+
alert_id: str,
|
|
209
|
+
service: SchemaWatcherServiceDep,
|
|
210
|
+
) -> SchemaWatcherAlertResponse:
|
|
211
|
+
"""Get a schema watcher alert by ID."""
|
|
212
|
+
alert = await service.get_alert(alert_id)
|
|
213
|
+
if not alert:
|
|
214
|
+
raise HTTPException(status_code=404, detail="Alert not found")
|
|
215
|
+
return alert
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@router.post(
|
|
219
|
+
"/alerts/{alert_id}/acknowledge",
|
|
220
|
+
response_model=SchemaWatcherAlertResponse,
|
|
221
|
+
summary="Acknowledge alert",
|
|
222
|
+
description="Acknowledge a schema watcher alert.",
|
|
223
|
+
)
|
|
224
|
+
async def acknowledge_alert(
|
|
225
|
+
alert_id: str,
|
|
226
|
+
request: SchemaWatcherAlertAcknowledge,
|
|
227
|
+
service: SchemaWatcherServiceDep,
|
|
228
|
+
) -> SchemaWatcherAlertResponse:
|
|
229
|
+
"""Acknowledge an alert."""
|
|
230
|
+
alert = await service.acknowledge_alert(
|
|
231
|
+
alert_id,
|
|
232
|
+
acknowledged_by=request.acknowledged_by,
|
|
233
|
+
)
|
|
234
|
+
if not alert:
|
|
235
|
+
raise HTTPException(status_code=404, detail="Alert not found")
|
|
236
|
+
return alert
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@router.post(
|
|
240
|
+
"/alerts/{alert_id}/resolve",
|
|
241
|
+
response_model=SchemaWatcherAlertResponse,
|
|
242
|
+
summary="Resolve alert",
|
|
243
|
+
description="Resolve a schema watcher alert.",
|
|
244
|
+
)
|
|
245
|
+
async def resolve_alert(
|
|
246
|
+
alert_id: str,
|
|
247
|
+
request: SchemaWatcherAlertResolve,
|
|
248
|
+
service: SchemaWatcherServiceDep,
|
|
249
|
+
) -> SchemaWatcherAlertResponse:
|
|
250
|
+
"""Resolve an alert."""
|
|
251
|
+
alert = await service.resolve_alert(
|
|
252
|
+
alert_id,
|
|
253
|
+
resolved_by=request.resolved_by,
|
|
254
|
+
resolution_notes=request.resolution_notes,
|
|
255
|
+
)
|
|
256
|
+
if not alert:
|
|
257
|
+
raise HTTPException(status_code=404, detail="Alert not found")
|
|
258
|
+
return alert
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# =============================================================================
|
|
262
|
+
# Run Endpoints
|
|
263
|
+
# =============================================================================
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@router.get(
|
|
267
|
+
"/runs",
|
|
268
|
+
response_model=PaginatedResponse[SchemaWatcherRunSummary],
|
|
269
|
+
summary="List runs",
|
|
270
|
+
description="List all schema watcher runs with optional filtering.",
|
|
271
|
+
)
|
|
272
|
+
async def list_runs(
|
|
273
|
+
service: SchemaWatcherServiceDep,
|
|
274
|
+
watcher_id: str | None = Query(None, description="Filter by watcher ID"),
|
|
275
|
+
status: SchemaWatcherRunStatus | None = Query(None, description="Filter by status"),
|
|
276
|
+
limit: int = Query(50, ge=1, le=100),
|
|
277
|
+
offset: int = Query(0, ge=0),
|
|
278
|
+
) -> PaginatedResponse[SchemaWatcherRunSummary]:
|
|
279
|
+
"""List schema watcher runs."""
|
|
280
|
+
runs, total = await service.list_runs(
|
|
281
|
+
watcher_id=watcher_id,
|
|
282
|
+
status=status,
|
|
283
|
+
limit=limit,
|
|
284
|
+
offset=offset,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return PaginatedResponse(
|
|
288
|
+
data=runs,
|
|
289
|
+
total=total,
|
|
290
|
+
offset=offset,
|
|
291
|
+
limit=limit,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@router.get(
|
|
296
|
+
"/runs/{run_id}",
|
|
297
|
+
response_model=SchemaWatcherRunResponse,
|
|
298
|
+
summary="Get run",
|
|
299
|
+
description="Get a specific schema watcher run by ID.",
|
|
300
|
+
)
|
|
301
|
+
async def get_run(
|
|
302
|
+
run_id: str,
|
|
303
|
+
service: SchemaWatcherServiceDep,
|
|
304
|
+
) -> SchemaWatcherRunResponse:
|
|
305
|
+
"""Get a schema watcher run by ID."""
|
|
306
|
+
run = await service.get_run(run_id)
|
|
307
|
+
if not run:
|
|
308
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
309
|
+
return run
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@router.get(
|
|
313
|
+
"/{watcher_id}",
|
|
314
|
+
response_model=SchemaWatcherResponse,
|
|
315
|
+
summary="Get schema watcher",
|
|
316
|
+
description="Get a specific schema watcher by ID.",
|
|
317
|
+
)
|
|
318
|
+
async def get_watcher(
|
|
319
|
+
watcher_id: str,
|
|
320
|
+
service: SchemaWatcherServiceDep,
|
|
321
|
+
) -> SchemaWatcherResponse:
|
|
322
|
+
"""Get a schema watcher by ID."""
|
|
323
|
+
watcher = await service.get_watcher(watcher_id)
|
|
324
|
+
if not watcher:
|
|
325
|
+
raise HTTPException(status_code=404, detail="Schema watcher not found")
|
|
326
|
+
return watcher
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@router.put(
|
|
330
|
+
"/{watcher_id}",
|
|
331
|
+
response_model=SchemaWatcherResponse,
|
|
332
|
+
summary="Update schema watcher",
|
|
333
|
+
description="Update a schema watcher's configuration.",
|
|
334
|
+
)
|
|
335
|
+
async def update_watcher(
|
|
336
|
+
watcher_id: str,
|
|
337
|
+
request: SchemaWatcherUpdate,
|
|
338
|
+
service: SchemaWatcherServiceDep,
|
|
339
|
+
) -> SchemaWatcherResponse:
|
|
340
|
+
"""Update a schema watcher."""
|
|
341
|
+
watcher = await service.update_watcher(watcher_id, request)
|
|
342
|
+
if not watcher:
|
|
343
|
+
raise HTTPException(status_code=404, detail="Schema watcher not found")
|
|
344
|
+
return watcher
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@router.delete(
|
|
348
|
+
"/{watcher_id}",
|
|
349
|
+
response_model=MessageResponse,
|
|
350
|
+
summary="Delete schema watcher",
|
|
351
|
+
description="Delete a schema watcher and all its alerts and runs.",
|
|
352
|
+
)
|
|
353
|
+
async def delete_watcher(
|
|
354
|
+
watcher_id: str,
|
|
355
|
+
service: SchemaWatcherServiceDep,
|
|
356
|
+
) -> MessageResponse:
|
|
357
|
+
"""Delete a schema watcher."""
|
|
358
|
+
deleted = await service.delete_watcher(watcher_id)
|
|
359
|
+
if not deleted:
|
|
360
|
+
raise HTTPException(status_code=404, detail="Schema watcher not found")
|
|
361
|
+
return MessageResponse(message="Schema watcher deleted successfully")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@router.post(
|
|
365
|
+
"/{watcher_id}/status",
|
|
366
|
+
response_model=SchemaWatcherResponse,
|
|
367
|
+
summary="Set watcher status",
|
|
368
|
+
description="Change the status of a schema watcher (active, paused, stopped).",
|
|
369
|
+
)
|
|
370
|
+
async def set_watcher_status(
|
|
371
|
+
watcher_id: str,
|
|
372
|
+
request: SchemaWatcherStatusAction,
|
|
373
|
+
service: SchemaWatcherServiceDep,
|
|
374
|
+
) -> SchemaWatcherResponse:
|
|
375
|
+
"""Set schema watcher status."""
|
|
376
|
+
watcher = await service.set_watcher_status(watcher_id, request.status)
|
|
377
|
+
if not watcher:
|
|
378
|
+
raise HTTPException(status_code=404, detail="Schema watcher not found")
|
|
379
|
+
return watcher
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@router.post(
|
|
383
|
+
"/{watcher_id}/check",
|
|
384
|
+
response_model=SchemaWatcherCheckNowResponse,
|
|
385
|
+
summary="Check schema now",
|
|
386
|
+
description="""
|
|
387
|
+
Trigger an immediate schema check for a watcher.
|
|
388
|
+
|
|
389
|
+
This performs:
|
|
390
|
+
1. Learn current schema from source using truthound
|
|
391
|
+
2. Compare with previous version using SchemaEvolutionDetector
|
|
392
|
+
3. Detect renames using ColumnRenameDetector (if enabled)
|
|
393
|
+
4. Save new version to SchemaHistory
|
|
394
|
+
5. Create alert if breaking changes detected
|
|
395
|
+
""",
|
|
396
|
+
)
|
|
397
|
+
async def check_now(
|
|
398
|
+
watcher_id: str,
|
|
399
|
+
service: SchemaWatcherServiceDep,
|
|
400
|
+
) -> SchemaWatcherCheckNowResponse:
|
|
401
|
+
"""Execute schema check immediately."""
|
|
402
|
+
try:
|
|
403
|
+
return await service.check_now(watcher_id)
|
|
404
|
+
except ValueError as e:
|
|
405
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# =============================================================================
|
|
409
|
+
# Schema Detection Endpoints (truthound integration)
|
|
410
|
+
# =============================================================================
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@router.post(
|
|
414
|
+
"/detect-changes",
|
|
415
|
+
response_model=SchemaDetectionResponse,
|
|
416
|
+
summary="Detect schema changes",
|
|
417
|
+
description="""
|
|
418
|
+
Detect changes between two schemas using truthound's SchemaEvolutionDetector.
|
|
419
|
+
|
|
420
|
+
Detects:
|
|
421
|
+
- Column additions/removals
|
|
422
|
+
- Type changes
|
|
423
|
+
- Nullable changes
|
|
424
|
+
- Constraint changes
|
|
425
|
+
- Column renames (with similarity algorithms)
|
|
426
|
+
""",
|
|
427
|
+
)
|
|
428
|
+
async def detect_schema_changes(
|
|
429
|
+
request: SchemaDetectionRequest,
|
|
430
|
+
service: SchemaWatcherServiceDep,
|
|
431
|
+
) -> SchemaDetectionResponse:
|
|
432
|
+
"""Detect schema changes between two schemas."""
|
|
433
|
+
return await service.detect_changes(
|
|
434
|
+
current_schema=request.current_schema,
|
|
435
|
+
baseline_schema=request.baseline_schema,
|
|
436
|
+
detect_renames=request.detect_renames,
|
|
437
|
+
rename_similarity_threshold=request.rename_similarity_threshold,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@router.post(
|
|
442
|
+
"/detect-renames",
|
|
443
|
+
response_model=RenameDetectionResponse,
|
|
444
|
+
summary="Detect column renames",
|
|
445
|
+
description="""
|
|
446
|
+
Detect column renames using truthound's ColumnRenameDetector.
|
|
447
|
+
|
|
448
|
+
Supports multiple similarity algorithms:
|
|
449
|
+
- composite: Weighted combination (default)
|
|
450
|
+
- levenshtein: Edit distance
|
|
451
|
+
- jaro_winkler: Short strings, prefixes
|
|
452
|
+
- ngram: Partial matches
|
|
453
|
+
- token: snake_case/camelCase names
|
|
454
|
+
""",
|
|
455
|
+
)
|
|
456
|
+
async def detect_column_renames(
|
|
457
|
+
request: RenameDetectionRequest,
|
|
458
|
+
service: SchemaWatcherServiceDep,
|
|
459
|
+
) -> RenameDetectionResponse:
|
|
460
|
+
"""Detect column renames between added and removed columns."""
|
|
461
|
+
return await service.detect_renames(
|
|
462
|
+
added_columns=request.added_columns,
|
|
463
|
+
removed_columns=request.removed_columns,
|
|
464
|
+
similarity_threshold=request.similarity_threshold,
|
|
465
|
+
require_type_match=request.require_type_match,
|
|
466
|
+
allow_compatible_types=request.allow_compatible_types,
|
|
467
|
+
algorithm=request.algorithm.value,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# =============================================================================
|
|
472
|
+
# Version History Endpoints (truthound integration)
|
|
473
|
+
# =============================================================================
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@router.get(
|
|
477
|
+
"/{watcher_id}/versions",
|
|
478
|
+
response_model=list[SchemaVersionSummary],
|
|
479
|
+
summary="List schema versions",
|
|
480
|
+
description="List schema versions tracked by this watcher using truthound's SchemaHistory.",
|
|
481
|
+
)
|
|
482
|
+
async def list_schema_versions(
|
|
483
|
+
watcher_id: str,
|
|
484
|
+
service: SchemaWatcherServiceDep,
|
|
485
|
+
limit: int = Query(50, ge=1, le=100),
|
|
486
|
+
) -> list[SchemaVersionSummary]:
|
|
487
|
+
"""List schema versions for a watcher."""
|
|
488
|
+
# Verify watcher exists
|
|
489
|
+
watcher = await service.get_watcher(watcher_id)
|
|
490
|
+
if not watcher:
|
|
491
|
+
raise HTTPException(status_code=404, detail="Schema watcher not found")
|
|
492
|
+
|
|
493
|
+
return await service.list_schema_versions(watcher_id, limit=limit)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@router.get(
|
|
497
|
+
"/{watcher_id}/versions/{version}",
|
|
498
|
+
response_model=SchemaVersionResponse,
|
|
499
|
+
summary="Get schema version",
|
|
500
|
+
description="Get a specific schema version by version string or ID.",
|
|
501
|
+
)
|
|
502
|
+
async def get_schema_version(
|
|
503
|
+
watcher_id: str,
|
|
504
|
+
version: str,
|
|
505
|
+
service: SchemaWatcherServiceDep,
|
|
506
|
+
) -> SchemaVersionResponse:
|
|
507
|
+
"""Get a specific schema version."""
|
|
508
|
+
result = await service.get_schema_version(watcher_id, version)
|
|
509
|
+
if not result:
|
|
510
|
+
raise HTTPException(status_code=404, detail="Schema version not found")
|
|
511
|
+
return result
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@router.post(
|
|
515
|
+
"/{watcher_id}/versions",
|
|
516
|
+
response_model=SchemaVersionResponse,
|
|
517
|
+
status_code=201,
|
|
518
|
+
summary="Save schema version",
|
|
519
|
+
description="Manually save a schema version to history.",
|
|
520
|
+
)
|
|
521
|
+
async def save_schema_version(
|
|
522
|
+
watcher_id: str,
|
|
523
|
+
request: SchemaVersionCreate,
|
|
524
|
+
service: SchemaWatcherServiceDep,
|
|
525
|
+
) -> SchemaVersionResponse:
|
|
526
|
+
"""Save a schema version."""
|
|
527
|
+
# Verify watcher exists
|
|
528
|
+
watcher = await service.get_watcher(watcher_id)
|
|
529
|
+
if not watcher:
|
|
530
|
+
raise HTTPException(status_code=404, detail="Schema watcher not found")
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
return await service.save_schema_version(
|
|
534
|
+
watcher_id,
|
|
535
|
+
request.schema,
|
|
536
|
+
version=request.version,
|
|
537
|
+
metadata=request.metadata,
|
|
538
|
+
)
|
|
539
|
+
except ValueError as e:
|
|
540
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@router.post(
|
|
544
|
+
"/{watcher_id}/versions/diff",
|
|
545
|
+
response_model=SchemaDiffResponse,
|
|
546
|
+
summary="Diff schema versions",
|
|
547
|
+
description="Get the diff between two schema versions.",
|
|
548
|
+
)
|
|
549
|
+
async def diff_schema_versions(
|
|
550
|
+
watcher_id: str,
|
|
551
|
+
request: SchemaDiffRequest,
|
|
552
|
+
service: SchemaWatcherServiceDep,
|
|
553
|
+
) -> SchemaDiffResponse:
|
|
554
|
+
"""Get diff between schema versions."""
|
|
555
|
+
# Verify watcher exists
|
|
556
|
+
watcher = await service.get_watcher(watcher_id)
|
|
557
|
+
if not watcher:
|
|
558
|
+
raise HTTPException(status_code=404, detail="Schema watcher not found")
|
|
559
|
+
|
|
560
|
+
try:
|
|
561
|
+
return await service.diff_versions(
|
|
562
|
+
watcher_id,
|
|
563
|
+
request.from_version,
|
|
564
|
+
request.to_version,
|
|
565
|
+
)
|
|
566
|
+
except ValueError as e:
|
|
567
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@router.post(
|
|
571
|
+
"/{watcher_id}/versions/rollback",
|
|
572
|
+
response_model=SchemaVersionResponse,
|
|
573
|
+
summary="Rollback schema version",
|
|
574
|
+
description="Rollback to a previous schema version (creates a new version matching the target).",
|
|
575
|
+
)
|
|
576
|
+
async def rollback_schema_version(
|
|
577
|
+
watcher_id: str,
|
|
578
|
+
request: SchemaRollbackRequest,
|
|
579
|
+
service: SchemaWatcherServiceDep,
|
|
580
|
+
) -> SchemaVersionResponse:
|
|
581
|
+
"""Rollback to a previous schema version."""
|
|
582
|
+
# Verify watcher exists
|
|
583
|
+
watcher = await service.get_watcher(watcher_id)
|
|
584
|
+
if not watcher:
|
|
585
|
+
raise HTTPException(status_code=404, detail="Schema watcher not found")
|
|
586
|
+
|
|
587
|
+
try:
|
|
588
|
+
return await service.rollback_version(
|
|
589
|
+
watcher_id,
|
|
590
|
+
request.to_version,
|
|
591
|
+
reason=request.reason,
|
|
592
|
+
)
|
|
593
|
+
except ValueError as e:
|
|
594
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# =============================================================================
|
|
598
|
+
# Watcher-specific Alert and Run Endpoints
|
|
599
|
+
# =============================================================================
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@router.get(
|
|
603
|
+
"/{watcher_id}/alerts",
|
|
604
|
+
response_model=PaginatedResponse[SchemaWatcherAlertSummary],
|
|
605
|
+
summary="List watcher alerts",
|
|
606
|
+
description="List alerts for a specific schema watcher.",
|
|
607
|
+
)
|
|
608
|
+
async def list_watcher_alerts(
|
|
609
|
+
watcher_id: str,
|
|
610
|
+
service: SchemaWatcherServiceDep,
|
|
611
|
+
status: SchemaWatcherAlertStatus | None = Query(None, description="Filter by status"),
|
|
612
|
+
limit: int = Query(50, ge=1, le=100),
|
|
613
|
+
offset: int = Query(0, ge=0),
|
|
614
|
+
) -> PaginatedResponse[SchemaWatcherAlertSummary]:
|
|
615
|
+
"""List alerts for a specific watcher."""
|
|
616
|
+
# Verify watcher exists
|
|
617
|
+
watcher = await service.get_watcher(watcher_id)
|
|
618
|
+
if not watcher:
|
|
619
|
+
raise HTTPException(status_code=404, detail="Schema watcher not found")
|
|
620
|
+
|
|
621
|
+
alerts, total = await service.list_alerts(
|
|
622
|
+
watcher_id=watcher_id,
|
|
623
|
+
status=status,
|
|
624
|
+
limit=limit,
|
|
625
|
+
offset=offset,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
return PaginatedResponse(
|
|
629
|
+
data=alerts,
|
|
630
|
+
total=total,
|
|
631
|
+
offset=offset,
|
|
632
|
+
limit=limit,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
@router.get(
|
|
637
|
+
"/{watcher_id}/runs",
|
|
638
|
+
response_model=PaginatedResponse[SchemaWatcherRunSummary],
|
|
639
|
+
summary="List watcher runs",
|
|
640
|
+
description="List runs for a specific schema watcher.",
|
|
641
|
+
)
|
|
642
|
+
async def list_watcher_runs(
|
|
643
|
+
watcher_id: str,
|
|
644
|
+
service: SchemaWatcherServiceDep,
|
|
645
|
+
status: SchemaWatcherRunStatus | None = Query(None, description="Filter by status"),
|
|
646
|
+
limit: int = Query(50, ge=1, le=100),
|
|
647
|
+
offset: int = Query(0, ge=0),
|
|
648
|
+
) -> PaginatedResponse[SchemaWatcherRunSummary]:
|
|
649
|
+
"""List runs for a specific watcher."""
|
|
650
|
+
# Verify watcher exists
|
|
651
|
+
watcher = await service.get_watcher(watcher_id)
|
|
652
|
+
if not watcher:
|
|
653
|
+
raise HTTPException(status_code=404, detail="Schema watcher not found")
|
|
654
|
+
|
|
655
|
+
runs, total = await service.list_runs(
|
|
656
|
+
watcher_id=watcher_id,
|
|
657
|
+
status=status,
|
|
658
|
+
limit=limit,
|
|
659
|
+
offset=offset,
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
return PaginatedResponse(
|
|
663
|
+
data=runs,
|
|
664
|
+
total=total,
|
|
665
|
+
offset=offset,
|
|
666
|
+
limit=limit,
|
|
667
|
+
)
|