This is an automated email from the ASF dual-hosted git repository.

yasithdev pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airavata-portals.git


The following commit(s) were added to refs/heads/main by this push:
     new ce55317ad Show per-stage experiment progress; fix single-file output 
download (#215)
ce55317ad is described below

commit ce55317ad2f8874e5c1f3d30ee063fad9bc5384d
Author: Yasith Jayawardana <[email protected]>
AuthorDate: Fri Jun 12 22:28:05 2026 -0400

    Show per-stage experiment progress; fix single-file output download (#215)
    
    ExperimentSummary.vue now renders the experiment's PROCESS -> TASK pipeline 
as a
    per-stage list (Environment Setup -> Data Staging -> Job Submission -> Job
    Monitoring), each with its own state badge, reason, and timestamp, with the 
job
    nested under Job Submission — instead of a single job row frozen at QUEUED.
    
    urls.py adds the /sdk/download/ route for single-file data-product downloads
    (previously only download-dir / download-experiment-dir were routed, so the
    output viewer's links 404'd).
    
    web.py _render_response now passes any HttpResponseBase through untouched.
    FileResponse / StreamingHttpResponse extend HttpResponseBase (not 
HttpResponse),
    so the previous isinstance(HttpResponse) check JSON-wrapped streamed 
downloads
    and raised TypeError on render. File downloads now stream correctly.
---
 README.md                                          |  19 +++
 .../django_airavata/apps/api/web.py                |   7 +-
 .../js/components/experiment/ExperimentSummary.vue | 170 +++++++++++++++++----
 airavata-django-portal/django_airavata/urls.py     |   8 +-
 4 files changed, 171 insertions(+), 33 deletions(-)

diff --git a/README.md b/README.md
index aaa0947e8..e7f13879c 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,25 @@ as a container; `settings_local.py` is generated 
automatically on first `tilt up
 if the file does not exist. Only the Django portal is wired into the Tiltfile 
today;
 other portals can be added as additional resources later.
 
+### Log in and run your first experiment (Echo)
+
+The backend stack seeds a ready-to-use tenant outside the JVM when its 
database is first
+created — directly from SQL, before the server starts (default gateway, SFTP 
storage, a
+docker-SLURM compute resource with a `normal` queue, the **Echo** application, 
and a
+**Default Project**, all shared with `default-admin`). So once both stacks are 
green you can
+run an experiment with zero manual setup:
+
+1. Open **https://gateway.airavata.host** and log in as **`default-admin`** / 
**`ade4#21242ftfd`**.
+2. Create an experiment: choose the **Echo** application, the **Default 
Project**, and the
+   **slurm** compute resource (queue `normal`). The `Input_to_Echo` field 
defaults to
+   `Hello, Airavata!`.
+3. Launch it — it runs on the docker-SLURM cluster (env setup → `sbatch` over 
SSH → `sacct`
+   monitoring → SFTP output staging) and reaches **COMPLETED**, with 
`Echo.stdout` holding
+   the echoed input.
+
+`tilt down` then `tilt up` brings the same working state back up (the database 
volume
+persists); `./devstack/devstack reset` (or wiping the `db_data` volume) 
re-seeds it from scratch.
+
 ## Repository Structure
 
 This repository contains the following sub-projects and templates:
diff --git a/airavata-django-portal/django_airavata/apps/api/web.py 
b/airavata-django-portal/django_airavata/apps/api/web.py
index 03a661915..5f1c5164f 100644
--- a/airavata-django-portal/django_airavata/apps/api/web.py
+++ b/airavata-django-portal/django_airavata/apps/api/web.py
@@ -30,7 +30,7 @@ import grpc
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ValidationError as DjangoValidationError
 from django.core.serializers.json import DjangoJSONEncoder
-from django.http import Http404, HttpResponse
+from django.http import Http404, HttpResponse, HttpResponseBase
 from django.urls import re_path, reverse  # noqa: F401  (reverse re-exported)
 from django.views import View
 
@@ -424,7 +424,10 @@ def _render_response(result):
     ``HttpResponse(status=204)``) passes through untouched. A handler that
     returned raw data is defensively wrapped in a :class:`Response`.
     """
-    if isinstance(result, HttpResponse):
+    # HttpResponseBase (not HttpResponse) — FileResponse / 
StreamingHttpResponse extend
+    # HttpResponseBase directly, so checking HttpResponse alone would wrap 
(and JSON-encode)
+    # streamed file downloads, breaking them.
+    if isinstance(result, HttpResponseBase):
         return result
     return Response(result)
 
diff --git 
a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/ExperimentSummary.vue
 
b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/ExperimentSummary.vue
index 5e585ad4d..295acfe43 100644
--- 
a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/ExperimentSummary.vue
+++ 
b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/ExperimentSummary.vue
@@ -115,36 +115,47 @@
                     {{ localFullExperiment.experimentStatusName }}
                   </td>
                 </tr>
-                <tr
-                  v-if="
-                    localFullExperiment.job_details &&
-                    localFullExperiment.job_details.length > 0
-                  "
-                >
-                  <th scope="row">Job</th>
+                <tr v-if="stages.length > 0">
+                  <th scope="row">Tasks</th>
                   <td>
-                    <table class="table">
-                      <thead>
-                        <th>Name</th>
-                        <th>ID</th>
-                        <th>Status</th>
-                        <th>Creation Time</th>
-                      </thead>
-                      <tr
-                        v-for="(jobDetail,
-                        index) in localFullExperiment.job_details"
-                        :key="jobDetail.job_id"
+                    <ul class="list-unstyled mb-0">
+                      <li
+                        v-for="stage in stages"
+                        :key="stage.taskId"
+                        class="d-flex align-items-start mb-2"
                       >
-                        <td>{{ jobDetail.job_name }}</td>
-                        <td>{{ jobDetail.job_id }}</td>
-                        <td>{{ jobDetail.jobStatusStateName }}</td>
-                        <td>
-                          <span :title="jobDetail.creation_time.toString()">{{
-                            jobCreationTimes[index]
-                          }}</span>
-                        </td>
-                      </tr>
-                    </table>
+                        <b-badge
+                          :variant="stage.variant"
+                          class="mr-2 mt-1 text-uppercase"
+                          style="min-width: 6rem"
+                          >{{ stage.stateLabel }}</b-badge
+                        >
+                        <div>
+                          <strong>{{ stage.typeLabel }}</strong>
+                          <span v-if="stage.reason" class="text-muted">
+                            — {{ stage.reason }}</span
+                          >
+                          <small v-if="stage.time" class="text-muted 
d-block">{{
+                            stage.time
+                          }}</small>
+                          <div v-if="stage.job" class="mt-1">
+                            <b-badge
+                              :variant="stage.job.variant"
+                              class="mr-2 text-uppercase"
+                              style="min-width: 6rem"
+                              >{{ stage.job.stateLabel }}</b-badge
+                            >
+                            <span class="text-muted"
+                              >Job {{ stage.job.name }} (ID
+                              {{ stage.job.id }})</span
+                            >
+                            <span v-if="stage.job.reason" class="text-muted">
+                              — {{ stage.job.reason }}</span
+                            >
+                          </div>
+                        </div>
+                      </li>
+                    </ul>
                   </td>
                 </tr>
                 <!--  TODO: leave this out for now -->
@@ -380,6 +391,50 @@ export default {
         moment(jobDetail.creation_time).fromNow()
       );
     },
+    // The experiment's PROCESS -> TASK pipeline as an ordered stage list (env 
setup, data
+    // staging, job submission, monitoring), each with its current state, the 
latest reason, and
+    // timing. The job (with its own live status) is nested under the Job 
Submission stage. Surfaces
+    // exactly which stage the experiment is in instead of a single job row 
frozen at QUEUED.
+    stages() {
+      const exp =
+        this.localFullExperiment && this.localFullExperiment.experiment;
+      if (!exp || !exp.processes || exp.processes.length === 0) {
+        return [];
+      }
+      const result = [];
+      exp.processes.forEach((process) => {
+        process.sortedTasks.forEach((task) => {
+          const latest = task.latestStatus;
+          const stateName = latest && latest.state ? latest.state.name : null;
+          const stage = {
+            taskId: task.task_id,
+            typeLabel: this.taskTypeLabel(task.task_type),
+            stateLabel: this.taskStateLabel(stateName),
+            variant: this.taskStateVariant(stateName),
+            reason: latest ? latest.reason : "",
+            time:
+              latest && latest.time_of_state_change
+                ? moment(latest.time_of_state_change).fromNow()
+                : "",
+            job: null,
+          };
+          if (task.jobs && task.jobs.length > 0) {
+            const job = task.jobs[0];
+            const js = job.latestJobStatus;
+            const jobStateName = js && js.job_state ? js.job_state.name : null;
+            stage.job = {
+              id: job.job_id,
+              name: job.job_name,
+              stateLabel: this.titleCase(jobStateName) || "Pending",
+              variant: this.jobStateVariant(jobStateName),
+              reason: js ? js.reason : "",
+            };
+          }
+          result.push(stage);
+        });
+      });
+      return result;
+    },
     editLink() {
       return urls.editExperiment(this.experiment);
     },
@@ -457,6 +512,65 @@ export default {
         ? dataProducts.filter((dp) => (dp ? true : false))
         : [];
     },
+    titleCase(s) {
+      if (!s) return "";
+      return s
+        .toLowerCase()
+        .split("_")
+        .map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : ""))
+        .join(" ");
+    },
+    taskTypeLabel(taskType) {
+      const name = taskType && taskType.name ? taskType.name : "";
+      const labels = {
+        ENV_SETUP: "Environment Setup",
+        DATA_STAGING: "Data Staging",
+        JOB_SUBMISSION: "Job Submission",
+        ENV_CLEANUP: "Environment Cleanup",
+        MONITORING: "Job Monitoring",
+        OUTPUT_FETCHING: "Output Fetching",
+      };
+      return labels[name] || this.titleCase(name) || "Task";
+    },
+    taskStateLabel(stateName) {
+      if (!stateName) return "Pending";
+      return this.titleCase(stateName.replace(/^TASK_STATE_/, ""));
+    },
+    taskStateVariant(stateName) {
+      switch (stateName) {
+        case "TASK_STATE_COMPLETED":
+          return "success";
+        case "TASK_STATE_EXECUTING":
+          return "info";
+        case "TASK_STATE_FAILED":
+          return "danger";
+        case "TASK_STATE_CANCELED":
+          return "warning";
+        case "TASK_STATE_CREATED":
+          return "secondary";
+        default:
+          return "light";
+      }
+    },
+    jobStateVariant(jobStateName) {
+      switch (jobStateName) {
+        case "COMPLETE":
+          return "success";
+        case "ACTIVE":
+          return "info";
+        case "SUBMITTED":
+        case "QUEUED":
+          return "secondary";
+        case "FAILED":
+        case "NON_CRITICAL_FAIL":
+          return "danger";
+        case "CANCELED":
+        case "SUSPENDED":
+          return "warning";
+        default:
+          return "light";
+      }
+    },
   },
 };
 </script>
diff --git a/airavata-django-portal/django_airavata/urls.py 
b/airavata-django-portal/django_airavata/urls.py
index b784b46fe..779efcc9e 100644
--- a/airavata-django-portal/django_airavata/urls.py
+++ b/airavata-django-portal/django_airavata/urls.py
@@ -21,6 +21,7 @@ from django.urls import path, re_path
 
 from . import views
 from .apps.api import downloads as api_downloads
+from .apps.api import views as api_views
 
 urlpatterns = [
     re_path(r"^admin/", include("django_airavata.apps.admin.urls")),
@@ -29,9 +30,10 @@ urlpatterns = [
     re_path(r"^api/", include("django_airavata.apps.api.urls")),
     re_path(r"^groups/", include("django_airavata.apps.groups.urls")),
     re_path(r"^dataparsers/", 
include("django_airavata.apps.dataparsers.urls")),
-    # Directory zip downloads the file browser links to (paths kept under /sdk/
-    # so the built frontend's hardcoded hrefs keep working). Single-file
-    # downloads go through the api app's download/download-file views.
+    # Directory zip + single-file downloads the file browser / output displays 
link
+    # to. Paths kept under /sdk/ so the built frontend's hardcoded hrefs keep 
working
+    # (the retired airavata_django_portal_sdk served these from /sdk/).
+    path("sdk/download/", api_views.download, name="sdk_download"),
     path("sdk/download-dir/", api_downloads.download_dir, name="download_dir"),
     path(
         "sdk/download-experiment-dir/<experiment_id>/",

Reply via email to