airflow-pytest-plugin 0.3.1__tar.gz → 0.3.2__tar.gz
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.
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/PKG-INFO +6 -2
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/README.md +5 -1
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/pyproject.toml +1 -1
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/routes/common.py +35 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/routes/compare.py +42 -2
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/routes/failures.py +22 -2
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/routes/flaky.py +28 -2
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/routes/monitoring.py +20 -3
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/routes/reports.py +180 -7
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/templates.py +270 -43
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/tests/test_web_app.py +94 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/.gitignore +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/LICENSE +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/examples/example_dag.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/compat/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/compat/airflow.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/config.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/layout.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/models.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/plugin.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/producer/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/producer/archiving_parser.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/retention.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/sources/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/sources/base.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/sources/filesystem.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/version.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/__main__.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/app.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/routes/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/tests/conftest.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/tests/test_archiving_parser.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/tests/test_authz_integration.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/tests/test_compat.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/tests/test_config.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/tests/test_filesystem_source.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/tests/test_layout.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/tests/test_models.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/tests/test_package.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/tests/test_plugin.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/tests/test_retention.py +0 -0
- {airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: airflow-pytest-plugin
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: View airflow-pytest-operator results in the Airflow 3 web UI.
|
|
5
5
|
Project-URL: Homepage, https://github.com/IKrysanov/airflow-pytest-plugin
|
|
6
6
|
Project-URL: Repository, https://github.com/IKrysanov/airflow-pytest-plugin
|
|
@@ -102,7 +102,10 @@ toggles, run numbers, a carousel beyond 30 runs, an optional **pass-rate trend
|
|
|
102
102
|
line** with a success-threshold gridline, and **tick runs in the list to filter
|
|
103
103
|
the chart to just their trend**) beside a **flaky-tests panel** (with its own
|
|
104
104
|
search and a quarantined-only toggle), KPI cards (including a clickable **unique
|
|
105
|
-
tests** count), and Airflow-matched colours and font
|
|
105
|
+
tests** count), and Airflow-matched colours and font. The run list is **grouped by
|
|
106
|
+
dag·task** by default (a checkbox toggles the flat list) — collapsible groups with
|
|
107
|
+
run count, pass-rate, average duration and last status, each sortable on its own;
|
|
108
|
+
select a whole group to chart its trend:
|
|
106
109
|
|
|
107
110
|

|
|
108
111
|
|
|
@@ -230,6 +233,7 @@ runtime. Endpoints (relative to the mount):
|
|
|
230
233
|
| `GET /` | the single-page viewer (HTML) |
|
|
231
234
|
| `GET /api/reports?dag_id=&run_id=` | summaries, newest first |
|
|
232
235
|
| `GET /api/reports/{report_id}` | one report with per-case rows |
|
|
236
|
+
| `GET /api/groups?dag_id=&task_id=` | runs aggregated by dag·task (count, pass-rate, avg duration, last status) |
|
|
233
237
|
| `GET /api/failures?dag_id=&run_id=&task_id=` | failed/errored cases across the visible runs |
|
|
234
238
|
| `GET /api/compare?base=&head=` | per-test diff between two runs (newly failed / fixed / …) |
|
|
235
239
|
| `GET /api/flaky?dag_id=&task_id=&window=` | flaky tests with score, trend, and a quarantine flag |
|
|
@@ -58,7 +58,10 @@ toggles, run numbers, a carousel beyond 30 runs, an optional **pass-rate trend
|
|
|
58
58
|
line** with a success-threshold gridline, and **tick runs in the list to filter
|
|
59
59
|
the chart to just their trend**) beside a **flaky-tests panel** (with its own
|
|
60
60
|
search and a quarantined-only toggle), KPI cards (including a clickable **unique
|
|
61
|
-
tests** count), and Airflow-matched colours and font
|
|
61
|
+
tests** count), and Airflow-matched colours and font. The run list is **grouped by
|
|
62
|
+
dag·task** by default (a checkbox toggles the flat list) — collapsible groups with
|
|
63
|
+
run count, pass-rate, average duration and last status, each sortable on its own;
|
|
64
|
+
select a whole group to chart its trend:
|
|
62
65
|
|
|
63
66
|

|
|
64
67
|
|
|
@@ -186,6 +189,7 @@ runtime. Endpoints (relative to the mount):
|
|
|
186
189
|
| `GET /` | the single-page viewer (HTML) |
|
|
187
190
|
| `GET /api/reports?dag_id=&run_id=` | summaries, newest first |
|
|
188
191
|
| `GET /api/reports/{report_id}` | one report with per-case rows |
|
|
192
|
+
| `GET /api/groups?dag_id=&task_id=` | runs aggregated by dag·task (count, pass-rate, avg duration, last status) |
|
|
189
193
|
| `GET /api/failures?dag_id=&run_id=&task_id=` | failed/errored cases across the visible runs |
|
|
190
194
|
| `GET /api/compare?base=&head=` | per-test diff between two runs (newly failed / fixed / …) |
|
|
191
195
|
| `GET /api/flaky?dag_id=&task_id=&window=` | flaky tests with score, trend, and a quarantine flag |
|
|
@@ -51,3 +51,38 @@ def ref_from_token(token: str) -> ReportRef:
|
|
|
51
51
|
return ReportRef.from_token(token)
|
|
52
52
|
except ValueError as exc:
|
|
53
53
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# -- OpenAPI helpers: real example payloads + status codes for Swagger --------
|
|
57
|
+
# Endpoints return a raw JSONResponse, so FastAPI can't infer a schema (Swagger shows
|
|
58
|
+
# an empty "string"). These attach a concrete example to the 200 and document the
|
|
59
|
+
# error codes each route can return.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def ok(example: Any, description: str = "Successful response") -> dict[int | str, Any]:
|
|
63
|
+
"""A 200 response documented with a concrete JSON ``example``."""
|
|
64
|
+
return {
|
|
65
|
+
200: {
|
|
66
|
+
"description": description,
|
|
67
|
+
"content": {"application/json": {"example": example}},
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _err(detail: str) -> dict[str, Any]:
|
|
73
|
+
return {"content": {"application/json": {"example": {"detail": detail}}}}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
#: Reusable error responses (FastAPI's HTTPException body is ``{"detail": "..."}``).
|
|
77
|
+
ERR_400: dict[int | str, Any] = {
|
|
78
|
+
400: {"description": "Malformed report token.", **_err("malformed report token")}
|
|
79
|
+
}
|
|
80
|
+
ERR_403: dict[int | str, Any] = {
|
|
81
|
+
403: {
|
|
82
|
+
"description": "Forbidden — not authorized for this dag (RBAC).",
|
|
83
|
+
**_err("not authorized"),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
ERR_404: dict[int | str, Any] = {
|
|
87
|
+
404: {"description": "Not found.", **_err("report not found")}
|
|
88
|
+
}
|
|
@@ -21,7 +21,15 @@ from typing import Any
|
|
|
21
21
|
from fastapi import APIRouter, Depends, HTTPException
|
|
22
22
|
from fastapi.responses import JSONResponse
|
|
23
23
|
|
|
24
|
-
from .common import
|
|
24
|
+
from .common import (
|
|
25
|
+
ERR_400,
|
|
26
|
+
ERR_403,
|
|
27
|
+
ERR_404,
|
|
28
|
+
FAIL_OUTCOMES,
|
|
29
|
+
RouteDeps,
|
|
30
|
+
ok,
|
|
31
|
+
ref_from_token,
|
|
32
|
+
)
|
|
25
33
|
|
|
26
34
|
TAG = "compare"
|
|
27
35
|
|
|
@@ -66,7 +74,39 @@ def build_router(deps: RouteDeps) -> APIRouter:
|
|
|
66
74
|
read_auth = deps.read_auth
|
|
67
75
|
user_dep = deps.user_dep
|
|
68
76
|
|
|
69
|
-
@router.get(
|
|
77
|
+
@router.get(
|
|
78
|
+
"/api/compare",
|
|
79
|
+
summary="Compare two runs",
|
|
80
|
+
responses={
|
|
81
|
+
**ok(
|
|
82
|
+
{
|
|
83
|
+
"newly_failed": [
|
|
84
|
+
{
|
|
85
|
+
"node_id": "tests/api.py::test_auth",
|
|
86
|
+
"base": "passed",
|
|
87
|
+
"head": "failed",
|
|
88
|
+
}
|
|
89
|
+
],
|
|
90
|
+
"fixed": [],
|
|
91
|
+
"still_failing": [],
|
|
92
|
+
"added": [],
|
|
93
|
+
"removed": [
|
|
94
|
+
{"node_id": "tests/api.py::test_legacy", "outcome": "passed"}
|
|
95
|
+
],
|
|
96
|
+
"counts": {
|
|
97
|
+
"newly_failed": 1,
|
|
98
|
+
"fixed": 0,
|
|
99
|
+
"still_failing": 0,
|
|
100
|
+
"added": 0,
|
|
101
|
+
"removed": 1,
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
),
|
|
105
|
+
**ERR_400,
|
|
106
|
+
**ERR_403,
|
|
107
|
+
**ERR_404,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
70
110
|
def compare(
|
|
71
111
|
base: str,
|
|
72
112
|
head: str,
|
|
@@ -21,7 +21,7 @@ from typing import Any
|
|
|
21
21
|
from fastapi import APIRouter, Depends
|
|
22
22
|
from fastapi.responses import JSONResponse
|
|
23
23
|
|
|
24
|
-
from .common import RouteDeps
|
|
24
|
+
from .common import RouteDeps, ok
|
|
25
25
|
|
|
26
26
|
TAG = "failures"
|
|
27
27
|
|
|
@@ -36,7 +36,27 @@ def build_router(deps: RouteDeps) -> APIRouter:
|
|
|
36
36
|
read_auth = deps.read_auth
|
|
37
37
|
user_dep = deps.user_dep
|
|
38
38
|
|
|
39
|
-
@router.get(
|
|
39
|
+
@router.get(
|
|
40
|
+
"/api/failures",
|
|
41
|
+
summary="Failed cases across runs",
|
|
42
|
+
responses=ok(
|
|
43
|
+
{
|
|
44
|
+
"failures": [
|
|
45
|
+
{
|
|
46
|
+
"id": "YXBpX2dhdGV3YXl8...",
|
|
47
|
+
"dag_id": "api_gateway",
|
|
48
|
+
"task_id": "integration_tests",
|
|
49
|
+
"run_id": "scheduled__2026-06-20",
|
|
50
|
+
"created_at": "2026-06-20T07:00:00+00:00",
|
|
51
|
+
"node_id": "tests/api.py::test_auth",
|
|
52
|
+
"outcome": "failed",
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"total": 1,
|
|
56
|
+
"capped": False,
|
|
57
|
+
}
|
|
58
|
+
),
|
|
59
|
+
)
|
|
40
60
|
def failures(
|
|
41
61
|
dag_id: str | None = None,
|
|
42
62
|
run_id: str | None = None,
|
|
@@ -26,7 +26,7 @@ from ...config import (
|
|
|
26
26
|
get_flaky_quarantine_score,
|
|
27
27
|
get_flaky_window,
|
|
28
28
|
)
|
|
29
|
-
from .common import FAIL_OUTCOMES, RouteDeps
|
|
29
|
+
from .common import FAIL_OUTCOMES, RouteDeps, ok
|
|
30
30
|
|
|
31
31
|
TAG = "flaky"
|
|
32
32
|
|
|
@@ -102,7 +102,33 @@ def build_router(deps: RouteDeps) -> APIRouter:
|
|
|
102
102
|
read_auth = deps.read_auth
|
|
103
103
|
user_dep = deps.user_dep
|
|
104
104
|
|
|
105
|
-
@router.get(
|
|
105
|
+
@router.get(
|
|
106
|
+
"/api/flaky",
|
|
107
|
+
summary="Flaky tests",
|
|
108
|
+
responses=ok(
|
|
109
|
+
{
|
|
110
|
+
"flaky": [
|
|
111
|
+
{
|
|
112
|
+
"dag_id": "api_gateway",
|
|
113
|
+
"task_id": "integration_tests",
|
|
114
|
+
"node_id": "tests/api.py::test_auth",
|
|
115
|
+
"runs": 8,
|
|
116
|
+
"fails": 4,
|
|
117
|
+
"flips": 7,
|
|
118
|
+
"score": 1.0,
|
|
119
|
+
"trend": "flat",
|
|
120
|
+
"quarantined": True,
|
|
121
|
+
"recent": ["passed", "failed", "passed", "failed"],
|
|
122
|
+
}
|
|
123
|
+
],
|
|
124
|
+
"total": 1,
|
|
125
|
+
"capped": False,
|
|
126
|
+
"window": 30,
|
|
127
|
+
"quarantine_score": 0.5,
|
|
128
|
+
"min_score": 0.1,
|
|
129
|
+
}
|
|
130
|
+
),
|
|
131
|
+
)
|
|
106
132
|
def flaky(
|
|
107
133
|
dag_id: str | None = None,
|
|
108
134
|
task_id: str | None = None,
|
|
@@ -23,7 +23,7 @@ from fastapi.responses import JSONResponse
|
|
|
23
23
|
|
|
24
24
|
from ...compat import airflow_auth_available
|
|
25
25
|
from ...version import __version__
|
|
26
|
-
from .common import RouteDeps
|
|
26
|
+
from .common import RouteDeps, ok
|
|
27
27
|
|
|
28
28
|
TAG = "monitoring"
|
|
29
29
|
_DIST_NAME = "airflow-pytest-plugin"
|
|
@@ -34,7 +34,20 @@ def build_router(deps: RouteDeps) -> APIRouter:
|
|
|
34
34
|
router = APIRouter(tags=[TAG])
|
|
35
35
|
src = deps.src
|
|
36
36
|
|
|
37
|
-
@router.get(
|
|
37
|
+
@router.get(
|
|
38
|
+
"/api/health",
|
|
39
|
+
summary="Health & readiness",
|
|
40
|
+
responses=ok(
|
|
41
|
+
{
|
|
42
|
+
"status": "ok",
|
|
43
|
+
"ready": True,
|
|
44
|
+
"reports_root": "/opt/airflow/pytest-reports",
|
|
45
|
+
"reports_root_exists": True,
|
|
46
|
+
"auth": "airflow",
|
|
47
|
+
"secure_xml": True,
|
|
48
|
+
}
|
|
49
|
+
),
|
|
50
|
+
)
|
|
38
51
|
def health() -> JSONResponse:
|
|
39
52
|
"""Liveness + readiness of the reader. No parameters, no report reads, no auth.
|
|
40
53
|
|
|
@@ -67,7 +80,11 @@ def build_router(deps: RouteDeps) -> APIRouter:
|
|
|
67
80
|
}
|
|
68
81
|
)
|
|
69
82
|
|
|
70
|
-
@router.get(
|
|
83
|
+
@router.get(
|
|
84
|
+
"/api/version",
|
|
85
|
+
summary="Build info",
|
|
86
|
+
responses=ok({"name": _DIST_NAME, "version": __version__}),
|
|
87
|
+
)
|
|
71
88
|
def version() -> JSONResponse:
|
|
72
89
|
"""The plugin's distribution name and version (from package metadata)."""
|
|
73
90
|
return JSONResponse({"name": _DIST_NAME, "version": __version__})
|
|
@@ -22,7 +22,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|
|
22
22
|
from fastapi.responses import JSONResponse, Response
|
|
23
23
|
|
|
24
24
|
from ...config import get_success_threshold
|
|
25
|
-
from .common import RouteDeps, ref_from_token
|
|
25
|
+
from .common import ERR_400, ERR_403, ERR_404, RouteDeps, ok, ref_from_token
|
|
26
26
|
|
|
27
27
|
TAG = "reports"
|
|
28
28
|
|
|
@@ -30,6 +30,84 @@ TAG = "reports"
|
|
|
30
30
|
#: KPI can't trigger an unbounded scan of every archived run on each filter change.
|
|
31
31
|
_UNIQUE_SCAN_CAP = 1000
|
|
32
32
|
|
|
33
|
+
# -- OpenAPI examples (illustrative; Swagger shows these instead of a bare "string") --
|
|
34
|
+
_EX_SUMMARY = {
|
|
35
|
+
"id": "ZXRsX2RhaWx5fHNjaGVkdWxlZF8yMDI2LTA2LTEwfHVuaXRfdGVzdHN8MXwtMQ",
|
|
36
|
+
"dag_id": "etl_daily",
|
|
37
|
+
"run_id": "scheduled__2026-06-10",
|
|
38
|
+
"task_id": "unit_tests",
|
|
39
|
+
"try_number": 1,
|
|
40
|
+
"map_index": -1,
|
|
41
|
+
"total": 4,
|
|
42
|
+
"passed": 3,
|
|
43
|
+
"failed": 1,
|
|
44
|
+
"skipped": 0,
|
|
45
|
+
"errors": 0,
|
|
46
|
+
"duration": 1.2,
|
|
47
|
+
"success": False,
|
|
48
|
+
"created_at": "2026-06-10T23:00:00+00:00",
|
|
49
|
+
"logical_date": None,
|
|
50
|
+
"has_allure": False,
|
|
51
|
+
}
|
|
52
|
+
_EX_CASE = {
|
|
53
|
+
"node_id": "tests/test_etl.py::test_load",
|
|
54
|
+
"name": "test_load",
|
|
55
|
+
"classname": "tests/test_etl.py",
|
|
56
|
+
"outcome": "failed",
|
|
57
|
+
"time": 0.3,
|
|
58
|
+
"message": "AssertionError: row count mismatch",
|
|
59
|
+
}
|
|
60
|
+
_EX_GROUP = {
|
|
61
|
+
"dag_id": "api_gateway",
|
|
62
|
+
"task_id": "integration_tests",
|
|
63
|
+
"runs": 8,
|
|
64
|
+
"passed": 2,
|
|
65
|
+
"pass_rate": 0.25,
|
|
66
|
+
"avg_duration": 1.6,
|
|
67
|
+
"last_status": "failed",
|
|
68
|
+
"last_created_at": "2026-06-20T07:00:00+00:00",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def summarize_groups(summaries: list[Any]) -> list[dict[str, Any]]:
|
|
73
|
+
"""Aggregate run summaries by dag·task (pure).
|
|
74
|
+
|
|
75
|
+
One entry per dag·task with the run count, how many passed (per the configured
|
|
76
|
+
success threshold), the pass rate, the average run duration, and the newest run's
|
|
77
|
+
status/time. Sorted by most-recent activity. Lets a grouped view or dashboard read
|
|
78
|
+
group stats without shipping every run -- the basis for scaling past in-browser
|
|
79
|
+
grouping.
|
|
80
|
+
"""
|
|
81
|
+
order: list[tuple[str, str]] = []
|
|
82
|
+
groups: dict[tuple[str, str], list[Any]] = {}
|
|
83
|
+
for s in summaries:
|
|
84
|
+
key = (s.ref.dag_id, s.ref.task_id)
|
|
85
|
+
if key not in groups:
|
|
86
|
+
groups[key] = []
|
|
87
|
+
order.append(key)
|
|
88
|
+
groups[key].append(s)
|
|
89
|
+
|
|
90
|
+
out: list[dict[str, Any]] = []
|
|
91
|
+
for dag, task in order:
|
|
92
|
+
runs = groups[(dag, task)]
|
|
93
|
+
newest = max(runs, key=lambda s: s.created_at or "")
|
|
94
|
+
passed = sum(1 for s in runs if s.success)
|
|
95
|
+
last = "passed" if newest.success else ("error" if newest.errors else "failed")
|
|
96
|
+
out.append(
|
|
97
|
+
{
|
|
98
|
+
"dag_id": dag,
|
|
99
|
+
"task_id": task,
|
|
100
|
+
"runs": len(runs),
|
|
101
|
+
"passed": passed,
|
|
102
|
+
"pass_rate": round(passed / len(runs), 3),
|
|
103
|
+
"avg_duration": round(sum(s.duration for s in runs) / len(runs), 3),
|
|
104
|
+
"last_status": last,
|
|
105
|
+
"last_created_at": newest.created_at,
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
out.sort(key=lambda g: g["last_created_at"] or "", reverse=True)
|
|
109
|
+
return out
|
|
110
|
+
|
|
33
111
|
|
|
34
112
|
def build_router(deps: RouteDeps) -> APIRouter:
|
|
35
113
|
"""Routes tagged ``reports``."""
|
|
@@ -39,7 +117,11 @@ def build_router(deps: RouteDeps) -> APIRouter:
|
|
|
39
117
|
delete_auth = deps.delete_auth
|
|
40
118
|
user_dep = deps.user_dep
|
|
41
119
|
|
|
42
|
-
@router.get(
|
|
120
|
+
@router.get(
|
|
121
|
+
"/api/reports",
|
|
122
|
+
summary="List runs",
|
|
123
|
+
responses=ok({"reports": [_EX_SUMMARY], "success_threshold": 0.85}),
|
|
124
|
+
)
|
|
43
125
|
def list_reports(
|
|
44
126
|
dag_id: str | None = None,
|
|
45
127
|
run_id: str | None = None,
|
|
@@ -62,7 +144,41 @@ def build_router(deps: RouteDeps) -> APIRouter:
|
|
|
62
144
|
}
|
|
63
145
|
)
|
|
64
146
|
|
|
65
|
-
@router.get(
|
|
147
|
+
@router.get(
|
|
148
|
+
"/api/groups",
|
|
149
|
+
summary="Run groups by dag·task",
|
|
150
|
+
responses=ok({"groups": [_EX_GROUP], "total": 1}),
|
|
151
|
+
)
|
|
152
|
+
def groups(
|
|
153
|
+
dag_id: str | None = None,
|
|
154
|
+
task_id: str | None = None,
|
|
155
|
+
user: Any = Depends(user_dep), # noqa: B008 - FastAPI dependency idiom
|
|
156
|
+
) -> JSONResponse:
|
|
157
|
+
"""Runs aggregated by dag·task: count, pass-rate, and the newest run's
|
|
158
|
+
status/time. Optional ``dag_id`` / ``task_id`` narrow by case-insensitive
|
|
159
|
+
substring; RBAC-filtered. Built for grouped views and dashboards so they can
|
|
160
|
+
show group stats without fetching every run (scales past in-browser grouping).
|
|
161
|
+
"""
|
|
162
|
+
task_q = (task_id or "").lower()
|
|
163
|
+
visible = [
|
|
164
|
+
s
|
|
165
|
+
for s in src.list_summaries(dag_id=dag_id)
|
|
166
|
+
if (not task_q or task_q in s.ref.task_id.lower())
|
|
167
|
+
and read_auth(s.ref.dag_id, user)
|
|
168
|
+
]
|
|
169
|
+
items = summarize_groups(visible)
|
|
170
|
+
return JSONResponse({"groups": items, "total": len(items)})
|
|
171
|
+
|
|
172
|
+
@router.get(
|
|
173
|
+
"/api/reports/{report_id}",
|
|
174
|
+
summary="Get a run",
|
|
175
|
+
responses={
|
|
176
|
+
**ok({**_EX_SUMMARY, "cases": [_EX_CASE]}),
|
|
177
|
+
**ERR_400,
|
|
178
|
+
**ERR_403,
|
|
179
|
+
**ERR_404,
|
|
180
|
+
},
|
|
181
|
+
)
|
|
66
182
|
def get_report(
|
|
67
183
|
report_id: str,
|
|
68
184
|
user: Any = Depends(user_dep), # noqa: B008 - FastAPI dependency idiom
|
|
@@ -82,7 +198,11 @@ def build_router(deps: RouteDeps) -> APIRouter:
|
|
|
82
198
|
raise HTTPException(status_code=404, detail="report not found")
|
|
83
199
|
return JSONResponse(detail.to_dict())
|
|
84
200
|
|
|
85
|
-
@router.delete(
|
|
201
|
+
@router.delete(
|
|
202
|
+
"/api/reports/{report_id}",
|
|
203
|
+
summary="Delete a run",
|
|
204
|
+
responses={**ok({"deleted": True}), **ERR_400, **ERR_403, **ERR_404},
|
|
205
|
+
)
|
|
86
206
|
def delete_report(
|
|
87
207
|
report_id: str,
|
|
88
208
|
user: Any = Depends(user_dep), # noqa: B008 - FastAPI dependency idiom
|
|
@@ -104,7 +224,17 @@ def build_router(deps: RouteDeps) -> APIRouter:
|
|
|
104
224
|
return JSONResponse({"deleted": True})
|
|
105
225
|
|
|
106
226
|
@router.get(
|
|
107
|
-
"/api/reports/{report_id}/allure.zip",
|
|
227
|
+
"/api/reports/{report_id}/allure.zip",
|
|
228
|
+
summary="Download Allure results",
|
|
229
|
+
responses={
|
|
230
|
+
200: {
|
|
231
|
+
"description": "Allure results archive.",
|
|
232
|
+
"content": {"application/zip": {}},
|
|
233
|
+
},
|
|
234
|
+
**ERR_400,
|
|
235
|
+
**ERR_403,
|
|
236
|
+
**ERR_404,
|
|
237
|
+
},
|
|
108
238
|
)
|
|
109
239
|
def allure_zip(
|
|
110
240
|
report_id: str,
|
|
@@ -132,7 +262,34 @@ def build_router(deps: RouteDeps) -> APIRouter:
|
|
|
132
262
|
},
|
|
133
263
|
)
|
|
134
264
|
|
|
135
|
-
@router.get(
|
|
265
|
+
@router.get(
|
|
266
|
+
"/api/test-history",
|
|
267
|
+
summary="Test history",
|
|
268
|
+
responses={
|
|
269
|
+
**ok(
|
|
270
|
+
{
|
|
271
|
+
"node_id": "tests/test_etl.py::test_load",
|
|
272
|
+
"dag_id": "etl_daily",
|
|
273
|
+
"task_id": "unit_tests",
|
|
274
|
+
"history": [
|
|
275
|
+
{
|
|
276
|
+
"run_id": "scheduled__2026-06-13",
|
|
277
|
+
"created_at": "2026-06-13T23:00:00+00:00",
|
|
278
|
+
"outcome": "passed",
|
|
279
|
+
"duration": 0.3,
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
"run_id": "scheduled__2026-06-12",
|
|
283
|
+
"created_at": "2026-06-12T23:00:00+00:00",
|
|
284
|
+
"outcome": "failed",
|
|
285
|
+
"duration": 0.31,
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
}
|
|
289
|
+
),
|
|
290
|
+
**ERR_403,
|
|
291
|
+
},
|
|
292
|
+
)
|
|
136
293
|
def test_history(
|
|
137
294
|
dag_id: str,
|
|
138
295
|
task_id: str,
|
|
@@ -178,7 +335,23 @@ def build_router(deps: RouteDeps) -> APIRouter:
|
|
|
178
335
|
}
|
|
179
336
|
)
|
|
180
337
|
|
|
181
|
-
@router.get(
|
|
338
|
+
@router.get(
|
|
339
|
+
"/api/unique-tests",
|
|
340
|
+
summary="Unique tests",
|
|
341
|
+
responses=ok(
|
|
342
|
+
{
|
|
343
|
+
"count": 189,
|
|
344
|
+
"capped": False,
|
|
345
|
+
"tests": [
|
|
346
|
+
{
|
|
347
|
+
"node_id": "tests/test_etl.py::test_load",
|
|
348
|
+
"dag_id": "etl_daily",
|
|
349
|
+
"task_id": "unit_tests",
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
}
|
|
353
|
+
),
|
|
354
|
+
)
|
|
182
355
|
def unique_tests(
|
|
183
356
|
dag_id: str | None = None,
|
|
184
357
|
task_id: str | None = None,
|
|
@@ -245,7 +245,12 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
245
245
|
.fb-dagtask { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
246
246
|
.fb-meta { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; flex: 0 0 auto; }
|
|
247
247
|
.fb-empty { color: var(--muted); font-size: 12.5px; padding: 16px 2px; }
|
|
248
|
-
|
|
248
|
+
/* Stacked board: cards size to their content (the row layout's flex:1 1 0 would
|
|
249
|
+
collapse them to a sliver in a column, hiding the chart/flaky behind overflow). */
|
|
250
|
+
@media (max-width: 860px) {
|
|
251
|
+
.board { flex-direction: column; }
|
|
252
|
+
.board > .card { flex: 0 0 auto; }
|
|
253
|
+
}
|
|
249
254
|
/* Unique-tests list: one wrapping column, so a long node id never forces a
|
|
250
255
|
horizontal scrollbar (the dialog body scrolls vertically). */
|
|
251
256
|
.uq-row { padding: 9px 4px; border-bottom: 1px solid var(--border); cursor: pointer;
|
|
@@ -322,21 +327,22 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
322
327
|
.bulk-del:focus-visible, .bulk-close:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }
|
|
323
328
|
/* Checkboxes styled like Airflow's (Chakra): rounded square, brand-blue + white tick when on. */
|
|
324
329
|
.sel-cell { width: 1%; white-space: nowrap; padding-right: 0; }
|
|
325
|
-
.sel-cell input[type="checkbox"], #case-grp, #flk-qonly, #flk-board-qonly, #trend-toggle {
|
|
330
|
+
.sel-cell input[type="checkbox"], #case-grp, #flk-qonly, #flk-board-qonly, #trend-toggle, #list-grp {
|
|
326
331
|
appearance: none; -webkit-appearance: none; margin: 0; width: 16px; height: 16px;
|
|
327
332
|
cursor: pointer; vertical-align: middle; background: var(--surface);
|
|
328
333
|
border: 1px solid var(--border); border-radius: 4px; flex: 0 0 auto;
|
|
329
334
|
display: inline-grid; place-content: center; transition: background .12s, border-color .12s; }
|
|
330
335
|
.sel-cell input[type="checkbox"]:hover, #case-grp:hover, #flk-qonly:hover,
|
|
331
|
-
#flk-board-qonly:hover, #trend-toggle:hover { border-color: var(--primary); }
|
|
336
|
+
#flk-board-qonly:hover, #trend-toggle:hover, #list-grp:hover { border-color: var(--primary); }
|
|
332
337
|
.sel-cell input[type="checkbox"]:focus-visible, #case-grp:focus-visible, #flk-qonly:focus-visible,
|
|
333
|
-
#flk-board-qonly:focus-visible, #trend-toggle:focus-visible {
|
|
338
|
+
#flk-board-qonly:focus-visible, #trend-toggle:focus-visible, #list-grp:focus-visible {
|
|
334
339
|
outline: 2px solid var(--ring); outline-offset: 1px; }
|
|
335
340
|
.sel-cell input[type="checkbox"]:checked, #case-grp:checked, #flk-qonly:checked,
|
|
336
|
-
#flk-board-qonly:checked, #trend-toggle:checked,
|
|
341
|
+
#flk-board-qonly:checked, #trend-toggle:checked, #list-grp:checked,
|
|
342
|
+
.sel-cell input[type="checkbox"]:indeterminate {
|
|
337
343
|
background: var(--primary); border-color: var(--primary); }
|
|
338
344
|
.sel-cell input[type="checkbox"]:checked::after, #case-grp:checked::after, #flk-qonly:checked::after,
|
|
339
|
-
#flk-board-qonly:checked::after, #trend-toggle:checked::after {
|
|
345
|
+
#flk-board-qonly:checked::after, #trend-toggle:checked::after, #list-grp:checked::after {
|
|
340
346
|
content: ""; width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0;
|
|
341
347
|
transform: rotate(45deg) translate(-0.5px, -1px); }
|
|
342
348
|
.sel-cell input[type="checkbox"]:indeterminate::after {
|
|
@@ -349,8 +355,8 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
349
355
|
font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em;
|
|
350
356
|
border-bottom: 1px solid var(--border); user-select: none;
|
|
351
357
|
}
|
|
352
|
-
th.sortable { cursor: pointer; }
|
|
353
|
-
th.sortable:hover { color: var(--fg); }
|
|
358
|
+
th.sortable, th.gsort, th.rsort { cursor: pointer; }
|
|
359
|
+
th.sortable:hover, th.gsort:hover, th.rsort:hover { color: var(--fg); }
|
|
354
360
|
th .arrow { opacity: .9; margin-left: 4px; }
|
|
355
361
|
tbody td { border-bottom: 1px solid var(--border); }
|
|
356
362
|
tbody tr { transition: background .12s; }
|
|
@@ -422,7 +428,8 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
422
428
|
.case-table tr.case.clickable:hover > td:first-child,
|
|
423
429
|
.case-table tr.case[aria-expanded="true"] > td:first-child { background: var(--surface-2); }
|
|
424
430
|
.case-table thead th:first-child { z-index: 3; }
|
|
425
|
-
.chev { display: inline-flex; color: var(--muted);
|
|
431
|
+
.chev { display: inline-flex; width: 16px; justify-content: center; color: var(--muted);
|
|
432
|
+
transition: transform .15s; }
|
|
426
433
|
.case[aria-expanded="true"] .chev { transform: rotate(90deg); }
|
|
427
434
|
.case-exp > td { padding: 0 12px 12px; border-bottom: 1px solid var(--border); }
|
|
428
435
|
.tb { margin: 0; padding: 2px 0 6px 12px; border-left: 2px solid var(--border);
|
|
@@ -480,6 +487,19 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
480
487
|
.case-table tr.grp > td { background: var(--surface-2); font-weight: 600; cursor: pointer;
|
|
481
488
|
user-select: none; position: sticky; left: 0; }
|
|
482
489
|
.case-table tr.grp .chev { transition: transform .15s; }
|
|
490
|
+
/* Run-list grouping by dag·task: toggle control + collapsible group headers. */
|
|
491
|
+
.list-ctrls { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 10px 12px; }
|
|
492
|
+
.list-grp-lbl { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px;
|
|
493
|
+
color: var(--muted); cursor: pointer; white-space: nowrap; }
|
|
494
|
+
#list .table-wrap > table { width: 100%; }
|
|
495
|
+
tr.lgrp > td { background: var(--surface-2); font-weight: 600; cursor: pointer; user-select: none; }
|
|
496
|
+
tr.lgrp .chev { transition: transform .15s; }
|
|
497
|
+
/* Align the grouped "DAG" header over the dag name (clears the chevron's footprint). */
|
|
498
|
+
th.gcol-dag { padding-left: 32px; }
|
|
499
|
+
/* A group's runs sit in their own full sub-table, marked by a left accent rather
|
|
500
|
+
than an indent gap (which looked off). */
|
|
501
|
+
tr.grp-runs > td { padding: 0; border-left: 2px solid var(--primary); background: var(--surface); }
|
|
502
|
+
tr.grp-more td { color: var(--muted); font-size: 12px; text-align: center; padding: 8px; }
|
|
483
503
|
|
|
484
504
|
@media (max-width: 680px) {
|
|
485
505
|
.header-inner { flex-direction: column; align-items: stretch; gap: 10px; padding: 10px 12px; }
|
|
@@ -749,7 +769,7 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
749
769
|
benchTitle: "Test durations (10s buckets)", uniqueTitle: "Unique tests",
|
|
750
770
|
cId: "ID", cStatus: "Status", cDag: "DAG", cTask: "Task", cRun: "Run", cTry: "Try",
|
|
751
771
|
cTotal: "Total", cPass: "Pass", cFail: "Fail", cErr: "Err", cSkip: "Skip",
|
|
752
|
-
cDuration: "Duration", cWhen: "When",
|
|
772
|
+
cDuration: "Duration", cWhen: "When", cRuns: "Runs", cPassRate: "Pass %", cAvgDur: "Avg time",
|
|
753
773
|
kRuns: "Runs", kPassingRuns: "Passing runs", kTests: "Unique tests", kFailures: "Failures",
|
|
754
774
|
kPassed: "Passed", kFailed: "Failed", kErrors: "Errors", kSkipped: "Skipped",
|
|
755
775
|
sPass: "PASS", sFail: "FAIL", sError: "ERROR", success: "success",
|
|
@@ -781,6 +801,8 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
781
801
|
historyFail: "Failed to load history: ", noHistory: "No history for this test.",
|
|
782
802
|
histDidntRun: "did not run",
|
|
783
803
|
caseSearch: "filter tests…", caseGroup: "Group by module",
|
|
804
|
+
listGroup: "Group by dag·task", runsWord: "runs", selectGroup: "Select group",
|
|
805
|
+
groupMore: "Showing 100 of {n} runs — filter to this dag·task to see all.",
|
|
784
806
|
failCapped: "Showing the first {n} failures.",
|
|
785
807
|
loadFail: "Failed to load reports: ", reportFail: "Failed to load report: ",
|
|
786
808
|
failuresFail: "Failed to load failures: ",
|
|
@@ -808,7 +830,7 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
808
830
|
benchTitle: "Время выполнения тестов (по 10с)", uniqueTitle: "Уникальные тесты",
|
|
809
831
|
cId: "ID", cStatus: "Статус", cDag: "DAG", cTask: "Задача", cRun: "Запуск", cTry: "Попытка",
|
|
810
832
|
cTotal: "Всего", cPass: "Усп", cFail: "Пров", cErr: "Ошиб", cSkip: "Проп",
|
|
811
|
-
cDuration: "Время", cWhen: "Когда",
|
|
833
|
+
cDuration: "Время", cWhen: "Когда", cRuns: "Прогоны", cPassRate: "Проход %", cAvgDur: "Ср. время",
|
|
812
834
|
kRuns: "Прогонов", kPassingRuns: "Успешных прогонов", kTests: "Уникальные тесты", kFailures: "Падений",
|
|
813
835
|
kPassed: "Пройдено", kFailed: "Провалено", kErrors: "Ошибки", kSkipped: "Пропущено",
|
|
814
836
|
sPass: "OK", sFail: "СБОЙ", sError: "ОШИБКА", success: "успех",
|
|
@@ -841,6 +863,8 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
841
863
|
historyFail: "Не удалось загрузить историю: ", noHistory: "Истории по этому тесту нет.",
|
|
842
864
|
histDidntRun: "не запускался",
|
|
843
865
|
caseSearch: "фильтр тестов…", caseGroup: "Группировать по модулю",
|
|
866
|
+
listGroup: "Группировать по dag·task", runsWord: "прогонов", selectGroup: "Выбрать группу",
|
|
867
|
+
groupMore: "Показаны 100 из {n} прогонов — отфильтруйте по этому dag·task.",
|
|
844
868
|
failCapped: "Показаны первые {n} падений.",
|
|
845
869
|
loadFail: "Не удалось загрузить отчёты: ", reportFail: "Не удалось загрузить отчёт: ",
|
|
846
870
|
failuresFail: "Не удалось загрузить падения: ",
|
|
@@ -902,6 +926,10 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
902
926
|
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
903
927
|
});
|
|
904
928
|
}
|
|
929
|
+
function debounce(fn, ms) {
|
|
930
|
+
var timer = null;
|
|
931
|
+
return function () { clearTimeout(timer); timer = setTimeout(fn, ms); };
|
|
932
|
+
}
|
|
905
933
|
function fmtDur(s) { return (Number(s) || 0).toFixed(2) + "s"; }
|
|
906
934
|
function fmtTime(s) {
|
|
907
935
|
if (!s) return "—";
|
|
@@ -1028,7 +1056,8 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1028
1056
|
var kpisEl = document.getElementById("kpis");
|
|
1029
1057
|
var allReports = []; // everything fetched
|
|
1030
1058
|
var reports = []; // the current filtered view
|
|
1031
|
-
var sort = { key: "created_at", dir: -1 };
|
|
1059
|
+
var sort = { key: "created_at", dir: -1 }; // run-level sort (flat list + within a group)
|
|
1060
|
+
var groupSort = { key: "created_at", dir: -1 }; // order of the dag·task groups
|
|
1032
1061
|
|
|
1033
1062
|
// Chart status series: [status key, report field, colour].
|
|
1034
1063
|
var ORDER = [
|
|
@@ -1045,6 +1074,10 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1045
1074
|
var chartScroll = null; // null => snap to newest; else a remembered scrollLeft (px)
|
|
1046
1075
|
var chartDragged = false;
|
|
1047
1076
|
var listPage = 0;
|
|
1077
|
+
var listGroup = true; // group the run list by dag·task (on by default; checkbox-toggled)
|
|
1078
|
+
var listExpanded = {}; // group key -> expanded? (collapsed by default)
|
|
1079
|
+
var groupRunSort = {}; // group key -> {key,dir} run-sort override for that group only
|
|
1080
|
+
var GROUP_ROW_CAP = 100; // max run rows rendered per expanded group (bounds the DOM)
|
|
1048
1081
|
var selectedIds = new Set(); // report ids ticked for bulk delete
|
|
1049
1082
|
|
|
1050
1083
|
var COLS = [
|
|
@@ -1413,17 +1446,20 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1413
1446
|
syncSelAll(); updateBulkBar(); renderChart();
|
|
1414
1447
|
}
|
|
1415
1448
|
|
|
1416
|
-
|
|
1417
|
-
|
|
1449
|
+
// Comparator for a given run-sort state {key, dir} -- reused by the flat list and
|
|
1450
|
+
// by each group (which may carry its own override).
|
|
1451
|
+
function runComparator(s) {
|
|
1452
|
+
var col = COLS.filter(function (c) { return c.key === s.key; })[0] || COLS[0];
|
|
1418
1453
|
var getv = col.get || function (r) { return r[col.key]; };
|
|
1419
|
-
|
|
1454
|
+
return function (a, b) {
|
|
1420
1455
|
var x = getv(a), y = getv(b);
|
|
1421
1456
|
if (typeof x === "string") { x = x.toLowerCase(); y = String(y).toLowerCase(); }
|
|
1422
|
-
if (x < y) return -1 *
|
|
1423
|
-
if (x > y) return 1 *
|
|
1457
|
+
if (x < y) return -1 * s.dir;
|
|
1458
|
+
if (x > y) return 1 * s.dir;
|
|
1424
1459
|
return 0;
|
|
1425
|
-
}
|
|
1460
|
+
};
|
|
1426
1461
|
}
|
|
1462
|
+
function sortReports() { reports.sort(runComparator(sort)); }
|
|
1427
1463
|
|
|
1428
1464
|
function renderRows(rows) {
|
|
1429
1465
|
return rows.map(function (r) {
|
|
@@ -1450,6 +1486,88 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1450
1486
|
return '<span class="arrow">' + (sort.dir === 1 ? "↑" : "↓") + "</span>";
|
|
1451
1487
|
}
|
|
1452
1488
|
|
|
1489
|
+
// Group the run list by dag·task: a checkbox toggles it (like the detail's
|
|
1490
|
+
// group-by-module). Each header collapses/expands and its checkbox selects the whole
|
|
1491
|
+
// group, which focuses the chart on it.
|
|
1492
|
+
function groupReports(rows) {
|
|
1493
|
+
var order = [], byKey = {};
|
|
1494
|
+
rows.forEach(function (r) {
|
|
1495
|
+
var key = JSON.stringify([r.dag_id, r.task_id]);
|
|
1496
|
+
if (!byKey[key]) {
|
|
1497
|
+
byKey[key] = { key: key, dag: r.dag_id, task: r.task_id, runs: [] };
|
|
1498
|
+
order.push(byKey[key]);
|
|
1499
|
+
}
|
|
1500
|
+
byKey[key].runs.push(r);
|
|
1501
|
+
});
|
|
1502
|
+
// The newest run drives the group's status / when and the status/recency
|
|
1503
|
+
// ordering -- independent of how runs are sorted within the group.
|
|
1504
|
+
order.forEach(function (g) {
|
|
1505
|
+
g.newest = g.runs.reduce(function (a, b) {
|
|
1506
|
+
return String(a.created_at || "") >= String(b.created_at || "") ? a : b;
|
|
1507
|
+
});
|
|
1508
|
+
g.passed = g.runs.filter(function (r) { return r.success; }).length;
|
|
1509
|
+
g.avgDur = g.runs.reduce(function (s, r) { return s + (+r.duration || 0); }, 0) / g.runs.length;
|
|
1510
|
+
});
|
|
1511
|
+
return order;
|
|
1512
|
+
}
|
|
1513
|
+
// Flat-list column headers -- sorted by the global run sort.
|
|
1514
|
+
function headCells() {
|
|
1515
|
+
return COLS.map(function (c) {
|
|
1516
|
+
var asc = sort.key === c.key ? (sort.dir === 1 ? "ascending" : "descending") : "none";
|
|
1517
|
+
return '<th class="sortable" data-key="' + c.key + '" aria-sort="' + asc + '">'
|
|
1518
|
+
+ esc(t(c.label)) + arrow(c.key) + "</th>";
|
|
1519
|
+
}).join("");
|
|
1520
|
+
}
|
|
1521
|
+
// A group's full column header -- sorts the runs of THAT group only (class rsort,
|
|
1522
|
+
// tagged with the group key); arrows reflect the group's effective sort `eff`.
|
|
1523
|
+
function subHead(g, eff) {
|
|
1524
|
+
var cells = COLS.map(function (c) {
|
|
1525
|
+
var on = eff.key === c.key;
|
|
1526
|
+
var ar = on ? '<span class="arrow">' + (eff.dir === 1 ? "↑" : "↓") + "</span>" : "";
|
|
1527
|
+
return '<th class="rsort" data-key="' + c.key + '" data-gkey="' + esc(g.key)
|
|
1528
|
+
+ '" aria-sort="' + (on ? (eff.dir === 1 ? "ascending" : "descending") : "none") + '">'
|
|
1529
|
+
+ esc(t(c.label)) + ar + "</th>";
|
|
1530
|
+
}).join("");
|
|
1531
|
+
return '<th class="sel-cell"></th>' + cells + "<th></th>";
|
|
1532
|
+
}
|
|
1533
|
+
// A group-level column header (reorders the groups; uses the separate groupSort).
|
|
1534
|
+
function gHeadCell(key, label, cls) {
|
|
1535
|
+
var asc = groupSort.key === key ? (groupSort.dir === 1 ? "ascending" : "descending") : "none";
|
|
1536
|
+
return '<th class="gsort' + (cls ? " " + cls : "") + '" data-key="' + key
|
|
1537
|
+
+ '" aria-sort="' + asc + '">' + esc(t(label)) + groupArrow(key) + "</th>";
|
|
1538
|
+
}
|
|
1539
|
+
function groupArrow(key) {
|
|
1540
|
+
if (groupSort.key !== key) return "";
|
|
1541
|
+
return '<span class="arrow">' + (groupSort.dir === 1 ? "↑" : "↓") + "</span>";
|
|
1542
|
+
}
|
|
1543
|
+
// A group's value for the active group-sort column.
|
|
1544
|
+
function groupVal(g) {
|
|
1545
|
+
if (groupSort.key === "dag_id") return g.dag.toLowerCase();
|
|
1546
|
+
if (groupSort.key === "task_id") return g.task.toLowerCase();
|
|
1547
|
+
if (groupSort.key === "runs") return g.runs.length;
|
|
1548
|
+
if (groupSort.key === "pass_rate") return g.passed / g.runs.length;
|
|
1549
|
+
if (groupSort.key === "avg_dur") return g.avgDur;
|
|
1550
|
+
if (groupSort.key === "status") return g.newest.success ? 2 : (g.newest.errors ? 0 : 1);
|
|
1551
|
+
return String(g.newest.created_at || ""); // created_at, and the default
|
|
1552
|
+
}
|
|
1553
|
+
function groupHeaderHtml(g) {
|
|
1554
|
+
var exp = !!listExpanded[g.key];
|
|
1555
|
+
var nSel = g.runs.filter(function (r) { return selectedIds.has(r.id); }).length;
|
|
1556
|
+
var rate = Math.round(g.passed / g.runs.length * 100);
|
|
1557
|
+
var st = statusOf(g.newest);
|
|
1558
|
+
return '<tr class="lgrp" data-key="' + esc(g.key) + '">'
|
|
1559
|
+
+ '<td class="sel-cell"><input type="checkbox" class="gsel" data-key="' + esc(g.key) + '"'
|
|
1560
|
+
+ (nSel === g.runs.length ? " checked" : "") + ' aria-label="' + esc(t("selectGroup")) + '"></td>'
|
|
1561
|
+
+ '<td class="mono"><span class="chev"' + (exp ? ' style="transform:rotate(90deg)"' : "")
|
|
1562
|
+
+ ">" + CHEV + "</span> " + esc(g.dag) + "</td>"
|
|
1563
|
+
+ '<td class="mono">' + esc(g.task) + "</td>"
|
|
1564
|
+
+ '<td class="num">' + g.runs.length + "</td>"
|
|
1565
|
+
+ '<td class="num">' + rate + "%</td>"
|
|
1566
|
+
+ '<td class="num">' + fmtDur(g.avgDur) + "</td>"
|
|
1567
|
+
+ "<td>" + badge(st, statusLabel(st)) + "</td>"
|
|
1568
|
+
+ '<td class="muted">' + fmtTime(g.newest.created_at) + "</td></tr>";
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1453
1571
|
function renderList() {
|
|
1454
1572
|
if (!reports.length) {
|
|
1455
1573
|
listEl.innerHTML = '<div class="state">'
|
|
@@ -1458,27 +1576,63 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1458
1576
|
return;
|
|
1459
1577
|
}
|
|
1460
1578
|
sortReports();
|
|
1461
|
-
var
|
|
1462
|
-
|
|
1463
|
-
var pageRows = reports.slice(listPage * PAGE_SIZE, listPage * PAGE_SIZE + PAGE_SIZE);
|
|
1464
|
-
|
|
1465
|
-
var head = COLS.map(function (c) {
|
|
1466
|
-
var asc = sort.key === c.key ? (sort.dir === 1 ? "ascending" : "descending") : "none";
|
|
1467
|
-
return '<th class="sortable" data-key="' + c.key + '" aria-sort="' + asc + '">'
|
|
1468
|
-
+ esc(t(c.label)) + arrow(c.key) + "</th>";
|
|
1469
|
-
}).join("");
|
|
1470
|
-
var pager = pages > 1
|
|
1471
|
-
? '<div class="pager"><button type="button" class="nav-btn" id="pg-prev"'
|
|
1472
|
-
+ (listPage <= 0 ? " disabled" : "") + ' aria-label="' + esc(t("prevPage")) + '">‹</button>'
|
|
1473
|
-
+ "<span>" + esc(t("page")) + " " + (listPage + 1) + " / " + pages + "</span>"
|
|
1474
|
-
+ '<button type="button" class="nav-btn" id="pg-next"'
|
|
1475
|
-
+ (listPage >= pages - 1 ? " disabled" : "") + ' aria-label="' + esc(t("nextPage")) + '">›</button></div>'
|
|
1476
|
-
: "";
|
|
1579
|
+
var ctrls = '<div class="list-ctrls"><label class="list-grp-lbl"><input type="checkbox" id="list-grp"'
|
|
1580
|
+
+ (listGroup ? " checked" : "") + "> " + esc(t("listGroup")) + "</label></div>";
|
|
1477
1581
|
var selAllTh = '<th class="sel-cell"><input type="checkbox" id="sel-all" aria-label="'
|
|
1478
1582
|
+ esc(t("selectAll")) + '"></th>';
|
|
1479
|
-
listEl.innerHTML = '<div class="table-wrap"><table><thead><tr>' + selAllTh + head
|
|
1480
|
-
+ "<th></th></tr></thead><tbody>" + renderRows(pageRows) + "</tbody></table></div>" + pager;
|
|
1481
1583
|
|
|
1584
|
+
var pages = 1, keyMap = {}, theadInner, body, pager = "";
|
|
1585
|
+
if (listGroup) {
|
|
1586
|
+
// The top header (gsort) reorders the GROUPS -- and, for run columns, the runs
|
|
1587
|
+
// too (handled in the click). Each opened group's own header (rsort) sorts only
|
|
1588
|
+
// that group's runs (groupRunSort override, else the global run sort).
|
|
1589
|
+
theadInner = selAllTh + gHeadCell("dag_id", "cDag", "gcol-dag")
|
|
1590
|
+
+ gHeadCell("task_id", "cTask")
|
|
1591
|
+
+ gHeadCell("runs", "cRuns") + gHeadCell("pass_rate", "cPassRate")
|
|
1592
|
+
+ gHeadCell("avg_dur", "cAvgDur") + gHeadCell("status", "cStatus")
|
|
1593
|
+
+ gHeadCell("created_at", "cWhen");
|
|
1594
|
+
var groups = groupReports(reports);
|
|
1595
|
+
groups.forEach(function (g) { keyMap[g.key] = g; });
|
|
1596
|
+
groups.sort(function (a, b) {
|
|
1597
|
+
var x = groupVal(a), y = groupVal(b);
|
|
1598
|
+
if (x < y) return -1 * groupSort.dir;
|
|
1599
|
+
if (x > y) return 1 * groupSort.dir;
|
|
1600
|
+
return 0;
|
|
1601
|
+
});
|
|
1602
|
+
body = groups.map(function (g) {
|
|
1603
|
+
if (!listExpanded[g.key]) return groupHeaderHtml(g);
|
|
1604
|
+
var eff = groupRunSort[g.key] || sort; // this group's own run sort, else global
|
|
1605
|
+
var runs = g.runs.slice().sort(runComparator(eff));
|
|
1606
|
+
var more = g.runs.length > GROUP_ROW_CAP
|
|
1607
|
+
? '<tr class="grp-more"><td colspan="' + (COLS.length + 2) + '">'
|
|
1608
|
+
+ esc(t("groupMore").replace("{n}", String(g.runs.length))) + "</td></tr>"
|
|
1609
|
+
: "";
|
|
1610
|
+
return groupHeaderHtml(g)
|
|
1611
|
+
+ '<tr class="grp-runs"><td colspan="8"><div class="table-wrap">'
|
|
1612
|
+
+ '<table class="sub-table"><thead><tr>' + subHead(g, eff) + "</tr></thead><tbody>"
|
|
1613
|
+
+ renderRows(runs.slice(0, GROUP_ROW_CAP)) + more + "</tbody></table></div></td></tr>";
|
|
1614
|
+
}).join("");
|
|
1615
|
+
} else {
|
|
1616
|
+
theadInner = selAllTh + headCells() + "<th></th>";
|
|
1617
|
+
pages = Math.ceil(reports.length / PAGE_SIZE);
|
|
1618
|
+
listPage = Math.max(0, Math.min(listPage, pages - 1));
|
|
1619
|
+
body = renderRows(reports.slice(listPage * PAGE_SIZE, listPage * PAGE_SIZE + PAGE_SIZE));
|
|
1620
|
+
pager = pages > 1
|
|
1621
|
+
? '<div class="pager"><button type="button" class="nav-btn" id="pg-prev"'
|
|
1622
|
+
+ (listPage <= 0 ? " disabled" : "") + ' aria-label="' + esc(t("prevPage")) + '">‹</button>'
|
|
1623
|
+
+ "<span>" + esc(t("page")) + " " + (listPage + 1) + " / " + pages + "</span>"
|
|
1624
|
+
+ '<button type="button" class="nav-btn" id="pg-next"'
|
|
1625
|
+
+ (listPage >= pages - 1 ? " disabled" : "") + ' aria-label="' + esc(t("nextPage")) + '">›</button></div>'
|
|
1626
|
+
: "";
|
|
1627
|
+
}
|
|
1628
|
+
listEl.innerHTML = ctrls + '<div class="table-wrap"><table><thead><tr>' + theadInner
|
|
1629
|
+
+ "</tr></thead><tbody>" + body + "</tbody></table></div>" + pager;
|
|
1630
|
+
|
|
1631
|
+
var lg = document.getElementById("list-grp");
|
|
1632
|
+
if (lg) lg.addEventListener("change", function () {
|
|
1633
|
+
// Start the (re)entered view clean: folded, no stale per-group sort overrides.
|
|
1634
|
+
listGroup = lg.checked; listExpanded = {}; groupRunSort = {}; listPage = 0; renderList();
|
|
1635
|
+
});
|
|
1482
1636
|
listEl.querySelectorAll("th.sortable").forEach(function (th) {
|
|
1483
1637
|
th.addEventListener("click", function () {
|
|
1484
1638
|
var k = th.getAttribute("data-key");
|
|
@@ -1487,9 +1641,48 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1487
1641
|
renderList();
|
|
1488
1642
|
});
|
|
1489
1643
|
});
|
|
1644
|
+
listEl.querySelectorAll("th.gsort").forEach(function (th) { // top header: groups + tests
|
|
1645
|
+
th.addEventListener("click", function () {
|
|
1646
|
+
var k = th.getAttribute("data-key");
|
|
1647
|
+
if (groupSort.key === k) groupSort.dir *= -1; else { groupSort.key = k; groupSort.dir = 1; }
|
|
1648
|
+
groupRunSort = {}; // drop per-group overrides -- the top header is the global control
|
|
1649
|
+
// If the column maps to a run field, move the runs (in every group) too.
|
|
1650
|
+
if (COLS.some(function (c) { return c.key === k; })) { sort.key = k; sort.dir = groupSort.dir; }
|
|
1651
|
+
renderList();
|
|
1652
|
+
});
|
|
1653
|
+
});
|
|
1654
|
+
listEl.querySelectorAll("th.rsort").forEach(function (th) { // sort one group's runs only
|
|
1655
|
+
th.addEventListener("click", function () {
|
|
1656
|
+
var gk = th.getAttribute("data-gkey"), k = th.getAttribute("data-key");
|
|
1657
|
+
var cur = groupRunSort[gk] || { key: sort.key, dir: sort.dir };
|
|
1658
|
+
groupRunSort[gk] = cur.key === k ? { key: k, dir: -cur.dir } : { key: k, dir: 1 };
|
|
1659
|
+
renderList();
|
|
1660
|
+
});
|
|
1661
|
+
});
|
|
1490
1662
|
var pgPrev = document.getElementById("pg-prev"), pgNext = document.getElementById("pg-next");
|
|
1491
1663
|
if (pgPrev) pgPrev.addEventListener("click", function () { if (listPage > 0) { listPage--; renderList(); } });
|
|
1492
1664
|
if (pgNext) pgNext.addEventListener("click", function () { if (listPage < pages - 1) { listPage++; renderList(); } });
|
|
1665
|
+
// Group headers: row toggles expand/collapse; its checkbox selects the whole group
|
|
1666
|
+
// (selecting focuses the chart, and ticks the group even while collapsed).
|
|
1667
|
+
listEl.querySelectorAll("tr.lgrp").forEach(function (tr) {
|
|
1668
|
+
tr.addEventListener("click", function () {
|
|
1669
|
+
var key = tr.getAttribute("data-key");
|
|
1670
|
+
listExpanded[key] = !listExpanded[key];
|
|
1671
|
+
renderList();
|
|
1672
|
+
});
|
|
1673
|
+
});
|
|
1674
|
+
listEl.querySelectorAll(".gsel").forEach(function (cb) {
|
|
1675
|
+
var g = keyMap[cb.getAttribute("data-key")];
|
|
1676
|
+
var nSel = g.runs.filter(function (r) { return selectedIds.has(r.id); }).length;
|
|
1677
|
+
cb.indeterminate = nSel > 0 && nSel < g.runs.length;
|
|
1678
|
+
cb.addEventListener("click", function (e) { e.stopPropagation(); });
|
|
1679
|
+
cb.addEventListener("change", function () {
|
|
1680
|
+
g.runs.forEach(function (r) {
|
|
1681
|
+
if (cb.checked) selectedIds.add(r.id); else selectedIds.delete(r.id);
|
|
1682
|
+
});
|
|
1683
|
+
renderChart(); renderList(); updateBulkBar();
|
|
1684
|
+
});
|
|
1685
|
+
});
|
|
1493
1686
|
listEl.querySelectorAll(".row-del").forEach(function (b) {
|
|
1494
1687
|
b.addEventListener("click", function (e) {
|
|
1495
1688
|
e.stopPropagation(); // don't open the detail when deleting
|
|
@@ -1508,25 +1701,54 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1508
1701
|
cb.addEventListener("change", function () {
|
|
1509
1702
|
var id = cb.getAttribute("data-id");
|
|
1510
1703
|
if (cb.checked) selectedIds.add(id); else selectedIds.delete(id);
|
|
1511
|
-
syncSelAll(); updateBulkBar(); renderChart(); // chart follows
|
|
1704
|
+
syncSelAll(); syncGroupChecks(); updateBulkBar(); renderChart(); // chart follows
|
|
1512
1705
|
});
|
|
1513
1706
|
});
|
|
1514
1707
|
var selAll = document.getElementById("sel-all");
|
|
1515
1708
|
if (selAll) selAll.addEventListener("change", function () {
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1709
|
+
if (listGroup) {
|
|
1710
|
+
// Select every run across all groups (even collapsed) and tick the groups.
|
|
1711
|
+
reports.forEach(function (r) {
|
|
1712
|
+
if (selAll.checked) selectedIds.add(r.id); else selectedIds.delete(r.id);
|
|
1713
|
+
});
|
|
1714
|
+
listEl.querySelectorAll(".sel").forEach(function (cb) { cb.checked = selAll.checked; });
|
|
1715
|
+
syncGroupChecks();
|
|
1716
|
+
} else {
|
|
1717
|
+
listEl.querySelectorAll(".sel").forEach(function (cb) {
|
|
1718
|
+
cb.checked = selAll.checked;
|
|
1719
|
+
var id = cb.getAttribute("data-id");
|
|
1720
|
+
if (selAll.checked) selectedIds.add(id); else selectedIds.delete(id);
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1521
1723
|
selAll.indeterminate = false;
|
|
1522
1724
|
updateBulkBar(); renderChart();
|
|
1523
1725
|
});
|
|
1524
1726
|
syncSelAll(); updateBulkBar();
|
|
1525
1727
|
}
|
|
1526
1728
|
|
|
1729
|
+
// Reflect the current selection onto the group checkboxes (incl. collapsed groups).
|
|
1730
|
+
function syncGroupChecks() {
|
|
1731
|
+
if (!listGroup) return;
|
|
1732
|
+
var byKey = {};
|
|
1733
|
+
groupReports(reports).forEach(function (g) { byKey[g.key] = g; });
|
|
1734
|
+
listEl.querySelectorAll(".gsel").forEach(function (cb) {
|
|
1735
|
+
var g = byKey[cb.getAttribute("data-key")];
|
|
1736
|
+
if (!g) return;
|
|
1737
|
+
var n = g.runs.filter(function (r) { return selectedIds.has(r.id); }).length;
|
|
1738
|
+
cb.checked = n === g.runs.length;
|
|
1739
|
+
cb.indeterminate = n > 0 && n < g.runs.length;
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1527
1743
|
function syncSelAll() {
|
|
1528
1744
|
var selAll = document.getElementById("sel-all");
|
|
1529
1745
|
if (!selAll) return;
|
|
1746
|
+
if (listGroup) { // count over all runs, since collapsed groups render no rows
|
|
1747
|
+
var sn = reports.filter(function (r) { return selectedIds.has(r.id); }).length;
|
|
1748
|
+
selAll.checked = reports.length > 0 && sn === reports.length;
|
|
1749
|
+
selAll.indeterminate = sn > 0 && sn < reports.length;
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1530
1752
|
var boxes = listEl.querySelectorAll(".sel"), n = 0;
|
|
1531
1753
|
boxes.forEach(function (cb) { if (cb.checked) n++; });
|
|
1532
1754
|
selAll.checked = boxes.length > 0 && n === boxes.length;
|
|
@@ -2589,8 +2811,13 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
2589
2811
|
document.addEventListener("pointermove", chartDragMove);
|
|
2590
2812
|
document.addEventListener("pointerup", chartDragEnd);
|
|
2591
2813
|
document.addEventListener("pointercancel", chartDragEnd);
|
|
2814
|
+
// Debounce the top filters: each keystroke otherwise re-renders the whole page
|
|
2815
|
+
// (chart + list + KPIs + flaky) -- costly on large datasets. Call with no arg so it
|
|
2816
|
+
// resets to page 1 / newest (binding the listener directly passes the Event as the
|
|
2817
|
+
// keepPage flag, which wrongly preserved the page).
|
|
2818
|
+
var debouncedFilter = debounce(function () { applyFilter(); }, 150);
|
|
2592
2819
|
["f-dag", "f-task", "f-run"].forEach(function (id) {
|
|
2593
|
-
document.getElementById(id).addEventListener("input",
|
|
2820
|
+
document.getElementById(id).addEventListener("input", debouncedFilter);
|
|
2594
2821
|
});
|
|
2595
2822
|
// Flaky-panel search + quarantined-only filter: client-side, re-render in place.
|
|
2596
2823
|
var flkBoardQ = document.getElementById("flk-board-q");
|
|
@@ -147,6 +147,24 @@ def test_index_has_feature_markers(client):
|
|
|
147
147
|
"trend-line",
|
|
148
148
|
"trend-thresh",
|
|
149
149
|
"trend-on",
|
|
150
|
+
'id="list-grp"',
|
|
151
|
+
"groupReports",
|
|
152
|
+
"listGroup",
|
|
153
|
+
"lgrp",
|
|
154
|
+
"gsel",
|
|
155
|
+
"grp-runs",
|
|
156
|
+
"syncGroupChecks",
|
|
157
|
+
"groupVal",
|
|
158
|
+
"groupMore",
|
|
159
|
+
"gHeadCell",
|
|
160
|
+
"gsort",
|
|
161
|
+
"groupArrow",
|
|
162
|
+
"headCells",
|
|
163
|
+
"cPassRate",
|
|
164
|
+
"rsort",
|
|
165
|
+
"groupRunSort",
|
|
166
|
+
"runComparator",
|
|
167
|
+
"cAvgDur",
|
|
150
168
|
"fillCases",
|
|
151
169
|
'id="case-q"',
|
|
152
170
|
'id="links-btn"',
|
|
@@ -191,6 +209,7 @@ def test_openapi_and_docs_serve(client):
|
|
|
191
209
|
doc = spec.json()
|
|
192
210
|
paths = doc["paths"]
|
|
193
211
|
assert "/api/flaky" in paths and "/api/test-history" in paths
|
|
212
|
+
assert "/api/groups" in paths and paths["/api/groups"]["get"]["tags"] == ["reports"]
|
|
194
213
|
# Routes are grouped into the documented sections; the UI assets stay out of it.
|
|
195
214
|
assert [t["name"] for t in doc["tags"]] == [
|
|
196
215
|
"monitoring",
|
|
@@ -206,6 +225,24 @@ def test_openapi_and_docs_serve(client):
|
|
|
206
225
|
assert "/icon.svg" not in paths and "/" not in paths # icons + viewer hidden
|
|
207
226
|
assert paths["/api/flaky"]["get"]["summary"] # every method has a summary
|
|
208
227
|
|
|
228
|
+
# Responses are documented with a real JSON example (not a bare "string") ...
|
|
229
|
+
def ex(path, method):
|
|
230
|
+
return paths[path][method]["responses"]["200"]["content"]["application/json"][
|
|
231
|
+
"example"
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
assert "reports" in ex("/api/reports", "get")
|
|
235
|
+
assert ex("/api/groups", "get")["groups"][0]["avg_duration"] is not None
|
|
236
|
+
assert "flaky" in ex("/api/flaky", "get")
|
|
237
|
+
# ... and the error status codes each route can return are declared.
|
|
238
|
+
for path, method in [
|
|
239
|
+
("/api/reports/{report_id}", "get"),
|
|
240
|
+
("/api/reports/{report_id}", "delete"),
|
|
241
|
+
("/api/compare", "get"),
|
|
242
|
+
]:
|
|
243
|
+
codes = set(paths[path][method]["responses"])
|
|
244
|
+
assert {"400", "403", "404"} <= codes, (path, method, codes)
|
|
245
|
+
|
|
209
246
|
|
|
210
247
|
def test_icon_routes(client):
|
|
211
248
|
for path in ("/icon.svg", "/icon-dark.svg"):
|
|
@@ -235,6 +272,63 @@ def test_reports_success_reflects_pass_rate_threshold(reports_root):
|
|
|
235
272
|
assert body["success_threshold"] == 0.85
|
|
236
273
|
|
|
237
274
|
|
|
275
|
+
def test_summarize_groups_aggregates_by_dag_task():
|
|
276
|
+
from airflow_pytest_plugin.models import ReportRef, ReportSummary
|
|
277
|
+
from airflow_pytest_plugin.web.routes.reports import summarize_groups
|
|
278
|
+
|
|
279
|
+
def mk(dag, task, run, created, *, success, errors=0, duration=0.1):
|
|
280
|
+
return ReportSummary(
|
|
281
|
+
ReportRef(dag, run, task, 1),
|
|
282
|
+
1,
|
|
283
|
+
1 if success else 0,
|
|
284
|
+
0 if success else 1,
|
|
285
|
+
0,
|
|
286
|
+
errors,
|
|
287
|
+
duration,
|
|
288
|
+
success,
|
|
289
|
+
created_at=created,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
groups = summarize_groups(
|
|
293
|
+
[
|
|
294
|
+
mk("d", "t", "r1", "2026-06-01T00:00:00+00:00", success=True, duration=2.0),
|
|
295
|
+
mk(
|
|
296
|
+
"d", "t", "r2", "2026-06-03T00:00:00+00:00", success=False, duration=4.0
|
|
297
|
+
), # newest of d·t
|
|
298
|
+
mk("d", "u", "r3", "2026-06-02T00:00:00+00:00", success=True),
|
|
299
|
+
mk("d", "e", "r4", "2026-05-01T00:00:00+00:00", success=False, errors=1),
|
|
300
|
+
]
|
|
301
|
+
)
|
|
302
|
+
by = {(g["dag_id"], g["task_id"]): g for g in groups}
|
|
303
|
+
assert by[("d", "t")]["runs"] == 2 and by[("d", "t")]["passed"] == 1
|
|
304
|
+
assert by[("d", "t")]["pass_rate"] == 0.5
|
|
305
|
+
assert by[("d", "t")]["avg_duration"] == 3.0 # (2.0 + 4.0) / 2
|
|
306
|
+
assert by[("d", "t")]["last_status"] == "failed" # newest run (r2) failed
|
|
307
|
+
assert (
|
|
308
|
+
by[("d", "u")]["pass_rate"] == 1.0 and by[("d", "u")]["last_status"] == "passed"
|
|
309
|
+
)
|
|
310
|
+
assert by[("d", "e")]["last_status"] == "error" # newest run errored
|
|
311
|
+
# Ordered by most-recent activity (d·t Jun 3, d·u Jun 2, d·e May 1).
|
|
312
|
+
assert [(g["dag_id"], g["task_id"]) for g in groups] == [
|
|
313
|
+
("d", "t"),
|
|
314
|
+
("d", "u"),
|
|
315
|
+
("d", "e"),
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_groups_endpoint_and_rbac(reports_root):
|
|
320
|
+
write_report(reports_root, ReportRef("dagA", "r1", "taskX", 1), passed=2)
|
|
321
|
+
write_report(reports_root, ReportRef("dagA", "r2", "taskX", 1), passed=1, failed=1)
|
|
322
|
+
write_report(reports_root, ReportRef("dagB", "r3", "taskY", 1), passed=3)
|
|
323
|
+
body = TestClient(make_app(reports_root)).get("/api/groups").json()
|
|
324
|
+
by = {(g["dag_id"], g["task_id"]): g for g in body["groups"]}
|
|
325
|
+
assert body["total"] == 2
|
|
326
|
+
assert by[("dagA", "taskX")]["runs"] == 2 and by[("dagB", "taskY")]["runs"] == 1
|
|
327
|
+
# RBAC: a reader denied dagB sees only its permitted group.
|
|
328
|
+
c = TestClient(make_app(reports_root, read_authorizer=lambda dag, u: dag == "dagA"))
|
|
329
|
+
assert {g["dag_id"] for g in c.get("/api/groups").json()["groups"]} == {"dagA"}
|
|
330
|
+
|
|
331
|
+
|
|
238
332
|
def test_detail_endpoint_round_trips_token(client):
|
|
239
333
|
token = client.get("/api/reports").json()["reports"][0]["id"]
|
|
240
334
|
r = client.get(f"/api/reports/{token}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/config.py
RENAMED
|
File without changes
|
{airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/layout.py
RENAMED
|
File without changes
|
{airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/models.py
RENAMED
|
File without changes
|
{airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/plugin.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/retention.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/version.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{airflow_pytest_plugin-0.3.1 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/app.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|