dagwick 0.1.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.
- dagwick/__init__.py +6 -0
- dagwick/api.py +183 -0
- dagwick/checks/__init__.py +5 -0
- dagwick/checks/_util.py +32 -0
- dagwick/checks/observability.py +40 -0
- dagwick/checks/ownership.py +53 -0
- dagwick/checks/reliability.py +57 -0
- dagwick/checks/scheduling.py +75 -0
- dagwick/checks/structure.py +40 -0
- dagwick/core.py +178 -0
- dagwick/plugin.py +33 -0
- dagwick/py.typed +0 -0
- dagwick/static/dagwick.js +328 -0
- dagwick/static/icon.svg +1 -0
- dagwick-0.1.0.dist-info/METADATA +80 -0
- dagwick-0.1.0.dist-info/RECORD +19 -0
- dagwick-0.1.0.dist-info/WHEEL +4 -0
- dagwick-0.1.0.dist-info/entry_points.txt +2 -0
- dagwick-0.1.0.dist-info/licenses/LICENSE +21 -0
dagwick/__init__.py
ADDED
dagwick/api.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""HTTP API served inside the Airflow api-server under ``/dagwick``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated, Any
|
|
10
|
+
|
|
11
|
+
from fastapi import Depends, FastAPI, HTTPException, Query
|
|
12
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
13
|
+
|
|
14
|
+
from dagwick import __version__
|
|
15
|
+
from dagwick.core import registered_checks, score_dag
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
19
|
+
FLEET_LIMIT = 2000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _require_dag_read() -> Any:
|
|
23
|
+
"""Reuse Airflow's own DAG permission check (same rules as the REST API)."""
|
|
24
|
+
from airflow.api_fastapi.core_api.security import requires_access_dag
|
|
25
|
+
|
|
26
|
+
return Depends(requires_access_dag(method="GET"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _require_login() -> Any:
|
|
30
|
+
from airflow.api_fastapi.core_api.security import requires_authenticated
|
|
31
|
+
|
|
32
|
+
return Depends(requires_authenticated())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _user() -> Any:
|
|
36
|
+
"""Airflow's own user resolver: Bearer header, OAuth token, or the UI cookie."""
|
|
37
|
+
from airflow.api_fastapi.core_api.security import get_user
|
|
38
|
+
|
|
39
|
+
return Depends(get_user)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _ScoreCache:
|
|
43
|
+
"""Scores keyed by (dag_id, dag_hash): a DAG is re-scored only when its code changes."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, max_entries: int = 5000) -> None:
|
|
46
|
+
self._data: dict[str, tuple[str, dict[str, Any]]] = {}
|
|
47
|
+
self._lock = threading.Lock()
|
|
48
|
+
self._max = max_entries
|
|
49
|
+
|
|
50
|
+
def get(self, dag_id: str, dag_hash: str) -> dict[str, Any] | None:
|
|
51
|
+
with self._lock:
|
|
52
|
+
hit = self._data.get(dag_id)
|
|
53
|
+
return hit[1] if hit and hit[0] == dag_hash else None
|
|
54
|
+
|
|
55
|
+
def put(self, dag_id: str, dag_hash: str, report: dict[str, Any]) -> None:
|
|
56
|
+
with self._lock:
|
|
57
|
+
if dag_id not in self._data and len(self._data) >= self._max:
|
|
58
|
+
self._data.pop(next(iter(self._data)))
|
|
59
|
+
self._data[dag_id] = (dag_hash, report)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_cache = _ScoreCache()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _score_by_id(dag_id: str) -> dict[str, Any] | None:
|
|
66
|
+
from airflow.models.serialized_dag import SerializedDagModel
|
|
67
|
+
|
|
68
|
+
sdm = SerializedDagModel.get(dag_id)
|
|
69
|
+
if sdm is None:
|
|
70
|
+
return None
|
|
71
|
+
cached = _cache.get(dag_id, sdm.dag_hash)
|
|
72
|
+
if cached is not None:
|
|
73
|
+
return cached
|
|
74
|
+
report = score_dag(sdm.dag)
|
|
75
|
+
_cache.put(dag_id, sdm.dag_hash, report)
|
|
76
|
+
return report
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _server_error(what: str, exc: BaseException) -> HTTPException:
|
|
80
|
+
ref = uuid.uuid4().hex[:12]
|
|
81
|
+
log.error("dagwick: %s (ref %s)", what, ref, exc_info=exc)
|
|
82
|
+
return HTTPException(status_code=500, detail=f"Something went wrong (ref {ref}).")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
app = FastAPI(title="Dagwick", version=__version__, docs_url=None, redoc_url=None, openapi_url=None)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
STATIC_FILES = {"dagwick.js": "application/javascript", "icon.svg": "image/svg+xml"}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.get("/static/{name}", include_in_schema=False)
|
|
92
|
+
def static(name: str) -> FileResponse:
|
|
93
|
+
media_type = STATIC_FILES.get(name) # allowlist: never build paths from user input
|
|
94
|
+
if media_type is None:
|
|
95
|
+
raise HTTPException(status_code=404)
|
|
96
|
+
return FileResponse(
|
|
97
|
+
STATIC_DIR / name, media_type=media_type, headers={"Cache-Control": "no-cache"}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.get("/api/score", dependencies=[_require_dag_read()])
|
|
102
|
+
def score(dag_id: str = Query(..., min_length=1, max_length=250)) -> dict[str, Any]:
|
|
103
|
+
try:
|
|
104
|
+
report = _score_by_id(dag_id)
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
raise _server_error(f"failed to score DAG {dag_id}", exc) from None
|
|
107
|
+
if report is None:
|
|
108
|
+
raise HTTPException(status_code=404, detail="DAG not found or not parsed yet.")
|
|
109
|
+
return report
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def fleet_entry(
|
|
113
|
+
dag_id: str, is_paused: bool, owners: str, report: dict[str, Any]
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
"""Compact per-DAG summary for the fleet page; issues sorted worst first."""
|
|
116
|
+
issues = [
|
|
117
|
+
{
|
|
118
|
+
"id": c["id"],
|
|
119
|
+
"title": c["title"],
|
|
120
|
+
"status": c["status"],
|
|
121
|
+
"gain": round(c["weight"] - c["points"], 1),
|
|
122
|
+
}
|
|
123
|
+
for c in report["checks"]
|
|
124
|
+
if c["status"] in ("fail", "warn")
|
|
125
|
+
]
|
|
126
|
+
issues.sort(key=lambda i: (i["status"] != "fail", -i["gain"]))
|
|
127
|
+
return {
|
|
128
|
+
"dag_id": dag_id,
|
|
129
|
+
"score": report["score"],
|
|
130
|
+
"grade": report["grade"],
|
|
131
|
+
"counts": report["counts"],
|
|
132
|
+
"is_paused": bool(is_paused),
|
|
133
|
+
"owners": owners or "",
|
|
134
|
+
"issues": issues,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.get("/api/fleet")
|
|
139
|
+
def fleet(user: Annotated[Any, _user()]) -> dict[str, Any]:
|
|
140
|
+
"""Scores for every active DAG the current user may read."""
|
|
141
|
+
from airflow.api_fastapi.app import get_auth_manager
|
|
142
|
+
from airflow.models.dag import DagModel
|
|
143
|
+
from airflow.utils.session import create_session
|
|
144
|
+
from sqlalchemy import select
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
allowed = get_auth_manager().get_authorized_dag_ids(user=user, method="GET")
|
|
148
|
+
with create_session() as session:
|
|
149
|
+
rows = session.execute(
|
|
150
|
+
select(DagModel.dag_id, DagModel.is_paused, DagModel.owners)
|
|
151
|
+
.where(DagModel.is_stale.is_(False), DagModel.dag_id.in_(allowed))
|
|
152
|
+
.order_by(DagModel.dag_id)
|
|
153
|
+
.limit(FLEET_LIMIT)
|
|
154
|
+
).all()
|
|
155
|
+
dags = []
|
|
156
|
+
for dag_id, is_paused, owners in rows:
|
|
157
|
+
report = _score_by_id(dag_id)
|
|
158
|
+
if report is not None:
|
|
159
|
+
dags.append(fleet_entry(dag_id, is_paused, owners, report))
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
raise _server_error("failed to build fleet view", exc) from None
|
|
162
|
+
return {"dags": dags, "truncated": len(rows) >= FLEET_LIMIT}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.get("/api/checks", dependencies=[_require_login()])
|
|
166
|
+
def checks() -> list[dict[str, Any]]:
|
|
167
|
+
return [
|
|
168
|
+
{
|
|
169
|
+
"id": c.id,
|
|
170
|
+
"title": c.title,
|
|
171
|
+
"category": c.category.value,
|
|
172
|
+
"weight": c.weight,
|
|
173
|
+
"fix": c.fix,
|
|
174
|
+
}
|
|
175
|
+
for c in registered_checks()
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@app.exception_handler(Exception)
|
|
180
|
+
async def _unhandled(_request: Any, exc: Exception) -> JSONResponse:
|
|
181
|
+
ref = uuid.uuid4().hex[:12]
|
|
182
|
+
log.error("dagwick: unhandled error (ref %s)", ref, exc_info=exc)
|
|
183
|
+
return JSONResponse(status_code=500, content={"detail": f"Internal error (ref {ref})."})
|
dagwick/checks/_util.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from dagwick.core import Finding, Status
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def coverage(
|
|
10
|
+
dag: Any,
|
|
11
|
+
predicate: Callable[[Any], bool],
|
|
12
|
+
*,
|
|
13
|
+
noun: str,
|
|
14
|
+
all_msg: str,
|
|
15
|
+
none_msg: str,
|
|
16
|
+
) -> Finding:
|
|
17
|
+
"""PASS if every task matches, WARN if some do, FAIL if none do."""
|
|
18
|
+
tasks = list(dag.tasks)
|
|
19
|
+
if not tasks:
|
|
20
|
+
return Finding(Status.SKIP, "The DAG has no tasks.")
|
|
21
|
+
missing = tuple(sorted(t.task_id for t in tasks if not predicate(t)))
|
|
22
|
+
if not missing:
|
|
23
|
+
return Finding(Status.PASS, all_msg)
|
|
24
|
+
if len(missing) == len(tasks):
|
|
25
|
+
return Finding(Status.FAIL, none_msg) # every task: listing them adds nothing
|
|
26
|
+
ok = len(tasks) - len(missing)
|
|
27
|
+
return Finding(Status.WARN, f"{ok} of {len(tasks)} tasks {noun}.", missing)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def has_schedule(dag: Any) -> bool:
|
|
31
|
+
timetable = getattr(dag, "timetable", None)
|
|
32
|
+
return timetable is not None and type(timetable).__name__ != "NullTimetable"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from dagwick.checks._util import coverage
|
|
6
|
+
from dagwick.core import Category, Finding, Status, check
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@check(
|
|
10
|
+
id="failure_alerts",
|
|
11
|
+
title="Someone hears about failures",
|
|
12
|
+
category=Category.OBSERVABILITY,
|
|
13
|
+
weight=15,
|
|
14
|
+
example='default_args={"on_failure_callback": send_slack_alert}',
|
|
15
|
+
fix="Add on_failure_callback (e.g. a Slack notifier) in default_args.",
|
|
16
|
+
)
|
|
17
|
+
def failure_alerts(dag: Any) -> Finding:
|
|
18
|
+
if getattr(dag, "has_on_failure_callback", False):
|
|
19
|
+
return Finding(Status.PASS, "The DAG has an on_failure_callback.")
|
|
20
|
+
return coverage(
|
|
21
|
+
dag,
|
|
22
|
+
lambda t: bool(getattr(t, "has_on_failure_callback", False)),
|
|
23
|
+
noun="alert on failure",
|
|
24
|
+
all_msg="Every task alerts on failure.",
|
|
25
|
+
none_msg="Nothing alerts on failure; it will fail silently.",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@check(
|
|
30
|
+
id="retry_alerts",
|
|
31
|
+
title="Retries are visible",
|
|
32
|
+
category=Category.OBSERVABILITY,
|
|
33
|
+
weight=3,
|
|
34
|
+
example='default_args={"on_retry_callback": send_slack_alert}',
|
|
35
|
+
fix="Add on_retry_callback so flaky tasks surface before they start failing.",
|
|
36
|
+
)
|
|
37
|
+
def retry_alerts(dag: Any) -> Finding:
|
|
38
|
+
if any(getattr(t, "has_on_retry_callback", False) for t in dag.tasks):
|
|
39
|
+
return Finding(Status.PASS, "Retries send a notification.")
|
|
40
|
+
return Finding(Status.WARN, "Retries happen silently.")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from dagwick.core import Category, Finding, Status, check
|
|
6
|
+
|
|
7
|
+
DEFAULT_OWNERS = {"", "airflow"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@check(
|
|
11
|
+
id="owner",
|
|
12
|
+
title="Has a real owner",
|
|
13
|
+
category=Category.OWNERSHIP,
|
|
14
|
+
weight=8,
|
|
15
|
+
example='default_args={"owner": "data-team"}',
|
|
16
|
+
fix="Set owner in default_args to the responsible team (e.g. 'data-team').",
|
|
17
|
+
)
|
|
18
|
+
def owner(dag: Any) -> Finding:
|
|
19
|
+
owners = {o.strip().lower() for o in (getattr(dag, "owner", "") or "").split(",")}
|
|
20
|
+
if owners - DEFAULT_OWNERS:
|
|
21
|
+
return Finding(Status.PASS, "An owner is set.")
|
|
22
|
+
return Finding(Status.FAIL, "Owner is the default 'airflow'; nobody is accountable.")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@check(
|
|
26
|
+
id="tags",
|
|
27
|
+
title="Tagged",
|
|
28
|
+
category=Category.OWNERSHIP,
|
|
29
|
+
weight=5,
|
|
30
|
+
example='DAG(..., tags=["sales", "ingest"])',
|
|
31
|
+
fix="Add tags (domain, layer) so the DAG is easy to find and filter.",
|
|
32
|
+
)
|
|
33
|
+
def tags(dag: Any) -> Finding:
|
|
34
|
+
found = sorted(getattr(dag, "tags", None) or [])
|
|
35
|
+
if found:
|
|
36
|
+
return Finding(Status.PASS, f"Tags: {', '.join(found)}.")
|
|
37
|
+
return Finding(Status.FAIL, "No tags.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@check(
|
|
41
|
+
id="docs",
|
|
42
|
+
title="Documented",
|
|
43
|
+
category=Category.OWNERSHIP,
|
|
44
|
+
weight=8,
|
|
45
|
+
example="DAG(..., doc_md=__doc__) # reuse the module docstring",
|
|
46
|
+
fix="Add doc_md explaining what the DAG loads, from where, and who to call.",
|
|
47
|
+
)
|
|
48
|
+
def docs(dag: Any) -> Finding:
|
|
49
|
+
if str(getattr(dag, "doc_md", None) or "").strip():
|
|
50
|
+
return Finding(Status.PASS, "doc_md is present.")
|
|
51
|
+
if str(getattr(dag, "description", None) or "").strip():
|
|
52
|
+
return Finding(Status.WARN, "Only a one-line description; no doc_md.")
|
|
53
|
+
return Finding(Status.FAIL, "No documentation.")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from dagwick.checks._util import coverage
|
|
6
|
+
from dagwick.core import Category, Finding, Status, check
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@check(
|
|
10
|
+
id="retries",
|
|
11
|
+
title="Tasks retry on failure",
|
|
12
|
+
category=Category.RELIABILITY,
|
|
13
|
+
weight=15,
|
|
14
|
+
example='default_args={\n "retries": 2,\n "retry_delay": timedelta(minutes=5),\n}',
|
|
15
|
+
fix="Set retries (for example 2) in default_args so transient errors recover on their own.",
|
|
16
|
+
)
|
|
17
|
+
def retries(dag: Any) -> Finding:
|
|
18
|
+
return coverage(
|
|
19
|
+
dag,
|
|
20
|
+
lambda t: (getattr(t, "retries", 0) or 0) >= 1,
|
|
21
|
+
noun="retry on failure",
|
|
22
|
+
all_msg="Every task retries at least once.",
|
|
23
|
+
none_msg="No task retries; one network blip fails the whole run.",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@check(
|
|
28
|
+
id="execution_timeout",
|
|
29
|
+
title="Tasks have a time limit",
|
|
30
|
+
category=Category.RELIABILITY,
|
|
31
|
+
weight=10,
|
|
32
|
+
example='default_args={"execution_timeout": timedelta(minutes=30)}',
|
|
33
|
+
fix="Set execution_timeout in default_args so a hung task frees its worker slot.",
|
|
34
|
+
)
|
|
35
|
+
def execution_timeout(dag: Any) -> Finding:
|
|
36
|
+
return coverage(
|
|
37
|
+
dag,
|
|
38
|
+
lambda t: getattr(t, "execution_timeout", None) is not None,
|
|
39
|
+
noun="have an execution_timeout",
|
|
40
|
+
all_msg="Every task has an execution_timeout.",
|
|
41
|
+
none_msg="No task has an execution_timeout; a hung task can run forever.",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@check(
|
|
46
|
+
id="dagrun_timeout",
|
|
47
|
+
title="Runs have a time limit",
|
|
48
|
+
category=Category.RELIABILITY,
|
|
49
|
+
weight=8,
|
|
50
|
+
example="DAG(..., dagrun_timeout=timedelta(hours=2))",
|
|
51
|
+
fix="Set dagrun_timeout on the DAG so a stuck run cannot block the next one.",
|
|
52
|
+
)
|
|
53
|
+
def dagrun_timeout(dag: Any) -> Finding:
|
|
54
|
+
timeout = getattr(dag, "dagrun_timeout", None)
|
|
55
|
+
if timeout is None:
|
|
56
|
+
return Finding(Status.FAIL, "dagrun_timeout is not set.")
|
|
57
|
+
return Finding(Status.PASS, f"Runs time out after {timeout}.")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from dagwick.checks._util import has_schedule
|
|
6
|
+
from dagwick.core import Category, Finding, Status, check
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@check(
|
|
10
|
+
id="schedule",
|
|
11
|
+
title="Has a schedule",
|
|
12
|
+
category=Category.SCHEDULING,
|
|
13
|
+
weight=5,
|
|
14
|
+
example='DAG(..., schedule="0 3 * * *")',
|
|
15
|
+
fix=(
|
|
16
|
+
"Set schedule= (cron, timedelta or assets). "
|
|
17
|
+
"Leave it None only if another DAG triggers this one."
|
|
18
|
+
),
|
|
19
|
+
)
|
|
20
|
+
def schedule(dag: Any) -> Finding:
|
|
21
|
+
if has_schedule(dag):
|
|
22
|
+
summary = getattr(dag.timetable, "summary", None) or type(dag.timetable).__name__
|
|
23
|
+
return Finding(Status.PASS, f"Scheduled: {summary}.")
|
|
24
|
+
return Finding(Status.WARN, "No schedule; this DAG only runs when triggered.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@check(
|
|
28
|
+
id="catchup",
|
|
29
|
+
title="No surprise backfills",
|
|
30
|
+
category=Category.SCHEDULING,
|
|
31
|
+
weight=5,
|
|
32
|
+
example="DAG(..., catchup=False)",
|
|
33
|
+
fix="Set catchup=False unless you really want every missed interval to run.",
|
|
34
|
+
)
|
|
35
|
+
def catchup(dag: Any) -> Finding:
|
|
36
|
+
if not has_schedule(dag):
|
|
37
|
+
return Finding(Status.SKIP, "Not scheduled, so catchup does not apply.")
|
|
38
|
+
if getattr(dag, "catchup", False):
|
|
39
|
+
return Finding(Status.WARN, "catchup=True; unpausing will backfill every missed interval.")
|
|
40
|
+
return Finding(Status.PASS, "catchup=False.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@check(
|
|
44
|
+
id="auto_pause",
|
|
45
|
+
title="Stops after repeated failures",
|
|
46
|
+
category=Category.SCHEDULING,
|
|
47
|
+
weight=3,
|
|
48
|
+
example="DAG(..., max_consecutive_failed_dag_runs=3)",
|
|
49
|
+
fix=(
|
|
50
|
+
"Set max_consecutive_failed_dag_runs (e.g. 3) "
|
|
51
|
+
"so a broken DAG pauses instead of failing all night."
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
def auto_pause(dag: Any) -> Finding:
|
|
55
|
+
if not has_schedule(dag):
|
|
56
|
+
return Finding(Status.SKIP, "Not scheduled, so auto-pause does not apply.")
|
|
57
|
+
n = getattr(dag, "max_consecutive_failed_dag_runs", 0) or 0
|
|
58
|
+
if n > 0:
|
|
59
|
+
return Finding(Status.PASS, f"Pauses itself after {n} failed runs in a row.")
|
|
60
|
+
return Finding(Status.WARN, "Keeps running no matter how many runs fail.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@check(
|
|
64
|
+
id="max_active_runs",
|
|
65
|
+
title="Bounded parallel runs",
|
|
66
|
+
category=Category.SCHEDULING,
|
|
67
|
+
weight=5,
|
|
68
|
+
example="DAG(..., max_active_runs=1)",
|
|
69
|
+
fix="Set max_active_runs (1 for most ETL) so backfills cannot flood the warehouse.",
|
|
70
|
+
)
|
|
71
|
+
def max_active_runs(dag: Any) -> Finding:
|
|
72
|
+
mar = getattr(dag, "max_active_runs", None)
|
|
73
|
+
if mar is not None and mar <= 4:
|
|
74
|
+
return Finding(Status.PASS, f"max_active_runs={mar}.")
|
|
75
|
+
return Finding(Status.WARN, f"max_active_runs={mar}; up to {mar} runs can overlap.")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from dagwick.core import Category, Finding, Status, check
|
|
7
|
+
|
|
8
|
+
SNAKE = re.compile(r"^[a-z0-9_]+(\.[a-z0-9_]+)*$") # dots come from task groups
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@check(
|
|
12
|
+
id="decomposition",
|
|
13
|
+
title="Split into steps",
|
|
14
|
+
category=Category.STRUCTURE,
|
|
15
|
+
weight=5,
|
|
16
|
+
fix="Split the work into smaller tasks so a failure retries only the broken step.",
|
|
17
|
+
)
|
|
18
|
+
def decomposition(dag: Any) -> Finding:
|
|
19
|
+
n = len(dag.tasks)
|
|
20
|
+
if n >= 2:
|
|
21
|
+
return Finding(Status.PASS, f"{n} tasks.")
|
|
22
|
+
return Finding(Status.WARN, f"Only {n} task; a failure reruns everything.")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@check(
|
|
26
|
+
id="naming",
|
|
27
|
+
title="Consistent names",
|
|
28
|
+
category=Category.STRUCTURE,
|
|
29
|
+
weight=3,
|
|
30
|
+
example='EmptyOperator(task_id="load_payments") # not "LoadPayments"',
|
|
31
|
+
fix="Use snake_case for dag_id and task_ids.",
|
|
32
|
+
)
|
|
33
|
+
def naming(dag: Any) -> Finding:
|
|
34
|
+
bad = [t.task_id for t in dag.tasks if not SNAKE.match(t.task_id)]
|
|
35
|
+
if not SNAKE.match(dag.dag_id):
|
|
36
|
+
bad.insert(0, dag.dag_id)
|
|
37
|
+
if not bad:
|
|
38
|
+
return Finding(Status.PASS, "dag_id and task_ids are snake_case.")
|
|
39
|
+
noun = "name is" if len(bad) == 1 else "names are"
|
|
40
|
+
return Finding(Status.WARN, f"{len(bad)} {noun} not snake_case.", tuple(bad))
|
dagwick/core.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Check registry and scoring engine.
|
|
2
|
+
|
|
3
|
+
A *check* is a function that receives a DAG (normally the serialized DAG read
|
|
4
|
+
from the metadata database) and returns a :class:`Finding`. Checks are
|
|
5
|
+
registered with the :func:`check` decorator, either inside this package or in
|
|
6
|
+
any third-party package that exposes a ``dagwick.checks`` entry point.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import asdict, dataclass, field
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from importlib.metadata import entry_points
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
ENTRY_POINT_GROUP = "dagwick.checks"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Status(str, Enum): # str mixin: StrEnum needs Python 3.11
|
|
24
|
+
PASS = "pass"
|
|
25
|
+
WARN = "warn" # half the points: works, but could be better
|
|
26
|
+
FAIL = "fail"
|
|
27
|
+
SKIP = "skip" # check does not apply to this DAG; excluded from the score
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Category(str, Enum):
|
|
31
|
+
RELIABILITY = "reliability"
|
|
32
|
+
SCHEDULING = "scheduling"
|
|
33
|
+
OBSERVABILITY = "observability"
|
|
34
|
+
OWNERSHIP = "ownership"
|
|
35
|
+
STRUCTURE = "structure"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
STATUS_CREDIT = {Status.PASS: 1.0, Status.WARN: 0.5, Status.FAIL: 0.0}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Finding:
|
|
43
|
+
"""What a check function returns.
|
|
44
|
+
|
|
45
|
+
``items`` names what is affected (usually task_ids) so the UI can point at it.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
status: Status
|
|
49
|
+
message: str
|
|
50
|
+
items: tuple[str, ...] = ()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class CheckSpec:
|
|
55
|
+
id: str
|
|
56
|
+
title: str
|
|
57
|
+
category: Category
|
|
58
|
+
weight: int
|
|
59
|
+
fix: str
|
|
60
|
+
func: Callable[[Any], Finding] = field(compare=False, repr=False)
|
|
61
|
+
example: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class CheckResult:
|
|
66
|
+
id: str
|
|
67
|
+
title: str
|
|
68
|
+
category: str
|
|
69
|
+
weight: int
|
|
70
|
+
status: str
|
|
71
|
+
message: str
|
|
72
|
+
fix: str
|
|
73
|
+
points: float
|
|
74
|
+
items: list[str]
|
|
75
|
+
example: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_REGISTRY: dict[str, CheckSpec] = {}
|
|
79
|
+
_plugins_loaded = False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def check(
|
|
83
|
+
*, id: str, title: str, category: Category, weight: int, fix: str, example: str = ""
|
|
84
|
+
) -> Callable[[Callable[[Any], Finding]], Callable[[Any], Finding]]:
|
|
85
|
+
"""Register a DAG check.
|
|
86
|
+
|
|
87
|
+
``fix`` is a short, actionable hint shown when the check does not pass;
|
|
88
|
+
``example`` is an optional code snippet showing the fix.
|
|
89
|
+
Re-registering the same ``id`` replaces the previous check, which lets a
|
|
90
|
+
deployment override a built-in rule.
|
|
91
|
+
"""
|
|
92
|
+
if weight <= 0:
|
|
93
|
+
raise ValueError(f"check {id!r}: weight must be positive")
|
|
94
|
+
|
|
95
|
+
def decorator(func: Callable[[Any], Finding]) -> Callable[[Any], Finding]:
|
|
96
|
+
_REGISTRY[id] = CheckSpec(id, title, category, weight, fix, func, example)
|
|
97
|
+
return func
|
|
98
|
+
|
|
99
|
+
return decorator
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_plugin_checks() -> None:
|
|
103
|
+
"""Import every module advertised under the ``dagwick.checks`` entry point."""
|
|
104
|
+
global _plugins_loaded
|
|
105
|
+
if _plugins_loaded:
|
|
106
|
+
return
|
|
107
|
+
_plugins_loaded = True
|
|
108
|
+
for ep in entry_points(group=ENTRY_POINT_GROUP):
|
|
109
|
+
try:
|
|
110
|
+
ep.load()
|
|
111
|
+
except Exception:
|
|
112
|
+
log.exception("Failed to load dagwick checks from entry point %s", ep.name)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def registered_checks() -> list[CheckSpec]:
|
|
116
|
+
from dagwick import checks as _builtin # noqa: F401 (registers built-ins)
|
|
117
|
+
|
|
118
|
+
load_plugin_checks()
|
|
119
|
+
return list(_REGISTRY.values())
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def grade_for(score: float) -> str:
|
|
123
|
+
for threshold, grade in ((90, "A"), (75, "B"), (60, "C"), (40, "D")):
|
|
124
|
+
if score >= threshold:
|
|
125
|
+
return grade
|
|
126
|
+
return "F"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _run_one(spec: CheckSpec, dag: Any) -> Finding:
|
|
130
|
+
try:
|
|
131
|
+
finding = spec.func(dag)
|
|
132
|
+
except Exception:
|
|
133
|
+
log.exception("dagwick check %s crashed on DAG %s", spec.id, getattr(dag, "dag_id", "?"))
|
|
134
|
+
return Finding(Status.SKIP, "This check could not run on this DAG.")
|
|
135
|
+
if not isinstance(finding, Finding):
|
|
136
|
+
log.error("dagwick check %s returned %r instead of a Finding", spec.id, type(finding))
|
|
137
|
+
return Finding(Status.SKIP, "This check returned an invalid result.")
|
|
138
|
+
return finding
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def score_dag(dag: Any) -> dict[str, Any]:
|
|
142
|
+
results: list[CheckResult] = []
|
|
143
|
+
earned = 0.0
|
|
144
|
+
possible = 0
|
|
145
|
+
for spec in registered_checks():
|
|
146
|
+
finding = _run_one(spec, dag)
|
|
147
|
+
points = 0.0
|
|
148
|
+
needs_work = finding.status in (Status.FAIL, Status.WARN)
|
|
149
|
+
if finding.status != Status.SKIP:
|
|
150
|
+
possible += spec.weight
|
|
151
|
+
points = spec.weight * STATUS_CREDIT[finding.status]
|
|
152
|
+
earned += points
|
|
153
|
+
results.append(
|
|
154
|
+
CheckResult(
|
|
155
|
+
id=spec.id,
|
|
156
|
+
title=spec.title,
|
|
157
|
+
category=spec.category.value,
|
|
158
|
+
weight=spec.weight,
|
|
159
|
+
status=finding.status.value,
|
|
160
|
+
message=finding.message,
|
|
161
|
+
fix=spec.fix if needs_work else "",
|
|
162
|
+
points=points,
|
|
163
|
+
items=list(finding.items) if needs_work else [],
|
|
164
|
+
example=spec.example if needs_work else "",
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
score = round(earned / possible * 100, 1) if possible else 0.0
|
|
169
|
+
counts = {s.value: sum(r.status == s.value for r in results) for s in Status}
|
|
170
|
+
return {
|
|
171
|
+
"dag_id": dag.dag_id,
|
|
172
|
+
"score": score,
|
|
173
|
+
"grade": grade_for(score),
|
|
174
|
+
"points": round(earned, 1),
|
|
175
|
+
"max_points": possible,
|
|
176
|
+
"counts": counts,
|
|
177
|
+
"checks": [asdict(r) for r in results],
|
|
178
|
+
}
|
dagwick/plugin.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, ClassVar
|
|
4
|
+
|
|
5
|
+
from airflow.plugins_manager import AirflowPlugin
|
|
6
|
+
|
|
7
|
+
from dagwick.api import app
|
|
8
|
+
|
|
9
|
+
BRAND = "Dagwick"
|
|
10
|
+
PREFIX = "/dagwick"
|
|
11
|
+
BUNDLE = f"{PREFIX}/static/dagwick.js"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DagwickPlugin(AirflowPlugin):
|
|
15
|
+
name = "dagwick"
|
|
16
|
+
|
|
17
|
+
fastapi_apps: ClassVar[list[dict[str, Any]]] = [
|
|
18
|
+
{"app": app, "url_prefix": PREFIX, "name": BRAND},
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
react_apps: ClassVar[list[dict[str, Any]]] = [
|
|
22
|
+
# Tab on every DAG page.
|
|
23
|
+
{"name": BRAND, "bundle_url": BUNDLE, "destination": "dag", "url_route": "dagwick"},
|
|
24
|
+
# Fleet overview in the left sidebar.
|
|
25
|
+
{
|
|
26
|
+
"name": BRAND, # short: the sidebar truncates long labels
|
|
27
|
+
"bundle_url": BUNDLE,
|
|
28
|
+
"destination": "nav",
|
|
29
|
+
"url_route": "dagwick-fleet",
|
|
30
|
+
"nav_top_level": True,
|
|
31
|
+
"icon": f"{PREFIX}/static/icon.svg",
|
|
32
|
+
},
|
|
33
|
+
]
|
dagwick/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
var DagwickBundle=(function(a,g){"use strict";const v="Dagwick",G="0.1.0",X="/dagwick/api",Q="dagwick";async function B(i,r,e){const d=new URL(`${X}${i}`,document.baseURI);for(const[l,w]of Object.entries(r))d.searchParams.set(l,w);const o=await fetch(d,{signal:e,credentials:"same-origin"});if(!o.ok){let l=`HTTP ${o.status}`;try{const w=await o.json();typeof w?.detail=="string"&&(l=w.detail)}catch{}throw new Error(l)}return o.json()}const V=(i,r)=>B("/score",{dag_id:i},r),J=i=>B("/fleet",{},i);function T(i,r){const[e,d]=g.useState(),[o,l]=g.useState(),[w,n]=g.useState(!0),[k,b]=g.useState(0);g.useEffect(()=>{const s=new AbortController;return n(!0),l(void 0),i(s.signal).then(d).catch(f=>{s.signal.aborted||l(f instanceof Error?f.message:String(f))}).finally(()=>!s.signal.aborted&&n(!1)),()=>s.abort()},[...r,k]);const h=g.useCallback(()=>b(s=>s+1),[]);return{data:e,error:o,loading:w,reload:h}}function K(i){return{A:"radiant",B:"happy",C:"calm",D:"worried",F:"out"}[i]}function N({mood:i,size:r=150}){const e={radiant:1.15,happy:1,calm:.8,worried:.55,out:0}[i],d=i!=="out";return a.jsxs("svg",{className:`dagwick-lamp dagwick-lamp--${i}`,width:r,height:r,viewBox:"0 0 160 160",role:"img","aria-label":d?`Lantern is ${i}`:"Lantern has gone out",children:[a.jsxs("defs",{children:[a.jsxs("radialGradient",{id:"dagwick-glow",cx:"50%",cy:"50%",r:"50%",children:[a.jsx("stop",{offset:"0%",stopColor:"var(--dagwick-flame-core)",stopOpacity:"0.6"}),a.jsx("stop",{offset:"55%",stopColor:"var(--dagwick-flame)",stopOpacity:"0.2"}),a.jsx("stop",{offset:"100%",stopColor:"var(--dagwick-flame)",stopOpacity:"0"})]}),a.jsxs("linearGradient",{id:"dagwick-flame-grad",x1:"0",y1:"1",x2:"0",y2:"0",children:[a.jsx("stop",{offset:"0%",stopColor:"var(--dagwick-flame-deep)"}),a.jsx("stop",{offset:"55%",stopColor:"var(--dagwick-flame)"}),a.jsx("stop",{offset:"100%",stopColor:"var(--dagwick-flame-core)"})]}),a.jsxs("linearGradient",{id:"dagwick-metal",x1:"0",y1:"0",x2:"1",y2:"0",children:[a.jsx("stop",{offset:"0%",stopColor:"var(--dagwick-metal-dark)"}),a.jsx("stop",{offset:"45%",stopColor:"var(--dagwick-metal)"}),a.jsx("stop",{offset:"100%",stopColor:"var(--dagwick-metal-dark)"})]}),a.jsxs("linearGradient",{id:"dagwick-glass",x1:"0",y1:"0",x2:"1",y2:"0",children:[a.jsx("stop",{offset:"0%",stopColor:"var(--dagwick-glass)",stopOpacity:"0.35"}),a.jsx("stop",{offset:"30%",stopColor:"var(--dagwick-glass)",stopOpacity:"0.08"}),a.jsx("stop",{offset:"100%",stopColor:"var(--dagwick-glass)",stopOpacity:"0.22"})]})]}),a.jsx("ellipse",{cx:"80",cy:"146",rx:"44",ry:"5",fill:"var(--dagwick-shadow)"}),d&&a.jsx("circle",{className:"dagwick-glow",cx:"80",cy:"88",r:44*e+16,fill:"url(#dagwick-glow)"}),i==="radiant"&&a.jsxs("g",{className:"dagwick-sparkles",fill:"var(--dagwick-flame-core)",children:[a.jsx("path",{d:"M128 52 l2 6 6 2 -6 2 -2 6 -2 -6 -6 -2 6 -2z"}),a.jsx("path",{d:"M30 70 l1.5 4 4 1.5 -4 1.5 -1.5 4 -1.5 -4 -4 -1.5 4 -1.5z"}),a.jsx("path",{d:"M132 100 l1.2 3 3 1.2 -3 1.2 -1.2 3 -1.2 -3 -3 -1.2 3 -1.2z"})]}),a.jsxs("g",{className:"dagwick-body",children:[a.jsx("path",{d:"M56 50 C 56 16, 104 16, 104 50",fill:"none",stroke:"var(--dagwick-metal-dark)",strokeWidth:"4",strokeLinecap:"round"}),a.jsx("circle",{cx:"80",cy:"24",r:"5",fill:"none",stroke:"var(--dagwick-metal-dark)",strokeWidth:"3.5"}),a.jsx("path",{d:"M62 44 L98 44 L110 58 L50 58 Z",fill:"url(#dagwick-metal)"}),a.jsx("rect",{x:"70",y:"38",width:"20",height:"7",rx:"2",fill:"var(--dagwick-metal-dark)"}),d&&a.jsxs("g",{className:"dagwick-flame",style:{transform:`scale(${e})`},children:[a.jsx("path",{d:"M80 106 C 67 98, 71 82, 80 68 C 82 79, 93 86, 89 97 C 87 102, 84 105, 80 106 Z",fill:"url(#dagwick-flame-grad)"}),a.jsx("path",{d:"M80 104 C 75 99, 77 92, 80 86 C 81 92, 86 95, 84 99 C 83 102, 81 103, 80 104 Z",fill:"var(--dagwick-flame-core)"})]}),a.jsx("path",{d:"M70 114 L90 114 L87 106 L73 106 Z",fill:"var(--dagwick-metal-dark)"}),a.jsx("path",{d:"M80 106 L80 102",stroke:"var(--dagwick-wick)",strokeWidth:"2.5",strokeLinecap:"round"}),a.jsx("path",{d:"M58 58 C 40 72, 40 100, 58 114 L102 114 C 120 100, 120 72, 102 58 Z",fill:"url(#dagwick-glass)",stroke:"var(--dagwick-glass-edge)",strokeWidth:"1.5"}),a.jsx("path",{d:"M54 72 C 50 82, 50 92, 54 102",fill:"none",stroke:"var(--dagwick-glass)",strokeOpacity:"0.55",strokeWidth:"3",strokeLinecap:"round"}),a.jsx("path",{d:"M66 58 C 58 76, 58 96, 66 114",fill:"none",stroke:"var(--dagwick-metal-dark)",strokeWidth:"2.5"}),a.jsx("path",{d:"M94 58 C 102 76, 102 96, 94 114",fill:"none",stroke:"var(--dagwick-metal-dark)",strokeWidth:"2.5"}),a.jsx("path",{d:"M48 114 L112 114 L116 134 C 116 141, 44 141, 44 134 Z",fill:"url(#dagwick-metal)"}),a.jsx("rect",{x:"44",y:"132",width:"72",height:"5",rx:"2.5",fill:"var(--dagwick-metal-dark)",opacity:"0.6"}),a.jsx(R,{mood:i})]}),!d&&a.jsx("g",{className:"dagwick-smoke",fill:"none",stroke:"var(--dagwick-smoke)",strokeWidth:"3",strokeLinecap:"round",children:a.jsx("path",{d:"M80 36 C 72 28, 88 22, 80 14 C 72 6, 88 0, 82 -6"})})]})}function R({mood:i}){const e="var(--dagwick-ink)",d={radiant:"M73 127 Q 80 134 87 127",happy:"M74 128 Q 80 132 86 128",calm:"M75 129 L 85 129",worried:"M74 131 Q 80 126 86 131",out:"M75 131 Q 80 128 85 131"}[i];return a.jsxs("g",{className:"dagwick-face",children:[i==="radiant"||i==="happy"?a.jsxs(a.Fragment,{children:[a.jsx("path",{d:"M65 122 q4 -4.5 8 0",stroke:e,strokeWidth:"2.6",fill:"none",strokeLinecap:"round"}),a.jsx("path",{d:"M87 122 q4 -4.5 8 0",stroke:e,strokeWidth:"2.6",fill:"none",strokeLinecap:"round"})]}):i==="out"?a.jsxs(a.Fragment,{children:[a.jsx("path",{d:"M65 121 l8 2",stroke:e,strokeWidth:"2.6",strokeLinecap:"round"}),a.jsx("path",{d:"M87 123 l8 -2",stroke:e,strokeWidth:"2.6",strokeLinecap:"round"})]}):a.jsxs("g",{className:"dagwick-eyes",children:[a.jsx("circle",{cx:"69",cy:122,r:"3",fill:e}),a.jsx("circle",{cx:"91",cy:122,r:"3",fill:e})]}),a.jsx("path",{d,stroke:e,strokeWidth:"2.6",fill:"none",strokeLinecap:"round"}),(i==="radiant"||i==="happy")&&a.jsxs(a.Fragment,{children:[a.jsx("circle",{cx:"60",cy:"127",r:"3.5",fill:"var(--dagwick-blush)"}),a.jsx("circle",{cx:"100",cy:"127",r:"3.5",fill:"var(--dagwick-blush)"})]}),i==="worried"&&a.jsx("path",{d:"M100 116 q3 5 0 8 q-3 -3 0 -8z",fill:"var(--dagwick-tear)"})]})}const E=()=>window.matchMedia?.("(prefers-reduced-motion: reduce)").matches??!1;function aa(i,r=900){const[e,d]=g.useState(E()?i:0),o=g.useRef(0);return g.useEffect(()=>{if(E())return d(i);const l=performance.now(),w=o.current;let n=0;const k=h=>{const s=Math.min(1,(h-l)/r);d(w+(i-w)*(1-Math.pow(1-s,3))),s<1?n=requestAnimationFrame(k):o.current=i};n=requestAnimationFrame(k);const b=setTimeout(()=>{cancelAnimationFrame(n),o.current=i,d(i)},r+100);return()=>{cancelAnimationFrame(n),clearTimeout(b)}},[i,r]),e}function O({score:i,grade:r}){const e=aa(i),d=52,o=2*Math.PI*d;return a.jsxs("div",{className:`dagwick-ring dagwick-grade-${r}`,"aria-label":`Score ${i} out of 100, grade ${r}`,children:[a.jsxs("svg",{width:"132",height:"132",viewBox:"0 0 132 132",children:[a.jsx("circle",{cx:"66",cy:"66",r:d,className:"dagwick-ring-track"}),a.jsx("circle",{cx:"66",cy:"66",r:d,className:"dagwick-ring-fill",strokeDasharray:o,strokeDashoffset:o*(1-e/100),transform:"rotate(-90 66 66)"})]}),a.jsxs("div",{className:"dagwick-ring-label",children:[a.jsx("span",{className:"dagwick-grade",children:r}),a.jsx("span",{className:"dagwick-score",children:Math.floor(e)})]})]})}function ia({code:i}){const[r,e]=g.useState(!1),d=async()=>{try{await navigator.clipboard.writeText(i),e(!0),setTimeout(()=>e(!1),1500)}catch{}};return a.jsxs("div",{className:"dagwick-code",children:[a.jsx("pre",{children:a.jsx("code",{children:i})}),a.jsx("button",{className:"dagwick-copy",onClick:d,"aria-label":"Copy code",children:r?"Copied ✓":"Copy"})]})}const ea={A:"I'm glowing! ✨",B:"Nice and warm.",C:"A little more oil?",D:"I'm flickering…",F:"It's dark in here."};function _({grade:i,size:r=150}){return a.jsxs("div",{className:"dagwick-mascot",children:[a.jsx("div",{className:"dagwick-bubble",children:ea[i]}),a.jsx(N,{mood:K(i),size:r})]})}function C({title:i,body:r,action:e}){return a.jsxs("div",{className:"dagwick-empty",children:[a.jsx(N,{mood:"out",size:110}),a.jsx("h3",{children:i}),a.jsx("p",{className:"dagwick-muted",children:r}),e]})}function I({label:i="Lighting the lamp…"}){return a.jsxs("div",{className:"dagwick-empty",children:[a.jsx(N,{mood:"calm",size:110}),a.jsx("p",{className:"dagwick-muted",children:i})]})}function W({grade:i,size:r="md"}){return a.jsx("span",{className:`dagwick-badge dagwick-badge--${i} dagwick-badge--${r}`,children:i})}function ra({score:i,grade:r}){const d=2*Math.PI*18;return a.jsxs("div",{className:`dagwick-mini dagwick-grade-${r}`,"aria-label":`Score ${i}, grade ${r}`,children:[a.jsxs("svg",{width:"46",height:"46",viewBox:"0 0 46 46",children:[a.jsx("circle",{cx:"23",cy:"23",r:18,className:"dagwick-ring-track"}),a.jsx("circle",{cx:"23",cy:"23",r:18,className:"dagwick-ring-fill",strokeDasharray:d,strokeDashoffset:d*(1-i/100),transform:"rotate(-90 23 23)"})]}),a.jsx("span",{children:Math.floor(i)})]})}function ca({dagId:i,children:r,className:e}){const d=globalThis.ReactRouterDOM?.Link,o=`/dags/${encodeURIComponent(i)}/plugin/${Q}`;return d?a.jsx(d,{to:o,className:e,children:r}):a.jsx("a",{href:new URL(o.slice(1),document.baseURI).href,className:e,children:r})}const Y=i=>a.jsxs("div",{className:"dagwick-eyebrow",children:[a.jsx("span",{className:"dagwick-brand",children:v})," · ",i]}),u={reliability:"Reliability",observability:"Observability",scheduling:"Scheduling",ownership:"Ownership",structure:"Structure"},P={fail:0,warn:1,pass:2,skip:3},da={A:"Shining bright. This DAG is in great shape.",B:"A steady flame. A few small things to polish.",C:"Burning, but dimly. Some important habits are missing.",D:"Flickering. This DAG needs attention soon.",F:"The lamp has gone out. Start with the red items below."},sa=[["A",90],["B",75],["C",60],["D",40]],x=i=>i.weight-i.points,q=(i,r)=>P[i.status]-P[r.status]||x(r)-x(i);function oa({dagId:i}){const{data:r,error:e,reload:d}=T(o=>V(i,o),[i]);return e?a.jsx(C,{title:"Couldn't check this DAG",body:e,action:a.jsx("button",{className:"dagwick-btn",onClick:d,children:"Try again"})}):r?a.jsx(la,{report:r},r.dag_id):a.jsx(I,{})}function ta(i){const r=[...sa].reverse().find(([,n])=>n>i.score);if(!r)return;const[e,d]=r;let o=d/100*i.max_points-i.points;const l=[],w=i.checks.filter(n=>n.status==="fail"||n.status==="warn").sort((n,k)=>x(k)-x(n));for(const n of w){if(o<=1e-9)break;l.push(n),o-=x(n)}return o<=1e-9?{grade:e,picks:l}:void 0}function la({report:i}){const r=i.counts.fail+i.counts.warn,[e,d]=g.useState(r?"attention":"all"),[o,l]=g.useState(()=>new Set(i.checks.filter(s=>s.fix).sort(q).slice(0,2).map(s=>s.id))),w=s=>l(f=>{const m=new Set(f);return m.delete(s)||m.add(s),m}),n=g.useMemo(()=>wa(i.checks),[i]),k=g.useMemo(()=>ta(i),[i]),b=g.useMemo(()=>i.checks.filter(s=>e==="all"?!0:e==="passed"?s.status==="pass":s.status==="fail"||s.status==="warn").sort(q),[i,e]),h=s=>{d("attention"),l(f=>new Set(f).add(s)),requestAnimationFrame(()=>document.getElementById(`dagwick-check-${s}`)?.scrollIntoView({behavior:"smooth",block:"center"}))};return a.jsxs(a.Fragment,{children:[a.jsxs("section",{className:`dagwick-hero dagwick-grade-${i.grade}`,children:[a.jsx(_,{grade:i.grade}),a.jsxs("div",{className:"dagwick-hero-text",children:[Y("DAG health"),a.jsx("h2",{className:"dagwick-dag",children:i.dag_id}),a.jsx("p",{className:"dagwick-headline",children:da[i.grade]}),a.jsxs("div",{className:"dagwick-chips",children:[a.jsx(z,{status:"pass",n:i.counts.pass,label:"passing"}),a.jsx(z,{status:"warn",n:i.counts.warn,label:"to improve"}),a.jsx(z,{status:"fail",n:i.counts.fail,label:"failing"})]})]}),a.jsx("div",{className:"dagwick-hero-side",children:a.jsx(O,{score:i.score,grade:i.grade})})]}),k?a.jsxs("section",{className:"dagwick-goal",children:[a.jsxs("div",{className:"dagwick-goal-text",children:[a.jsx("span",{className:"dagwick-goal-step",children:"Next step"}),"Fix ",k.picks.length===1?"this check":`these ${k.picks.length} checks`," to reach"," ",a.jsx("span",{className:`dagwick-badge dagwick-badge--${k.grade}`,children:k.grade})]}),a.jsx("div",{className:"dagwick-goal-picks",children:k.picks.map(s=>a.jsxs("button",{className:"dagwick-pick",onClick:()=>h(s.id),children:[s.title," ",a.jsxs("span",{className:"dagwick-pick-gain",children:["+",x(s).toFixed(s.points%1?1:0)]})]},s.id))})]}):i.score<100?a.jsx("section",{className:"dagwick-goal dagwick-goal--top",children:a.jsxs("div",{className:"dagwick-goal-text",children:[a.jsx("span",{className:"dagwick-goal-step",children:"Top grade"})," Polish the last ",r," item",r===1?"":"s"," for a perfect 100."]})}):null,a.jsx("section",{className:"dagwick-cats","aria-label":"Score by category",children:n.map(s=>a.jsxs("div",{className:"dagwick-cat",children:[a.jsxs("div",{className:"dagwick-cat-head",children:[a.jsx("span",{children:u[s.id]??s.id}),a.jsxs("span",{className:"dagwick-muted",children:[Math.round(s.pct),"%"]})]}),a.jsx("div",{className:"dagwick-bar",children:a.jsx("div",{className:"dagwick-bar-fill",style:{width:`${s.pct}%`,background:ka(s.pct)}})})]},s.id))}),a.jsxs("section",{className:"dagwick-list",children:[a.jsxs("div",{className:"dagwick-tabs",role:"tablist",children:[a.jsxs(M,{active:e==="attention",onClick:()=>d("attention"),children:["Needs attention ",a.jsx("span",{className:"dagwick-count",children:r})]}),a.jsxs(M,{active:e==="all",onClick:()=>d("all"),children:["All checks ",a.jsx("span",{className:"dagwick-count",children:i.checks.length})]}),a.jsxs(M,{active:e==="passed",onClick:()=>d("passed"),children:["Passed ",a.jsx("span",{className:"dagwick-count",children:i.counts.pass})]})]}),b.length===0?a.jsx("div",{className:"dagwick-clear",children:"Nothing here. Every check in this view is passing. ✨"}):a.jsx("ul",{className:"dagwick-checks",children:b.map(s=>a.jsx(ga,{check:s,open:o.has(s.id),onToggle:()=>w(s.id)},s.id))})]}),a.jsxs("footer",{className:"dagwick-foot dagwick-muted",children:[i.points," / ",i.max_points," points · scored automatically from the latest parsed version of this DAG · ",v," ",G]})]})}const na={pass:"✓",warn:"!",fail:"✕",skip:"–"},A=8;function ga({check:i,open:r,onToggle:e}){const[d,o]=g.useState(!1),l=!!i.fix,w=d?i.items:i.items.slice(0,A);return a.jsxs("li",{id:`dagwick-check-${i.id}`,className:`dagwick-check dagwick-check--${i.status}${r&&l?" is-open":""}`,children:[a.jsx("span",{className:"dagwick-icon","aria-label":i.status,children:na[i.status]}),a.jsxs("div",{className:"dagwick-check-body",children:[a.jsxs("button",{className:"dagwick-check-head",onClick:l?e:void 0,disabled:!l,"aria-expanded":l?r:void 0,children:[a.jsx("span",{className:"dagwick-check-title",children:i.title}),a.jsx("span",{className:"dagwick-tag",children:u[i.category]??i.category}),a.jsxs("span",{className:"dagwick-pts",children:[i.status==="skip"?"n/a":`${i.points} / ${i.weight}`,l&&a.jsx("span",{className:"dagwick-caret",children:r?"▾":"▸"})]})]}),a.jsx("div",{className:"dagwick-check-msg",children:i.message}),r&&l&&a.jsxs("div",{className:"dagwick-detail",children:[i.items.length>0&&a.jsxs("div",{className:"dagwick-items",children:[a.jsx("span",{className:"dagwick-items-label",children:"Affected"}),w.map(n=>a.jsx("code",{className:"dagwick-item",children:n},n)),i.items.length>A&&a.jsx("button",{className:"dagwick-more",onClick:()=>o(!d),children:d?"show less":`+${i.items.length-A} more`})]}),a.jsxs("div",{className:"dagwick-fix",children:[a.jsx("span",{className:"dagwick-fix-label",children:"How to fix"}),i.fix,i.example&&a.jsx(ia,{code:i.example})]})]})]})]})}function z({status:i,n:r,label:e}){return a.jsxs("span",{className:`dagwick-chip dagwick-chip--${i}`,children:[a.jsx("strong",{children:r})," ",e]})}function M({active:i,onClick:r,children:e}){return a.jsx("button",{role:"tab","aria-selected":i,className:`dagwick-tab${i?" is-active":""}`,onClick:r,children:e})}function wa(i){const r=new Map;for(const e of i){if(e.status==="skip")continue;const d=r.get(e.category)??{earned:0,possible:0};d.earned+=e.points,d.possible+=e.weight,r.set(e.category,d)}return Object.keys(u).filter(e=>r.has(e)).concat([...r.keys()].filter(e=>!(e in u))).map(e=>{const d=r.get(e);return{id:e,pct:d.possible?d.earned/d.possible*100:0}})}function ka(i){return i>=90?"var(--dagwick-pass)":i>=60?"var(--dagwick-warn)":"var(--dagwick-fail)"}const H=["A","B","C","D","F"],pa={A:"Shining",B:"Steady",C:"Dim",D:"Flickering",F:"Out"},fa={A:"The whole fleet is shining.",B:"Most DAGs burn steady.",C:"The fleet glows dimly. A few DAGs need care.",D:"Many DAGs are flickering.",F:"Most of the fleet is in the dark."},ha=[{id:"worst",label:"Needs care"},{id:"best",label:"Healthiest"},{id:"name",label:"A–Z"}],S=3,ba=6;function xa(i){return i>=90?"A":i>=75?"B":i>=60?"C":i>=40?"D":"F"}function ma(){const{data:i,error:r,reload:e}=T(d=>J(d),[]);return r?a.jsx(C,{title:"Couldn't load the fleet",body:r,action:a.jsx("button",{className:"dagwick-btn",onClick:e,children:"Try again"})}):i?i.dags.length===0?a.jsx(C,{title:"No DAGs yet",body:"Once Airflow parses a DAG you can read, it shows up here."}):a.jsx(va,{dags:i.dags,truncated:i.truncated}):a.jsx(I,{label:"Checking every DAG…"})}function va({dags:i,truncated:r}){const[e,d]=g.useState(""),[o,l]=g.useState(),[w,n]=g.useState(),[k,b]=g.useState("worst"),h=i.reduce((c,t)=>c+t.score,0)/i.length,s=xa(h),f=g.useMemo(()=>Object.fromEntries(H.map(c=>[c,i.filter(t=>t.grade===c).length])),[i]),m=i.filter(c=>c.counts.fail>0).length,j=f.A+f.B,Z=g.useMemo(()=>{const c=new Map;for(const t of i)for(const p of t.issues){const F=c.get(p.id)??{id:p.id,title:p.title,dags:0,fails:0};F.dags+=1,p.status==="fail"&&(F.fails+=1),c.set(p.id,F)}return[...c.values()].sort((t,p)=>p.dags-t.dags||p.fails-t.fails).slice(0,ba)},[i]),L=g.useMemo(()=>{const c=e.trim().toLowerCase();return i.filter(t=>(!o||t.grade===o)&&(!w||t.issues.some(p=>p.id===w.id))&&(!c||t.dag_id.toLowerCase().includes(c)||t.owners.toLowerCase().includes(c))).sort((t,p)=>k==="name"?t.dag_id.localeCompare(p.dag_id):k==="best"?p.score-t.score:t.score-p.score)},[i,e,o,w,k]),Na=!!(o||w||e.trim());return a.jsxs(a.Fragment,{children:[a.jsxs("section",{className:`dagwick-hero dagwick-grade-${s}`,children:[a.jsx(_,{grade:s}),a.jsxs("div",{className:"dagwick-hero-text",children:[Y("Fleet health"),a.jsx("h2",{className:"dagwick-dag",children:"All DAGs"}),a.jsx("p",{className:"dagwick-headline",children:fa[s]}),a.jsxs("div",{className:"dagwick-stats",children:[a.jsx(y,{value:i.length,label:"DAGs"}),a.jsx(y,{value:(Math.floor(h*10)/10).toFixed(1),label:"average score"}),a.jsx(y,{value:m,label:"need care",tone:m?"fail":void 0}),a.jsx(y,{value:j,label:"healthy (A–B)",tone:j?"pass":void 0})]})]}),a.jsxs("div",{className:"dagwick-hero-side",children:[a.jsx(O,{score:h,grade:s}),a.jsx("span",{className:"dagwick-muted dagwick-ring-caption",children:"fleet average"})]})]}),a.jsx("section",{className:"dagwick-grades","aria-label":"Filter by grade",children:H.map(c=>{const t=f[c],p=o===c;return a.jsxs("button",{className:`dagwick-gcard dagwick-gcard--${c}${p?" is-active":""}${o&&!p?" is-dim":""}`,disabled:t===0,onClick:()=>l(p?void 0:c),"aria-pressed":p,children:[a.jsx(W,{grade:c,size:"lg"}),a.jsxs("div",{className:"dagwick-gcard-text",children:[a.jsx("span",{className:"dagwick-gcard-n",children:t}),a.jsx("span",{className:"dagwick-muted",children:pa[c]})]}),a.jsx("div",{className:"dagwick-gcard-bar",children:a.jsx("div",{style:{width:`${t/i.length*100}%`}})})]},c)})}),a.jsxs("div",{className:"dagwick-fleet-grid",children:[a.jsxs("section",{className:"dagwick-list",children:[a.jsxs("div",{className:"dagwick-toolbar",children:[a.jsxs("div",{className:"dagwick-search-wrap",children:[a.jsxs("svg",{viewBox:"0 0 24 24",width:"16",height:"16","aria-hidden":"true",children:[a.jsx("circle",{cx:"11",cy:"11",r:"7",fill:"none",stroke:"currentColor",strokeWidth:"2"}),a.jsx("path",{d:"M20 20l-4-4",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round"})]}),a.jsx("input",{className:"dagwick-search",type:"search",placeholder:"Search DAGs or owners",value:e,onChange:c=>d(c.target.value),"aria-label":"Search DAGs"})]}),a.jsx("div",{className:"dagwick-seg",role:"radiogroup","aria-label":"Sort",children:ha.map(c=>a.jsx("button",{role:"radio","aria-checked":k===c.id,className:k===c.id?"is-active":"",onClick:()=>b(c.id),children:c.label},c.id))})]}),Na&&a.jsxs("div",{className:"dagwick-filters",children:[a.jsxs("span",{className:"dagwick-muted",children:[L.length," of ",i.length," DAGs"]}),o&&a.jsxs(D,{onClear:()=>l(void 0),children:["Grade ",o]}),w&&a.jsx(D,{onClear:()=>n(void 0),children:w.title}),e.trim()&&a.jsxs(D,{onClear:()=>d(""),children:["“",e.trim(),"”"]})]}),L.length===0?a.jsx("div",{className:"dagwick-clear",children:"No DAG matches."}):a.jsx("ul",{className:"dagwick-rows",children:L.map(c=>a.jsx("li",{children:a.jsxs(ca,{dagId:c.dag_id,className:`dagwick-row dagwick-row--${c.grade}`,children:[a.jsx(ra,{score:c.score,grade:c.grade}),a.jsxs("div",{className:"dagwick-row-main",children:[a.jsxs("div",{className:"dagwick-row-title",children:[a.jsx("span",{className:"dagwick-row-name",children:c.dag_id}),c.is_paused&&a.jsx("span",{className:"dagwick-tag",children:"paused"})]}),a.jsxs("div",{className:"dagwick-row-sub dagwick-muted",children:[c.owners&&a.jsx("span",{children:c.owners}),a.jsx("span",{children:c.counts.fail+c.counts.warn===0?"All checks passing":`${c.counts.fail} failing · ${c.counts.warn} to improve`})]}),c.issues.length>0&&a.jsxs("div",{className:"dagwick-row-issues",children:[c.issues.slice(0,S).map(t=>a.jsx("span",{className:`dagwick-ichip dagwick-ichip--${t.status}`,children:t.title},t.id)),c.issues.length>S&&a.jsxs("span",{className:"dagwick-ichip dagwick-ichip--more",children:["+",c.issues.length-S]})]})]}),a.jsx(W,{grade:c.grade}),a.jsx("span",{className:"dagwick-row-go","aria-hidden":"true",children:"›"})]})},c.dag_id))})]}),a.jsx("aside",{className:"dagwick-side",children:a.jsxs("div",{className:"dagwick-side-card",children:[a.jsx("h3",{className:"dagwick-side-title",children:"Most common issues"}),Z.length===0?a.jsx("p",{className:"dagwick-muted dagwick-side-empty",children:"No issues anywhere. ✨"}):a.jsx("ul",{className:"dagwick-common",children:Z.map(c=>{const t=w?.id===c.id;return a.jsx("li",{children:a.jsxs("button",{className:`dagwick-common-item${t?" is-active":""}`,onClick:()=>n(t?void 0:{id:c.id,title:c.title}),"aria-pressed":t,children:[a.jsxs("div",{className:"dagwick-common-head",children:[a.jsx("span",{children:c.title}),a.jsxs("span",{className:"dagwick-muted",children:[c.dags,"/",i.length]})]}),a.jsx("div",{className:"dagwick-bar",children:a.jsx("div",{className:"dagwick-bar-fill",style:{width:`${c.dags/i.length*100}%`,background:c.fails>=c.dags/2?"var(--dagwick-fail)":"var(--dagwick-warn)"}})})]})},c.id)})}),a.jsx("p",{className:"dagwick-side-hint dagwick-muted",children:"Click an issue to see which DAGs have it."})]})})]}),a.jsxs("footer",{className:"dagwick-foot dagwick-muted",children:[r?`Showing the first ${i.length} DAGs. `:"","Scored automatically from the latest parsed version of each DAG. · ",v," ",G]})]})}function y({value:i,label:r,tone:e}){return a.jsxs("div",{className:`dagwick-stat${e?` dagwick-stat--${e}`:""}`,children:[a.jsx("span",{className:"dagwick-stat-value",children:i}),a.jsx("span",{className:"dagwick-stat-label",children:r})]})}function D({children:i,onClear:r}){return a.jsxs("button",{className:"dagwick-fchip",onClick:r,"aria-label":"Remove filter",children:[i," ",a.jsx("span",{"aria-hidden":"true",children:"✕"})]})}const ua=`
|
|
2
|
+
.dagwick {
|
|
3
|
+
--dagwick-bg: #ffffff;
|
|
4
|
+
--dagwick-surface: #f8f7f4;
|
|
5
|
+
--dagwick-border: #e7e3dc;
|
|
6
|
+
--dagwick-text: #1d2129;
|
|
7
|
+
--dagwick-muted: #6b6f78;
|
|
8
|
+
--dagwick-pass: #15803d;
|
|
9
|
+
--dagwick-pass-bg: #e8f5ec;
|
|
10
|
+
--dagwick-warn: #b45309;
|
|
11
|
+
--dagwick-warn-bg: #fdf3e2;
|
|
12
|
+
--dagwick-fail: #c2410c;
|
|
13
|
+
--dagwick-fail-bg: #fdece4;
|
|
14
|
+
--dagwick-accent: #d97706;
|
|
15
|
+
--dagwick-flame: #f59e0b;
|
|
16
|
+
--dagwick-flame-deep: #ea580c;
|
|
17
|
+
--dagwick-flame-core: #fde68a;
|
|
18
|
+
--dagwick-metal: #e0a040;
|
|
19
|
+
--dagwick-metal-dark: #8a5a1c;
|
|
20
|
+
--dagwick-glass: #bfe3f7;
|
|
21
|
+
--dagwick-glass-edge: rgba(120, 150, 170, 0.55);
|
|
22
|
+
--dagwick-wick: #3f2a1d;
|
|
23
|
+
--dagwick-ink: #3b1f12;
|
|
24
|
+
--dagwick-blush: #f4a28c;
|
|
25
|
+
--dagwick-tear: #60a5fa;
|
|
26
|
+
--dagwick-smoke: #a8a29e;
|
|
27
|
+
--dagwick-shadow: rgba(60, 40, 20, 0.12);
|
|
28
|
+
color: var(--dagwick-text);
|
|
29
|
+
font: inherit;
|
|
30
|
+
padding: 8px 4px 24px;
|
|
31
|
+
width: 100%;
|
|
32
|
+
max-width: 1440px;
|
|
33
|
+
}
|
|
34
|
+
html.dark .dagwick {
|
|
35
|
+
--dagwick-bg: #171a21;
|
|
36
|
+
--dagwick-surface: #1e222b;
|
|
37
|
+
--dagwick-border: #2c313c;
|
|
38
|
+
--dagwick-text: #ebe7e0;
|
|
39
|
+
--dagwick-muted: #9a9fab;
|
|
40
|
+
--dagwick-pass: #4ade80;
|
|
41
|
+
--dagwick-pass-bg: rgba(74, 222, 128, 0.1);
|
|
42
|
+
--dagwick-warn: #fbbf24;
|
|
43
|
+
--dagwick-warn-bg: rgba(251, 191, 36, 0.1);
|
|
44
|
+
--dagwick-fail: #fb7c4c;
|
|
45
|
+
--dagwick-fail-bg: rgba(251, 124, 76, 0.12);
|
|
46
|
+
--dagwick-accent: #fbbf24;
|
|
47
|
+
--dagwick-shadow: rgba(0, 0, 0, 0.35);
|
|
48
|
+
--dagwick-glass: #dbeefa;
|
|
49
|
+
--dagwick-glass-edge: rgba(200, 225, 240, 0.4);
|
|
50
|
+
--dagwick-smoke: #6b7280;
|
|
51
|
+
}
|
|
52
|
+
.dagwick * { box-sizing: border-box; }
|
|
53
|
+
.dagwick-muted { color: var(--dagwick-muted); }
|
|
54
|
+
|
|
55
|
+
.dagwick-hero {
|
|
56
|
+
display: grid;
|
|
57
|
+
grid-template-columns: auto 1fr auto;
|
|
58
|
+
gap: 24px;
|
|
59
|
+
align-items: center;
|
|
60
|
+
padding: 20px 24px;
|
|
61
|
+
border: 1px solid var(--dagwick-border);
|
|
62
|
+
border-radius: 16px;
|
|
63
|
+
background:
|
|
64
|
+
radial-gradient(600px 220px at 0% 0%, color-mix(in srgb, var(--dagwick-flame) 14%, transparent), transparent 70%),
|
|
65
|
+
var(--dagwick-bg);
|
|
66
|
+
}
|
|
67
|
+
.dagwick-grade-D, .dagwick-grade-F { background: var(--dagwick-bg); }
|
|
68
|
+
.dagwick-eyebrow { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--dagwick-muted); }
|
|
69
|
+
.dagwick-brand { color: var(--dagwick-accent); font-weight: 700; }
|
|
70
|
+
.dagwick-dag { margin: 4px 0 6px; font-size: 22px; font-weight: 700; word-break: break-all; }
|
|
71
|
+
.dagwick-headline { margin: 0 0 12px; font-size: 15px; }
|
|
72
|
+
.dagwick-potential { margin: 10px 0 0; font-size: 13px; color: var(--dagwick-muted); }
|
|
73
|
+
.dagwick-potential strong { color: var(--dagwick-accent); }
|
|
74
|
+
.dagwick-chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
75
|
+
.dagwick-chip { font-size: 13px; padding: 3px 10px; border-radius: 999px; }
|
|
76
|
+
.dagwick-chip--pass { color: var(--dagwick-pass); background: var(--dagwick-pass-bg); }
|
|
77
|
+
.dagwick-chip--warn { color: var(--dagwick-warn); background: var(--dagwick-warn-bg); }
|
|
78
|
+
.dagwick-chip--fail { color: var(--dagwick-fail); background: var(--dagwick-fail-bg); }
|
|
79
|
+
.dagwick-hero-side { display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
|
80
|
+
|
|
81
|
+
.dagwick-ring { position: relative; width: 132px; height: 132px; }
|
|
82
|
+
.dagwick-ring-track { fill: none; stroke: var(--dagwick-border); stroke-width: 10; }
|
|
83
|
+
.dagwick-ring-fill { fill: none; stroke: var(--dagwick-accent); stroke-width: 10; stroke-linecap: round; transition: stroke-dashoffset .9s cubic-bezier(.2,.8,.2,1); }
|
|
84
|
+
.dagwick-grade-A .dagwick-ring-fill { stroke: var(--dagwick-pass); }
|
|
85
|
+
.dagwick-grade-D .dagwick-ring-fill, .dagwick-grade-F .dagwick-ring-fill { stroke: var(--dagwick-fail); }
|
|
86
|
+
.dagwick-ring-label { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1; }
|
|
87
|
+
.dagwick-grade { font-size: 40px; font-weight: 800; }
|
|
88
|
+
.dagwick-score { font-size: 13px; color: var(--dagwick-muted); margin-top: 4px; }
|
|
89
|
+
|
|
90
|
+
.dagwick-btn {
|
|
91
|
+
font: inherit; font-size: 13px; padding: 6px 14px; border-radius: 8px; cursor: pointer;
|
|
92
|
+
color: var(--dagwick-text); background: var(--dagwick-surface); border: 1px solid var(--dagwick-border);
|
|
93
|
+
}
|
|
94
|
+
.dagwick-btn:hover:not(:disabled) { border-color: var(--dagwick-accent); }
|
|
95
|
+
.dagwick-btn:disabled { opacity: .6; cursor: progress; }
|
|
96
|
+
.dagwick-btn:focus-visible, .dagwick-tab:focus-visible { outline: 2px solid var(--dagwick-accent); outline-offset: 2px; }
|
|
97
|
+
|
|
98
|
+
.dagwick-cats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin: 16px 0; }
|
|
99
|
+
.dagwick-cat { padding: 12px 14px; border: 1px solid var(--dagwick-border); border-radius: 12px; background: var(--dagwick-bg); }
|
|
100
|
+
.dagwick-cat-head { display: flex; justify-content: space-between; font-size: 13px; font-weight: 600; margin-bottom: 8px; }
|
|
101
|
+
.dagwick-bar { height: 6px; border-radius: 99px; background: var(--dagwick-border); overflow: hidden; }
|
|
102
|
+
.dagwick-bar-fill { height: 100%; border-radius: 99px; transition: width .9s cubic-bezier(.2,.8,.2,1); }
|
|
103
|
+
|
|
104
|
+
.dagwick-list { border: 1px solid var(--dagwick-border); border-radius: 16px; background: var(--dagwick-bg); overflow: hidden; }
|
|
105
|
+
.dagwick-tabs { display: flex; gap: 4px; padding: 8px; border-bottom: 1px solid var(--dagwick-border); background: var(--dagwick-surface); flex-wrap: wrap; }
|
|
106
|
+
.dagwick-tab { font: inherit; font-size: 13px; padding: 6px 12px; border-radius: 8px; border: 0; background: transparent; color: var(--dagwick-muted); cursor: pointer; }
|
|
107
|
+
.dagwick-tab.is-active { background: var(--dagwick-bg); color: var(--dagwick-text); box-shadow: 0 1px 2px var(--dagwick-shadow); }
|
|
108
|
+
.dagwick-count { margin-left: 4px; font-size: 11px; padding: 1px 6px; border-radius: 99px; background: var(--dagwick-border); }
|
|
109
|
+
.dagwick-checks { list-style: none; margin: 0; padding: 0; }
|
|
110
|
+
.dagwick-check { display: flex; gap: 14px; padding: 14px 18px; border-top: 1px solid var(--dagwick-border); }
|
|
111
|
+
.dagwick-check:first-child { border-top: 0; }
|
|
112
|
+
.dagwick-icon { flex: none; width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; font-size: 13px; font-weight: 700; }
|
|
113
|
+
.dagwick-check--pass .dagwick-icon { color: var(--dagwick-pass); background: var(--dagwick-pass-bg); }
|
|
114
|
+
.dagwick-check--warn .dagwick-icon { color: var(--dagwick-warn); background: var(--dagwick-warn-bg); }
|
|
115
|
+
.dagwick-check--fail .dagwick-icon { color: var(--dagwick-fail); background: var(--dagwick-fail-bg); }
|
|
116
|
+
.dagwick-check--skip .dagwick-icon { color: var(--dagwick-muted); background: var(--dagwick-surface); }
|
|
117
|
+
.dagwick-check--skip { opacity: .7; }
|
|
118
|
+
.dagwick-check-body { flex: 1; min-width: 0; }
|
|
119
|
+
.dagwick-check-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
120
|
+
.dagwick-check-title { font-weight: 600; }
|
|
121
|
+
.dagwick-tag { font-size: 11px; padding: 1px 8px; border-radius: 99px; border: 1px solid var(--dagwick-border); color: var(--dagwick-muted); }
|
|
122
|
+
.dagwick-pts { margin-left: auto; font-size: 12px; color: var(--dagwick-muted); font-variant-numeric: tabular-nums; }
|
|
123
|
+
.dagwick-check-msg { font-size: 14px; color: var(--dagwick-muted); margin-top: 2px; }
|
|
124
|
+
.dagwick-fix { margin-top: 8px; font-size: 13px; padding: 8px 12px; border-radius: 8px; background: var(--dagwick-surface); border-left: 3px solid var(--dagwick-accent); }
|
|
125
|
+
.dagwick-fix-label { display: block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--dagwick-accent); margin-bottom: 2px; }
|
|
126
|
+
.dagwick-clear { padding: 28px; text-align: center; color: var(--dagwick-muted); }
|
|
127
|
+
.dagwick-foot { font-size: 12px; margin-top: 12px; text-align: right; }
|
|
128
|
+
.dagwick-empty { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 48px 16px; text-align: center; }
|
|
129
|
+
.dagwick-empty h3 { margin: 8px 0 0; }
|
|
130
|
+
|
|
131
|
+
/* Mascot + speech bubble */
|
|
132
|
+
.dagwick-mascot { position: relative; display: flex; flex-direction: column; align-items: center; }
|
|
133
|
+
.dagwick-bubble {
|
|
134
|
+
position: relative; margin-bottom: -6px; padding: 5px 12px; border-radius: 12px; font-size: 13px; white-space: nowrap;
|
|
135
|
+
background: var(--dagwick-surface); border: 1px solid var(--dagwick-border); box-shadow: 0 2px 6px var(--dagwick-shadow);
|
|
136
|
+
animation: dagwick-pop .5s cubic-bezier(.2,1.4,.4,1) both;
|
|
137
|
+
}
|
|
138
|
+
.dagwick-bubble::after {
|
|
139
|
+
content: ""; position: absolute; left: 50%; bottom: -6px; width: 10px; height: 10px; transform: translateX(-50%) rotate(45deg);
|
|
140
|
+
background: var(--dagwick-surface); border-right: 1px solid var(--dagwick-border); border-bottom: 1px solid var(--dagwick-border);
|
|
141
|
+
}
|
|
142
|
+
@keyframes dagwick-pop { from { opacity: 0; transform: scale(.6) translateY(6px); } to { opacity: 1; transform: none; } }
|
|
143
|
+
|
|
144
|
+
/* Grade badges */
|
|
145
|
+
.dagwick-badge { display: inline-grid; place-items: center; min-width: 26px; height: 26px; padding: 0 6px; border-radius: 8px; font-weight: 800; font-size: 14px; color: #fff; }
|
|
146
|
+
.dagwick-badge--A { background: #16a34a; } .dagwick-badge--B { background: #65a30d; } .dagwick-badge--C { background: #d97706; }
|
|
147
|
+
.dagwick-badge--D { background: #ea580c; } .dagwick-badge--F { background: #dc2626; }
|
|
148
|
+
.dagwick-ring.dagwick-grade-B .dagwick-ring-fill { stroke: #84cc16; }
|
|
149
|
+
|
|
150
|
+
/* Next goal */
|
|
151
|
+
.dagwick-goal {
|
|
152
|
+
display: flex; align-items: center; gap: 12px 16px; flex-wrap: wrap; margin-top: 16px; padding: 12px 16px; border-radius: 12px;
|
|
153
|
+
border: 1px dashed color-mix(in srgb, var(--dagwick-accent) 55%, transparent);
|
|
154
|
+
background: color-mix(in srgb, var(--dagwick-flame) 7%, var(--dagwick-bg));
|
|
155
|
+
}
|
|
156
|
+
.dagwick-goal-text { display: flex; align-items: center; gap: 8px; font-size: 14px; flex-wrap: wrap; }
|
|
157
|
+
.dagwick-goal-step { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--dagwick-accent); }
|
|
158
|
+
.dagwick-goal-picks { display: flex; gap: 8px; flex-wrap: wrap; margin-left: auto; }
|
|
159
|
+
.dagwick-pick {
|
|
160
|
+
font: inherit; font-size: 13px; padding: 4px 10px; border-radius: 99px; cursor: pointer;
|
|
161
|
+
color: var(--dagwick-text); background: var(--dagwick-bg); border: 1px solid var(--dagwick-border);
|
|
162
|
+
}
|
|
163
|
+
.dagwick-pick:hover { border-color: var(--dagwick-accent); }
|
|
164
|
+
.dagwick-pick-gain { color: var(--dagwick-pass); font-weight: 700; margin-left: 2px; }
|
|
165
|
+
|
|
166
|
+
/* Expandable check rows */
|
|
167
|
+
.dagwick-check-head { all: unset; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; width: 100%; cursor: pointer; }
|
|
168
|
+
.dagwick-check-head:disabled { cursor: default; }
|
|
169
|
+
.dagwick-check-head:focus-visible { outline: 2px solid var(--dagwick-accent); outline-offset: 4px; border-radius: 4px; }
|
|
170
|
+
.dagwick-caret { margin-left: 8px; color: var(--dagwick-muted); }
|
|
171
|
+
.dagwick-check { transition: background .2s; }
|
|
172
|
+
.dagwick-check.is-open { background: color-mix(in srgb, var(--dagwick-surface) 60%, transparent); }
|
|
173
|
+
.dagwick-detail { margin-top: 10px; display: flex; flex-direction: column; gap: 10px; animation: dagwick-fade .25s ease both; }
|
|
174
|
+
@keyframes dagwick-fade { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
|
175
|
+
.dagwick-items { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
176
|
+
.dagwick-items-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--dagwick-muted); margin-right: 2px; }
|
|
177
|
+
.dagwick-item { font-size: 12px; padding: 2px 8px; border-radius: 6px; background: var(--dagwick-fail-bg); color: var(--dagwick-fail); }
|
|
178
|
+
.dagwick-check--warn .dagwick-item { background: var(--dagwick-warn-bg); color: var(--dagwick-warn); }
|
|
179
|
+
.dagwick-more { all: unset; font-size: 12px; color: var(--dagwick-accent); cursor: pointer; }
|
|
180
|
+
.dagwick-fix { margin-top: 0; }
|
|
181
|
+
.dagwick-code { position: relative; margin-top: 8px; }
|
|
182
|
+
.dagwick-code pre {
|
|
183
|
+
margin: 0; padding: 10px 70px 10px 12px; border-radius: 8px; overflow-x: auto; font-size: 12.5px; line-height: 1.5;
|
|
184
|
+
background: #11131a; color: #f3e9d7; border: 1px solid #262a35;
|
|
185
|
+
}
|
|
186
|
+
.dagwick-copy {
|
|
187
|
+
position: absolute; top: 6px; right: 6px; font: inherit; font-size: 11px; padding: 3px 8px; border-radius: 6px; cursor: pointer;
|
|
188
|
+
color: #f3e9d7; background: #262a35; border: 1px solid #343947;
|
|
189
|
+
}
|
|
190
|
+
.dagwick-copy:hover { border-color: var(--dagwick-flame); }
|
|
191
|
+
|
|
192
|
+
@media (max-width: 720px) {
|
|
193
|
+
.dagwick-goal-picks { margin-left: 0; }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* Fleet */
|
|
197
|
+
.dagwick-badge--sm { min-width: 20px; height: 20px; font-size: 11px; border-radius: 6px; }
|
|
198
|
+
.dagwick-badge--lg { min-width: 38px; height: 38px; font-size: 20px; border-radius: 11px; }
|
|
199
|
+
.dagwick-ring-caption { font-size: 12px; }
|
|
200
|
+
.dagwick-stats { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 4px; }
|
|
201
|
+
.dagwick-stat {
|
|
202
|
+
display: flex; flex-direction: column; min-width: 104px; padding: 8px 14px; border-radius: 10px;
|
|
203
|
+
background: color-mix(in srgb, var(--dagwick-surface) 80%, transparent); border: 1px solid var(--dagwick-border);
|
|
204
|
+
}
|
|
205
|
+
.dagwick-stat-value { font-size: 20px; font-weight: 800; line-height: 1.2; font-variant-numeric: tabular-nums; }
|
|
206
|
+
.dagwick-stat-label { font-size: 12px; color: var(--dagwick-muted); }
|
|
207
|
+
.dagwick-stat--fail .dagwick-stat-value { color: var(--dagwick-fail); }
|
|
208
|
+
.dagwick-stat--pass .dagwick-stat-value { color: var(--dagwick-pass); }
|
|
209
|
+
|
|
210
|
+
.dagwick-grades { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin: 16px 0; }
|
|
211
|
+
.dagwick-gcard {
|
|
212
|
+
font: inherit; color: inherit; text-align: left; cursor: pointer; position: relative; overflow: hidden;
|
|
213
|
+
display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: center;
|
|
214
|
+
padding: 12px 14px 16px; border-radius: 12px; border: 1px solid var(--dagwick-border); background: var(--dagwick-bg);
|
|
215
|
+
transition: border-color .15s, transform .15s, opacity .2s, box-shadow .15s;
|
|
216
|
+
}
|
|
217
|
+
.dagwick-gcard:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 14px var(--dagwick-shadow); }
|
|
218
|
+
.dagwick-gcard:disabled { cursor: default; opacity: .45; }
|
|
219
|
+
.dagwick-gcard.is-dim { opacity: .5; }
|
|
220
|
+
.dagwick-gcard.is-active { border-color: var(--dagwick-accent); box-shadow: 0 0 0 1px var(--dagwick-accent) inset; }
|
|
221
|
+
.dagwick-gcard:focus-visible { outline: 2px solid var(--dagwick-accent); outline-offset: 2px; }
|
|
222
|
+
.dagwick-gcard-text { display: flex; flex-direction: column; line-height: 1.15; }
|
|
223
|
+
.dagwick-gcard-n { font-size: 22px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
|
224
|
+
.dagwick-gcard-text .dagwick-muted { font-size: 12px; }
|
|
225
|
+
.dagwick-gcard-bar { position: absolute; left: 0; right: 0; bottom: 0; height: 4px; background: var(--dagwick-border); }
|
|
226
|
+
.dagwick-gcard-bar > div { height: 100%; transition: width .6s cubic-bezier(.2,.8,.2,1); }
|
|
227
|
+
.dagwick-gcard--A .dagwick-gcard-bar > div { background: #16a34a; } .dagwick-gcard--B .dagwick-gcard-bar > div { background: #65a30d; }
|
|
228
|
+
.dagwick-gcard--C .dagwick-gcard-bar > div { background: #d97706; } .dagwick-gcard--D .dagwick-gcard-bar > div { background: #ea580c; }
|
|
229
|
+
.dagwick-gcard--F .dagwick-gcard-bar > div { background: #dc2626; }
|
|
230
|
+
|
|
231
|
+
.dagwick-fleet-grid { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 16px; align-items: start; }
|
|
232
|
+
.dagwick-toolbar { display: flex; gap: 10px; align-items: center; padding: 10px; border-bottom: 1px solid var(--dagwick-border); background: var(--dagwick-surface); flex-wrap: wrap; }
|
|
233
|
+
.dagwick-search-wrap { position: relative; flex: 1; min-width: 200px; color: var(--dagwick-muted); }
|
|
234
|
+
.dagwick-search-wrap svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); pointer-events: none; }
|
|
235
|
+
.dagwick-search {
|
|
236
|
+
width: 100%; font: inherit; font-size: 14px; padding: 7px 12px 7px 34px; border-radius: 8px;
|
|
237
|
+
color: var(--dagwick-text); background: var(--dagwick-bg); border: 1px solid var(--dagwick-border); outline: none;
|
|
238
|
+
}
|
|
239
|
+
.dagwick-search:focus { border-color: var(--dagwick-accent); }
|
|
240
|
+
.dagwick-seg { display: inline-flex; padding: 3px; gap: 2px; border-radius: 9px; background: var(--dagwick-bg); border: 1px solid var(--dagwick-border); }
|
|
241
|
+
.dagwick-seg button { font: inherit; font-size: 13px; padding: 5px 12px; border-radius: 6px; border: 0; cursor: pointer; background: transparent; color: var(--dagwick-muted); }
|
|
242
|
+
.dagwick-seg button.is-active { background: var(--dagwick-surface); color: var(--dagwick-text); box-shadow: 0 1px 2px var(--dagwick-shadow); }
|
|
243
|
+
.dagwick-seg button:focus-visible { outline: 2px solid var(--dagwick-accent); outline-offset: 1px; }
|
|
244
|
+
.dagwick-filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 8px 14px; font-size: 13px; border-bottom: 1px solid var(--dagwick-border); }
|
|
245
|
+
.dagwick-fchip {
|
|
246
|
+
font: inherit; font-size: 12px; padding: 3px 10px; border-radius: 99px; cursor: pointer;
|
|
247
|
+
color: var(--dagwick-accent); background: color-mix(in srgb, var(--dagwick-flame) 12%, transparent);
|
|
248
|
+
border: 1px solid color-mix(in srgb, var(--dagwick-accent) 40%, transparent);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.dagwick-rows { list-style: none; margin: 0; padding: 0; }
|
|
252
|
+
.dagwick-rows li + li { border-top: 1px solid var(--dagwick-border); }
|
|
253
|
+
.dagwick-row {
|
|
254
|
+
display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 14px; align-items: center;
|
|
255
|
+
padding: 12px 16px 12px 13px; color: inherit; text-decoration: none; border-left: 3px solid transparent;
|
|
256
|
+
transition: background .15s;
|
|
257
|
+
}
|
|
258
|
+
.dagwick-row--A { border-left-color: #16a34a; } .dagwick-row--B { border-left-color: #65a30d; } .dagwick-row--C { border-left-color: #d97706; }
|
|
259
|
+
.dagwick-row--D { border-left-color: #ea580c; } .dagwick-row--F { border-left-color: #dc2626; }
|
|
260
|
+
.dagwick-row:hover { background: var(--dagwick-surface); }
|
|
261
|
+
.dagwick-row:hover .dagwick-row-name { color: var(--dagwick-accent); }
|
|
262
|
+
.dagwick-row:hover .dagwick-row-go { transform: translateX(3px); color: var(--dagwick-accent); }
|
|
263
|
+
.dagwick-row:focus-visible { outline: 2px solid var(--dagwick-accent); outline-offset: -2px; }
|
|
264
|
+
.dagwick-row-main { min-width: 0; }
|
|
265
|
+
.dagwick-row-title { display: flex; align-items: center; gap: 8px; }
|
|
266
|
+
.dagwick-row-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
267
|
+
.dagwick-row-sub { font-size: 12.5px; display: flex; gap: 10px; flex-wrap: wrap; margin-top: 1px; }
|
|
268
|
+
.dagwick-row-sub span + span::before { content: "·"; margin-right: 10px; }
|
|
269
|
+
.dagwick-row-issues { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 7px; }
|
|
270
|
+
.dagwick-ichip { font-size: 11.5px; padding: 2px 8px; border-radius: 6px; white-space: nowrap; }
|
|
271
|
+
.dagwick-ichip--fail { color: var(--dagwick-fail); background: var(--dagwick-fail-bg); }
|
|
272
|
+
.dagwick-ichip--warn { color: var(--dagwick-warn); background: var(--dagwick-warn-bg); }
|
|
273
|
+
.dagwick-ichip--more { color: var(--dagwick-muted); background: var(--dagwick-surface); }
|
|
274
|
+
.dagwick-row-go { font-size: 22px; color: var(--dagwick-muted); transition: transform .15s, color .15s; }
|
|
275
|
+
.dagwick-mini { position: relative; width: 46px; height: 46px; flex: none; }
|
|
276
|
+
.dagwick-mini .dagwick-ring-track { stroke-width: 5; }
|
|
277
|
+
.dagwick-mini .dagwick-ring-fill { stroke-width: 5; }
|
|
278
|
+
.dagwick-mini span { position: absolute; inset: 0; display: grid; place-items: center; font-size: 13px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
|
279
|
+
|
|
280
|
+
.dagwick-side { position: sticky; top: 12px; }
|
|
281
|
+
.dagwick-side-card { padding: 16px; border: 1px solid var(--dagwick-border); border-radius: 16px; background: var(--dagwick-bg); }
|
|
282
|
+
.dagwick-side-title { margin: 0 0 12px; font-size: 14px; font-weight: 700; }
|
|
283
|
+
.dagwick-side-empty { margin: 0; font-size: 13px; }
|
|
284
|
+
.dagwick-side-hint { margin: 12px 0 0; font-size: 12px; }
|
|
285
|
+
.dagwick-common { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
|
286
|
+
.dagwick-common-item { all: unset; box-sizing: border-box; display: block; width: 100%; cursor: pointer; padding: 8px 10px; border-radius: 10px; border: 1px solid transparent; }
|
|
287
|
+
.dagwick-common-item:hover { background: var(--dagwick-surface); }
|
|
288
|
+
.dagwick-common-item.is-active { background: var(--dagwick-surface); border-color: var(--dagwick-accent); }
|
|
289
|
+
.dagwick-common-item:focus-visible { outline: 2px solid var(--dagwick-accent); }
|
|
290
|
+
.dagwick-common-head { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; margin-bottom: 6px; }
|
|
291
|
+
.dagwick-common-head .dagwick-muted { font-variant-numeric: tabular-nums; }
|
|
292
|
+
|
|
293
|
+
@media (max-width: 1100px) {
|
|
294
|
+
.dagwick-fleet-grid { grid-template-columns: 1fr; }
|
|
295
|
+
.dagwick-side { position: static; order: -1; }
|
|
296
|
+
}
|
|
297
|
+
@media (max-width: 720px) {
|
|
298
|
+
.dagwick-grades { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
299
|
+
.dagwick-row { grid-template-columns: auto minmax(0, 1fr) auto; }
|
|
300
|
+
.dagwick-row-go { display: none; }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* The lamp */
|
|
304
|
+
.dagwick-lamp { flex: none; overflow: visible; }
|
|
305
|
+
.dagwick-flame { transform-box: fill-box; transform-origin: 50% 100%; }
|
|
306
|
+
.dagwick-flame path:first-child { transform-box: fill-box; transform-origin: 50% 100%; animation: dagwick-sway 2.4s ease-in-out infinite; }
|
|
307
|
+
.dagwick-lamp--worried .dagwick-flame path:first-child { animation: dagwick-flicker .5s steps(2) infinite; }
|
|
308
|
+
.dagwick-glow { animation: dagwick-breathe 3s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
|
309
|
+
.dagwick-sparkles path { animation: dagwick-twinkle 2s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
|
310
|
+
.dagwick-sparkles path:nth-child(2) { animation-delay: .6s; }
|
|
311
|
+
.dagwick-sparkles path:nth-child(3) { animation-delay: 1.2s; }
|
|
312
|
+
.dagwick-lamp--radiant .dagwick-body, .dagwick-lamp--happy .dagwick-body { animation: dagwick-bob 3s ease-in-out infinite; }
|
|
313
|
+
.dagwick-eyes { animation: dagwick-blink 4s infinite; transform-box: fill-box; transform-origin: center; }
|
|
314
|
+
.dagwick-smoke path { stroke-dasharray: 80; animation: dagwick-smoke 3.2s ease-in-out infinite; }
|
|
315
|
+
@keyframes dagwick-sway { 0%,100% { transform: rotate(-3deg) scaleY(1); } 50% { transform: rotate(3deg) scaleY(1.06); } }
|
|
316
|
+
@keyframes dagwick-flicker { 0% { opacity: 1; transform: scaleY(1); } 100% { opacity: .55; transform: scaleY(.8) rotate(4deg); } }
|
|
317
|
+
@keyframes dagwick-breathe { 0%,100% { opacity: .85; transform: scale(1); } 50% { opacity: 1; transform: scale(1.06); } }
|
|
318
|
+
@keyframes dagwick-twinkle { 0%,100% { opacity: 0; transform: scale(.4); } 50% { opacity: 1; transform: scale(1); } }
|
|
319
|
+
@keyframes dagwick-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
|
|
320
|
+
@keyframes dagwick-blink { 0%,92%,100% { transform: scaleY(1); } 95% { transform: scaleY(.1); } }
|
|
321
|
+
@keyframes dagwick-smoke { 0% { stroke-dashoffset: 80; opacity: 0; } 30% { opacity: .9; } 100% { stroke-dashoffset: -80; opacity: 0; } }
|
|
322
|
+
@media (prefers-reduced-motion: reduce) { .dagwick *, .dagwick-lamp * { animation: none !important; transition: none !important; } }
|
|
323
|
+
|
|
324
|
+
@media (max-width: 720px) {
|
|
325
|
+
.dagwick-hero { grid-template-columns: 1fr; justify-items: center; text-align: center; }
|
|
326
|
+
.dagwick-chips { justify-content: center; }
|
|
327
|
+
}
|
|
328
|
+
`;function ya({dagId:i}){return a.jsxs("div",{className:"dagwick",children:[a.jsx("style",{children:ua}),i?a.jsx(oa,{dagId:i},i):a.jsx(ma,{})]})}function $(i){return a.jsx(ya,{...i})}const U=globalThis;return U[v]=$,U.AirflowPlugin=$,$})(ReactJSXRuntime,React);
|
dagwick/static/icon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M8 7.5C8 2.5 16 2.5 16 7.5" stroke="#8a5a1c" stroke-width="1.6" stroke-linecap="round"/><path d="M9.2 6.6h5.6l1.8 2H7.4z" fill="#e0a040"/><path d="M8.6 8.6c-2.6 2-2.6 6 0 8h6.8c2.6-2 2.6-6 0-8z" fill="#bfe3f7" fill-opacity=".35" stroke="#8a5a1c" stroke-width="1.1"/><path d="M12 16c-1.9-1.1-1.4-3.4 0-5.2.3 1.5 1.9 2.4 1.3 3.9-.2.7-.7 1.1-1.3 1.3z" fill="#f59e0b"/><path d="M7.2 16.6h9.6l.6 2.8c0 1-10.8 1-10.8 0z" fill="#e0a040"/></svg>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dagwick
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The wick that keeps your DAGs lit: DAG quality scores, a fleet view, and fixes you can act on, inside the Airflow 3 UI.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ardalan-jalal/dagwick
|
|
6
|
+
Project-URL: Repository, https://github.com/ardalan-jalal/dagwick
|
|
7
|
+
Project-URL: Issues, https://github.com/ardalan-jalal/dagwick/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/ardalan-jalal/dagwick/blob/main/CHANGELOG.md
|
|
9
|
+
Author: The Dagwick Authors
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: airflow,airflow-plugin,apache-airflow,dag,data-quality,linting
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Plugins
|
|
15
|
+
Classifier: Environment :: Web Environment
|
|
16
|
+
Classifier: Framework :: Apache Airflow
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
27
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.10
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Dagwick
|
|
33
|
+
|
|
34
|
+

|
|
35
|
+
|
|
36
|
+
## Setup
|
|
37
|
+
|
|
38
|
+
**1. Install** it where Airflow runs:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install dagwick
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Using Docker? Add `dagwick` to your Airflow image's `requirements.txt`.
|
|
45
|
+
|
|
46
|
+
**2. Restart** the Airflow api-server.
|
|
47
|
+
|
|
48
|
+
**3. Open Airflow.** You now have:
|
|
49
|
+
|
|
50
|
+
- a **Dagwick** tab on every DAG page, and
|
|
51
|
+
- a **Dagwick** page in the sidebar that grades all your DAGs.
|
|
52
|
+
|
|
53
|
+
That's it. There is nothing to configure.
|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
|
|
57
|
+

|
|
58
|
+
|
|
59
|
+
## Requirements
|
|
60
|
+
|
|
61
|
+
- Apache Airflow 3.1 or newer
|
|
62
|
+
- Python 3.10 – 3.15
|
|
63
|
+
|
|
64
|
+
Dagwick has no dependencies of its own, so installing it never changes your
|
|
65
|
+
other packages. It is read-only and only shows each user the DAGs they can
|
|
66
|
+
already see.
|
|
67
|
+
|
|
68
|
+
## Remove
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip uninstall dagwick
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Then restart the api-server.
|
|
75
|
+
|
|
76
|
+
## More
|
|
77
|
+
|
|
78
|
+
- [What it checks, and how to add your own checks](https://github.com/ardalan-jalal/dagwick/blob/main/docs/checks.md)
|
|
79
|
+
- [Security](https://github.com/ardalan-jalal/dagwick/blob/main/SECURITY.md) · [Contributing](https://github.com/ardalan-jalal/dagwick/blob/main/CONTRIBUTING.md) · [Changelog](https://github.com/ardalan-jalal/dagwick/blob/main/CHANGELOG.md)
|
|
80
|
+
- License: [MIT](https://github.com/ardalan-jalal/dagwick/blob/main/LICENSE)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
dagwick/__init__.py,sha256=Na4TZVOcM8wIKSOAGu31v7gkr0OhY4TF0UbEkBat5eo,232
|
|
2
|
+
dagwick/api.py,sha256=vGJTduJppTSW6qTIECLFD_e8fkCBWnmvK63q0nzHNn8,6121
|
|
3
|
+
dagwick/core.py,sha256=Dh83t-9afuccFhOXXLHzF3Rvj1AgYcTZG9rfDjQ4y1w,5296
|
|
4
|
+
dagwick/plugin.py,sha256=fiphK9NfEwP65l8SlhuSRLudldYI_7Wc-AK0YU2WfzE,921
|
|
5
|
+
dagwick/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
dagwick/checks/__init__.py,sha256=DDtQ58m34eJR2Hfc29pZziZ1S08fD9lkXV44iBRFhy8,235
|
|
7
|
+
dagwick/checks/_util.py,sha256=YTZtCxnPUoqXfYqokDgLHCOZyGpW4lXLbienDEJSG54,1001
|
|
8
|
+
dagwick/checks/observability.py,sha256=6pTj1AHcmaj_s6HLG_Za232nHq5kqWIH-ClDUx6VUHo,1382
|
|
9
|
+
dagwick/checks/ownership.py,sha256=K26sOwCwDAP0ptPqVMcrTxdB3-YaveDfPQN-4DH76T0,1701
|
|
10
|
+
dagwick/checks/reliability.py,sha256=6UYkZtX66TkDEEJVkps7wtA16LW-KrNWEZFxlUgOHyI,1892
|
|
11
|
+
dagwick/checks/scheduling.py,sha256=XaP4p38Lphk1ekSCz-Une95Epuu1PiJCyzkxZ24Yepk,2560
|
|
12
|
+
dagwick/checks/structure.py,sha256=YkEluRCK833ye09_frbo2_HfC6n9JKSpqvlQwkvcR1o,1255
|
|
13
|
+
dagwick/static/dagwick.js,sha256=YOSdESKks474g8XvBrNByA7pHeOEZWiamSn2GvELL80,46180
|
|
14
|
+
dagwick/static/icon.svg,sha256=hxll4HluV4ZA9tvvdUNuclDrBJutv3hh5GvrF3nZgwI,519
|
|
15
|
+
dagwick-0.1.0.dist-info/METADATA,sha256=wQPL0rrtrJT8XzogHerGa-ytYrSubXUgMAqgkV68cUo,2976
|
|
16
|
+
dagwick-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
17
|
+
dagwick-0.1.0.dist-info/entry_points.txt,sha256=-HthUQ9YgHpYMMp5LN5cgd3BSXDdsHgElx7351IAIS0,57
|
|
18
|
+
dagwick-0.1.0.dist-info/licenses/LICENSE,sha256=tnI3Zj22l8cI-aj9IUQbNV5CAEyvLSXRtS9sxt4ecPI,1076
|
|
19
|
+
dagwick-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Dagwick Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|