|
6 | 6 | from sqlalchemy import select |
7 | 7 |
|
8 | 8 | from testgen.common.date_service import parse_since |
9 | | -from testgen.common.enums import Disposition, ImpactDimension, IssueLikelihood, JobStatus, PiiRisk, QualityDimension |
| 9 | +from testgen.common.enums import ( |
| 10 | + Disposition, |
| 11 | + ImpactDimension, |
| 12 | + IssueLikelihood, |
| 13 | + JobStatus, |
| 14 | + MonitorType, |
| 15 | + PiiRisk, |
| 16 | + QualityDimension, |
| 17 | +) |
10 | 18 | from testgen.common.flavors import FLAVOR_CODE_TO_FAMILY, FLAVOR_CODE_TO_LABEL, SqlFlavorLabel |
11 | 19 | from testgen.common.models import get_current_session |
12 | 20 | from testgen.common.models.connection import Connection |
@@ -57,6 +65,7 @@ class DocGroup(StrEnum): |
57 | 65 | DISCOVER = "Discover what TestGen knows about" |
58 | 66 | INVESTIGATE = "Investigate quality issues" |
59 | 67 | BROWSE_PROFILING = "Browse profiling results" |
| 68 | + MONITORS = "Browse monitor health and events" |
60 | 69 | TRIGGER = "Trigger profiling, tests, and test generation" |
61 | 70 | SCORING = "Track data quality scores" |
62 | 71 | MANAGE = "Manage TestGen configuration" |
@@ -340,6 +349,51 @@ def next_scheduled_run( |
340 | 349 | return min(s.get_sample_triggering_timestamps(1)[0] for s in schedules) |
341 | 350 |
|
342 | 351 |
|
| 352 | +# Monitor type — internal ``test_type`` value ↔ user-facing short label (the form |
| 353 | +# callers pass and the form rendered in output). |
| 354 | +_MONITOR_TYPE_USER_TO_DB: dict[str, MonitorType] = { |
| 355 | + "freshness": MonitorType.FRESHNESS, |
| 356 | + "volume": MonitorType.VOLUME, |
| 357 | + "schema": MonitorType.SCHEMA, |
| 358 | + "metric": MonitorType.METRIC, |
| 359 | +} |
| 360 | + |
| 361 | + |
| 362 | +def parse_monitor_type(value: str, label: str = "monitor_type") -> MonitorType: |
| 363 | + """Validate a user-facing monitor type label and return the stored ``MonitorType``. |
| 364 | +
|
| 365 | + Accepts ``freshness`` / ``volume`` / ``schema`` / ``metric``. ``label`` names the |
| 366 | + caller's argument in the error message — pass ``"anomaly_type"`` when the |
| 367 | + public arg is named differently from ``monitor_type``. |
| 368 | + """ |
| 369 | + db_value = _MONITOR_TYPE_USER_TO_DB.get(value) |
| 370 | + if db_value is None: |
| 371 | + valid = ", ".join(_MONITOR_TYPE_USER_TO_DB) |
| 372 | + raise MCPUserError(f"Invalid {label} `{value}`. Valid values: {valid}") |
| 373 | + return db_value |
| 374 | + |
| 375 | + |
| 376 | +class MonitorTableSort(StrEnum): |
| 377 | + """User-facing values accepted for the ``sort_by`` argument on ``list_monitored_tables``. |
| 378 | +
|
| 379 | + When an ``anomaly_type`` filter is set, ``anomaly_count_desc`` sorts by that type's |
| 380 | + count; with no filter, it sorts by total anomalies across all types. |
| 381 | + """ |
| 382 | + |
| 383 | + TABLE_NAME = "table_name" |
| 384 | + ANOMALY_COUNT_DESC = "anomaly_count_desc" |
| 385 | + LATEST_UPDATE_DESC = "latest_update_desc" |
| 386 | + ROW_COUNT_CHANGE_DESC = "row_count_change_desc" |
| 387 | + |
| 388 | + |
| 389 | +def parse_monitor_table_sort(value: str) -> MonitorTableSort: |
| 390 | + try: |
| 391 | + return MonitorTableSort(value) |
| 392 | + except ValueError as err: |
| 393 | + valid = ", ".join(s.value for s in MonitorTableSort) |
| 394 | + raise MCPUserError(f"Invalid sort_by `{value}`. Valid values: {valid}") from err |
| 395 | + |
| 396 | + |
343 | 397 | def parse_disposition(value: str) -> Disposition: |
344 | 398 | """Validate a user-facing disposition label and return the stored ``Disposition``. |
345 | 399 |
|
@@ -529,10 +583,17 @@ def resolve_issue_type(name: str) -> str: |
529 | 583 |
|
530 | 584 |
|
531 | 585 | def format_page_info(total: int, page: int, limit: int) -> str: |
532 | | - """Shared pagination summary line for MCP tool output.""" |
| 586 | + """Shared pagination summary line for MCP tool output. |
| 587 | +
|
| 588 | + Returns empty for a zero total *or* when ``page`` is past the last page \u2014 |
| 589 | + the caller's own "no rows on page N" message is more useful than a |
| 590 | + ``Showing 6\u20135 of 5`` nonsense range. |
| 591 | + """ |
533 | 592 | if total == 0: |
534 | 593 | return "" |
535 | 594 | start = (page - 1) * limit + 1 |
| 595 | + if start > total: |
| 596 | + return "" |
536 | 597 | end = min(start + limit - 1, total) |
537 | 598 | return f"Showing {start}\u2013{end} of {total} (page {page})." |
538 | 599 |
|
@@ -595,6 +656,23 @@ def resolve_test_suite(test_suite_id: str) -> TestSuite: |
595 | 656 | return suite |
596 | 657 |
|
597 | 658 |
|
| 659 | +def resolve_monitored_table_group(table_group_id: str) -> tuple[TableGroup, TestSuite | None]: |
| 660 | + """Resolve a table group ID and look up its linked monitor suite. |
| 661 | +
|
| 662 | + Returns ``(table_group, monitor_suite)``. ``monitor_suite`` is ``None`` when the |
| 663 | + table group has no ``monitor_test_suite_id`` set, or that pointer doesn't resolve |
| 664 | + to an ``is_monitor=True`` suite — callers render the "not monitored" output in |
| 665 | + that case rather than raising an inaccessible error. Raises |
| 666 | + ``MCPResourceNotAccessible`` when the table group itself is missing or out of |
| 667 | + scope. |
| 668 | + """ |
| 669 | + tg = resolve_table_group(table_group_id) |
| 670 | + if tg.monitor_test_suite_id is None: |
| 671 | + return tg, None |
| 672 | + suite = TestSuite.get(tg.monitor_test_suite_id, TestSuite.is_monitor.is_(True)) |
| 673 | + return tg, suite |
| 674 | + |
| 675 | + |
598 | 676 | def resolve_profiling_run(job_execution_id: str) -> ProfilingRun: |
599 | 677 | """Resolve a profiling run by id-or-JE-id, scoped to allowed projects. |
600 | 678 |
|
|
0 commit comments