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>/",