mnoorchenar commited on
Commit
edc9558
Β·
1 Parent(s): 6cd2d76

Update 2026-03-25 18:13:59

Browse files
Dockerfile CHANGED
@@ -1,25 +1,57 @@
1
  FROM python:3.11-slim
2
 
3
- # Install system dependencies required by LightGBM (OpenMP runtime)
4
- RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 && rm -rf /var/lib/apt/lists/*
5
-
6
- # HuggingFace Spaces requires non-root user with UID 1000
 
 
 
 
 
7
  RUN useradd -m -u 1000 user
8
- USER user
9
- ENV PATH="/home/user/.local/bin:$PATH"
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  WORKDIR /app
12
 
13
- COPY --chown=user:user requirements.txt .
 
 
14
  RUN pip install --no-cache-dir -r requirements.txt
15
 
16
- COPY --chown=user:user . .
 
 
 
 
17
 
18
- RUN mkdir -p mlruns logs
 
19
 
20
- EXPOSE 7860
 
 
 
 
21
 
22
- ENV PYTHONUNBUFFERED=1
23
- ENV FLASK_ENV=production
24
 
25
- CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", "--threads", "4", "--timeout", "300", "--log-level", "info", "app:app"]
 
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
- ctx = request.get_json(force=True) or {}
336
- exec_id = execute_dag(dag, ctx)
337
- return jsonify({"exec_id": exec_id, "status": "queued"})
 
 
 
 
 
 
 
 
 
 
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("sqlite:///mlflow.db")
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("sqlite:///mlflow.db")
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("sqlite:///mlflow.db")
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> Pipelines
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 %}Pipelines{% endblock %}
5
- {% block page_title %}<i class="fa-solid fa-diagram-project" style="color:var(--cyan)"></i> Pipelines{% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  {% block content %}
8
- <div class="page-title">Pipeline Orchestration</div>
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
- <!-- Pipeline description + run button -->
25
- <div class="card mb-20" style="margin-bottom:16px">
26
- <div class="flex-between">
27
- <div>
28
- <div class="card-title" id="pipeline-name" style="font-size:1rem;margin-bottom:4px">Training Pipeline</div>
29
- <div id="pipeline-desc" style="font-size:.85rem;color:var(--text-secondary)">
30
- End-to-end model training: ingest β†’ preprocess β†’ train β†’ evaluate β†’ register
31
- </div>
32
- </div>
33
- <div class="flex-gap">
34
- <!-- Context form for training pipeline -->
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
- <!-- DAG canvas -->
48
- <div class="card mb-20" style="margin-bottom:16px">
49
- <div class="card-header">
50
- <div class="card-title"><i class="fa-solid fa-sitemap" style="color:var(--accent)"></i> DAG Graph</div>
51
- <span id="exec-status-badge"></span>
 
 
 
 
 
 
 
 
 
52
  </div>
53
- <div id="dag-canvas" class="dag-canvas" style="height:320px"></div>
54
- </div>
55
 
56
- <!-- Execution state grid + log -->
57
- <div class="grid-2">
58
- <div class="card">
59
- <div class="card-header">
60
- <div class="card-title"><i class="fa-solid fa-list-check" style="color:var(--success)"></i> Task Status</div>
61
- <div id="exec-progress-wrap" style="display:flex;align-items:center;gap:8px;font-size:.82rem;color:var(--text-muted)">
62
- <span id="exec-pct">β€”</span>
 
63
  </div>
64
  </div>
65
- <div id="task-list" style="display:flex;flex-direction:column;gap:6px"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  </div>
67
 
68
- <div class="card">
69
- <div class="card-header">
70
- <div class="card-title"><i class="fa-solid fa-terminal" style="color:var(--warning)"></i> Execution Log</div>
71
- <button class="btn btn-ghost btn-sm" onclick="clearLog()"><i class="fa-solid fa-trash"></i></button>
 
 
 
 
 
 
 
 
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
- const DAGS = {{ dags | safe }};
81
- let currentPipeline = 'training_pipeline';
82
- let currentExecId = null;
83
- let pollIv = null;
84
- let currentDag = null;
85
-
86
- const STATUS_COLORS = {
87
- pending: '#30363d', running: '#f59e0b', success: '#22c55e', failed: '#ef4444',
88
- };
89
- const STATUS_ICONS = {
90
- pending: '⏳', running: '⚑', success: 'βœ…', failed: '❌',
 
 
 
 
 
 
 
91
  };
92
 
93
- // ── Init ─────────────────────────────────────────────────────────────────────
94
- document.addEventListener('DOMContentLoaded', () => {
95
- switchPipeline('training_pipeline',
96
- document.querySelector('.tab-btn.active'));
97
- });
 
 
 
98
 
 
 
 
 
 
99
  function switchPipeline(id, btn) {
100
- currentPipeline = id;
101
- currentExecId = null;
102
  if (pollIv) { clearInterval(pollIv); pollIv = null; }
103
-
104
- document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
 
105
  btn.classList.add('active');
 
 
 
 
 
 
 
 
106
 
107
- const dag = DAGS[id];
108
- currentDag = dag;
109
-
110
- document.getElementById('pipeline-name').textContent = dag.name;
111
- document.getElementById('pipeline-desc').textContent = dag.description;
112
-
113
- // Show dataset selector only for training pipeline
114
- document.getElementById('ctx-form').style.display =
115
- id === 'training_pipeline' ? 'flex' : 'none';
116
-
117
- renderDAG(dag);
118
- renderTaskList(dag, {});
119
- document.getElementById('exec-log').textContent = 'Waiting for execution…';
120
- document.getElementById('exec-status-badge').innerHTML = '';
121
- document.getElementById('exec-pct').textContent = 'β€”';
122
- document.getElementById('btn-run-pipeline').disabled = false;
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
- // Build edge traces
147
- const edgeTraces = [];
 
 
 
 
 
 
 
 
 
148
  tasks.forEach(t => {
149
- t.upstream.forEach(upId => {
150
- if (!nodeX[upId]) return;
151
- edgeTraces.push({
152
- type: 'scatter', mode: 'lines',
153
- x: [nodeX[upId], nodeX[t.task_id]],
154
- y: [nodeY[upId], nodeY[t.task_id]],
155
- line: { color: '#30363d', width: 2 },
156
- hoverinfo: 'none', showlegend: false,
157
- });
158
- });
 
 
 
 
 
 
 
 
 
 
159
  });
160
 
161
- // Node trace
162
- const nodeColors = tasks.map(t => {
163
- const st = (taskStates[t.task_id] || {}).status || 'pending';
164
- return STATUS_COLORS[st] || '#30363d';
165
- });
166
- const nodeText = tasks.map(t => {
167
- const st = (taskStates[t.task_id] || {}).status || 'pending';
168
- return `${t.icon} ${t.name}<br><span style="font-size:10px">${STATUS_ICONS[st]} ${st}</span>`;
169
- });
170
- const nodeHover = tasks.map(t => {
171
- const st = (taskStates[t.task_id] || {}).status || 'pending';
172
- return `<b>${t.name}</b><br>${t.description}<br>Status: ${st}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
- const nodeTrace = {
176
- type: 'scatter', mode: 'markers+text',
177
- x: tasks.map(t => nodeX[t.task_id]),
178
- y: tasks.map(t => nodeY[t.task_id]),
179
- text: nodeText,
180
- textposition: 'bottom center',
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
- <span style="font-size:.72rem;padding:2px 7px;border-radius:10px;background:${clr}22;color:${clr};white-space:nowrap">${STATUS_ICONS[st]} ${st}</span>
217
- </div>`;
218
- }).join('');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  }
220
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  // ── Execute pipeline ──────────────────────────────────────────────────────────
222
  async function runPipeline() {
223
- document.getElementById('btn-run-pipeline').disabled = true;
224
- document.getElementById('btn-run-pipeline').innerHTML = '<span class="spinner"></span> Running…';
225
- document.getElementById('exec-log').textContent = '';
226
- document.getElementById('exec-status-badge').innerHTML =
227
- '<span class="badge badge-warning"><span class="spinner" style="width:9px;height:9px;border-width:1.5px"></span> Running</span>';
 
 
228
 
229
  const ctx = {};
230
- if (currentPipeline === 'training_pipeline') {
231
- ctx.dataset = document.getElementById('ctx-dataset').value;
 
 
232
  }
233
 
234
  try {
235
- const res = await fetch(`/api/pipeline/${currentPipeline}/execute`, {
236
- method: 'POST', headers:{'Content-Type':'application/json'},
237
  body: JSON.stringify(ctx),
238
  });
239
- const data = await res.json();
240
- currentExecId = data.exec_id;
241
- pollExecution();
242
- } catch(e) {
243
- showToast('Failed to start pipeline', 'error');
244
- document.getElementById('btn-run-pipeline').disabled = false;
245
- document.getElementById('btn-run-pipeline').innerHTML = '<i class="fa-solid fa-play"></i> Execute DAG';
246
- }
247
  }
248
 
249
- function pollExecution() {
 
250
  if (pollIv) clearInterval(pollIv);
251
  pollIv = setInterval(async () => {
252
- const res = await fetch(`/api/pipeline/status/${currentExecId}`);
253
- const exec = await res.json();
254
-
255
- // Update progress
256
- document.getElementById('exec-pct').textContent = exec.progress + '%';
257
-
258
- // Update log
259
- const logEl = document.getElementById('exec-log');
260
- logEl.innerHTML = (exec.logs || []).map(line => {
261
- let cls = '';
262
- if (line.includes('βœ”')) cls = 'log-line-ok';
263
- else if (line.includes('βœ–')) cls = 'log-line-err';
264
- else if (line.includes('β–Ά')) cls = 'log-line-info';
265
- return `<div class="${cls}">${line}</div>`;
266
- }).join('');
267
- logEl.scrollTop = logEl.scrollHeight;
268
-
269
- // Update DAG
270
- if (exec.task_states) {
271
- renderDAG(currentDag, exec.task_states);
272
- renderTaskList(currentDag, exec.task_states);
273
- }
274
-
275
- if (exec.status === 'completed') {
276
- clearInterval(pollIv); pollIv = null;
277
- document.getElementById('exec-status-badge').innerHTML =
278
- '<span class="badge badge-success"><i class="fa-solid fa-check"></i> Completed</span>';
279
- document.getElementById('btn-run-pipeline').disabled = false;
280
- document.getElementById('btn-run-pipeline').innerHTML = '<i class="fa-solid fa-rotate-right"></i> Run Again';
281
- showToast(`Pipeline "${currentDag.name}" completed`, 'success');
282
- } else if (exec.status === 'failed') {
283
- clearInterval(pollIv); pollIv = null;
284
- document.getElementById('exec-status-badge').innerHTML =
285
- '<span class="badge badge-danger"><i class="fa-solid fa-xmark"></i> Failed</span>';
286
- document.getElementById('btn-run-pipeline').disabled = false;
287
- document.getElementById('btn-run-pipeline').innerHTML = '<i class="fa-solid fa-play"></i> Retry';
288
- showToast('Pipeline failed: ' + (exec.error || 'unknown'), 'error');
289
- }
290
- }, 800);
291
- }
292
-
293
- function clearLog() { document.getElementById('exec-log').textContent = 'Cleared.'; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 %}