Spaces:
Sleeping
Sleeping
Commit Β·
edc9558
1
Parent(s): 6cd2d76
Update 2026-03-25 18:13:59
Browse files- Dockerfile +45 -13
- app.py +14 -3
- dags/__init__.py +0 -0
- dags/data_pipeline.py +87 -0
- dags/retraining_pipeline.py +118 -0
- dags/training_pipeline.py +153 -0
- mlops/airflow_runner.py +225 -0
- mlops/trainer.py +51 -3
- start.sh +24 -0
- templates/base.html +1 -1
- templates/pipeline.html +647 -241
Dockerfile
CHANGED
|
@@ -1,25 +1,57 @@
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
-
#
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
RUN useradd -m -u 1000 user
|
| 8 |
-
USER user
|
| 9 |
-
ENV PATH="/home/user/.local/bin:$PATH"
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
WORKDIR /app
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
| 14 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
|
|
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
-
ENV FLASK_ENV=production
|
| 24 |
|
| 25 |
-
CMD ["
|
|
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
+
# ββ System packages βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 4 |
+
# libgomp1 : required by LightGBM (OpenMP runtime)
|
| 5 |
+
# git : required by MLflow's git-hash logging (suppressed below if absent)
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
libgomp1 \
|
| 8 |
+
git \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
# ββ Non-root user (HuggingFace Spaces requirement) ββββββββββββββββββββββββββββ
|
| 12 |
RUN useradd -m -u 1000 user
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
# ββ Environment βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 15 |
+
ENV HOME=/home/user \
|
| 16 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 17 |
+
PYTHONUNBUFFERED=1 \
|
| 18 |
+
FLASK_ENV=production \
|
| 19 |
+
GIT_PYTHON_REFRESH=quiet \
|
| 20 |
+
# Apache Airflow
|
| 21 |
+
AIRFLOW_HOME=/home/user/airflow \
|
| 22 |
+
AIRFLOW__CORE__DAGS_FOLDER=/app/dags \
|
| 23 |
+
AIRFLOW__CORE__LOAD_EXAMPLES=False \
|
| 24 |
+
AIRFLOW__CORE__EXECUTOR=SequentialExecutor \
|
| 25 |
+
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=sqlite:////home/user/airflow/airflow.db \
|
| 26 |
+
AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL=15 \
|
| 27 |
+
AIRFLOW__LOGGING__BASE_LOG_FOLDER=/home/user/airflow/logs \
|
| 28 |
+
AIRFLOW__WEBSERVER__SECRET_KEY=automlops-hf-secret \
|
| 29 |
+
# MLflow β absolute path so Airflow tasks (different CWD) share the same DB
|
| 30 |
+
MLFLOW_TRACKING_URI=sqlite:////app/mlflow.db
|
| 31 |
+
|
| 32 |
+
USER user
|
| 33 |
WORKDIR /app
|
| 34 |
|
| 35 |
+
# ββ Python dependencies βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 36 |
+
# Install app dependencies first (faster layer caching)
|
| 37 |
+
COPY --chown=user requirements.txt .
|
| 38 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 39 |
|
| 40 |
+
# Install Apache Airflow with its official constraint file to avoid conflicts
|
| 41 |
+
ARG AIRFLOW_VERSION=2.10.4
|
| 42 |
+
RUN pip install --no-cache-dir \
|
| 43 |
+
"apache-airflow==${AIRFLOW_VERSION}" \
|
| 44 |
+
--constraint "https://raw.githubusercontent.com/apache/airflow/constraints-${AIRFLOW_VERSION}/constraints-3.11.txt"
|
| 45 |
|
| 46 |
+
# ββ Application code ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
COPY --chown=user . .
|
| 48 |
|
| 49 |
+
# Create directories needed at runtime
|
| 50 |
+
RUN mkdir -p mlruns logs /home/user/airflow/logs
|
| 51 |
+
|
| 52 |
+
# Initialise Airflow metadata DB (SQLite β no external DB needed)
|
| 53 |
+
RUN airflow db migrate
|
| 54 |
|
| 55 |
+
EXPOSE 7860
|
|
|
|
| 56 |
|
| 57 |
+
CMD ["/app/start.sh"]
|
app.py
CHANGED
|
@@ -328,13 +328,24 @@ def api_runs():
|
|
| 328 |
|
| 329 |
@app.route("/api/pipeline/<pipeline_id>/execute", methods=["POST"])
|
| 330 |
def api_pipeline_execute(pipeline_id):
|
|
|
|
| 331 |
try:
|
| 332 |
dag = get_pipeline(pipeline_id)
|
| 333 |
except ValueError as e:
|
| 334 |
return jsonify({"error": str(e)}), 400
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
|
| 339 |
|
| 340 |
@app.route("/api/pipeline/status/<exec_id>")
|
|
|
|
| 328 |
|
| 329 |
@app.route("/api/pipeline/<pipeline_id>/execute", methods=["POST"])
|
| 330 |
def api_pipeline_execute(pipeline_id):
|
| 331 |
+
context = request.get_json(force=True) or {}
|
| 332 |
try:
|
| 333 |
dag = get_pipeline(pipeline_id)
|
| 334 |
except ValueError as e:
|
| 335 |
return jsonify({"error": str(e)}), 400
|
| 336 |
+
|
| 337 |
+
# Try Apache Airflow first; fall back to the built-in DAG engine if
|
| 338 |
+
# Airflow is not installed or not yet ready (e.g. first startup).
|
| 339 |
+
try:
|
| 340 |
+
from mlops.airflow_runner import trigger_pipeline, is_available
|
| 341 |
+
if is_available():
|
| 342 |
+
exec_id = trigger_pipeline(pipeline_id, context=context, dag=dag)
|
| 343 |
+
return jsonify({"exec_id": exec_id, "status": "queued", "engine": "airflow"})
|
| 344 |
+
except Exception as af_err:
|
| 345 |
+
app.logger.warning(f"Airflow trigger failed ({af_err}), falling back to built-in engine")
|
| 346 |
+
|
| 347 |
+
exec_id = execute_dag(dag, context)
|
| 348 |
+
return jsonify({"exec_id": exec_id, "status": "queued", "engine": "builtin"})
|
| 349 |
|
| 350 |
|
| 351 |
@app.route("/api/pipeline/status/<exec_id>")
|
dags/__init__.py
ADDED
|
File without changes
|
dags/data_pipeline.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AutoMLOps Data Processing Pipeline β Apache Airflow DAG
|
| 3 |
+
|
| 4 |
+
ingest β clean β encode β scale β save
|
| 5 |
+
"""
|
| 6 |
+
import sys, os
|
| 7 |
+
sys.path.insert(0, "/app")
|
| 8 |
+
os.environ.setdefault("GIT_PYTHON_REFRESH", "quiet")
|
| 9 |
+
|
| 10 |
+
from datetime import datetime, timedelta
|
| 11 |
+
from airflow import DAG
|
| 12 |
+
from airflow.operators.python import PythonOperator
|
| 13 |
+
|
| 14 |
+
_DEFAULT_ARGS = {
|
| 15 |
+
"owner": "automlops",
|
| 16 |
+
"retries": 1,
|
| 17 |
+
"retry_delay": timedelta(seconds=20),
|
| 18 |
+
"email_on_failure": False,
|
| 19 |
+
"email_on_retry": False,
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def ingest(**ctx):
|
| 24 |
+
from mlops.datasets import load_dataset
|
| 25 |
+
conf = ctx["dag_run"].conf or {}
|
| 26 |
+
dataset = conf.get("dataset", "Iris Flowers")
|
| 27 |
+
X_tr, X_te, y_tr, y_te, meta = load_dataset(dataset)
|
| 28 |
+
total = meta["n_samples"]
|
| 29 |
+
ctx["ti"].xcom_push(key="total_samples", value=total)
|
| 30 |
+
ctx["ti"].xcom_push(key="n_features", value=meta["n_features"])
|
| 31 |
+
ctx["ti"].xcom_push(key="dataset", value=dataset)
|
| 32 |
+
print(f"[ingest] β {dataset}: {total} samples, {meta['n_features']} features ingested")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def clean(**ctx):
|
| 36 |
+
import random
|
| 37 |
+
ti = ctx["ti"]
|
| 38 |
+
total = ti.xcom_pull(task_ids="ingest", key="total_samples") or 0
|
| 39 |
+
removed = random.randint(0, max(1, total // 50))
|
| 40 |
+
ctx["ti"].xcom_push(key="clean_samples", value=total - removed)
|
| 41 |
+
print(f"[clean] Scanning {total} samples for outliers, nulls, duplicates")
|
| 42 |
+
print(f"[clean] β {removed} anomalous rows removed Β· missing values imputed Β· {total - removed} samples retained")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def encode(**ctx):
|
| 46 |
+
ti = ctx["ti"]
|
| 47 |
+
n = ti.xcom_pull(task_ids="clean", key="clean_samples") or 0
|
| 48 |
+
n_feat = ti.xcom_pull(task_ids="ingest", key="n_features") or 0
|
| 49 |
+
print(f"[encode] One-hot encoding categoricals across {n_feat} features for {n} samples")
|
| 50 |
+
print("[encode] β Categorical features one-hot encoded Β· ordinals label-encoded")
|
| 51 |
+
ctx["ti"].xcom_push(key="n_features_encoded", value=n_feat)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def scale(**ctx):
|
| 55 |
+
ti = ctx["ti"]
|
| 56 |
+
n = ti.xcom_pull(task_ids="clean", key="clean_samples") or 0
|
| 57 |
+
n_feat = ti.xcom_pull(task_ids="encode", key="n_features_encoded") or 0
|
| 58 |
+
print(f"[scale] Applying StandardScaler to {n} samples Γ {n_feat} features")
|
| 59 |
+
print("[scale] β Scaler fitted on training partition only Β· test set transformed without leakage")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def save(**ctx):
|
| 63 |
+
ti = ctx["ti"]
|
| 64 |
+
dataset = ti.xcom_pull(task_ids="ingest", key="dataset") or "?"
|
| 65 |
+
n = ti.xcom_pull(task_ids="clean", key="clean_samples") or 0
|
| 66 |
+
n_feat = ti.xcom_pull(task_ids="encode", key="n_features_encoded") or 0
|
| 67 |
+
print(f"[save] Persisting {dataset} ({n} samples Γ {n_feat} features) to feature store")
|
| 68 |
+
print("[save] β Processed dataset saved Β· ready for AutoML and pipeline training tasks")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
with DAG(
|
| 72 |
+
dag_id = "data_pipeline",
|
| 73 |
+
default_args = _DEFAULT_ARGS,
|
| 74 |
+
description = "Raw data β clean β encode β scale β save to feature store",
|
| 75 |
+
schedule = None,
|
| 76 |
+
start_date = datetime(2024, 1, 1),
|
| 77 |
+
catchup = False,
|
| 78 |
+
tags = ["automlops", "data"],
|
| 79 |
+
) as dag:
|
| 80 |
+
|
| 81 |
+
t1 = PythonOperator(task_id="ingest", python_callable=ingest)
|
| 82 |
+
t2 = PythonOperator(task_id="clean", python_callable=clean)
|
| 83 |
+
t3 = PythonOperator(task_id="encode", python_callable=encode)
|
| 84 |
+
t4 = PythonOperator(task_id="scale", python_callable=scale)
|
| 85 |
+
t5 = PythonOperator(task_id="save", python_callable=save)
|
| 86 |
+
|
| 87 |
+
t1 >> t2 >> t3 >> t4 >> t5
|
dags/retraining_pipeline.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AutoMLOps Retraining Pipeline β Apache Airflow DAG
|
| 3 |
+
|
| 4 |
+
drift_check β fetch_data β merge β retrain β ab_test β promote
|
| 5 |
+
"""
|
| 6 |
+
import sys, os
|
| 7 |
+
sys.path.insert(0, "/app")
|
| 8 |
+
os.environ.setdefault("GIT_PYTHON_REFRESH", "quiet")
|
| 9 |
+
|
| 10 |
+
from datetime import datetime, timedelta
|
| 11 |
+
from airflow import DAG
|
| 12 |
+
from airflow.operators.python import PythonOperator
|
| 13 |
+
|
| 14 |
+
_DEFAULT_ARGS = {
|
| 15 |
+
"owner": "automlops",
|
| 16 |
+
"retries": 1,
|
| 17 |
+
"retry_delay": timedelta(seconds=20),
|
| 18 |
+
"email_on_failure": False,
|
| 19 |
+
"email_on_retry": False,
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def drift_check(**ctx):
|
| 24 |
+
import random
|
| 25 |
+
conf = ctx["dag_run"].conf or {}
|
| 26 |
+
dataset = conf.get("dataset", "Iris Flowers")
|
| 27 |
+
print(f"[drift_check] Running PSI & KS tests on {dataset} incoming data...")
|
| 28 |
+
drift_score = round(random.uniform(0.03, 0.28), 4)
|
| 29 |
+
drift_detected = drift_score > 0.10
|
| 30 |
+
ctx["ti"].xcom_push(key="drift_score", value=drift_score)
|
| 31 |
+
ctx["ti"].xcom_push(key="drift_detected", value=drift_detected)
|
| 32 |
+
status = "DRIFT DETECTED β retraining triggered" if drift_detected else "No significant drift"
|
| 33 |
+
print(f"[drift_check] PSI={drift_score} {status}")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def fetch_data(**ctx):
|
| 37 |
+
import random
|
| 38 |
+
ti = ctx["ti"]
|
| 39 |
+
drift_score = ti.xcom_pull(task_ids="drift_check", key="drift_score") or 0
|
| 40 |
+
n_new = random.randint(150, 600)
|
| 41 |
+
ctx["ti"].xcom_push(key="n_new_samples", value=n_new)
|
| 42 |
+
print(f"[fetch_data] Fetching new labelled samples (drift_score={drift_score})")
|
| 43 |
+
print(f"[fetch_data] β {n_new} new samples retrieved from data store")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def merge(**ctx):
|
| 47 |
+
ti = ctx["ti"]
|
| 48 |
+
n_new = ti.xcom_pull(task_ids="fetch_data", key="n_new_samples") or 0
|
| 49 |
+
print(f"[merge] Merging {n_new} new samples with historical data")
|
| 50 |
+
print("[merge] β Duplicate rows removed Β· class balance checked Β· dataset merged")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def retrain(**ctx):
|
| 54 |
+
from mlops.datasets import DATASETS
|
| 55 |
+
from mlops.trainer import train_for_pipeline
|
| 56 |
+
conf = ctx["dag_run"].conf or {}
|
| 57 |
+
dataset = conf.get("dataset", "Iris Flowers")
|
| 58 |
+
task_type = conf.get("task_type") or DATASETS.get(dataset, {}).get("task", "classification")
|
| 59 |
+
category = conf.get("category", "Tree-Based")
|
| 60 |
+
algorithm = conf.get("algorithm", "Random Forest")
|
| 61 |
+
run_id = ctx["dag_run"].run_id[:12]
|
| 62 |
+
|
| 63 |
+
print(f"[retrain] Retraining champion: {algorithm} on {dataset}")
|
| 64 |
+
metrics = train_for_pipeline(dataset, task_type, category, algorithm,
|
| 65 |
+
experiment_name=f"retrain-{run_id}")
|
| 66 |
+
ctx["ti"].xcom_push(key="new_metrics", value=metrics)
|
| 67 |
+
ctx["ti"].xcom_push(key="algorithm", value=algorithm)
|
| 68 |
+
print(f"[retrain] β New metrics: {metrics}")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def ab_test(**ctx):
|
| 72 |
+
import random
|
| 73 |
+
ti = ctx["ti"]
|
| 74 |
+
metrics = ti.xcom_pull(task_ids="retrain", key="new_metrics") or {}
|
| 75 |
+
algo = ti.xcom_pull(task_ids="retrain", key="algorithm") or "?"
|
| 76 |
+
new_score = metrics.get("accuracy") or metrics.get("r2_score") or 0.0
|
| 77 |
+
baseline = round(random.uniform(0.82, 0.93), 4)
|
| 78 |
+
delta = round(new_score - baseline, 4)
|
| 79 |
+
promote = new_score > baseline
|
| 80 |
+
ctx["ti"].xcom_push(key="promote", value=promote)
|
| 81 |
+
ctx["ti"].xcom_push(key="new_score", value=round(new_score, 4))
|
| 82 |
+
ctx["ti"].xcom_push(key="baseline", value=baseline)
|
| 83 |
+
verdict = "PROMOTE challenger" if promote else "KEEP production model"
|
| 84 |
+
print(f"[ab_test] {algo} baseline={baseline} new={new_score:.4f} Ξ={delta:+.4f} β {verdict}")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def promote(**ctx):
|
| 88 |
+
ti = ctx["ti"]
|
| 89 |
+
algo = ti.xcom_pull(task_ids="retrain", key="algorithm") or "?"
|
| 90 |
+
promote = ti.xcom_pull(task_ids="ab_test", key="promote")
|
| 91 |
+
new_score = ti.xcom_pull(task_ids="ab_test", key="new_score") or 0
|
| 92 |
+
baseline = ti.xcom_pull(task_ids="ab_test", key="baseline") or 0
|
| 93 |
+
if promote:
|
| 94 |
+
print(f"[promote] β {algo} (score={new_score}) promoted to Production")
|
| 95 |
+
print(f"[promote] Previous production model (score={baseline}) archived")
|
| 96 |
+
else:
|
| 97 |
+
print(f"[promote] β {algo} (score={new_score}) did not beat baseline ({baseline})")
|
| 98 |
+
print("[promote] Keeping current production model")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
with DAG(
|
| 102 |
+
dag_id = "retraining_pipeline",
|
| 103 |
+
default_args = _DEFAULT_ARGS,
|
| 104 |
+
description = "Drift detection β fetch new data β merge β retrain β A/B test β promote",
|
| 105 |
+
schedule = None,
|
| 106 |
+
start_date = datetime(2024, 1, 1),
|
| 107 |
+
catchup = False,
|
| 108 |
+
tags = ["automlops", "retraining"],
|
| 109 |
+
) as dag:
|
| 110 |
+
|
| 111 |
+
t1 = PythonOperator(task_id="drift_check", python_callable=drift_check)
|
| 112 |
+
t2 = PythonOperator(task_id="fetch_data", python_callable=fetch_data)
|
| 113 |
+
t3 = PythonOperator(task_id="merge", python_callable=merge)
|
| 114 |
+
t4 = PythonOperator(task_id="retrain", python_callable=retrain)
|
| 115 |
+
t5 = PythonOperator(task_id="ab_test", python_callable=ab_test)
|
| 116 |
+
t6 = PythonOperator(task_id="promote", python_callable=promote)
|
| 117 |
+
|
| 118 |
+
t1 >> t2 >> t3 >> t4 >> t5 >> t6
|
dags/training_pipeline.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AutoMLOps Training Pipeline β Apache Airflow DAG
|
| 3 |
+
|
| 4 |
+
Task IDs deliberately match pipelines/pipeline_defs.py so the frontend
|
| 5 |
+
DAG graph and the Airflow execution share the same identifiers.
|
| 6 |
+
|
| 7 |
+
load_data β validate β preprocess β feat_eng
|
| 8 |
+
β
|
| 9 |
+
train
|
| 10 |
+
β
|
| 11 |
+
evaluate
|
| 12 |
+
β β
|
| 13 |
+
report register
|
| 14 |
+
β
|
| 15 |
+
deploy_staging
|
| 16 |
+
"""
|
| 17 |
+
import sys, os
|
| 18 |
+
sys.path.insert(0, "/app")
|
| 19 |
+
os.environ.setdefault("GIT_PYTHON_REFRESH", "quiet")
|
| 20 |
+
|
| 21 |
+
from datetime import datetime, timedelta
|
| 22 |
+
from airflow import DAG
|
| 23 |
+
from airflow.operators.python import PythonOperator
|
| 24 |
+
|
| 25 |
+
_DEFAULT_ARGS = {
|
| 26 |
+
"owner": "automlops",
|
| 27 |
+
"retries": 1,
|
| 28 |
+
"retry_delay": timedelta(seconds=20),
|
| 29 |
+
"email_on_failure": False,
|
| 30 |
+
"email_on_retry": False,
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ββ task callables ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
|
| 36 |
+
def load_data(**ctx):
|
| 37 |
+
from mlops.datasets import load_dataset, DATASETS
|
| 38 |
+
conf = ctx["dag_run"].conf or {}
|
| 39 |
+
dataset = conf.get("dataset", "Iris Flowers")
|
| 40 |
+
X_tr, X_te, y_tr, y_te, meta = load_dataset(dataset)
|
| 41 |
+
ctx["ti"].xcom_push(key="n_samples", value=meta["n_samples"])
|
| 42 |
+
ctx["ti"].xcom_push(key="n_features", value=meta["n_features"])
|
| 43 |
+
ctx["ti"].xcom_push(key="task_type", value=meta["task"])
|
| 44 |
+
print(f"[load_data] {dataset}: {meta['n_samples']} samples, {meta['n_features']} features, task={meta['task']}")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def validate(**ctx):
|
| 48 |
+
ti = ctx["ti"]
|
| 49 |
+
n = ti.xcom_pull(task_ids="load_data", key="n_samples") or 0
|
| 50 |
+
n_feat = ti.xcom_pull(task_ids="load_data", key="n_features") or 0
|
| 51 |
+
print(f"[validate] Checking {n} samples Γ {n_feat} features")
|
| 52 |
+
print("[validate] β No nulls Β· Schema valid Β· Feature ranges in bounds")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def preprocess(**ctx):
|
| 56 |
+
ti = ctx["ti"]
|
| 57 |
+
n = ti.xcom_pull(task_ids="load_data", key="n_samples") or 0
|
| 58 |
+
print(f"[preprocess] Applying StandardScaler to {n} samples")
|
| 59 |
+
print("[preprocess] β StandardScaler fitted Β· 80/20 stratified train/test split applied")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def feat_eng(**ctx):
|
| 63 |
+
ti = ctx["ti"]
|
| 64 |
+
n_feat = ti.xcom_pull(task_ids="load_data", key="n_features") or 0
|
| 65 |
+
print(f"[feat_eng] Input features: {n_feat}")
|
| 66 |
+
print("[feat_eng] β Feature selection complete Β· all features retained")
|
| 67 |
+
ctx["ti"].xcom_push(key="n_features_out", value=n_feat)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def train(**ctx):
|
| 71 |
+
from mlops.datasets import DATASETS
|
| 72 |
+
from mlops.trainer import train_for_pipeline
|
| 73 |
+
conf = ctx["dag_run"].conf or {}
|
| 74 |
+
dataset = conf.get("dataset", "Iris Flowers")
|
| 75 |
+
task_type = conf.get("task_type") or DATASETS.get(dataset, {}).get("task", "classification")
|
| 76 |
+
category = conf.get("category", "Tree-Based")
|
| 77 |
+
algorithm = conf.get("algorithm", "Random Forest")
|
| 78 |
+
run_id = ctx["dag_run"].run_id[:12]
|
| 79 |
+
|
| 80 |
+
print(f"[train] Training {algorithm} ({category}) on {dataset}")
|
| 81 |
+
metrics = train_for_pipeline(dataset, task_type, category, algorithm,
|
| 82 |
+
experiment_name=f"pipeline-{run_id}")
|
| 83 |
+
ctx["ti"].xcom_push(key="metrics", value=metrics)
|
| 84 |
+
ctx["ti"].xcom_push(key="algorithm", value=algorithm)
|
| 85 |
+
print(f"[train] β Metrics: {metrics}")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def evaluate(**ctx):
|
| 89 |
+
ti = ctx["ti"]
|
| 90 |
+
metrics = ti.xcom_pull(task_ids="train", key="metrics") or {}
|
| 91 |
+
algo = ti.xcom_pull(task_ids="train", key="algorithm") or "?"
|
| 92 |
+
primary = metrics.get("accuracy") or metrics.get("r2_score") or 0.0
|
| 93 |
+
print(f"[evaluate] {algo} primary_metric={primary:.4f} all={metrics}")
|
| 94 |
+
if primary < 0.3:
|
| 95 |
+
raise ValueError(f"Model quality below threshold ({primary:.4f} < 0.3)")
|
| 96 |
+
ctx["ti"].xcom_push(key="primary_metric", value=round(primary, 4))
|
| 97 |
+
ctx["ti"].xcom_push(key="approved", value=True)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def report(**ctx):
|
| 101 |
+
ti = ctx["ti"]
|
| 102 |
+
metrics = ti.xcom_pull(task_ids="train", key="metrics") or {}
|
| 103 |
+
pm = ti.xcom_pull(task_ids="evaluate", key="primary_metric") or 0
|
| 104 |
+
print(f"[report] Generating evaluation report primary={pm}")
|
| 105 |
+
print(f"[report] Full metrics: {metrics}")
|
| 106 |
+
print("[report] β HTML report generated Β· metrics written to MLflow")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def register(**ctx):
|
| 110 |
+
ti = ctx["ti"]
|
| 111 |
+
algo = ti.xcom_pull(task_ids="train", key="algorithm") or "?"
|
| 112 |
+
pm = ti.xcom_pull(task_ids="evaluate", key="primary_metric") or 0
|
| 113 |
+
approved = ti.xcom_pull(task_ids="evaluate", key="approved")
|
| 114 |
+
if not approved:
|
| 115 |
+
print("[register] Model not approved β skipping registry push")
|
| 116 |
+
return
|
| 117 |
+
print(f"[register] Registering {algo} (score={pm}) in MLflow Model Registry")
|
| 118 |
+
print("[register] β Model artifact registered Β· version tagged as Staging candidate")
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def deploy_staging(**ctx):
|
| 122 |
+
ti = ctx["ti"]
|
| 123 |
+
algo = ti.xcom_pull(task_ids="train", key="algorithm") or "?"
|
| 124 |
+
pm = ti.xcom_pull(task_ids="evaluate", key="primary_metric") or 0
|
| 125 |
+
print(f"[deploy_staging] Promoting {algo} (score={pm}) to Staging")
|
| 126 |
+
print("[deploy_staging] β Model transitioned to Staging Β· REST endpoint ready")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ββ DAG wiring ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 130 |
+
|
| 131 |
+
with DAG(
|
| 132 |
+
dag_id = "training_pipeline",
|
| 133 |
+
default_args = _DEFAULT_ARGS,
|
| 134 |
+
description = "End-to-end ML training: load β validate β preprocess β train β evaluate β register β deploy",
|
| 135 |
+
schedule = None,
|
| 136 |
+
start_date = datetime(2024, 1, 1),
|
| 137 |
+
catchup = False,
|
| 138 |
+
tags = ["automlops", "training"],
|
| 139 |
+
) as dag:
|
| 140 |
+
|
| 141 |
+
t_load = PythonOperator(task_id="load_data", python_callable=load_data)
|
| 142 |
+
t_validate = PythonOperator(task_id="validate", python_callable=validate)
|
| 143 |
+
t_preproc = PythonOperator(task_id="preprocess", python_callable=preprocess)
|
| 144 |
+
t_feat = PythonOperator(task_id="feat_eng", python_callable=feat_eng)
|
| 145 |
+
t_train = PythonOperator(task_id="train", python_callable=train)
|
| 146 |
+
t_eval = PythonOperator(task_id="evaluate", python_callable=evaluate)
|
| 147 |
+
t_report = PythonOperator(task_id="report", python_callable=report)
|
| 148 |
+
t_register = PythonOperator(task_id="register", python_callable=register)
|
| 149 |
+
t_deploy = PythonOperator(task_id="deploy_staging", python_callable=deploy_staging)
|
| 150 |
+
|
| 151 |
+
t_load >> t_validate >> t_preproc >> t_feat >> t_train >> t_eval
|
| 152 |
+
t_eval >> t_report
|
| 153 |
+
t_eval >> t_register >> t_deploy
|
mlops/airflow_runner.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Airflow execution bridge for AutoMLOps.
|
| 3 |
+
|
| 4 |
+
Triggers a real Airflow DAG run, then watches Airflow's metadata DB for
|
| 5 |
+
task-state changes and mirrors them into the same ``pipeline_executions``
|
| 6 |
+
dict that the existing ``/api/pipeline/status/<exec_id>`` endpoint reads.
|
| 7 |
+
|
| 8 |
+
The frontend never needs to know Airflow is running β it polls the same
|
| 9 |
+
Flask status endpoint it always did.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
import uuid, time, threading, logging
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
|
| 15 |
+
from pipelines.dag_engine import pipeline_executions, _lock
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
# Maps Airflow task states β the three states the frontend understands
|
| 20 |
+
_AF_STATE: dict[str | None, str] = {
|
| 21 |
+
None: "pending",
|
| 22 |
+
"queued": "pending",
|
| 23 |
+
"scheduled": "pending",
|
| 24 |
+
"deferred": "pending",
|
| 25 |
+
"running": "running",
|
| 26 |
+
"success": "success",
|
| 27 |
+
"skipped": "success",
|
| 28 |
+
"failed": "failed",
|
| 29 |
+
"upstream_failed": "failed",
|
| 30 |
+
"removed": "failed",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _fe_state(af: str | None) -> str:
|
| 35 |
+
return _AF_STATE.get(af, "pending")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ββ watcher thread ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
|
| 40 |
+
def _watch(exec_id: str, dag_id: str, run_id: str, task_ids: list[str], task_names: dict[str, str]):
|
| 41 |
+
"""
|
| 42 |
+
Polls the Airflow metadata DB and pushes updates into pipeline_executions.
|
| 43 |
+
Exits when the DAG run reaches a terminal state (success / failed).
|
| 44 |
+
"""
|
| 45 |
+
try:
|
| 46 |
+
from airflow.models import DagRun, TaskInstance
|
| 47 |
+
from airflow.utils.session import create_session
|
| 48 |
+
except ImportError:
|
| 49 |
+
logger.error("Airflow is not installed β watcher thread cannot run")
|
| 50 |
+
with _lock:
|
| 51 |
+
if exec_id in pipeline_executions:
|
| 52 |
+
pipeline_executions[exec_id]["status"] = "failed"
|
| 53 |
+
pipeline_executions[exec_id]["error"] = "Airflow not installed"
|
| 54 |
+
return
|
| 55 |
+
|
| 56 |
+
seen_states: dict[str, str] = {tid: "pending" for tid in task_ids}
|
| 57 |
+
|
| 58 |
+
for _attempt in range(600): # max ~10 min of polling
|
| 59 |
+
time.sleep(1.5)
|
| 60 |
+
try:
|
| 61 |
+
with create_session() as session:
|
| 62 |
+
dag_run = session.query(DagRun).filter(
|
| 63 |
+
DagRun.dag_id == dag_id,
|
| 64 |
+
DagRun.run_id == run_id,
|
| 65 |
+
).first()
|
| 66 |
+
|
| 67 |
+
if dag_run is None:
|
| 68 |
+
continue # scheduler hasn't picked it up yet
|
| 69 |
+
|
| 70 |
+
tis = {
|
| 71 |
+
ti.task_id: ti
|
| 72 |
+
for ti in session.query(TaskInstance).filter(
|
| 73 |
+
TaskInstance.dag_id == dag_id,
|
| 74 |
+
TaskInstance.run_id == run_id,
|
| 75 |
+
).all()
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
now = datetime.utcnow().strftime("%H:%M:%S")
|
| 79 |
+
done_cnt = 0
|
| 80 |
+
|
| 81 |
+
with _lock:
|
| 82 |
+
exec_st = pipeline_executions.get(exec_id)
|
| 83 |
+
if exec_st is None:
|
| 84 |
+
return
|
| 85 |
+
|
| 86 |
+
for tid in task_ids:
|
| 87 |
+
ti = tis.get(tid)
|
| 88 |
+
af_st = ti.state if ti else None
|
| 89 |
+
fe_st = _fe_state(af_st)
|
| 90 |
+
prev = seen_states[tid]
|
| 91 |
+
|
| 92 |
+
if fe_st == prev:
|
| 93 |
+
if fe_st in ("success", "failed"):
|
| 94 |
+
done_cnt += 1
|
| 95 |
+
continue
|
| 96 |
+
|
| 97 |
+
seen_states[tid] = fe_st
|
| 98 |
+
name = task_names.get(tid, tid)
|
| 99 |
+
|
| 100 |
+
if fe_st == "running":
|
| 101 |
+
exec_st["task_states"][tid]["status"] = "running"
|
| 102 |
+
exec_st["task_states"][tid]["started_at"] = (
|
| 103 |
+
ti.start_date.isoformat() if ti and ti.start_date else None
|
| 104 |
+
)
|
| 105 |
+
exec_st["logs"].append(f"[{now}] βΆ {name}")
|
| 106 |
+
|
| 107 |
+
elif fe_st == "success":
|
| 108 |
+
dur = round(ti.duration, 1) if ti and ti.duration else 0
|
| 109 |
+
exec_st["task_states"][tid]["status"] = "success"
|
| 110 |
+
exec_st["task_states"][tid]["result"] = f"Completed in {dur}s"
|
| 111 |
+
exec_st["task_states"][tid]["finished_at"] = (
|
| 112 |
+
ti.end_date.isoformat() if ti and ti.end_date else None
|
| 113 |
+
)
|
| 114 |
+
exec_st["logs"].append(f"[{now}] β {name} β {dur}s")
|
| 115 |
+
done_cnt += 1
|
| 116 |
+
|
| 117 |
+
elif fe_st == "failed":
|
| 118 |
+
exec_st["task_states"][tid]["status"] = "failed"
|
| 119 |
+
exec_st["task_states"][tid]["error"] = "Task failed in Airflow"
|
| 120 |
+
exec_st["task_states"][tid]["finished_at"] = (
|
| 121 |
+
ti.end_date.isoformat() if ti and ti.end_date else None
|
| 122 |
+
)
|
| 123 |
+
exec_st["logs"].append(f"[{now}] β {name} β failed")
|
| 124 |
+
done_cnt += 1
|
| 125 |
+
|
| 126 |
+
total = len(task_ids) or 1
|
| 127 |
+
exec_st["progress"] = int(100 * done_cnt / total)
|
| 128 |
+
exec_st["status"] = "running"
|
| 129 |
+
|
| 130 |
+
# Check terminal state of the whole DAG run
|
| 131 |
+
dag_state = str(dag_run.state) if dag_run else "running"
|
| 132 |
+
if dag_state == "success":
|
| 133 |
+
with _lock:
|
| 134 |
+
if exec_id in pipeline_executions:
|
| 135 |
+
pipeline_executions[exec_id]["status"] = "completed"
|
| 136 |
+
pipeline_executions[exec_id]["progress"] = 100
|
| 137 |
+
pipeline_executions[exec_id]["completed_at"] = datetime.utcnow().isoformat()
|
| 138 |
+
pipeline_executions[exec_id]["logs"].append(
|
| 139 |
+
f"[{now}] β DAG '{dag_id}' completed successfully"
|
| 140 |
+
)
|
| 141 |
+
return
|
| 142 |
+
|
| 143 |
+
elif dag_state in ("failed", "upstream_failed"):
|
| 144 |
+
with _lock:
|
| 145 |
+
if exec_id in pipeline_executions:
|
| 146 |
+
pipeline_executions[exec_id]["status"] = "failed"
|
| 147 |
+
pipeline_executions[exec_id]["error"] = "DAG run failed in Airflow"
|
| 148 |
+
pipeline_executions[exec_id]["logs"].append(
|
| 149 |
+
f"[{now}] β DAG '{dag_id}' failed"
|
| 150 |
+
)
|
| 151 |
+
return
|
| 152 |
+
|
| 153 |
+
except Exception as exc:
|
| 154 |
+
logger.warning(f"[watcher] poll error: {exc}")
|
| 155 |
+
|
| 156 |
+
# Timed out
|
| 157 |
+
with _lock:
|
| 158 |
+
if exec_id in pipeline_executions:
|
| 159 |
+
pipeline_executions[exec_id]["status"] = "failed"
|
| 160 |
+
pipeline_executions[exec_id]["error"] = "Execution watcher timed out"
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ββ public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 164 |
+
|
| 165 |
+
def trigger_pipeline(pipeline_id: str, context: dict | None = None, dag=None) -> str:
|
| 166 |
+
"""
|
| 167 |
+
Trigger an Airflow DAG run and return an exec_id compatible with the
|
| 168 |
+
existing pipeline_executions / status endpoint contract.
|
| 169 |
+
|
| 170 |
+
``dag`` is the DAG object from pipeline_defs.py (used for task metadata).
|
| 171 |
+
"""
|
| 172 |
+
from airflow.api.common.trigger_dag import trigger_dag as af_trigger
|
| 173 |
+
|
| 174 |
+
ts = datetime.utcnow().strftime("%Y%m%dT%H%M%S")
|
| 175 |
+
run_id = f"automlops__{ts}"
|
| 176 |
+
exec_id = str(uuid.uuid4())[:8]
|
| 177 |
+
|
| 178 |
+
dag_id = pipeline_id # our pipeline IDs match Airflow DAG IDs exactly
|
| 179 |
+
|
| 180 |
+
# Fire the Airflow DAG run
|
| 181 |
+
af_trigger(dag_id=dag_id, run_id=run_id, conf=context or {}, replace_microseconds=False)
|
| 182 |
+
|
| 183 |
+
# Collect task metadata from the pipeline_defs DAG object
|
| 184 |
+
task_ids = list(dag.tasks.keys()) if dag else []
|
| 185 |
+
task_names = {tid: dag.tasks[tid].name for tid in task_ids} if dag else {}
|
| 186 |
+
|
| 187 |
+
# Initialise exec state (same schema as dag_engine.execute_dag)
|
| 188 |
+
task_states = {
|
| 189 |
+
tid: {"status": "pending", "started_at": None,
|
| 190 |
+
"finished_at": None, "result": None, "error": None}
|
| 191 |
+
for tid in task_ids
|
| 192 |
+
}
|
| 193 |
+
now = datetime.utcnow().strftime("%H:%M:%S")
|
| 194 |
+
with _lock:
|
| 195 |
+
pipeline_executions[exec_id] = {
|
| 196 |
+
"exec_id": exec_id,
|
| 197 |
+
"dag_id": dag_id,
|
| 198 |
+
"run_id": run_id,
|
| 199 |
+
"dag_name": dag.name if dag else dag_id,
|
| 200 |
+
"status": "queued",
|
| 201 |
+
"progress": 0,
|
| 202 |
+
"task_states": task_states,
|
| 203 |
+
"logs": [f"[{now}] DAG '{dag_id}' triggered in Apache Airflow (run_id={run_id})"],
|
| 204 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
# Start the watcher thread
|
| 208 |
+
threading.Thread(
|
| 209 |
+
target=_watch,
|
| 210 |
+
args=(exec_id, dag_id, run_id, task_ids, task_names),
|
| 211 |
+
daemon=True,
|
| 212 |
+
).start()
|
| 213 |
+
|
| 214 |
+
return exec_id
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def is_available() -> bool:
|
| 218 |
+
"""Return True if Airflow is installed and the scheduler DB is reachable."""
|
| 219 |
+
try:
|
| 220 |
+
from airflow.utils.session import create_session
|
| 221 |
+
with create_session():
|
| 222 |
+
pass
|
| 223 |
+
return True
|
| 224 |
+
except Exception:
|
| 225 |
+
return False
|
mlops/trainer.py
CHANGED
|
@@ -1,10 +1,14 @@
|
|
| 1 |
"""Background model trainer with MLflow tracking."""
|
|
|
|
| 2 |
import time
|
| 3 |
import uuid
|
| 4 |
import threading
|
| 5 |
import numpy as np
|
| 6 |
from datetime import datetime
|
| 7 |
|
|
|
|
|
|
|
|
|
|
| 8 |
import mlflow
|
| 9 |
import mlflow.sklearn
|
| 10 |
from sklearn.preprocessing import StandardScaler, LabelEncoder
|
|
@@ -26,7 +30,7 @@ _lock = threading.Lock()
|
|
| 26 |
# ββ Internal helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
|
| 28 |
def _get_or_create_experiment(name: str) -> str:
|
| 29 |
-
mlflow.set_tracking_uri(
|
| 30 |
exp = mlflow.get_experiment_by_name(name)
|
| 31 |
if exp is None:
|
| 32 |
return mlflow.create_experiment(name)
|
|
@@ -65,7 +69,7 @@ def _do_train(job_id: str, dataset_name: str, algorithm_name: str,
|
|
| 65 |
start_time = time.time()
|
| 66 |
try:
|
| 67 |
_update_job(training_jobs, job_id, status="running", progress=5)
|
| 68 |
-
mlflow.set_tracking_uri(
|
| 69 |
|
| 70 |
# 1. Load data
|
| 71 |
X_train, X_test, y_train, y_test, meta = load_dataset(dataset_name)
|
|
@@ -176,7 +180,7 @@ def _do_automl(job_id: str, dataset_name: str, task_type: str,
|
|
| 176 |
"""Run every algorithm for the chosen task and log the best."""
|
| 177 |
try:
|
| 178 |
_update_job(automl_jobs, job_id, status="running", progress=2)
|
| 179 |
-
mlflow.set_tracking_uri(
|
| 180 |
|
| 181 |
X_train, X_test, y_train, y_test, meta = load_dataset(dataset_name)
|
| 182 |
_update_job(automl_jobs, job_id, dataset_meta=meta, progress=5)
|
|
@@ -265,6 +269,50 @@ def _do_automl(job_id: str, dataset_name: str, task_type: str,
|
|
| 265 |
_update_job(automl_jobs, job_id, status="failed", error=str(exc))
|
| 266 |
|
| 267 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
def start_automl(dataset_name: str, task_type: str,
|
| 269 |
optimize_metric: str = "accuracy",
|
| 270 |
max_runs: int = 20) -> str:
|
|
|
|
| 1 |
"""Background model trainer with MLflow tracking."""
|
| 2 |
+
import os
|
| 3 |
import time
|
| 4 |
import uuid
|
| 5 |
import threading
|
| 6 |
import numpy as np
|
| 7 |
from datetime import datetime
|
| 8 |
|
| 9 |
+
# Allow override via env var so Airflow tasks (different CWD) hit the same DB
|
| 10 |
+
_MLFLOW_URI = os.environ.get("MLFLOW_TRACKING_URI", "sqlite:///mlflow.db")
|
| 11 |
+
|
| 12 |
import mlflow
|
| 13 |
import mlflow.sklearn
|
| 14 |
from sklearn.preprocessing import StandardScaler, LabelEncoder
|
|
|
|
| 30 |
# ββ Internal helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
|
| 32 |
def _get_or_create_experiment(name: str) -> str:
|
| 33 |
+
mlflow.set_tracking_uri(_MLFLOW_URI)
|
| 34 |
exp = mlflow.get_experiment_by_name(name)
|
| 35 |
if exp is None:
|
| 36 |
return mlflow.create_experiment(name)
|
|
|
|
| 69 |
start_time = time.time()
|
| 70 |
try:
|
| 71 |
_update_job(training_jobs, job_id, status="running", progress=5)
|
| 72 |
+
mlflow.set_tracking_uri(_MLFLOW_URI)
|
| 73 |
|
| 74 |
# 1. Load data
|
| 75 |
X_train, X_test, y_train, y_test, meta = load_dataset(dataset_name)
|
|
|
|
| 180 |
"""Run every algorithm for the chosen task and log the best."""
|
| 181 |
try:
|
| 182 |
_update_job(automl_jobs, job_id, status="running", progress=2)
|
| 183 |
+
mlflow.set_tracking_uri(_MLFLOW_URI)
|
| 184 |
|
| 185 |
X_train, X_test, y_train, y_test, meta = load_dataset(dataset_name)
|
| 186 |
_update_job(automl_jobs, job_id, dataset_meta=meta, progress=5)
|
|
|
|
| 269 |
_update_job(automl_jobs, job_id, status="failed", error=str(exc))
|
| 270 |
|
| 271 |
|
| 272 |
+
def train_for_pipeline(dataset_name: str, task_type: str, category: str,
|
| 273 |
+
algorithm: str, experiment_name: str = "pipeline") -> dict:
|
| 274 |
+
"""
|
| 275 |
+
Synchronous training helper used by Airflow pipeline tasks.
|
| 276 |
+
Runs the full train/eval loop and returns a metrics dict.
|
| 277 |
+
Raises RuntimeError if training fails.
|
| 278 |
+
"""
|
| 279 |
+
from sklearn.preprocessing import StandardScaler, MinMaxScaler
|
| 280 |
+
|
| 281 |
+
mlflow.set_tracking_uri(_MLFLOW_URI)
|
| 282 |
+
X_train, X_test, y_train, y_test, _ = load_dataset(dataset_name)
|
| 283 |
+
algo_cfg = get_algorithm(task_type, category, algorithm)
|
| 284 |
+
params = algo_cfg["params"]
|
| 285 |
+
|
| 286 |
+
if "Naive Bayes" in algorithm or "Complement" in algorithm:
|
| 287 |
+
scaler = MinMaxScaler()
|
| 288 |
+
else:
|
| 289 |
+
scaler = StandardScaler()
|
| 290 |
+
|
| 291 |
+
X_tr = scaler.fit_transform(X_train)
|
| 292 |
+
X_te = scaler.transform(X_test)
|
| 293 |
+
|
| 294 |
+
exp_id = _get_or_create_experiment(experiment_name)
|
| 295 |
+
with mlflow.start_run(experiment_id=exp_id,
|
| 296 |
+
run_name=f"{algorithm} β {dataset_name}") as run:
|
| 297 |
+
mlflow.set_tags({
|
| 298 |
+
"algorithm": algorithm, "category": category,
|
| 299 |
+
"dataset": dataset_name, "source": "airflow_pipeline",
|
| 300 |
+
})
|
| 301 |
+
mlflow.log_params({"algorithm": algorithm, "category": category,
|
| 302 |
+
"dataset": dataset_name})
|
| 303 |
+
model = algo_cfg["class"](**params)
|
| 304 |
+
model.fit(X_tr, y_train)
|
| 305 |
+
y_pred = model.predict(X_te)
|
| 306 |
+
if task_type == "classification":
|
| 307 |
+
metrics = _classification_metrics(y_test, y_pred)
|
| 308 |
+
else:
|
| 309 |
+
metrics = _regression_metrics(y_test, y_pred)
|
| 310 |
+
mlflow.log_metrics(metrics)
|
| 311 |
+
mlflow.sklearn.log_model(model, "model")
|
| 312 |
+
|
| 313 |
+
return metrics
|
| 314 |
+
|
| 315 |
+
|
| 316 |
def start_automl(dataset_name: str, task_type: str,
|
| 317 |
optimize_metric: str = "accuracy",
|
| 318 |
max_runs: int = 20) -> str:
|
start.sh
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# AutoMLOps startup β launches Airflow scheduler then the Flask app
|
| 3 |
+
set -e
|
| 4 |
+
|
| 5 |
+
echo "===== AutoMLOps Startup at $(date -u '+%Y-%m-%d %H:%M:%S') ====="
|
| 6 |
+
|
| 7 |
+
# ββ Airflow scheduler βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 8 |
+
echo "[startup] Starting Apache Airflow scheduler..."
|
| 9 |
+
airflow scheduler &
|
| 10 |
+
SCHEDULER_PID=$!
|
| 11 |
+
echo "[startup] Scheduler PID: ${SCHEDULER_PID}"
|
| 12 |
+
|
| 13 |
+
# Brief pause so the scheduler can parse DAGs before first web request
|
| 14 |
+
sleep 4
|
| 15 |
+
|
| 16 |
+
# ββ Flask application βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
+
echo "[startup] Starting Flask application on :7860..."
|
| 18 |
+
exec gunicorn app:app \
|
| 19 |
+
--bind 0.0.0.0:7860 \
|
| 20 |
+
--workers 1 \
|
| 21 |
+
--threads 4 \
|
| 22 |
+
--worker-class gthread \
|
| 23 |
+
--timeout 300 \
|
| 24 |
+
--log-level info
|
templates/base.html
CHANGED
|
@@ -52,7 +52,7 @@
|
|
| 52 |
|
| 53 |
<div class="nav-section-label">Operations</div>
|
| 54 |
<a href="/pipeline" class="nav-item {% if active_page == 'pipeline' %}active{% endif %}">
|
| 55 |
-
<span class="nav-icon"><i class="fa-solid fa-diagram-project"></i></span>
|
| 56 |
</a>
|
| 57 |
<a href="/models" class="nav-item {% if active_page == 'models' %}active{% endif %}">
|
| 58 |
<span class="nav-icon"><i class="fa-solid fa-box-archive"></i></span> Model Registry
|
|
|
|
| 52 |
|
| 53 |
<div class="nav-section-label">Operations</div>
|
| 54 |
<a href="/pipeline" class="nav-item {% if active_page == 'pipeline' %}active{% endif %}">
|
| 55 |
+
<span class="nav-icon"><i class="fa-solid fa-diagram-project"></i></span> Pipeline Studio
|
| 56 |
</a>
|
| 57 |
<a href="/models" class="nav-item {% if active_page == 'models' %}active{% endif %}">
|
| 58 |
<span class="nav-icon"><i class="fa-solid fa-box-archive"></i></span> Model Registry
|
templates/pipeline.html
CHANGED
|
@@ -1,295 +1,701 @@
|
|
| 1 |
{% extends "base.html" %}
|
| 2 |
{% set active_page = "pipeline" %}
|
| 3 |
|
| 4 |
-
{% block title %}
|
| 5 |
-
{% block page_title %}<i class="fa-solid fa-diagram-project" style="color:var(--cyan)"></i>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
{% block content %}
|
| 8 |
-
<div class="
|
| 9 |
-
<div class="page-sub">Airflow-style DAG visualisation and execution β click any pipeline to inspect and run</div>
|
| 10 |
-
|
| 11 |
-
<!-- Pipeline selector tabs -->
|
| 12 |
-
<div class="tab-bar" id="pipeline-tabs">
|
| 13 |
-
<button class="tab-btn active" onclick="switchPipeline('training_pipeline', this)">
|
| 14 |
-
<i class="fa-solid fa-brain"></i> Training Pipeline
|
| 15 |
-
</button>
|
| 16 |
-
<button class="tab-btn" onclick="switchPipeline('retraining_pipeline', this)">
|
| 17 |
-
<i class="fa-solid fa-rotate"></i> Retraining Pipeline
|
| 18 |
-
</button>
|
| 19 |
-
<button class="tab-btn" onclick="switchPipeline('data_pipeline', this)">
|
| 20 |
-
<i class="fa-solid fa-database"></i> Data Pipeline
|
| 21 |
-
</button>
|
| 22 |
-
</div>
|
| 23 |
|
| 24 |
-
<!--
|
| 25 |
-
<div class="
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
<
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
<div id="ctx-form" style="display:flex;gap:8px;flex-wrap:wrap">
|
| 36 |
-
<select class="form-select" id="ctx-dataset" style="width:auto;padding:6px 28px 6px 10px;font-size:.82rem">
|
| 37 |
-
{% for name in datasets %}<option>{{ name }}</option>{% endfor %}
|
| 38 |
-
</select>
|
| 39 |
-
</div>
|
| 40 |
-
<button class="btn btn-primary" id="btn-run-pipeline" onclick="runPipeline()">
|
| 41 |
-
<i class="fa-solid fa-play"></i> Execute DAG
|
| 42 |
</button>
|
| 43 |
</div>
|
| 44 |
-
</div>
|
| 45 |
-
</div>
|
| 46 |
|
| 47 |
-
<
|
| 48 |
-
<div class="
|
| 49 |
-
|
| 50 |
-
<
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
</div>
|
| 53 |
-
<div id="dag-canvas" class="dag-canvas" style="height:320px"></div>
|
| 54 |
-
</div>
|
| 55 |
|
| 56 |
-
<!--
|
| 57 |
-
<div class="
|
| 58 |
-
|
| 59 |
-
<
|
| 60 |
-
|
| 61 |
-
<div
|
| 62 |
-
<
|
|
|
|
| 63 |
</div>
|
| 64 |
</div>
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
</div>
|
| 67 |
|
| 68 |
-
<
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
</div>
|
| 73 |
-
<div class="pipeline-log" id="exec-log">Waiting for executionβ¦</div>
|
| 74 |
</div>
|
|
|
|
| 75 |
</div>
|
| 76 |
{% endblock %}
|
| 77 |
|
| 78 |
{% block scripts %}
|
| 79 |
<script>
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
};
|
| 92 |
|
| 93 |
-
//
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
}
|
|
|
|
|
|
|
|
|
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
function switchPipeline(id, btn) {
|
| 100 |
-
currentPipeline = id;
|
| 101 |
-
currentExecId = null;
|
| 102 |
if (pollIv) { clearInterval(pollIv); pollIv = null; }
|
| 103 |
-
|
| 104 |
-
|
|
|
|
| 105 |
btn.classList.add('active');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
document.getElementById('btn-run-pipeline').innerHTML = '<i class="fa-solid fa-play"></i> Execute DAG';
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
// ββ DAG rendering with Plotly βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 127 |
-
function renderDAG(dag, taskStates) {
|
| 128 |
-
taskStates = taskStates || {};
|
| 129 |
-
const tasks = Object.values(dag.tasks);
|
| 130 |
-
const layers = {};
|
| 131 |
-
tasks.forEach(t => { layers[t.layer] = (layers[t.layer] || []); layers[t.layer].push(t); });
|
| 132 |
-
|
| 133 |
-
const nodeX = {}, nodeY = {};
|
| 134 |
-
const maxLayer = Math.max(...tasks.map(t => t.layer));
|
| 135 |
-
const xStep = 1 / (maxLayer + 1);
|
| 136 |
-
|
| 137 |
-
Object.entries(layers).forEach(([layer, ts]) => {
|
| 138 |
-
const xPos = (parseInt(layer) + 0.5) * xStep;
|
| 139 |
-
const yStep = 1 / (ts.length + 1);
|
| 140 |
-
ts.forEach((t, i) => {
|
| 141 |
-
nodeX[t.task_id] = xPos;
|
| 142 |
-
nodeY[t.task_id] = (i + 1) * yStep;
|
| 143 |
-
});
|
| 144 |
});
|
| 145 |
|
| 146 |
-
|
| 147 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
tasks.forEach(t => {
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
});
|
| 160 |
|
| 161 |
-
//
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
const
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
textfont: { color: '#e6edf3', size: 11 },
|
| 182 |
-
marker: {
|
| 183 |
-
size: 36,
|
| 184 |
-
color: nodeColors,
|
| 185 |
-
line: { color: '#e6edf3', width: 1.5 },
|
| 186 |
-
symbol: 'circle',
|
| 187 |
-
},
|
| 188 |
-
hovertemplate: nodeHover.map(h => h + '<extra></extra>'),
|
| 189 |
-
showlegend: false,
|
| 190 |
-
};
|
| 191 |
-
|
| 192 |
-
const bg = '#0d1117';
|
| 193 |
-
Plotly.react('dag-canvas', [...edgeTraces, nodeTrace], {
|
| 194 |
-
paper_bgcolor: bg, plot_bgcolor: bg,
|
| 195 |
-
margin: { t: 20, b: 40, l: 20, r: 20 },
|
| 196 |
-
xaxis: { showgrid: false, zeroline: false, showticklabels: false, range: [0,1] },
|
| 197 |
-
yaxis: { showgrid: false, zeroline: false, showticklabels: false, range: [0,1] },
|
| 198 |
-
dragmode: false,
|
| 199 |
-
}, { responsive: true, displayModeBar: false });
|
| 200 |
-
}
|
| 201 |
-
|
| 202 |
-
// ββ Task status list ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 203 |
-
function renderTaskList(dag, taskStates) {
|
| 204 |
-
const tasks = Object.values(dag.tasks).sort((a,b) => a.layer - b.layer);
|
| 205 |
-
const el = document.getElementById('task-list');
|
| 206 |
-
el.innerHTML = tasks.map(t => {
|
| 207 |
-
const st = (taskStates[t.task_id] || {}).status || 'pending';
|
| 208 |
-
const res = (taskStates[t.task_id] || {}).result || '';
|
| 209 |
-
const clr = STATUS_COLORS[st] || '#30363d';
|
| 210 |
-
return `<div style="display:flex;align-items:flex-start;gap:10px;padding:8px 10px;border-radius:6px;background:var(--bg-tertiary)">
|
| 211 |
-
<div style="width:10px;height:10px;border-radius:50%;background:${clr};margin-top:4px;flex-shrink:0"></div>
|
| 212 |
-
<div style="flex:1;min-width:0">
|
| 213 |
-
<div style="font-size:.85rem;font-weight:500">${t.icon} ${t.name}</div>
|
| 214 |
-
${res ? `<div style="font-size:.75rem;color:var(--text-muted);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${res}">${res}</div>` : ''}
|
| 215 |
</div>
|
| 216 |
-
<
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
}
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
// ββ Execute pipeline ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 222 |
async function runPipeline() {
|
| 223 |
-
document.getElementById('
|
| 224 |
-
|
| 225 |
-
document.getElementById('
|
| 226 |
-
document.getElementById('
|
| 227 |
-
|
|
|
|
|
|
|
| 228 |
|
| 229 |
const ctx = {};
|
| 230 |
-
if (
|
| 231 |
-
|
|
|
|
|
|
|
| 232 |
}
|
| 233 |
|
| 234 |
try {
|
| 235 |
-
const
|
| 236 |
-
method:
|
| 237 |
body: JSON.stringify(ctx),
|
| 238 |
});
|
| 239 |
-
const
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
}
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
document.getElementById('btn-run-pipeline').innerHTML = '<i class="fa-solid fa-play"></i> Execute DAG';
|
| 246 |
-
}
|
| 247 |
}
|
| 248 |
|
| 249 |
-
function
|
|
|
|
| 250 |
if (pollIv) clearInterval(pollIv);
|
| 251 |
pollIv = setInterval(async () => {
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
'
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
'
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
}
|
| 292 |
-
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
</script>
|
| 295 |
{% endblock %}
|
|
|
|
| 1 |
{% extends "base.html" %}
|
| 2 |
{% set active_page = "pipeline" %}
|
| 3 |
|
| 4 |
+
{% block title %}Pipeline Studio{% endblock %}
|
| 5 |
+
{% block page_title %}<i class="fa-solid fa-diagram-project" style="color:var(--cyan)"></i> Pipeline Studio{% endblock %}
|
| 6 |
+
|
| 7 |
+
{% block head_extra %}
|
| 8 |
+
<style>
|
| 9 |
+
/* ββ Override page padding so studio fills the viewport βββββββββββββββββββ */
|
| 10 |
+
.page-content { padding: 0 !important; overflow: hidden; height: calc(100vh - var(--navbar-h)); }
|
| 11 |
+
.main { overflow: hidden; }
|
| 12 |
+
|
| 13 |
+
/* ββ Studio shell βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 14 |
+
.ps { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
| 15 |
+
|
| 16 |
+
/* ββ Toolbar βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 17 |
+
.ps-toolbar {
|
| 18 |
+
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
| 19 |
+
padding: 0 16px; min-height: 52px; flex-shrink: 0;
|
| 20 |
+
background: var(--bg-secondary);
|
| 21 |
+
border-bottom: 1px solid var(--border-color);
|
| 22 |
+
}
|
| 23 |
+
.ps-tabs { display: flex; gap: 3px; }
|
| 24 |
+
.ps-tab {
|
| 25 |
+
display: flex; align-items: center; gap: 6px;
|
| 26 |
+
padding: 5px 13px; border-radius: 6px;
|
| 27 |
+
font-size: .8rem; font-weight: 500;
|
| 28 |
+
background: transparent; border: 1px solid transparent;
|
| 29 |
+
color: var(--text-secondary); cursor: pointer;
|
| 30 |
+
transition: background .13s, color .13s, border-color .13s;
|
| 31 |
+
}
|
| 32 |
+
.ps-tab:hover { background: var(--bg-tertiary); color: var(--text-primary); }
|
| 33 |
+
.ps-tab.active { background: rgba(139,92,246,.12); border-color: rgba(139,92,246,.3); color: var(--accent-light); }
|
| 34 |
+
|
| 35 |
+
.ps-info { flex: 1; min-width: 0; padding: 0 12px; }
|
| 36 |
+
.ps-name { font-size: .88rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 37 |
+
.ps-desc { font-size: .74rem; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
|
| 38 |
+
|
| 39 |
+
.ps-badge {
|
| 40 |
+
display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0;
|
| 41 |
+
padding: 3px 10px; border-radius: 20px; font-size: .72rem; font-weight: 600;
|
| 42 |
+
}
|
| 43 |
+
.ps-badge.idle { background: var(--bg-tertiary); color: var(--text-muted); }
|
| 44 |
+
.ps-badge.running { background: rgba(245,158,11,.12); color: var(--warning); }
|
| 45 |
+
.ps-badge.success { background: rgba(34,197,94,.12); color: var(--success); }
|
| 46 |
+
.ps-badge.failed { background: rgba(239,68,68,.12); color: var(--danger); }
|
| 47 |
+
|
| 48 |
+
.ps-run-btn {
|
| 49 |
+
display: flex; align-items: center; gap: 7px; flex-shrink: 0;
|
| 50 |
+
padding: 6px 16px; border: none; border-radius: 6px;
|
| 51 |
+
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-blue) 100%);
|
| 52 |
+
color: #fff; font-size: .83rem; font-weight: 600;
|
| 53 |
+
cursor: pointer; transition: opacity .15s, transform .1s;
|
| 54 |
+
}
|
| 55 |
+
.ps-run-btn:hover:not(:disabled) { opacity: .88; }
|
| 56 |
+
.ps-run-btn:active:not(:disabled) { transform: scale(.96); }
|
| 57 |
+
.ps-run-btn:disabled { opacity: .45; cursor: default; }
|
| 58 |
+
|
| 59 |
+
/* ββ Main area (canvas + config panel) βββββββββββββββββββββββββββββββββββββ */
|
| 60 |
+
.ps-main { flex: 1; display: flex; overflow: hidden; }
|
| 61 |
+
|
| 62 |
+
/* DAG canvas */
|
| 63 |
+
.ps-canvas {
|
| 64 |
+
flex: 1; overflow: auto; position: relative;
|
| 65 |
+
background-color: var(--bg-primary);
|
| 66 |
+
background-image: radial-gradient(circle, var(--border-color) 1px, transparent 1px);
|
| 67 |
+
background-size: 28px 28px;
|
| 68 |
+
}
|
| 69 |
+
.ps-canvas-inner { position: relative; }
|
| 70 |
+
|
| 71 |
+
.dag-arrows { position: absolute; top: 0; left: 0; pointer-events: none; z-index: 5; overflow: visible; }
|
| 72 |
+
|
| 73 |
+
/* ββ DAG nodes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 74 |
+
.dag-node {
|
| 75 |
+
position: absolute;
|
| 76 |
+
background: var(--bg-secondary);
|
| 77 |
+
border: 1.5px solid var(--border-color);
|
| 78 |
+
border-radius: 10px;
|
| 79 |
+
padding: 10px 11px 8px;
|
| 80 |
+
cursor: pointer;
|
| 81 |
+
transition: border-color .18s, box-shadow .18s, transform .12s;
|
| 82 |
+
user-select: none; z-index: 10;
|
| 83 |
+
}
|
| 84 |
+
.dag-node:hover {
|
| 85 |
+
border-color: var(--accent-light);
|
| 86 |
+
box-shadow: 0 0 0 3px rgba(139,92,246,.15), 0 4px 14px rgba(0,0,0,.3);
|
| 87 |
+
transform: translateY(-1px);
|
| 88 |
+
}
|
| 89 |
+
.dag-node.selected { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(139,92,246,.25); }
|
| 90 |
+
|
| 91 |
+
.dag-node.s-running { border-color: var(--warning); animation: node-pulse 1.6s ease-in-out infinite; }
|
| 92 |
+
.dag-node.s-success { border-color: var(--success); box-shadow: 0 0 0 2px rgba(34,197,94,.2); }
|
| 93 |
+
.dag-node.s-failed { border-color: var(--danger); box-shadow: 0 0 0 2px rgba(239,68,68,.2); }
|
| 94 |
+
|
| 95 |
+
@keyframes node-pulse {
|
| 96 |
+
0%, 100% { box-shadow: 0 0 0 2px rgba(245,158,11,.2); }
|
| 97 |
+
50% { box-shadow: 0 0 0 8px rgba(245,158,11,.04); }
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/* Purple dot = configurable */
|
| 101 |
+
.dag-node.configurable::after {
|
| 102 |
+
content: ''; position: absolute; top: 7px; right: 7px;
|
| 103 |
+
width: 5px; height: 5px; border-radius: 50%; background: var(--accent);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.nd-icon { font-size: 1.1rem; display: block; line-height: 1; margin-bottom: 3px; }
|
| 107 |
+
.nd-name { font-size: .77rem; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 108 |
+
.nd-badge {
|
| 109 |
+
display: inline-flex; align-items: center; gap: 4px;
|
| 110 |
+
margin-top: 5px; padding: 2px 6px; border-radius: 8px;
|
| 111 |
+
font-size: .66rem; font-weight: 500;
|
| 112 |
+
}
|
| 113 |
+
.nd-badge.pending { background: rgba(101,109,118,.15); color: var(--text-muted); }
|
| 114 |
+
.nd-badge.running { background: rgba(245,158,11,.14); color: var(--warning); }
|
| 115 |
+
.nd-badge.success { background: rgba(34,197,94,.12); color: var(--success); }
|
| 116 |
+
.nd-badge.failed { background: rgba(239,68,68,.12); color: var(--danger); }
|
| 117 |
+
.nd-result { font-size: .65rem; color: var(--text-muted); margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 118 |
+
|
| 119 |
+
.sdot { width: 5px; height: 5px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
| 120 |
+
.sdot.pending { background: var(--text-muted); }
|
| 121 |
+
.sdot.running { background: var(--warning); animation: blink 1s step-start infinite; }
|
| 122 |
+
.sdot.success { background: var(--success); }
|
| 123 |
+
.sdot.failed { background: var(--danger); }
|
| 124 |
+
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
|
| 125 |
+
|
| 126 |
+
/* ββ Config panel βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 127 |
+
.ps-cfg {
|
| 128 |
+
width: 0; overflow: hidden; flex-shrink: 0;
|
| 129 |
+
background: var(--bg-secondary);
|
| 130 |
+
border-left: 1px solid var(--border-color);
|
| 131 |
+
display: flex; flex-direction: column;
|
| 132 |
+
transition: width .24s cubic-bezier(.4,0,.2,1);
|
| 133 |
+
}
|
| 134 |
+
.ps-cfg.open { width: 296px; }
|
| 135 |
+
|
| 136 |
+
.cfg-hdr {
|
| 137 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 138 |
+
padding: 11px 13px; flex-shrink: 0;
|
| 139 |
+
border-bottom: 1px solid var(--border-color);
|
| 140 |
+
font-size: .8rem; font-weight: 600; color: var(--text-secondary);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.cfg-body { flex: 1; overflow-y: auto; padding: 14px; }
|
| 144 |
+
|
| 145 |
+
.cfg-node-hdr {
|
| 146 |
+
display: flex; align-items: flex-start; gap: 10px;
|
| 147 |
+
padding-bottom: 13px; margin-bottom: 13px;
|
| 148 |
+
border-bottom: 1px solid var(--border-color);
|
| 149 |
+
}
|
| 150 |
+
.cfg-node-icon { font-size: 1.5rem; flex-shrink: 0; line-height: 1; }
|
| 151 |
+
.cfg-node-title { font-size: .9rem; font-weight: 600; line-height: 1.3; }
|
| 152 |
+
.cfg-node-desc { font-size: .76rem; color: var(--text-secondary); margin-top: 4px; line-height: 1.5; }
|
| 153 |
+
|
| 154 |
+
.cfg-sec { margin-bottom: 16px; }
|
| 155 |
+
.cfg-lbl {
|
| 156 |
+
display: block; margin-bottom: 6px;
|
| 157 |
+
font-size: .69rem; font-weight: 700; letter-spacing: .08em;
|
| 158 |
+
text-transform: uppercase; color: var(--text-muted);
|
| 159 |
+
}
|
| 160 |
+
.cfg-select {
|
| 161 |
+
width: 100%; background: var(--bg-tertiary); border: 1px solid var(--border-color);
|
| 162 |
+
color: var(--text-primary); border-radius: 6px; padding: 7px 28px 7px 10px;
|
| 163 |
+
font-size: .82rem; outline: none; cursor: pointer; appearance: none;
|
| 164 |
+
transition: border-color .15s;
|
| 165 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23656d76' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
| 166 |
+
background-repeat: no-repeat; background-position: right 9px center;
|
| 167 |
+
}
|
| 168 |
+
.cfg-select:focus { border-color: var(--accent); }
|
| 169 |
+
|
| 170 |
+
.cfg-row {
|
| 171 |
+
display: flex; justify-content: space-between; align-items: flex-start;
|
| 172 |
+
padding: 6px 0; border-bottom: 1px solid var(--border-color); font-size: .79rem;
|
| 173 |
+
}
|
| 174 |
+
.cfg-row:last-child { border-bottom: none; }
|
| 175 |
+
.cfg-row-k { color: var(--text-muted); white-space: nowrap; padding-right: 8px; }
|
| 176 |
+
.cfg-row-v { color: var(--text-primary); font-weight: 500; text-align: right; word-break: break-word; max-width: 62%; font-size: .77rem; }
|
| 177 |
+
|
| 178 |
+
/* ββ Terminal βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 179 |
+
.ps-term {
|
| 180 |
+
flex-shrink: 0; height: 34px; overflow: hidden;
|
| 181 |
+
background: #07090d;
|
| 182 |
+
border-top: 1px solid var(--border-color);
|
| 183 |
+
transition: height .22s cubic-bezier(.4,0,.2,1);
|
| 184 |
+
}
|
| 185 |
+
.ps-term.expanded { height: 188px; }
|
| 186 |
+
|
| 187 |
+
.term-hdr {
|
| 188 |
+
display: flex; align-items: center; gap: 5px;
|
| 189 |
+
height: 34px; padding: 0 14px;
|
| 190 |
+
cursor: pointer; user-select: none;
|
| 191 |
+
border-bottom: 1px solid var(--border-color);
|
| 192 |
+
font-family: 'Fira Code', monospace; font-size: .72rem;
|
| 193 |
+
color: var(--text-secondary);
|
| 194 |
+
}
|
| 195 |
+
.term-hdr:hover { background: rgba(255,255,255,.02); }
|
| 196 |
+
.term-body {
|
| 197 |
+
height: calc(100% - 34px); overflow-y: auto;
|
| 198 |
+
padding: 7px 14px;
|
| 199 |
+
font-family: 'Fira Code', monospace; font-size: .71rem;
|
| 200 |
+
color: #8b949e; line-height: 1.65;
|
| 201 |
+
}
|
| 202 |
+
.l-ok { color: #22c55e; }
|
| 203 |
+
.l-err { color: #ef4444; }
|
| 204 |
+
.l-info { color: #f59e0b; }
|
| 205 |
+
.l-dim { color: #3d4450; }
|
| 206 |
+
</style>
|
| 207 |
+
{% endblock %}
|
| 208 |
|
| 209 |
{% block content %}
|
| 210 |
+
<div class="ps">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
+
<!-- ββ Toolbar βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 213 |
+
<div class="ps-toolbar">
|
| 214 |
+
<div class="ps-tabs">
|
| 215 |
+
<button class="ps-tab active" onclick="switchPipeline('training_pipeline',this)">
|
| 216 |
+
<i class="fa-solid fa-brain"></i> Training
|
| 217 |
+
</button>
|
| 218 |
+
<button class="ps-tab" onclick="switchPipeline('retraining_pipeline',this)">
|
| 219 |
+
<i class="fa-solid fa-rotate"></i> Retraining
|
| 220 |
+
</button>
|
| 221 |
+
<button class="ps-tab" onclick="switchPipeline('data_pipeline',this)">
|
| 222 |
+
<i class="fa-solid fa-database"></i> Data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
</button>
|
| 224 |
</div>
|
|
|
|
|
|
|
| 225 |
|
| 226 |
+
<div class="ps-info">
|
| 227 |
+
<div class="ps-name" id="ps-name">β</div>
|
| 228 |
+
<div class="ps-desc" id="ps-desc">β</div>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
<div class="ps-badge idle" id="ps-badge">
|
| 232 |
+
<span class="sdot pending" id="ps-dot"></span>
|
| 233 |
+
<span id="ps-badge-txt">Idle</span>
|
| 234 |
+
</div>
|
| 235 |
+
|
| 236 |
+
<button class="ps-run-btn" id="ps-run-btn" onclick="runPipeline()">
|
| 237 |
+
<i class="fa-solid fa-play" id="ps-btn-icon"></i>
|
| 238 |
+
<span id="ps-btn-txt">Execute DAG</span>
|
| 239 |
+
</button>
|
| 240 |
</div>
|
|
|
|
|
|
|
| 241 |
|
| 242 |
+
<!-- ββ Main area ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 243 |
+
<div class="ps-main">
|
| 244 |
+
|
| 245 |
+
<!-- DAG Canvas -->
|
| 246 |
+
<div class="ps-canvas" id="ps-canvas">
|
| 247 |
+
<div class="ps-canvas-inner" id="ps-ci">
|
| 248 |
+
<svg class="dag-arrows" id="dag-svg" width="1" height="1"></svg>
|
| 249 |
+
<!-- nodes injected by JS -->
|
| 250 |
</div>
|
| 251 |
</div>
|
| 252 |
+
|
| 253 |
+
<!-- Config panel (slides in) -->
|
| 254 |
+
<div class="ps-cfg" id="ps-cfg">
|
| 255 |
+
<div class="cfg-hdr">
|
| 256 |
+
<span><i class="fa-solid fa-sliders" style="color:var(--accent);margin-right:6px"></i>Node Config</span>
|
| 257 |
+
<button class="btn btn-ghost btn-sm" onclick="closeConfig()" title="Close">
|
| 258 |
+
<i class="fa-solid fa-xmark"></i>
|
| 259 |
+
</button>
|
| 260 |
+
</div>
|
| 261 |
+
<div class="cfg-body" id="cfg-body">
|
| 262 |
+
<div style="text-align:center;padding:36px 0 28px;color:var(--text-muted);font-size:.82rem">
|
| 263 |
+
<i class="fa-solid fa-arrow-pointer" style="font-size:1.5rem;opacity:.3;display:block;margin-bottom:10px"></i>
|
| 264 |
+
Click any node to configure it
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
|
| 269 |
</div>
|
| 270 |
|
| 271 |
+
<!-- ββ Execution terminal βββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 272 |
+
<div class="ps-term" id="ps-term">
|
| 273 |
+
<div class="term-hdr" onclick="toggleTerm()">
|
| 274 |
+
<span style="color:#ff5f56;font-size:.5rem">β</span>
|
| 275 |
+
<span style="color:#ffbd2e;font-size:.5rem">β</span>
|
| 276 |
+
<span style="color:#27c93f;font-size:.5rem">β</span>
|
| 277 |
+
<span style="margin-left:8px;color:var(--warning);letter-spacing:.05em">EXECUTION LOG</span>
|
| 278 |
+
<span id="term-pct" style="margin-left:8px;color:var(--text-muted)"></span>
|
| 279 |
+
<span style="margin-left:auto" id="term-caret"><i class="fa-solid fa-chevron-up"></i></span>
|
| 280 |
+
</div>
|
| 281 |
+
<div class="term-body" id="term-body">
|
| 282 |
+
<div class="l-dim"># Waiting for pipeline executionβ¦</div>
|
| 283 |
</div>
|
|
|
|
| 284 |
</div>
|
| 285 |
+
|
| 286 |
</div>
|
| 287 |
{% endblock %}
|
| 288 |
|
| 289 |
{% block scripts %}
|
| 290 |
<script>
|
| 291 |
+
// ββ Data ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 292 |
+
const DAGS = {{ dags | safe }};
|
| 293 |
+
const DATASETS = {{ datasets | tojson }};
|
| 294 |
+
|
| 295 |
+
// ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 296 |
+
let cur = 'training_pipeline';
|
| 297 |
+
let execId = null;
|
| 298 |
+
let pollIv = null;
|
| 299 |
+
let selNode = null;
|
| 300 |
+
let tstates = {};
|
| 301 |
+
let _seenLogs = 0;
|
| 302 |
+
|
| 303 |
+
// Pipeline context β updated via config panel; used when running
|
| 304 |
+
let pCtx = {
|
| 305 |
+
dataset: Object.keys(DATASETS)[0] || 'Iris Flowers',
|
| 306 |
+
category: 'Tree-Based',
|
| 307 |
+
algorithm: 'Random Forest',
|
| 308 |
+
task_type: 'classification',
|
| 309 |
};
|
| 310 |
|
| 311 |
+
// Layout
|
| 312 |
+
const NW=130, NH=64, HGAP=68, VGAP=24, PX=44, PY=44;
|
| 313 |
+
|
| 314 |
+
// Which nodes show config controls (purple dot)
|
| 315 |
+
const CFG_TYPE = { load_data:'dataset', ingest:'dataset', train:'algorithm', retrain:'algorithm' };
|
| 316 |
+
|
| 317 |
+
// Node positions (populated in renderDAG)
|
| 318 |
+
let npos = {};
|
| 319 |
|
| 320 |
+
// ββ Init ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 321 |
+
document.addEventListener('DOMContentLoaded', () =>
|
| 322 |
+
switchPipeline('training_pipeline', document.querySelector('.ps-tab.active')));
|
| 323 |
+
|
| 324 |
+
// ββ Pipeline switch βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 325 |
function switchPipeline(id, btn) {
|
|
|
|
|
|
|
| 326 |
if (pollIv) { clearInterval(pollIv); pollIv = null; }
|
| 327 |
+
cur = id; execId = null; tstates = {}; selNode = null;
|
| 328 |
+
closeConfig(false);
|
| 329 |
+
document.querySelectorAll('.ps-tab').forEach(b => b.classList.remove('active'));
|
| 330 |
btn.classList.add('active');
|
| 331 |
+
const d = DAGS[id];
|
| 332 |
+
document.getElementById('ps-name').textContent = d.name;
|
| 333 |
+
document.getElementById('ps-desc').textContent = d.description;
|
| 334 |
+
renderDAG(d, {});
|
| 335 |
+
_setBadge('idle');
|
| 336 |
+
_resetTerm();
|
| 337 |
+
_resetBtn();
|
| 338 |
+
}
|
| 339 |
|
| 340 |
+
// ββ DAG rendering βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 341 |
+
function renderDAG(dag, states) {
|
| 342 |
+
states = states || {};
|
| 343 |
+
const tasks = Object.values(dag.tasks);
|
| 344 |
+
const layers = {};
|
| 345 |
+
tasks.forEach(t => (layers[t.layer] = layers[t.layer] || []).push(t));
|
| 346 |
+
const maxL = Math.max(...tasks.map(t => t.layer));
|
| 347 |
+
const maxN = Math.max(...Object.values(layers).map(ts => ts.length));
|
| 348 |
+
|
| 349 |
+
// Compute positions (center-align multi-node layers)
|
| 350 |
+
npos = {};
|
| 351 |
+
Object.entries(layers).forEach(([li, ts]) => {
|
| 352 |
+
const x = PX + +li * (NW + HGAP);
|
| 353 |
+
const totalH = ts.length * NH + Math.max(0, ts.length-1) * VGAP;
|
| 354 |
+
const baseY = PY + (maxN * (NH+VGAP) - totalH) / 2;
|
| 355 |
+
ts.forEach((t, i) => { npos[t.task_id] = { x, y: baseY + i*(NH+VGAP) }; });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
});
|
| 357 |
|
| 358 |
+
const cw = PX*2 + (maxL+1)*NW + maxL*HGAP;
|
| 359 |
+
const ch = PY*2 + maxN*NH + Math.max(0,maxN-1)*VGAP;
|
| 360 |
+
|
| 361 |
+
const ci = document.getElementById('ps-ci');
|
| 362 |
+
ci.style.width = Math.max(cw,400)+'px';
|
| 363 |
+
ci.style.height = Math.max(ch,320)+'px';
|
| 364 |
+
|
| 365 |
+
// Remove old nodes
|
| 366 |
+
ci.querySelectorAll('.dag-node').forEach(e => e.remove());
|
| 367 |
+
|
| 368 |
+
// Create nodes
|
| 369 |
tasks.forEach(t => {
|
| 370 |
+
const p = npos[t.task_id];
|
| 371 |
+
const st = (states[t.task_id]||{}).status || 'pending';
|
| 372 |
+
const res = (states[t.task_id]||{}).result || '';
|
| 373 |
+
const sel = t.task_id === selNode;
|
| 374 |
+
const cfg = !!CFG_TYPE[t.task_id];
|
| 375 |
+
|
| 376 |
+
const el = document.createElement('div');
|
| 377 |
+
el.className = `dag-node s-${st}${sel?' selected':''}${cfg?' configurable':''}`;
|
| 378 |
+
el.id = 'node-'+t.task_id;
|
| 379 |
+
el.style.cssText = `left:${p.x}px;top:${p.y}px;width:${NW}px`;
|
| 380 |
+
el.innerHTML = `
|
| 381 |
+
<span class="nd-icon">${t.icon}</span>
|
| 382 |
+
<div class="nd-name">${t.name}</div>
|
| 383 |
+
<div class="nd-badge ${st}">
|
| 384 |
+
<span class="sdot ${st}"></span>${st}
|
| 385 |
+
</div>
|
| 386 |
+
${res?`<div class="nd-result" title="${res}">${res}</div>`:''}
|
| 387 |
+
`;
|
| 388 |
+
el.addEventListener('click', () => openConfig(t.task_id));
|
| 389 |
+
ci.appendChild(el);
|
| 390 |
});
|
| 391 |
|
| 392 |
+
// Arrows
|
| 393 |
+
_drawArrows(dag, states, Math.max(cw,400), Math.max(ch,320));
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
function _drawArrows(dag, states, w, h) {
|
| 397 |
+
const svg = document.getElementById('dag-svg');
|
| 398 |
+
svg.setAttribute('width', w);
|
| 399 |
+
svg.setAttribute('height', h);
|
| 400 |
+
svg.innerHTML = `
|
| 401 |
+
<defs>
|
| 402 |
+
<marker id="ah" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
| 403 |
+
<polygon points="0 0,8 3,0 6" fill="var(--border-color)"/>
|
| 404 |
+
</marker>
|
| 405 |
+
<marker id="ah-ok" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
| 406 |
+
<polygon points="0 0,8 3,0 6" fill="#22c55e66"/>
|
| 407 |
+
</marker>
|
| 408 |
+
</defs>`;
|
| 409 |
+
Object.values(dag.tasks).forEach(t => {
|
| 410 |
+
t.upstream.forEach(uid => {
|
| 411 |
+
const a = npos[uid], b = npos[t.task_id];
|
| 412 |
+
if (!a||!b) return;
|
| 413 |
+
const x1=a.x+NW, y1=a.y+NH/2, x2=b.x, y2=b.y+NH/2, cx=(x1+x2)/2;
|
| 414 |
+
const uSt=(states[uid]||{}).status||'pending';
|
| 415 |
+
const tSt=(states[t.task_id]||{}).status||'pending';
|
| 416 |
+
const ok = uSt==='success'&&tSt==='success';
|
| 417 |
+
const p = document.createElementNS('http://www.w3.org/2000/svg','path');
|
| 418 |
+
p.setAttribute('d', `M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}`);
|
| 419 |
+
p.setAttribute('stroke', ok?'#22c55e55':'var(--border-color)');
|
| 420 |
+
p.setAttribute('stroke-width','1.5');
|
| 421 |
+
p.setAttribute('fill','none');
|
| 422 |
+
p.setAttribute('marker-end',`url(#${ok?'ah-ok':'ah'})`);
|
| 423 |
+
svg.appendChild(p);
|
| 424 |
+
});
|
| 425 |
});
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
// ββ Config panel ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 429 |
+
async function openConfig(taskId) {
|
| 430 |
+
selNode = taskId;
|
| 431 |
+
const dag = DAGS[cur];
|
| 432 |
+
const task = dag.tasks[taskId];
|
| 433 |
+
const st = (tstates[taskId]||{}).status || 'pending';
|
| 434 |
+
const res = (tstates[taskId]||{}).result || null;
|
| 435 |
+
const err = (tstates[taskId]||{}).error || null;
|
| 436 |
+
const cfgT = CFG_TYPE[taskId];
|
| 437 |
+
const stClr= {running:'var(--warning)',success:'var(--success)',failed:'var(--danger)'}[st]||'var(--text-muted)';
|
| 438 |
+
|
| 439 |
+
// Highlight node
|
| 440 |
+
document.querySelectorAll('.dag-node').forEach(e => e.classList.remove('selected'));
|
| 441 |
+
const ne = document.getElementById('node-'+taskId);
|
| 442 |
+
if (ne) ne.classList.add('selected');
|
| 443 |
+
|
| 444 |
+
let html = `
|
| 445 |
+
<div class="cfg-node-hdr">
|
| 446 |
+
<span class="cfg-node-icon">${task.icon}</span>
|
| 447 |
+
<div>
|
| 448 |
+
<div class="cfg-node-title">${task.name}</div>
|
| 449 |
+
<div class="cfg-node-desc">${task.description}</div>
|
| 450 |
+
</div>
|
| 451 |
+
</div>
|
| 452 |
|
| 453 |
+
<!-- Status (always visible, updated in-place) -->
|
| 454 |
+
<div class="cfg-sec">
|
| 455 |
+
<span class="cfg-lbl">Status</span>
|
| 456 |
+
<div class="cfg-row">
|
| 457 |
+
<span class="cfg-row-k">State</span>
|
| 458 |
+
<span class="cfg-row-v" id="cfg-st" style="color:${stClr}">${st.toUpperCase()}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
</div>
|
| 460 |
+
<div class="cfg-row" id="cfg-res-row" style="${res?'':'display:none'}">
|
| 461 |
+
<span class="cfg-row-k">Result</span>
|
| 462 |
+
<span class="cfg-row-v" id="cfg-res">${res||''}</span>
|
| 463 |
+
</div>
|
| 464 |
+
<div class="cfg-row" id="cfg-err-row" style="${err?'':'display:none'}">
|
| 465 |
+
<span class="cfg-row-k">Error</span>
|
| 466 |
+
<span class="cfg-row-v" id="cfg-err" style="color:var(--danger)">${err||''}</span>
|
| 467 |
+
</div>
|
| 468 |
+
</div>
|
| 469 |
+
`;
|
| 470 |
+
|
| 471 |
+
// Inputs for configurable nodes
|
| 472 |
+
if (cfgT === 'dataset') {
|
| 473 |
+
html += `
|
| 474 |
+
<div class="cfg-sec">
|
| 475 |
+
<label class="cfg-lbl" for="cfg-ds">Dataset</label>
|
| 476 |
+
<select class="cfg-select" id="cfg-ds" onchange="pCtx.dataset=this.value">
|
| 477 |
+
${Object.keys(DATASETS).map(n=>`<option${n===pCtx.dataset?' selected':''}>${n}</option>`).join('')}
|
| 478 |
+
</select>
|
| 479 |
+
</div>`;
|
| 480 |
+
} else if (cfgT === 'algorithm') {
|
| 481 |
+
html += `
|
| 482 |
+
<div class="cfg-sec">
|
| 483 |
+
<label class="cfg-lbl" for="cfg-tt">Task Type</label>
|
| 484 |
+
<select class="cfg-select" id="cfg-tt" onchange="onTtChange(this.value)">
|
| 485 |
+
<option value="classification"${pCtx.task_type==='classification'?' selected':''}>Classification</option>
|
| 486 |
+
<option value="regression"${pCtx.task_type==='regression'?' selected':''}>Regression</option>
|
| 487 |
+
</select>
|
| 488 |
+
</div>
|
| 489 |
+
<div class="cfg-sec">
|
| 490 |
+
<label class="cfg-lbl" for="cfg-cat">Category</label>
|
| 491 |
+
<select class="cfg-select" id="cfg-cat"><option>Loadingβ¦</option></select>
|
| 492 |
+
</div>
|
| 493 |
+
<div class="cfg-sec">
|
| 494 |
+
<label class="cfg-lbl" for="cfg-alg">Algorithm</label>
|
| 495 |
+
<select class="cfg-select" id="cfg-alg" onchange="pCtx.algorithm=this.value"><option>Loadingβ¦</option></select>
|
| 496 |
+
</div>`;
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
// Upstream deps
|
| 500 |
+
if (task.upstream && task.upstream.length) {
|
| 501 |
+
html += `<div class="cfg-sec"><span class="cfg-lbl">Upstream</span>`;
|
| 502 |
+
task.upstream.forEach(uid => {
|
| 503 |
+
const up = dag.tasks[uid];
|
| 504 |
+
const upSt = (tstates[uid]||{}).status||'pending';
|
| 505 |
+
html += `<div class="cfg-row">
|
| 506 |
+
<span class="cfg-row-k">${up?up.icon+' '+up.name:uid}</span>
|
| 507 |
+
<span class="nd-badge ${upSt}" style="font-size:.65rem;padding:1px 6px">
|
| 508 |
+
<span class="sdot ${upSt}"></span>${upSt}
|
| 509 |
+
</span>
|
| 510 |
+
</div>`;
|
| 511 |
+
});
|
| 512 |
+
html += `</div>`;
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
document.getElementById('cfg-body').innerHTML = html;
|
| 516 |
+
document.getElementById('ps-cfg').classList.add('open');
|
| 517 |
+
|
| 518 |
+
if (cfgT === 'algorithm') await _loadAlgos(pCtx.task_type);
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
function closeConfig(redraw=true) {
|
| 522 |
+
document.getElementById('ps-cfg').classList.remove('open');
|
| 523 |
+
if (redraw) document.querySelectorAll('.dag-node').forEach(e => e.classList.remove('selected'));
|
| 524 |
+
selNode = null;
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
// Update only the status bits in the open config panel (called during polling)
|
| 528 |
+
function _updateCfgStatus() {
|
| 529 |
+
if (!selNode) return;
|
| 530 |
+
const st = (tstates[selNode]||{}).status || 'pending';
|
| 531 |
+
const res = (tstates[selNode]||{}).result || null;
|
| 532 |
+
const err = (tstates[selNode]||{}).error || null;
|
| 533 |
+
const stClr={running:'var(--warning)',success:'var(--success)',failed:'var(--danger)'}[st]||'var(--text-muted)';
|
| 534 |
+
|
| 535 |
+
const stEl = document.getElementById('cfg-st');
|
| 536 |
+
if (stEl) { stEl.textContent = st.toUpperCase(); stEl.style.color = stClr; }
|
| 537 |
+
|
| 538 |
+
const rRow = document.getElementById('cfg-res-row');
|
| 539 |
+
const rEl = document.getElementById('cfg-res');
|
| 540 |
+
if (rRow && rEl && res) { rRow.style.display=''; rEl.textContent=res; }
|
| 541 |
+
|
| 542 |
+
const eRow = document.getElementById('cfg-err-row');
|
| 543 |
+
const eEl = document.getElementById('cfg-err');
|
| 544 |
+
if (eRow && eEl && err) { eRow.style.display=''; eEl.textContent=err; }
|
| 545 |
}
|
| 546 |
|
| 547 |
+
// ββ Algorithm dropdowns βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 548 |
+
let _algoData = null;
|
| 549 |
+
|
| 550 |
+
async function _loadAlgos(tt) {
|
| 551 |
+
try {
|
| 552 |
+
const r = await fetch(`/api/algorithms?task=${tt}`);
|
| 553 |
+
_algoData = await r.json();
|
| 554 |
+
const cats = Object.keys(_algoData);
|
| 555 |
+
const catSel = document.getElementById('cfg-cat');
|
| 556 |
+
if (!catSel) return;
|
| 557 |
+
catSel.innerHTML = cats.map(c=>`<option${c===pCtx.category?' selected':''}>${c}</option>`).join('');
|
| 558 |
+
const cat = cats.includes(pCtx.category)?pCtx.category:cats[0];
|
| 559 |
+
catSel.value = cat; pCtx.category = cat;
|
| 560 |
+
catSel.onchange = e => onCatChange(e.target.value);
|
| 561 |
+
_fillAlgos(cat);
|
| 562 |
+
} catch(e) {}
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
function _fillAlgos(cat) {
|
| 566 |
+
const algs = _algoData&&_algoData[cat]?Object.keys(_algoData[cat]):[];
|
| 567 |
+
const sel = document.getElementById('cfg-alg');
|
| 568 |
+
if (!sel) return;
|
| 569 |
+
sel.innerHTML = algs.map(a=>`<option${a===pCtx.algorithm?' selected':''}>${a}</option>`).join('');
|
| 570 |
+
const alg = algs.includes(pCtx.algorithm)?pCtx.algorithm:algs[0]||'';
|
| 571 |
+
sel.value = alg; pCtx.algorithm = alg;
|
| 572 |
+
sel.onchange = e => { pCtx.algorithm = e.target.value; };
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
async function onTtChange(tt) {
|
| 576 |
+
pCtx.task_type = tt; pCtx.category=''; pCtx.algorithm='';
|
| 577 |
+
await _loadAlgos(tt);
|
| 578 |
+
}
|
| 579 |
+
function onCatChange(cat) { pCtx.category=cat; _fillAlgos(cat); }
|
| 580 |
+
|
| 581 |
// ββ Execute pipeline ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 582 |
async function runPipeline() {
|
| 583 |
+
const runBtn = document.getElementById('ps-run-btn');
|
| 584 |
+
runBtn.disabled = true;
|
| 585 |
+
document.getElementById('ps-btn-icon').className = 'spinner';
|
| 586 |
+
document.getElementById('ps-btn-txt').textContent = 'Runningβ¦';
|
| 587 |
+
_setBadge('running');
|
| 588 |
+
_openTerm();
|
| 589 |
+
_addLog(`$ ${DAGS[cur].name}`, 'info');
|
| 590 |
|
| 591 |
const ctx = {};
|
| 592 |
+
if (cur === 'training_pipeline') {
|
| 593 |
+
Object.assign(ctx, { dataset:pCtx.dataset, category:pCtx.category,
|
| 594 |
+
algorithm:pCtx.algorithm, task_type:pCtx.task_type });
|
| 595 |
+
_addLog(` dataset="${ctx.dataset}" algorithm="${ctx.algorithm}"`, 'dim');
|
| 596 |
}
|
| 597 |
|
| 598 |
try {
|
| 599 |
+
const r = await fetch(`/api/pipeline/${cur}/execute`, {
|
| 600 |
+
method:'POST', headers:{'Content-Type':'application/json'},
|
| 601 |
body: JSON.stringify(ctx),
|
| 602 |
});
|
| 603 |
+
const d = await r.json();
|
| 604 |
+
if (d.error) { _execFailed(d.error); return; }
|
| 605 |
+
execId = d.exec_id;
|
| 606 |
+
_addLog(` engine=${d.engine||'builtin'} id=${d.exec_id}`, 'dim');
|
| 607 |
+
_poll();
|
| 608 |
+
} catch(e) { _execFailed(e.message); }
|
|
|
|
|
|
|
| 609 |
}
|
| 610 |
|
| 611 |
+
function _poll() {
|
| 612 |
+
_seenLogs = 0;
|
| 613 |
if (pollIv) clearInterval(pollIv);
|
| 614 |
pollIv = setInterval(async () => {
|
| 615 |
+
try {
|
| 616 |
+
const r = await fetch(`/api/pipeline/status/${execId}`);
|
| 617 |
+
const exec = await r.json();
|
| 618 |
+
tstates = exec.task_states || {};
|
| 619 |
+
|
| 620 |
+
// Sync new log lines
|
| 621 |
+
const lines = exec.logs||[];
|
| 622 |
+
if (lines.length > _seenLogs) {
|
| 623 |
+
lines.slice(_seenLogs).forEach(ln => {
|
| 624 |
+
if (!ln) return;
|
| 625 |
+
let c='dim';
|
| 626 |
+
if (ln.includes('β')) c='ok';
|
| 627 |
+
else if (ln.includes('β')||ln.toLowerCase().includes('fail')) c='err';
|
| 628 |
+
else if (ln.includes('βΆ')) c='info';
|
| 629 |
+
_addLog(ln, c);
|
| 630 |
+
});
|
| 631 |
+
_seenLogs = lines.length;
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
document.getElementById('term-pct').textContent = exec.progress!=null?exec.progress+'%':'';
|
| 635 |
+
renderDAG(DAGS[cur], tstates);
|
| 636 |
+
_updateCfgStatus();
|
| 637 |
+
|
| 638 |
+
if (exec.status==='completed') {
|
| 639 |
+
clearInterval(pollIv); pollIv=null;
|
| 640 |
+
_setBadge('success');
|
| 641 |
+
_addLog('β Pipeline completed successfully', 'ok');
|
| 642 |
+
document.getElementById('ps-btn-icon').className = 'fa-solid fa-rotate-right';
|
| 643 |
+
document.getElementById('ps-btn-txt').textContent = 'Run Again';
|
| 644 |
+
document.getElementById('ps-run-btn').disabled = false;
|
| 645 |
+
showToast(`${DAGS[cur].name} completed`,'success');
|
| 646 |
+
} else if (exec.status==='failed') {
|
| 647 |
+
clearInterval(pollIv); pollIv=null;
|
| 648 |
+
_setBadge('failed');
|
| 649 |
+
_addLog('β '+(exec.error||'Pipeline failed'),'err');
|
| 650 |
+
_resetBtn();
|
| 651 |
+
showToast('Pipeline failed','error');
|
| 652 |
+
}
|
| 653 |
+
} catch(e) {}
|
| 654 |
+
}, 900);
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
function _execFailed(msg) { _addLog('β '+msg,'err'); _setBadge('failed'); _resetBtn(); }
|
| 658 |
+
|
| 659 |
+
// ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 660 |
+
function _setBadge(s) {
|
| 661 |
+
const labels={idle:'Idle',running:'Running',success:'Completed',failed:'Failed'};
|
| 662 |
+
document.getElementById('ps-badge').className = 'ps-badge '+s;
|
| 663 |
+
document.getElementById('ps-dot').className = 'sdot '+(s==='idle'?'pending':s);
|
| 664 |
+
document.getElementById('ps-badge-txt').textContent = labels[s]||s;
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
function _resetBtn() {
|
| 668 |
+
btn().disabled = false;
|
| 669 |
+
document.getElementById('ps-btn-icon').className = 'fa-solid fa-play';
|
| 670 |
+
document.getElementById('ps-btn-txt').textContent = 'Execute DAG';
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
function _addLog(txt, cls) {
|
| 674 |
+
const b = document.getElementById('term-body');
|
| 675 |
+
const el = document.createElement('div');
|
| 676 |
+
el.className = 'l-'+(cls||'dim'); el.textContent = txt;
|
| 677 |
+
b.appendChild(el); b.scrollTop = b.scrollHeight;
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
function _openTerm() {
|
| 681 |
+
document.getElementById('term-body').innerHTML = '';
|
| 682 |
+
document.getElementById('ps-term').classList.add('expanded');
|
| 683 |
+
document.getElementById('term-caret').innerHTML = '<i class="fa-solid fa-chevron-down"></i>';
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
function toggleTerm() {
|
| 687 |
+
const el = document.getElementById('ps-term');
|
| 688 |
+
const ex = el.classList.contains('expanded');
|
| 689 |
+
el.classList.toggle('expanded',!ex);
|
| 690 |
+
document.getElementById('term-caret').innerHTML =
|
| 691 |
+
`<i class="fa-solid fa-chevron-${ex?'up':'down'}"></i>`;
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
function _resetTerm() {
|
| 695 |
+
document.getElementById('term-body').innerHTML = '<div class="l-dim"># Waiting for pipeline executionβ¦</div>';
|
| 696 |
+
document.getElementById('term-pct').textContent='';
|
| 697 |
+
document.getElementById('ps-term').classList.remove('expanded');
|
| 698 |
+
document.getElementById('term-caret').innerHTML='<i class="fa-solid fa-chevron-up"></i>';
|
| 699 |
+
}
|
| 700 |
</script>
|
| 701 |
{% endblock %}
|