From 0c9a9acaa74ff53db8ef7d6526648fb1f047396f Mon Sep 17 00:00:00 2001
From: erick-gege
Date: Tue, 19 May 2026 11:57:58 -0500
Subject: [PATCH 1/7] =?UTF-8?q?feat:=20frontend=20UX=20=E2=80=94=20product?=
=?UTF-8?q?=20tour,=20deploy=20stages,=20job=20details?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
estela-api/api/serializers/deploy.py | 15 +
estela-api/api/serializers/job.py | 35 ++
estela-api/config/celery.py | 4 +
estela-api/core/tasks.py | 74 +++-
estela-api/core/views.py | 7 +
estela-web/src/pages/DeployListPage/index.tsx | 55 ++-
.../src/pages/DeployListPage/styles.scss | 20 +
estela-web/src/pages/JobCreateModal/index.tsx | 57 ++-
estela-web/src/pages/JobDataPage/index.tsx | 4 +-
estela-web/src/pages/JobDetailPage/index.tsx | 398 ++++++++++--------
.../pages/ProjectCronJobListPage/index.tsx | 3 +
.../src/pages/ProjectJobListPage/index.tsx | 7 +
.../src/pages/SpiderDetailPage/index.tsx | 2 +
estela-web/src/pages/SpiderListPage/index.tsx | 2 +
.../api/generated-api/models/Deploy.ts | 4 +-
.../api/generated-api/models/SpiderJob.ts | 7 +
.../src/shared/layouts/ProjectLayout.tsx | 2 +
.../src/shared/projectSideNav/index.tsx | 10 +-
estela-web/src/tour/TourOverlay.scss | 166 ++++++++
estela-web/src/tour/TourOverlay.tsx | 310 ++++++++++++++
estela-web/src/tour/index.ts | 2 +
estela-web/src/tour/steps.ts | 121 ++++++
estela-web/src/tour/store.ts | 186 ++++++++
estela-web/src/tour/types.ts | 55 +++
24 files changed, 1336 insertions(+), 210 deletions(-)
create mode 100644 estela-web/src/tour/TourOverlay.scss
create mode 100644 estela-web/src/tour/TourOverlay.tsx
create mode 100644 estela-web/src/tour/index.ts
create mode 100644 estela-web/src/tour/steps.ts
create mode 100644 estela-web/src/tour/store.ts
create mode 100644 estela-web/src/tour/types.ts
diff --git a/estela-api/api/serializers/deploy.py b/estela-api/api/serializers/deploy.py
index 955a9eff..7ce00371 100644
--- a/estela-api/api/serializers/deploy.py
+++ b/estela-api/api/serializers/deploy.py
@@ -14,6 +14,21 @@ class DeploySerializer(serializers.ModelSerializer):
def get_spiders_count(self, obj):
return obj.spiders.count()
+ def to_representation(self, instance):
+ data = super().to_representation(instance)
+ if instance.status == Deploy.BUILDING_STATUS:
+ try:
+ import redis as redis_lib
+ from django.conf import settings
+
+ redis_conn = redis_lib.from_url(settings.REDIS_URL)
+ stage = redis_conn.get(f"deploy_stage:{instance.did}")
+ if stage:
+ data["status"] = stage.decode()
+ except Exception:
+ pass
+ return data
+
class Meta:
model = Deploy
fields = ["did", "project", "user", "status", "spiders_count", "created"]
diff --git a/estela-api/api/serializers/job.py b/estela-api/api/serializers/job.py
index 12fe4913..d71abdcd 100644
--- a/estela-api/api/serializers/job.py
+++ b/estela-api/api/serializers/job.py
@@ -8,6 +8,8 @@
SpiderJobEnvVarSerializer,
SpiderJobTagSerializer,
)
+import redis as redis_lib
+from django.conf import settings
from api.utils import (
delete_stats_from_redis,
update_stats_from_redis,
@@ -38,6 +40,7 @@ class SpiderJobSerializer(serializers.ModelSerializer):
spider = serializers.SerializerMethodField("get_spider")
storage_size = serializers.SerializerMethodField("get_storage_size")
database_insertion_progress = serializers.SerializerMethodField("get_database_insertion_progress")
+ peak_memory = serializers.SerializerMethodField("get_peak_memory")
class Meta:
model = SpiderJob
@@ -60,6 +63,7 @@ class Meta:
"database_insertion_progress",
"storage_size",
"resource_tier",
+ "peak_memory",
)
def get_spider(self, instance):
@@ -85,6 +89,37 @@ def get_database_insertion_progress(self, instance):
# Return the actual database insertion progress value from the model
return instance.database_insertion_progress
+ def get_peak_memory(self, instance):
+ if instance.status == SpiderJob.RUNNING_STATUS:
+ try:
+ redis_conn = redis_lib.from_url(settings.REDIS_URL)
+ raw_stats = redis_conn.hgetall(f"scrapy_stats_{instance.key}")
+ if raw_stats:
+ job_stats = {key.decode(): value.decode() for key, value in raw_stats.items()}
+ mem = job_stats.get("resources/peak_memory_bytes") or job_stats.get("memusage/max")
+ if mem:
+ mem_bytes = int(float(mem))
+ if mem_bytes > 0:
+ return mem_bytes
+ except Exception:
+ pass
+ else:
+ try:
+ if spiderdata_db_client.get_connection():
+ pid = str(instance.spider.project.pid)
+ job_collection_name = get_collection_name(instance, "stats")
+ job_stats = spiderdata_db_client.get_job_stats(pid, job_collection_name)
+ if job_stats:
+ for stat in job_stats:
+ mem = stat.get("resources/peak_memory_bytes")
+ if mem is not None:
+ mem_bytes = int(float(mem))
+ if mem_bytes > 0:
+ return mem_bytes
+ except Exception:
+ pass
+ return None
+
class SpiderJobCreateEnvVarSerializer(serializers.Serializer):
evid = serializers.IntegerField(required=False, help_text="Env var id.")
diff --git a/estela-api/config/celery.py b/estela-api/config/celery.py
index f4fc9d91..9662e253 100644
--- a/estela-api/config/celery.py
+++ b/estela-api/config/celery.py
@@ -27,6 +27,10 @@
"task": "core.tasks.update_mongodb_insertion_progress",
"schedule": 60,
},
+ "update-deploy-stages": {
+ "task": "core.tasks.update_deploy_stages",
+ "schedule": 15,
+ },
}
diff --git a/estela-api/core/tasks.py b/estela-api/core/tasks.py
index 654bd053..4e94be05 100644
--- a/estela-api/core/tasks.py
+++ b/estela-api/core/tasks.py
@@ -20,6 +20,7 @@
from config.job_manager import job_manager, spiderdata_db_client
from core.models import (
DataStatus,
+ Deploy,
Permission,
Project,
ProxyProvider,
@@ -587,5 +588,76 @@ def update_mongodb_insertion_progress():
logging.info(f"Job {job.jid} excluded after {stall_count} cycles with no progress")
except Exception as e:
logging.error(f"Error updating progress for job {job.jid}: {str(e)}")
-
+
logging.info(f"Completed MongoDB insertion progress updates")
+
+
+@celery_app.task(name="core.tasks.update_deploy_stages")
+def update_deploy_stages():
+ deploys = Deploy.objects.filter(status=Deploy.BUILDING_STATUS)[:50]
+
+ if not deploys:
+ return
+
+ try:
+ config.load_incluster_config()
+ core_api = client.CoreV1Api()
+ batch_api = client.BatchV1Api()
+ namespace = getattr(settings, "K8S_NAMESPACE", "default")
+ except Exception:
+ return
+
+ DOWNLOADING = "DOWNLOADING"
+ BUILDING = "BUILDING"
+
+ try:
+ redis_conn = redis.from_url(settings.REDIS_URL)
+ except Exception:
+ redis_conn = None
+
+ active_ids = set()
+ for deploy in deploys:
+ active_ids.add(deploy.did)
+ job_name = f"deploy-project-{deploy.did}"
+ try:
+ batch_api.read_namespaced_job(job_name, namespace)
+ pods = core_api.list_namespaced_pod(
+ namespace, label_selector=f"job-name={job_name}"
+ )
+ if not pods.items:
+ if redis_conn:
+ redis_conn.delete(f"deploy_stage:{deploy.did}")
+ continue
+
+ pod = pods.items[0]
+ pod_status = pod.status
+ init_statuses = pod_status.init_container_statuses or []
+ new_stage = None
+
+ if init_statuses:
+ for i, ics in enumerate(init_statuses):
+ if ics.state and (ics.state.running or ics.state.waiting):
+ new_stage = DOWNLOADING if i == 0 else BUILDING
+ break
+
+ if redis_conn:
+ if new_stage:
+ redis_conn.set(f"deploy_stage:{deploy.did}", new_stage, ex=300)
+ else:
+ redis_conn.delete(f"deploy_stage:{deploy.did}")
+ except Exception:
+ pass
+
+ # Clean up Redis keys for deploys that are no longer BUILDING
+ if redis_conn:
+ try:
+ existing_keys = redis_conn.keys("deploy_stage:*")
+ for key in existing_keys:
+ try:
+ did = int(key.decode().split(":")[1])
+ if did not in active_ids:
+ redis_conn.delete(key)
+ except (ValueError, IndexError):
+ pass
+ except Exception:
+ pass
diff --git a/estela-api/core/views.py b/estela-api/core/views.py
index 1c679ad2..ee41b4bb 100644
--- a/estela-api/core/views.py
+++ b/estela-api/core/views.py
@@ -8,6 +8,7 @@
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from rest_framework.authtoken.models import Token
+import redis as redis_lib
def launch_deploy_job(pid, did, container_image):
@@ -42,6 +43,12 @@ def launch_deploy_job(pid, did, container_image):
isbuild=True, # Triggers Kaniko 3-container pipeline
)
+ try:
+ redis_conn = redis_lib.from_url(settings.REDIS_URL)
+ redis_conn.set(f"deploy_stage:{did}", "DOWNLOADING", ex=300)
+ except Exception:
+ pass
+
def send_verification_email(user, request):
mail_subject = "Activate your estela account."
diff --git a/estela-web/src/pages/DeployListPage/index.tsx b/estela-web/src/pages/DeployListPage/index.tsx
index 1a4aa75e..6052e172 100644
--- a/estela-web/src/pages/DeployListPage/index.tsx
+++ b/estela-web/src/pages/DeployListPage/index.tsx
@@ -10,9 +10,10 @@ import WelcomeDeploy from "../../assets/images/welcomeDeploy.svg";
import "./styles.scss";
import { API_BASE_URL } from "../../constants";
import { ApiService, AuthService } from "../../services";
-import { ApiProjectsDeploysListRequest, Deploy, UserDetail } from "../../services/api";
+import { ApiProjectsDeploysListRequest, Deploy, DeployStatusEnum, UserDetail } from "../../services/api";
import { resourceNotAllowedNotification, Spin, PaginationItem } from "../../shared";
import { convertDateToString } from "../../utils";
+import { TourStore } from "../../tour";
const { Content } = Layout;
const { Text, Paragraph } = Typography;
@@ -29,6 +30,52 @@ interface RouteParams {
projectId: string;
}
+const STAGE_LABELS: Record = {
+ DOWNLOADING: "Downloading project",
+ BUILDING: "Building image",
+};
+
+const STAGE_STEP: Record = {
+ DOWNLOADING: 1,
+ BUILDING: 2,
+};
+
+const DeployStageProgress = ({ stage }: { stage: string }) => {
+ const label = STAGE_LABELS[stage] || stage;
+ const stepIndex = STAGE_STEP[stage] || 1;
+
+ return (
+
+
+
+
+ {label} · step {stepIndex} of 2
+
+
+
+ {[1, 2].map((step) => (
+
+ ))}
+
+
+ );
+};
+
+const ACTIVE_STAGES = [DeployStatusEnum.Downloading, DeployStatusEnum.Building, DeployStatusEnum.Deploying];
+
export class DeployListPage extends Component, DeployListPageState> {
PAGE_SIZE = 10;
state: DeployListPageState = {
@@ -83,8 +130,8 @@ export class DeployListPage extends Component,
dataIndex: "status",
render: (state: string): ReactElement => (
- {state === "BUILDING" ? (
- Waiting
+ {ACTIVE_STAGES.includes(state as DeployStatusEnum) ? (
+
) : state === "SUCCESS" ? (
Completed
) : (
@@ -98,6 +145,7 @@ export class DeployListPage extends Component,
];
async componentDidMount(): Promise {
+ TourStore.setRoute("deploys");
await this.getProjectDeploys(1);
}
@@ -132,6 +180,7 @@ export class DeployListPage extends Component,
loaded: true,
modalIsOpen: results.count === 0,
});
+ TourStore.setDeploys(deploys);
},
(error: unknown) => {
error;
diff --git a/estela-web/src/pages/DeployListPage/styles.scss b/estela-web/src/pages/DeployListPage/styles.scss
index c72a36ac..a4ed3d7e 100644
--- a/estela-web/src/pages/DeployListPage/styles.scss
+++ b/estela-web/src/pages/DeployListPage/styles.scss
@@ -4,4 +4,24 @@
.ant-table-thead .ant-table-cell {
background-color: white;
border:none;
+}
+
+.deploy-stage-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background-color: #4D47C3;
+ animation: pulseDot 2s infinite;
+ flex-shrink: 0;
+}
+
+@keyframes pulseDot {
+ 0% { box-shadow: 0 0 0 0 rgba(77,71,195,0.55); }
+ 70% { box-shadow: 0 0 0 6px rgba(77,71,195,0); }
+ 100% { box-shadow: 0 0 0 0 rgba(77,71,195,0); }
+}
+
+@keyframes shimmer {
+ 0% { background-position: -120px 0; }
+ 100% { background-position: 120px 0; }
}
\ No newline at end of file
diff --git a/estela-web/src/pages/JobCreateModal/index.tsx b/estela-web/src/pages/JobCreateModal/index.tsx
index a3f33b7e..563d5aa5 100644
--- a/estela-web/src/pages/JobCreateModal/index.tsx
+++ b/estela-web/src/pages/JobCreateModal/index.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from "react";
+import React, { useState, useEffect, useRef } from "react";
import { Modal, Button, message, Row, Select, Space, Input, Tag, Checkbox, Tooltip } from "antd";
import { EyeInvisibleOutlined } from "@ant-design/icons";
import type { CheckboxChangeEvent } from "antd/es/checkbox";
@@ -20,6 +20,7 @@ import { ProxySettings } from "../../components/ProxySettingsPage";
import { resourceNotAllowedNotification, invalidDataNotification, incorrectDataNotification } from "../../shared";
import { DEFAULT_RESOURCE_TIER, PREDEFINED_TIERS } from "../../constants";
import { checkExternalError } from "../../defaultComponents";
+import { TourStore } from "../../tour";
import Run from "../../assets/icons/play.svg";
import Add from "../../assets/icons/add.svg";
@@ -153,6 +154,11 @@ export default function JobCreateModal({
pid: projectId,
sid: "",
});
+ const runBtnRef = useRef(null);
+
+ useEffect(() => {
+ TourStore.setRunButtonEl(runBtnRef.current);
+ }, []);
// MaskedTag component was replaced with inline implementation using EyeInvisibleOutlined
@@ -460,6 +466,8 @@ export default function JobCreateModal({
};
apiService.apiProjectsSpidersJobsCreate(requests).then(
(response: SpiderJobCreate) => {
+ TourStore.markStepSeen("step-3");
+ sessionStorage.setItem("tour_just_created", "true");
setLoading(false);
// Close the modal first if an onClose callback is provided
if (onClose) {
@@ -552,26 +560,37 @@ export default function JobCreateModal({
}
}, [projectEnvVars, spiderEnvVars, jobData]);
+ useEffect(() => {
+ TourStore.setNewJobModalOpen(open);
+ return () => {
+ TourStore.setNewJobModalOpen(false);
+ };
+ }, [open]);
+
return (
<>
{!hideRunButton && (
- }
- size="large"
- className="flex items-center stroke-white border-estela hover:stroke-estela bg-estela text-white hover:text-estela text-sm hover:border-estela rounded-md"
- onClick={() => {
- if (spiders.length == 0) {
- message.error("No spiders found. Please make a new deploy.");
- history.push(`/projects/${projectId}/deploys`);
- } else {
- setOpen(true);
- }
- }}
- loading={isLoadingSpiders}
- disabled={isLoadingSpiders}
- >
- {isLoadingSpiders ? "Loading spiders..." : "Run new job"}
-
+
+ }
+ size="large"
+ className="flex items-center stroke-white border-estela hover:stroke-estela bg-estela text-white hover:text-estela text-sm hover:border-estela rounded-md"
+ onClick={() => {
+ if (spiders.length == 0) {
+ message.error("No spiders found. Please make a new deploy.");
+ history.push(`/projects/${projectId}/deploys`);
+ } else {
+ setOpen(true);
+ TourStore.markStepSeen("step-2");
+ TourStore.markOpenedRunModal();
+ }
+ }}
+ loading={isLoadingSpiders}
+ disabled={isLoadingSpiders}
+ >
+ {isLoadingSpiders ? "Loading spiders..." : "Run new job"}
+
+
)}
{externalComponent}
NEW JOB
}
footer={null}
>
-
+
Spider
-
-
-
-
- Storage
-
-
-
- {`${formatBytes(storageSize).quantity} ${formatBytes(storageSize).type}`}
-
-
-
- of project
-
-
-
-
-
-
-
-
-
- Item Count
-
-
-
- {itemCountInRedis || 0}
-
-
-
- this job
-
-
-
- 98 ? 100 : lifespanPercentage}%`,
- }}
- >
-
-
-
-
-
- Proc. Time
-
-
-
- {durationToString(lifespan || 0)}
-
-
-
- this job
-
-
-
- 98 ? 100 : lifespanPercentage}%`,
- }}
- >
-
-
-
-
-
-
+
Fields
+
+
+ downloadMenu(
+ "items",
+ this.projectId,
+ this.spiderId,
+ String(this.jobId),
+ (v: boolean) => this.setState({ loadedDownloadButton: v }),
+ )
+ }
+ >
+ }
+ className="float-right flex items-center mr-2 stroke-white border-estela hover:stroke-estela bg-estela text-white hover:text-estela text-sm hover:border-estela rounded-md"
+ >
+ Download items
+
+
+
diff --git a/estela-web/src/pages/ProjectCronJobListPage/index.tsx b/estela-web/src/pages/ProjectCronJobListPage/index.tsx
index 76f5395c..fa0d1b3e 100644
--- a/estela-web/src/pages/ProjectCronJobListPage/index.tsx
+++ b/estela-web/src/pages/ProjectCronJobListPage/index.tsx
@@ -25,6 +25,7 @@ import {
} from "../../shared";
import CronjobCreateModal from "../CronjobCreateModal";
import { convertDateToString } from "../../utils";
+import { TourStore } from "../../tour";
const { Content } = Layout;
@@ -208,6 +209,8 @@ export class ProjectCronJobListPage extends Component {
+ TourStore.setRoute("cronjobs");
+ TourStore.markVisitedSchedule();
const requestParams: ApiProjectsReadRequest = { pid: this.projectId };
this.apiService.apiProjectsRead(requestParams).then(
(response: Project) => {
diff --git a/estela-web/src/pages/ProjectJobListPage/index.tsx b/estela-web/src/pages/ProjectJobListPage/index.tsx
index 2b6f8f7d..8d7ed737 100644
--- a/estela-web/src/pages/ProjectJobListPage/index.tsx
+++ b/estela-web/src/pages/ProjectJobListPage/index.tsx
@@ -18,6 +18,7 @@ import {
} from "../../services/api";
import { resourceNotAllowedNotification, Spin, PaginationItem, RouteParams } from "../../shared";
import { convertDateToString } from "../../utils";
+import { TourStore } from "../../tour";
const { Content } = Layout;
const { Text } = Typography;
@@ -191,6 +192,8 @@ export class ProjectJobListPage extends Component {
+ TourStore.setRoute("jobs");
+ TourStore.markVisitedJobsOverview();
const requestParams: ApiProjectsReadRequest = { pid: this.projectId };
this.apiService.apiProjectsRead(requestParams).then(
(response: Project) => {
@@ -260,6 +263,10 @@ export class ProjectJobListPage extends Component 0) {
+ TourStore.setHasAnyJobs(true);
+ }
});
};
diff --git a/estela-web/src/pages/SpiderDetailPage/index.tsx b/estela-web/src/pages/SpiderDetailPage/index.tsx
index bcdcb717..e493f356 100644
--- a/estela-web/src/pages/SpiderDetailPage/index.tsx
+++ b/estela-web/src/pages/SpiderDetailPage/index.tsx
@@ -40,6 +40,7 @@ import {
SpiderJobEnvVar,
} from "../../services/api";
import { resourceNotAllowedNotification, Spin, PaginationItem } from "../../shared";
+import { TourStore } from "../../tour";
import { convertDateToString, handleInvalidDataError } from "../../utils";
const { Content } = Layout;
@@ -202,6 +203,7 @@ export class SpiderDetailPage extends Component
];
async componentDidMount(): Promise {
+ TourStore.setRoute("spider-detail");
const requestParams: ApiProjectsSpidersReadRequest = { pid: this.projectId, sid: parseInt(this.spiderId) };
this.apiService.apiProjectsSpidersRead(requestParams).then(
async (response: Spider) => {
diff --git a/estela-web/src/pages/SpiderListPage/index.tsx b/estela-web/src/pages/SpiderListPage/index.tsx
index 2d41b5c5..80749e54 100644
--- a/estela-web/src/pages/SpiderListPage/index.tsx
+++ b/estela-web/src/pages/SpiderListPage/index.tsx
@@ -8,6 +8,7 @@ import "./styles.scss";
import { ApiService } from "../../services";
import { ApiProjectsSpidersListRequest, Spider } from "../../services/api";
import { resourceNotAllowedNotification, Spin, PaginationItem } from "../../shared";
+import { TourStore } from "../../tour";
import { ColumnsType } from "antd/lib/table";
import moment from "moment";
@@ -47,6 +48,7 @@ export class SpiderListPage extends Component,
projectId: string = this.props.match.params.projectId;
async componentDidMount(): Promise {
+ TourStore.setRoute("spiders");
await this.getProjectSpiders(1);
}
diff --git a/estela-web/src/services/api/generated-api/models/Deploy.ts b/estela-web/src/services/api/generated-api/models/Deploy.ts
index cd548203..650c35dc 100644
--- a/estela-web/src/services/api/generated-api/models/Deploy.ts
+++ b/estela-web/src/services/api/generated-api/models/Deploy.ts
@@ -69,8 +69,10 @@ export interface Deploy {
* @enum {string}
*/
export enum DeployStatusEnum {
- Success = 'SUCCESS',
+ Downloading = 'DOWNLOADING',
Building = 'BUILDING',
+ Deploying = 'DEPLOYING',
+ Success = 'SUCCESS',
Failure = 'FAILURE',
Canceled = 'CANCELED'
}
diff --git a/estela-web/src/services/api/generated-api/models/SpiderJob.ts b/estela-web/src/services/api/generated-api/models/SpiderJob.ts
index c284c320..adaa7c23 100644
--- a/estela-web/src/services/api/generated-api/models/SpiderJob.ts
+++ b/estela-web/src/services/api/generated-api/models/SpiderJob.ts
@@ -142,6 +142,12 @@ export interface SpiderJob {
* @memberof SpiderJob
*/
resourceTier?: SpiderJobResourceTierEnum;
+ /**
+ * Peak memory usage in bytes during the spider job run.
+ * @type {number}
+ * @memberof SpiderJob
+ */
+ readonly peakMemory?: number;
}
/**
@@ -195,6 +201,7 @@ export function SpiderJobFromJSONTyped(json: any, ignoreDiscriminator: boolean):
'databaseInsertionProgress': !exists(json, 'database_insertion_progress') ? undefined : json['database_insertion_progress'],
'storageSize': !exists(json, 'storage_size') ? undefined : json['storage_size'],
'resourceTier': !exists(json, 'resource_tier') ? undefined : json['resource_tier'],
+ 'peakMemory': !exists(json, 'peak_memory') ? undefined : json['peak_memory'],
};
}
diff --git a/estela-web/src/shared/layouts/ProjectLayout.tsx b/estela-web/src/shared/layouts/ProjectLayout.tsx
index 5b1dab71..04d16327 100644
--- a/estela-web/src/shared/layouts/ProjectLayout.tsx
+++ b/estela-web/src/shared/layouts/ProjectLayout.tsx
@@ -3,6 +3,7 @@ import { useParams, useLocation } from "react-router-dom";
import { Layout } from "antd";
import { Header, ProjectSideNav } from "..";
import ExternalVerifier from "ExternalComponents/ExternalVerifier";
+import { TourOverlay } from "../../tour";
interface RouteParams {
projectId: string;
@@ -42,6 +43,7 @@ export const ProjectLayout: React.FC = ({ children }) => {
{children}
+
);
};
diff --git a/estela-web/src/shared/projectSideNav/index.tsx b/estela-web/src/shared/projectSideNav/index.tsx
index 7d2ab241..5cc4b547 100644
--- a/estela-web/src/shared/projectSideNav/index.tsx
+++ b/estela-web/src/shared/projectSideNav/index.tsx
@@ -49,7 +49,10 @@ export const ProjectSideNav: React.FC = ({ project
{
key: "jobs",
label: (
-
+
updatePath("jobs")}>
Overview
@@ -59,7 +62,10 @@ export const ProjectSideNav: React.FC = ({ project
{
key: "cronjobs",
label: (
-
+
updatePath("cronjobs")}>
Schedule
diff --git a/estela-web/src/tour/TourOverlay.scss b/estela-web/src/tour/TourOverlay.scss
new file mode 100644
index 00000000..3ea56558
--- /dev/null
+++ b/estela-web/src/tour/TourOverlay.scss
@@ -0,0 +1,166 @@
+.tour-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 10000;
+}
+
+.tour-overlay-clickable {
+ cursor: pointer;
+}
+
+.tour-spotlight {
+ position: fixed;
+ background: transparent;
+ border: 2px solid #4d47c3;
+ border-radius: 6px;
+ box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.55);
+ z-index: 10001;
+ pointer-events: none;
+ transition: top 0.15s ease, left 0.15s ease, width 0.15s ease, height 0.15s ease;
+ animation: tourFadeIn 0.3s ease;
+}
+
+.tour-tooltip {
+ position: fixed;
+ z-index: 15000;
+ width: 320px;
+ background: white;
+ border-radius: 12px;
+ padding: 24px;
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.18);
+ pointer-events: auto;
+ animation: tourFadeIn 0.3s ease;
+}
+
+.tour-tooltip-arrow {
+ position: absolute;
+ width: 12px;
+ height: 12px;
+ background: white;
+ transform: rotate(45deg);
+}
+
+.tour-tooltip-arrow-top {
+ bottom: -6px;
+ left: 24px;
+ box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.06);
+}
+
+.tour-tooltip-arrow-bottom {
+ top: -6px;
+ left: 24px;
+ box-shadow: -4px -4px 8px rgba(0, 0, 0, 0.06);
+}
+
+.tour-tooltip-arrow-left {
+ right: -6px;
+ top: 24px;
+ box-shadow: 4px -4px 8px rgba(0, 0, 0, 0.06);
+}
+
+.tour-tooltip-arrow-right {
+ left: -6px;
+ top: 24px;
+ box-shadow: -4px 4px 8px rgba(0, 0, 0, 0.06);
+}
+
+.tour-progress-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: #eef2ff;
+ color: #4d47c3;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.5px;
+ padding: 4px 10px;
+ border-radius: 20px;
+ margin-bottom: 4px;
+}
+
+.tour-progress-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #4d47c3;
+}
+
+.tour-progress-dot-inactive {
+ background: #9ca3af;
+}
+
+.tour-subtag {
+ font-size: 12px;
+ color: #6b7280;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ font-weight: 500;
+ margin-bottom: 8px;
+}
+
+.tour-title {
+ font-size: 18px;
+ font-weight: 700;
+ color: #111827;
+ margin-bottom: 12px;
+ line-height: 1.3;
+}
+
+.tour-description {
+ font-size: 14px;
+ color: #4b5563;
+ line-height: 1.6;
+ margin-bottom: 20px;
+}
+
+.tour-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.tour-btn-primary {
+ background: #4d47c3;
+ color: white;
+ border: none;
+ padding: 8px 24px;
+ border-radius: 8px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.tour-btn-primary:hover {
+ background: #3b35a0;
+}
+
+.tour-btn-primary-green {
+ background: #16a34a;
+}
+
+.tour-btn-primary-green:hover {
+ background: #15803d;
+}
+
+.tour-btn-secondary {
+ background: none;
+ color: #6b7280;
+ border: none;
+ font-size: 13px;
+ cursor: pointer;
+ padding: 4px 8px;
+}
+
+.tour-btn-secondary:hover {
+ color: #374151;
+}
+
+@keyframes tourFadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
diff --git a/estela-web/src/tour/TourOverlay.tsx b/estela-web/src/tour/TourOverlay.tsx
new file mode 100644
index 00000000..c5ea1a95
--- /dev/null
+++ b/estela-web/src/tour/TourOverlay.tsx
@@ -0,0 +1,310 @@
+import React, { useEffect, useState, useCallback, useRef } from "react";
+import { TourStep } from "./types";
+import { TourStore } from "./store";
+import { findNextStep } from "./steps";
+import "./TourOverlay.scss";
+
+interface Rect {
+ top: number;
+ left: number;
+ width: number;
+ height: number;
+ right: number;
+ bottom: number;
+}
+
+type Placement = "right" | "left" | "top" | "bottom";
+
+function computeTooltipStyle(
+ targetRect: Rect,
+ placement: Placement,
+): { top: number; left: number; arrowClass: string } {
+ const GAP = 12;
+ const TW = 320;
+ const TH = 280;
+ const viewW = window.innerWidth;
+ const viewH = window.innerHeight;
+
+ let top: number;
+ let left: number;
+ let arrowClass: string;
+
+ const fitsRight = targetRect.right + GAP + TW <= viewW - 16;
+ const fitsLeft = targetRect.left - GAP - TW >= 16;
+ const fitsBottom = targetRect.bottom + GAP + TH <= viewH - 16;
+ const fitsTop = targetRect.top - GAP - TH >= 16;
+
+ let finalPlacement = placement;
+
+ if (placement === "right" && !fitsRight) {
+ finalPlacement = fitsLeft ? "left" : fitsBottom ? "bottom" : "top";
+ } else if (placement === "left" && !fitsLeft) {
+ finalPlacement = fitsRight ? "right" : fitsBottom ? "bottom" : "top";
+ } else if (placement === "top" && !fitsTop) {
+ finalPlacement = fitsBottom ? "bottom" : fitsLeft ? "left" : "right";
+ } else if (placement === "bottom" && !fitsBottom) {
+ finalPlacement = fitsTop ? "top" : fitsLeft ? "left" : "right";
+ }
+
+ switch (finalPlacement) {
+ case "right":
+ top = targetRect.top + targetRect.height / 2 - TH / 2;
+ left = targetRect.right + GAP;
+ top = Math.max(16, Math.min(top, viewH - TH - 16));
+ arrowClass = "tour-tooltip-arrow-left";
+ break;
+ case "left":
+ top = targetRect.top + targetRect.height / 2 - TH / 2;
+ left = targetRect.left - TW - GAP;
+ top = Math.max(16, Math.min(top, viewH - TH - 16));
+ arrowClass = "tour-tooltip-arrow-right";
+ break;
+ case "bottom":
+ top = targetRect.bottom + GAP;
+ left = targetRect.left + targetRect.width / 2 - TW / 2;
+ left = Math.max(16, Math.min(left, viewW - TW - 16));
+ arrowClass = "tour-tooltip-arrow-top";
+ break;
+ case "top":
+ top = targetRect.top - TH - GAP;
+ left = targetRect.left + targetRect.width / 2 - TW / 2;
+ left = Math.max(16, Math.min(left, viewW - TW - 16));
+ arrowClass = "tour-tooltip-arrow-bottom";
+ break;
+ }
+
+ return { top: Math.round(top), left: Math.round(left), arrowClass };
+}
+
+function renderDots(total: number, seen: number): React.ReactNode {
+ return Array.from({ length: total }, (_, i) => (
+
+ ));
+}
+
+export const TourOverlay: React.FC = () => {
+ const [activeStep, setActiveStep] = useState(null);
+ const [targetRect, setTargetRect] = useState(null);
+ const [visible, setVisible] = useState(false);
+ const delayRef = useRef | null>(null);
+ const retryRef = useRef | null>(null);
+ const activeStepRef = useRef(null);
+ const prevStepIdRef = useRef(null);
+ const [totalSeen, setTotalSeen] = useState(0);
+
+ useEffect(() => {
+ activeStepRef.current = activeStep;
+ }, [activeStep]);
+
+ const evaluate = useCallback(() => {
+ TourStore.init();
+ const ctx = TourStore.getCtx();
+ setTotalSeen(ctx.seenSteps.length);
+ const step = findNextStep(ctx);
+ setActiveStep(step);
+ }, []);
+
+ const findTarget = useCallback((selector: string): HTMLElement | null => {
+ // Prefer direct ref for run-new-job target
+ if (selector === '[data-tour-target="run-new-job"]') {
+ return TourStore.getRunButtonEl();
+ }
+ return document.querySelector(selector) as HTMLElement | null;
+ }, []);
+
+ const positionSpotlight = useCallback((step: TourStep, el: HTMLElement) => {
+ const measureAndShow = () => {
+ const r = el.getBoundingClientRect();
+ setTargetRect({
+ top: r.top,
+ left: r.left,
+ width: r.width,
+ height: r.height,
+ right: r.right,
+ bottom: r.bottom,
+ });
+ setVisible(true);
+ };
+ if (step.delayMs && step.delayMs > 0) {
+ if (delayRef.current) clearTimeout(delayRef.current);
+ delayRef.current = setTimeout(measureAndShow, step.delayMs);
+ } else {
+ requestAnimationFrame(measureAndShow);
+ }
+ }, []);
+
+ const measureAndShow = useCallback(() => {
+ // Clear any pending retry interval and delay
+ if (retryRef.current) {
+ clearInterval(retryRef.current);
+ retryRef.current = null;
+ }
+ if (delayRef.current) {
+ clearTimeout(delayRef.current);
+ delayRef.current = null;
+ }
+
+ const step = findNextStep(TourStore.getCtx());
+
+ // Hide spotlight when step changes to prevent flash at old position
+ if (step?.id !== prevStepIdRef.current) {
+ setVisible(false);
+ setTargetRect(null);
+ }
+ prevStepIdRef.current = step?.id || null;
+
+ setActiveStep(step);
+ setTotalSeen(TourStore.getCtx().seenSteps.length);
+
+ if (!step) {
+ setVisible(false);
+ return;
+ }
+
+ const el = findTarget(step.targetSelector);
+ if (!el) {
+ setVisible(false);
+ // Target not in DOM yet — retry every 500ms until it appears
+ retryRef.current = setInterval(() => {
+ const retryEl = findTarget(step.targetSelector);
+ if (retryEl) {
+ const id = retryRef.current;
+ if (id) clearInterval(id);
+ retryRef.current = null;
+ positionSpotlight(step, retryEl);
+ }
+ }, 500);
+ return;
+ }
+
+ positionSpotlight(step, el);
+ }, [positionSpotlight]);
+
+ useEffect(() => {
+ evaluate();
+ measureAndShow();
+
+ const unsub = TourStore.subscribe(() => {
+ evaluate();
+ measureAndShow();
+ });
+
+ const handleResize = () => {
+ const step = activeStepRef.current;
+ if (step) {
+ const el = findTarget(step.targetSelector);
+ if (el) {
+ const r = el.getBoundingClientRect();
+ setTargetRect({
+ top: r.top,
+ left: r.left,
+ width: r.width,
+ height: r.height,
+ right: r.right,
+ bottom: r.bottom,
+ });
+ }
+ }
+ };
+
+ window.addEventListener("resize", handleResize);
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && visible && activeStep) {
+ TourStore.markStepSeen(activeStep.id);
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ unsub();
+ window.removeEventListener("resize", handleResize);
+ window.removeEventListener("keydown", handleKeyDown);
+ if (delayRef.current) clearTimeout(delayRef.current);
+ if (retryRef.current) clearInterval(retryRef.current);
+ };
+ }, []);
+
+ const handleGotIt = () => {
+ if (activeStep) {
+ TourStore.markStepSeen(activeStep.id);
+ if (activeStep.id === "step-4") {
+ TourStore.markVisitedJobDetail();
+ }
+ }
+ };
+
+ const handleSkip = () => {
+ TourStore.skipAll();
+ };
+
+ const handleOverlayClick = () => {
+ if (activeStep) {
+ TourStore.markStepSeen(activeStep.id);
+ }
+ };
+
+ if (!activeStep || !targetRect || !visible) {
+ return null;
+ }
+
+ const hasSpotlight = activeStep.spotlight !== false;
+ const stepNum = parseInt(activeStep.id.split("-")[1], 10);
+ const tooltipPos = computeTooltipStyle(targetRect, activeStep.placement || "right");
+ const pad = activeStep.spotlightPadding || { h: 8, v: 6 };
+
+ return (
+
+ {hasSpotlight && (
+
+ )}
+
+
+
+
+
+ {renderDots(5, totalSeen)}
+ STEP {stepNum} OF 5
+
+
+
{activeStep.content.subtag}
+
{activeStep.content.title}
+
{activeStep.content.description}
+
+
+
+
+
+
+
+ );
+};
+
+export default TourOverlay;
diff --git a/estela-web/src/tour/index.ts b/estela-web/src/tour/index.ts
new file mode 100644
index 00000000..48a63f17
--- /dev/null
+++ b/estela-web/src/tour/index.ts
@@ -0,0 +1,2 @@
+export { TourOverlay } from "./TourOverlay";
+export { TourStore } from "./store";
diff --git a/estela-web/src/tour/steps.ts b/estela-web/src/tour/steps.ts
new file mode 100644
index 00000000..90cccf2e
--- /dev/null
+++ b/estela-web/src/tour/steps.ts
@@ -0,0 +1,121 @@
+import { TourStep, TourContext } from "./types";
+import { TourStore } from "./store";
+
+export const TOUR_STEPS: TourStep[] = [
+ {
+ id: "step-1",
+ targetSelector: '[data-tour-target="jobs-overview"]',
+ placement: "right",
+ spotlight: true,
+ trigger: (ctx: TourContext) => {
+ const isDeploys = ctx.currentRoute === "deploys";
+ const successDeploys = ctx.deploys.filter((d) => d.status === "SUCCESS");
+ const hasExactlyOneSuccess = successDeploys.length === 1;
+ const noJobs = !TourStore.getHasAnyJobs();
+ return isDeploys && hasExactlyOneSuccess && ctx.neverVisitedJobsOverview && noJobs;
+ },
+ content: {
+ tag: "STEP 1 OF 5",
+ subtag: "Getting started",
+ title: "Now run your spiders",
+ description: "Your deploy is live. Head to Jobs → Overview to launch a job and start collecting data.",
+ primaryLabel: "Got it",
+ secondaryLabel: "Skip tour",
+ },
+ },
+ {
+ id: "step-2",
+ targetSelector: '[data-tour-target="run-new-job"]',
+ placement: "bottom",
+ spotlight: true,
+ spotlightPadding: { h: 0, v: 0 },
+ delayMs: 100,
+ trigger: (ctx: TourContext) => {
+ const isJobsOverview = ctx.currentRoute === "jobs";
+ const step1Seen = ctx.seenSteps.includes("step-1");
+ return isJobsOverview && step1Seen && ctx.neverOpenedRunModal;
+ },
+ content: {
+ tag: "STEP 2 OF 5",
+ subtag: "Run a spider",
+ title: "Launch your first job",
+ description: "Click 'Run new job' to choose a spider and start a scraping run.",
+ primaryLabel: "Got it",
+ secondaryLabel: "Skip tour",
+ },
+ },
+ {
+ id: "step-3",
+ targetSelector: '[data-tour-target="spider-field"]',
+ placement: "top",
+ spotlight: true,
+ spotlightPadding: { h: 12, v: 8 },
+ trigger: (ctx: TourContext) => {
+ const step2Seen = ctx.seenSteps.includes("step-2");
+ return ctx.newJobModalOpen && step2Seen;
+ },
+ content: {
+ tag: "STEP 3 OF 5",
+ subtag: "Configuration",
+ title: "Pick a spider — the rest can stay on defaults",
+ description:
+ "Choose which spider to run. The other options (Data persistence, Resource Tier, etc.) work great on their defaults for your first run. When you're ready, hit Create.",
+ primaryLabel: "Got it",
+ secondaryLabel: "Skip tour",
+ },
+ },
+ {
+ id: "step-4",
+ targetSelector: '[data-tour-target="job-stats"]',
+ placement: "bottom",
+ spotlight: true,
+ delayMs: 100,
+ trigger: (ctx: TourContext) => {
+ const isJobDetail = ctx.currentRoute === "job-detail";
+ const step3Seen = ctx.seenSteps.includes("step-3");
+ return isJobDetail && step3Seen && ctx.justCreatedJob && ctx.neverVisitedJobDetail;
+ },
+ content: {
+ tag: "STEP 4 OF 5",
+ subtag: "Track progress",
+ title: "Here's your job — live",
+ description:
+ "This is the job's overview. As your spider runs you'll see bandwidth, item count and memory fill in. When it finishes, your scraped items will be downloadable from here.",
+ primaryLabel: "Got it",
+ secondaryLabel: "Skip tour",
+ },
+ },
+ {
+ id: "step-5",
+ targetSelector: '[data-tour-target="jobs-schedule"]',
+ placement: "right",
+ spotlight: true,
+ delayMs: 1000,
+ trigger: (ctx: TourContext) => {
+ return (
+ ctx.seenSteps.includes("step-1") &&
+ ctx.seenSteps.includes("step-2") &&
+ ctx.seenSteps.includes("step-3") &&
+ ctx.step4Completed &&
+ ctx.neverVisitedSchedule
+ );
+ },
+ content: {
+ tag: "STEP 5 OF 5",
+ subtag: "Power user move",
+ title: "Run jobs on a schedule",
+ description:
+ "Want your spiders to run automatically? Open Jobs → Schedule to set up cron-style schedules so jobs launch without you.",
+ primaryLabel: "All done",
+ secondaryLabel: "Skip tour",
+ },
+ },
+];
+
+export function findNextStep(ctx: TourContext): TourStep | null {
+ for (const step of TOUR_STEPS) {
+ if (ctx.seenSteps.includes(step.id)) continue;
+ if (step.trigger(ctx)) return step;
+ }
+ return null;
+}
diff --git a/estela-web/src/tour/store.ts b/estela-web/src/tour/store.ts
new file mode 100644
index 00000000..71e190c5
--- /dev/null
+++ b/estela-web/src/tour/store.ts
@@ -0,0 +1,186 @@
+import { TourContext, defaultTourContext } from "./types";
+import { AuthService } from "../services";
+import { Deploy } from "../services/api";
+
+type Listener = () => void;
+
+const TOUR_KEY_PREFIX = "estela_tour_";
+
+function getTourKey(): string {
+ const username = AuthService.getUserUsername() || "anonymous";
+ return `${TOUR_KEY_PREFIX}${username}`;
+}
+
+function loadPersisted(): { seenSteps: string[]; lastCompletedStepAt: string | null } {
+ try {
+ const raw = localStorage.getItem(getTourKey());
+ if (raw) return JSON.parse(raw);
+ } catch {
+ // ignore corrupt data
+ }
+ return { seenSteps: [], lastCompletedStepAt: null };
+}
+
+function persistSeen(steps: string[], lastCompletedStepAt?: string) {
+ const data = {
+ seenSteps: steps,
+ lastCompletedStepAt: lastCompletedStepAt || new Date().toISOString(),
+ };
+ localStorage.setItem(getTourKey(), JSON.stringify(data));
+}
+
+// Navigation tracking keys
+const NAV_JOBS_OVERVIEW = `${getTourKey()}_visited_jobs_overview`;
+const NAV_JOB_DETAIL = `${getTourKey()}_visited_job_detail`;
+const NAV_SCHEDULE = `${getTourKey()}_visited_schedule`;
+const OPENED_RUN_MODAL = `${getTourKey()}_opened_run_modal`;
+
+export const TourStore = {
+ _ctx: { ...defaultTourContext } as TourContext,
+ _listeners: new Set(),
+
+ init() {
+ const persisted = loadPersisted();
+ this._ctx.seenSteps = persisted.seenSteps;
+ this._ctx.lastCompletedStepAt = persisted.lastCompletedStepAt;
+ this._ctx.neverVisitedJobsOverview = !localStorage.getItem(NAV_JOBS_OVERVIEW);
+ this._ctx.neverOpenedRunModal = !localStorage.getItem(OPENED_RUN_MODAL);
+ this._ctx.neverVisitedJobDetail = !localStorage.getItem(NAV_JOB_DETAIL);
+ this._ctx.neverVisitedSchedule = !localStorage.getItem(NAV_SCHEDULE);
+
+ const flag = sessionStorage.getItem("tour_just_created");
+ this._ctx.justCreatedJob = flag === "true";
+ },
+
+ getCtx(): TourContext {
+ return this._ctx;
+ },
+
+ update(partial: Partial) {
+ Object.assign(this._ctx, partial);
+ this._notify();
+ },
+
+ setDeploys(deploys: Deploy[]) {
+ this._ctx.deploys = deploys;
+ this._notify();
+ },
+
+ setJobs(jobs: unknown[]) {
+ this._ctx.jobs = jobs;
+ this._notify();
+ },
+
+ setRoute(route: string) {
+ this._ctx.currentRoute = route;
+ this._notify();
+ },
+
+ setNewJobModalOpen(open: boolean) {
+ this._ctx.newJobModalOpen = open;
+ this._notify();
+ },
+
+ markStepSeen(stepId: string) {
+ // Require all previous steps to be completed first
+ const stepOrder = ["step-1", "step-2", "step-3", "step-4", "step-5"];
+ const idx = stepOrder.indexOf(stepId);
+ if (idx === -1) return;
+ for (let i = 0; i < idx; i++) {
+ if (!this._ctx.seenSteps.includes(stepOrder[i])) {
+ return;
+ }
+ }
+ if (!this._ctx.seenSteps.includes(stepId)) {
+ this._ctx.seenSteps = [...this._ctx.seenSteps, stepId];
+ this._ctx.lastCompletedStepAt = new Date().toISOString();
+ persistSeen(this._ctx.seenSteps, this._ctx.lastCompletedStepAt);
+ }
+ // Step-4 specific: flag for step-5 trigger
+ if (stepId === "step-4") {
+ this._ctx.step4Completed = true;
+ }
+ this._notify();
+ },
+
+ skipAll() {
+ const allSteps = ["step-1", "step-2", "step-3", "step-4", "step-5"];
+ this._ctx.seenSteps = allSteps;
+ this._ctx.lastCompletedStepAt = new Date().toISOString();
+ persistSeen(allSteps, this._ctx.lastCompletedStepAt);
+ // Clear session flag
+ sessionStorage.removeItem("tour_just_created");
+ this._notify();
+ },
+
+ markVisitedJobsOverview() {
+ if (!localStorage.getItem(NAV_JOBS_OVERVIEW)) {
+ localStorage.setItem(NAV_JOBS_OVERVIEW, "true");
+ this._ctx.neverVisitedJobsOverview = false;
+ this._notify();
+ }
+ },
+
+ markOpenedRunModal() {
+ if (!localStorage.getItem(OPENED_RUN_MODAL)) {
+ localStorage.setItem(OPENED_RUN_MODAL, "true");
+ this._ctx.neverOpenedRunModal = false;
+ this._notify();
+ }
+ },
+
+ markVisitedJobDetail() {
+ if (!localStorage.getItem(NAV_JOB_DETAIL)) {
+ localStorage.setItem(NAV_JOB_DETAIL, "true");
+ this._ctx.neverVisitedJobDetail = false;
+ this._notify();
+ }
+ },
+
+ markVisitedSchedule() {
+ if (!localStorage.getItem(NAV_SCHEDULE)) {
+ localStorage.setItem(NAV_SCHEDULE, "true");
+ this._ctx.neverVisitedSchedule = false;
+ this._notify();
+ }
+ },
+
+ clearJustCreatedFlag() {
+ this._ctx.justCreatedJob = false;
+ sessionStorage.removeItem("tour_just_created");
+ this._notify();
+ },
+
+ // Direct element refs for reliable spotlight targeting
+ _runBtnEl: null as HTMLElement | null,
+ setRunButtonEl(el: HTMLElement | null) {
+ this._runBtnEl = el;
+ },
+ getRunButtonEl(): HTMLElement | null {
+ return this._runBtnEl;
+ },
+
+ // Track whether user has any spider jobs
+ _hasAnyJobs: false,
+ setHasAnyJobs(val: boolean) {
+ this._hasAnyJobs = val;
+ if (val) {
+ localStorage.setItem(`${getTourKey()}_has_jobs`, "true");
+ }
+ },
+ getHasAnyJobs(): boolean {
+ if (this._hasAnyJobs) return true;
+ return !!localStorage.getItem(`${getTourKey()}_has_jobs`);
+ },
+
+ subscribe(fn: Listener): () => void {
+ this._listeners.add(fn);
+ return () => {
+ this._listeners.delete(fn);
+ };
+ },
+
+ _notify() {
+ this._listeners.forEach((fn) => fn());
+ },
+};
diff --git a/estela-web/src/tour/types.ts b/estela-web/src/tour/types.ts
new file mode 100644
index 00000000..91494b8e
--- /dev/null
+++ b/estela-web/src/tour/types.ts
@@ -0,0 +1,55 @@
+import { Deploy } from "../services/api";
+
+export type TourStepId = "step-1" | "step-2" | "step-3" | "step-4" | "step-5";
+
+export interface TourStepContent {
+ tag: string;
+ subtag: string;
+ title: string;
+ description: string;
+ primaryLabel: string;
+ secondaryLabel: string;
+}
+
+export interface TourStep {
+ id: TourStepId;
+ targetSelector: string;
+ placement?: "right" | "left" | "top" | "bottom";
+ spotlight?: boolean;
+ delayMs?: number;
+ spotlightPadding?: { h: number; v: number };
+ trigger: (ctx: TourContext) => boolean;
+ content: TourStepContent;
+}
+
+export interface TourContext {
+ currentRoute: string;
+ deploys: Deploy[];
+ jobs: unknown[];
+ newJobModalOpen: boolean;
+ seenSteps: string[];
+ lastCompletedStepAt: string | null;
+ /** Set from sessionStorage when arriving at job detail from Create */
+ justCreatedJob: boolean;
+ /** Set when step-4 is completed to trigger step-5 on same page */
+ step4Completed: boolean;
+ neverVisitedJobsOverview: boolean;
+ neverOpenedRunModal: boolean;
+ neverVisitedJobDetail: boolean;
+ neverVisitedSchedule: boolean;
+}
+
+export const defaultTourContext: TourContext = {
+ currentRoute: "",
+ deploys: [],
+ jobs: [],
+ newJobModalOpen: false,
+ seenSteps: [],
+ lastCompletedStepAt: null,
+ justCreatedJob: false,
+ step4Completed: false,
+ neverVisitedJobsOverview: true,
+ neverOpenedRunModal: true,
+ neverVisitedJobDetail: true,
+ neverVisitedSchedule: true,
+};
From 7de9004192cdfa118c2d0126dd902176deb965b8 Mon Sep 17 00:00:00 2001
From: erick-gege
Date: Wed, 20 May 2026 17:34:33 -0500
Subject: [PATCH 2/7] simplify deploy stage tracking and product tour
---
estela-api/api/serializers/deploy.py | 39 +++++++---
estela-api/config/celery.py | 4 --
estela-api/core/tasks.py | 72 -------------------
estela-api/core/views.py | 6 --
estela-web/src/pages/DeployListPage/index.tsx | 19 ++++-
estela-web/src/pages/JobCreateModal/index.tsx | 1 -
.../pages/ProjectCronJobListPage/index.tsx | 1 -
.../src/pages/ProjectJobListPage/index.tsx | 5 --
estela-web/src/tour/TourOverlay.tsx | 3 -
estela-web/src/tour/steps.ts | 32 ++++-----
estela-web/src/tour/store.ts | 70 ++----------------
estela-web/src/tour/types.ts | 13 +---
12 files changed, 69 insertions(+), 196 deletions(-)
diff --git a/estela-api/api/serializers/deploy.py b/estela-api/api/serializers/deploy.py
index 7ce00371..ae2e0e6e 100644
--- a/estela-api/api/serializers/deploy.py
+++ b/estela-api/api/serializers/deploy.py
@@ -1,10 +1,36 @@
from rest_framework import serializers
+from django.conf import settings
from api.serializers.project import UserDetailSerializer
from api.serializers.spider import SpiderSerializer
from core.models import Deploy, Spider
+def _get_deploy_stage(did: int) -> str | None:
+ """Query K8s pod init-container statuses to determine DOWNLOADING vs BUILDING stage."""
+ try:
+ from kubernetes import client, config as k8s_config
+
+ k8s_config.load_incluster_config()
+ core_api = client.CoreV1Api()
+ batch_api = client.BatchV1Api()
+ namespace = getattr(settings, "K8S_NAMESPACE", "default")
+ job_name = f"deploy-project-{did}"
+
+ batch_api.read_namespaced_job(job_name, namespace)
+ pods = core_api.list_namespaced_pod(namespace, label_selector=f"job-name={job_name}")
+ if not pods.items:
+ return None
+
+ init_statuses = pods.items[0].status.init_container_statuses or []
+ for i, ics in enumerate(init_statuses):
+ if ics.state and (ics.state.running or ics.state.waiting):
+ return "DOWNLOADING" if i == 0 else "BUILDING"
+ except Exception:
+ pass
+ return None
+
+
class DeploySerializer(serializers.ModelSerializer):
spiders_count = serializers.SerializerMethodField(help_text="Number of spiders in this deploy.")
user = UserDetailSerializer(
@@ -17,16 +43,9 @@ def get_spiders_count(self, obj):
def to_representation(self, instance):
data = super().to_representation(instance)
if instance.status == Deploy.BUILDING_STATUS:
- try:
- import redis as redis_lib
- from django.conf import settings
-
- redis_conn = redis_lib.from_url(settings.REDIS_URL)
- stage = redis_conn.get(f"deploy_stage:{instance.did}")
- if stage:
- data["status"] = stage.decode()
- except Exception:
- pass
+ stage = _get_deploy_stage(instance.did)
+ if stage:
+ data["status"] = stage
return data
class Meta:
diff --git a/estela-api/config/celery.py b/estela-api/config/celery.py
index 9662e253..f4fc9d91 100644
--- a/estela-api/config/celery.py
+++ b/estela-api/config/celery.py
@@ -27,10 +27,6 @@
"task": "core.tasks.update_mongodb_insertion_progress",
"schedule": 60,
},
- "update-deploy-stages": {
- "task": "core.tasks.update_deploy_stages",
- "schedule": 15,
- },
}
diff --git a/estela-api/core/tasks.py b/estela-api/core/tasks.py
index 4e94be05..d930d47b 100644
--- a/estela-api/core/tasks.py
+++ b/estela-api/core/tasks.py
@@ -20,7 +20,6 @@
from config.job_manager import job_manager, spiderdata_db_client
from core.models import (
DataStatus,
- Deploy,
Permission,
Project,
ProxyProvider,
@@ -590,74 +589,3 @@ def update_mongodb_insertion_progress():
logging.error(f"Error updating progress for job {job.jid}: {str(e)}")
logging.info(f"Completed MongoDB insertion progress updates")
-
-
-@celery_app.task(name="core.tasks.update_deploy_stages")
-def update_deploy_stages():
- deploys = Deploy.objects.filter(status=Deploy.BUILDING_STATUS)[:50]
-
- if not deploys:
- return
-
- try:
- config.load_incluster_config()
- core_api = client.CoreV1Api()
- batch_api = client.BatchV1Api()
- namespace = getattr(settings, "K8S_NAMESPACE", "default")
- except Exception:
- return
-
- DOWNLOADING = "DOWNLOADING"
- BUILDING = "BUILDING"
-
- try:
- redis_conn = redis.from_url(settings.REDIS_URL)
- except Exception:
- redis_conn = None
-
- active_ids = set()
- for deploy in deploys:
- active_ids.add(deploy.did)
- job_name = f"deploy-project-{deploy.did}"
- try:
- batch_api.read_namespaced_job(job_name, namespace)
- pods = core_api.list_namespaced_pod(
- namespace, label_selector=f"job-name={job_name}"
- )
- if not pods.items:
- if redis_conn:
- redis_conn.delete(f"deploy_stage:{deploy.did}")
- continue
-
- pod = pods.items[0]
- pod_status = pod.status
- init_statuses = pod_status.init_container_statuses or []
- new_stage = None
-
- if init_statuses:
- for i, ics in enumerate(init_statuses):
- if ics.state and (ics.state.running or ics.state.waiting):
- new_stage = DOWNLOADING if i == 0 else BUILDING
- break
-
- if redis_conn:
- if new_stage:
- redis_conn.set(f"deploy_stage:{deploy.did}", new_stage, ex=300)
- else:
- redis_conn.delete(f"deploy_stage:{deploy.did}")
- except Exception:
- pass
-
- # Clean up Redis keys for deploys that are no longer BUILDING
- if redis_conn:
- try:
- existing_keys = redis_conn.keys("deploy_stage:*")
- for key in existing_keys:
- try:
- did = int(key.decode().split(":")[1])
- if did not in active_ids:
- redis_conn.delete(key)
- except (ValueError, IndexError):
- pass
- except Exception:
- pass
diff --git a/estela-api/core/views.py b/estela-api/core/views.py
index ee41b4bb..f5f81d23 100644
--- a/estela-api/core/views.py
+++ b/estela-api/core/views.py
@@ -8,7 +8,6 @@
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from rest_framework.authtoken.models import Token
-import redis as redis_lib
def launch_deploy_job(pid, did, container_image):
@@ -43,11 +42,6 @@ def launch_deploy_job(pid, did, container_image):
isbuild=True, # Triggers Kaniko 3-container pipeline
)
- try:
- redis_conn = redis_lib.from_url(settings.REDIS_URL)
- redis_conn.set(f"deploy_stage:{did}", "DOWNLOADING", ex=300)
- except Exception:
- pass
def send_verification_email(user, request):
diff --git a/estela-web/src/pages/DeployListPage/index.tsx b/estela-web/src/pages/DeployListPage/index.tsx
index 6052e172..112bfbd3 100644
--- a/estela-web/src/pages/DeployListPage/index.tsx
+++ b/estela-web/src/pages/DeployListPage/index.tsx
@@ -10,7 +10,13 @@ import WelcomeDeploy from "../../assets/images/welcomeDeploy.svg";
import "./styles.scss";
import { API_BASE_URL } from "../../constants";
import { ApiService, AuthService } from "../../services";
-import { ApiProjectsDeploysListRequest, Deploy, DeployStatusEnum, UserDetail } from "../../services/api";
+import {
+ ApiProjectsDeploysListRequest,
+ ApiProjectsJobsRequest,
+ Deploy,
+ DeployStatusEnum,
+ UserDetail,
+} from "../../services/api";
import { resourceNotAllowedNotification, Spin, PaginationItem } from "../../shared";
import { convertDateToString } from "../../utils";
import { TourStore } from "../../tour";
@@ -181,6 +187,17 @@ export class DeployListPage extends Component,
modalIsOpen: results.count === 0,
});
TourStore.setDeploys(deploys);
+
+ const successCount = deploys.filter((d) => d.status === "SUCCESS").length;
+ if (successCount === 1) {
+ const jobsParams: ApiProjectsJobsRequest = { pid: this.projectId, page: 1, pageSize: 1 };
+ this.apiService
+ .apiProjectsJobs(jobsParams)
+ .then((response) => {
+ TourStore.setProjectHasJobs(response.count > 0);
+ })
+ .catch(() => undefined);
+ }
},
(error: unknown) => {
error;
diff --git a/estela-web/src/pages/JobCreateModal/index.tsx b/estela-web/src/pages/JobCreateModal/index.tsx
index 563d5aa5..a14780a9 100644
--- a/estela-web/src/pages/JobCreateModal/index.tsx
+++ b/estela-web/src/pages/JobCreateModal/index.tsx
@@ -582,7 +582,6 @@ export default function JobCreateModal({
} else {
setOpen(true);
TourStore.markStepSeen("step-2");
- TourStore.markOpenedRunModal();
}
}}
loading={isLoadingSpiders}
diff --git a/estela-web/src/pages/ProjectCronJobListPage/index.tsx b/estela-web/src/pages/ProjectCronJobListPage/index.tsx
index fa0d1b3e..b128bf51 100644
--- a/estela-web/src/pages/ProjectCronJobListPage/index.tsx
+++ b/estela-web/src/pages/ProjectCronJobListPage/index.tsx
@@ -210,7 +210,6 @@ export class ProjectCronJobListPage extends Component {
TourStore.setRoute("cronjobs");
- TourStore.markVisitedSchedule();
const requestParams: ApiProjectsReadRequest = { pid: this.projectId };
this.apiService.apiProjectsRead(requestParams).then(
(response: Project) => {
diff --git a/estela-web/src/pages/ProjectJobListPage/index.tsx b/estela-web/src/pages/ProjectJobListPage/index.tsx
index 8d7ed737..81528dff 100644
--- a/estela-web/src/pages/ProjectJobListPage/index.tsx
+++ b/estela-web/src/pages/ProjectJobListPage/index.tsx
@@ -193,7 +193,6 @@ export class ProjectJobListPage extends Component {
TourStore.setRoute("jobs");
- TourStore.markVisitedJobsOverview();
const requestParams: ApiProjectsReadRequest = { pid: this.projectId };
this.apiService.apiProjectsRead(requestParams).then(
(response: Project) => {
@@ -263,10 +262,6 @@ export class ProjectJobListPage extends Component 0) {
- TourStore.setHasAnyJobs(true);
- }
});
};
diff --git a/estela-web/src/tour/TourOverlay.tsx b/estela-web/src/tour/TourOverlay.tsx
index c5ea1a95..8f655741 100644
--- a/estela-web/src/tour/TourOverlay.tsx
+++ b/estela-web/src/tour/TourOverlay.tsx
@@ -229,9 +229,6 @@ export const TourOverlay: React.FC = () => {
const handleGotIt = () => {
if (activeStep) {
TourStore.markStepSeen(activeStep.id);
- if (activeStep.id === "step-4") {
- TourStore.markVisitedJobDetail();
- }
}
};
diff --git a/estela-web/src/tour/steps.ts b/estela-web/src/tour/steps.ts
index 90cccf2e..5858f38b 100644
--- a/estela-web/src/tour/steps.ts
+++ b/estela-web/src/tour/steps.ts
@@ -1,5 +1,4 @@
import { TourStep, TourContext } from "./types";
-import { TourStore } from "./store";
export const TOUR_STEPS: TourStep[] = [
{
@@ -11,8 +10,9 @@ export const TOUR_STEPS: TourStep[] = [
const isDeploys = ctx.currentRoute === "deploys";
const successDeploys = ctx.deploys.filter((d) => d.status === "SUCCESS");
const hasExactlyOneSuccess = successDeploys.length === 1;
- const noJobs = !TourStore.getHasAnyJobs();
- return isDeploys && hasExactlyOneSuccess && ctx.neverVisitedJobsOverview && noJobs;
+ const noJobs = !ctx.projectHasJobs;
+ const notSeen = !ctx.seenSteps.includes("step-1");
+ return isDeploys && hasExactlyOneSuccess && noJobs && notSeen;
},
content: {
tag: "STEP 1 OF 5",
@@ -31,9 +31,7 @@ export const TOUR_STEPS: TourStep[] = [
spotlightPadding: { h: 0, v: 0 },
delayMs: 100,
trigger: (ctx: TourContext) => {
- const isJobsOverview = ctx.currentRoute === "jobs";
- const step1Seen = ctx.seenSteps.includes("step-1");
- return isJobsOverview && step1Seen && ctx.neverOpenedRunModal;
+ return ctx.currentRoute === "jobs" && ctx.seenSteps.includes("step-1") && !ctx.seenSteps.includes("step-2");
},
content: {
tag: "STEP 2 OF 5",
@@ -50,9 +48,9 @@ export const TOUR_STEPS: TourStep[] = [
placement: "top",
spotlight: true,
spotlightPadding: { h: 12, v: 8 },
+ delayMs: 500,
trigger: (ctx: TourContext) => {
- const step2Seen = ctx.seenSteps.includes("step-2");
- return ctx.newJobModalOpen && step2Seen;
+ return ctx.newJobModalOpen && ctx.seenSteps.includes("step-2") && !ctx.seenSteps.includes("step-3");
},
content: {
tag: "STEP 3 OF 5",
@@ -71,9 +69,12 @@ export const TOUR_STEPS: TourStep[] = [
spotlight: true,
delayMs: 100,
trigger: (ctx: TourContext) => {
- const isJobDetail = ctx.currentRoute === "job-detail";
- const step3Seen = ctx.seenSteps.includes("step-3");
- return isJobDetail && step3Seen && ctx.justCreatedJob && ctx.neverVisitedJobDetail;
+ return (
+ ctx.currentRoute === "job-detail" &&
+ ctx.seenSteps.includes("step-3") &&
+ !ctx.seenSteps.includes("step-4") &&
+ ctx.justCreatedJob
+ );
},
content: {
tag: "STEP 4 OF 5",
@@ -92,13 +93,7 @@ export const TOUR_STEPS: TourStep[] = [
spotlight: true,
delayMs: 1000,
trigger: (ctx: TourContext) => {
- return (
- ctx.seenSteps.includes("step-1") &&
- ctx.seenSteps.includes("step-2") &&
- ctx.seenSteps.includes("step-3") &&
- ctx.step4Completed &&
- ctx.neverVisitedSchedule
- );
+ return ctx.step4Completed && !ctx.seenSteps.includes("step-5");
},
content: {
tag: "STEP 5 OF 5",
@@ -114,7 +109,6 @@ export const TOUR_STEPS: TourStep[] = [
export function findNextStep(ctx: TourContext): TourStep | null {
for (const step of TOUR_STEPS) {
- if (ctx.seenSteps.includes(step.id)) continue;
if (step.trigger(ctx)) return step;
}
return null;
diff --git a/estela-web/src/tour/store.ts b/estela-web/src/tour/store.ts
index 71e190c5..2becb967 100644
--- a/estela-web/src/tour/store.ts
+++ b/estela-web/src/tour/store.ts
@@ -29,12 +29,6 @@ function persistSeen(steps: string[], lastCompletedStepAt?: string) {
localStorage.setItem(getTourKey(), JSON.stringify(data));
}
-// Navigation tracking keys
-const NAV_JOBS_OVERVIEW = `${getTourKey()}_visited_jobs_overview`;
-const NAV_JOB_DETAIL = `${getTourKey()}_visited_job_detail`;
-const NAV_SCHEDULE = `${getTourKey()}_visited_schedule`;
-const OPENED_RUN_MODAL = `${getTourKey()}_opened_run_modal`;
-
export const TourStore = {
_ctx: { ...defaultTourContext } as TourContext,
_listeners: new Set(),
@@ -43,10 +37,6 @@ export const TourStore = {
const persisted = loadPersisted();
this._ctx.seenSteps = persisted.seenSteps;
this._ctx.lastCompletedStepAt = persisted.lastCompletedStepAt;
- this._ctx.neverVisitedJobsOverview = !localStorage.getItem(NAV_JOBS_OVERVIEW);
- this._ctx.neverOpenedRunModal = !localStorage.getItem(OPENED_RUN_MODAL);
- this._ctx.neverVisitedJobDetail = !localStorage.getItem(NAV_JOB_DETAIL);
- this._ctx.neverVisitedSchedule = !localStorage.getItem(NAV_SCHEDULE);
const flag = sessionStorage.getItem("tour_just_created");
this._ctx.justCreatedJob = flag === "true";
@@ -66,11 +56,6 @@ export const TourStore = {
this._notify();
},
- setJobs(jobs: unknown[]) {
- this._ctx.jobs = jobs;
- this._notify();
- },
-
setRoute(route: string) {
this._ctx.currentRoute = route;
this._notify();
@@ -81,8 +66,12 @@ export const TourStore = {
this._notify();
},
+ setProjectHasJobs(val: boolean) {
+ this._ctx.projectHasJobs = val;
+ this._notify();
+ },
+
markStepSeen(stepId: string) {
- // Require all previous steps to be completed first
const stepOrder = ["step-1", "step-2", "step-3", "step-4", "step-5"];
const idx = stepOrder.indexOf(stepId);
if (idx === -1) return;
@@ -96,7 +85,6 @@ export const TourStore = {
this._ctx.lastCompletedStepAt = new Date().toISOString();
persistSeen(this._ctx.seenSteps, this._ctx.lastCompletedStepAt);
}
- // Step-4 specific: flag for step-5 trigger
if (stepId === "step-4") {
this._ctx.step4Completed = true;
}
@@ -108,50 +96,17 @@ export const TourStore = {
this._ctx.seenSteps = allSteps;
this._ctx.lastCompletedStepAt = new Date().toISOString();
persistSeen(allSteps, this._ctx.lastCompletedStepAt);
- // Clear session flag
sessionStorage.removeItem("tour_just_created");
this._notify();
},
- markVisitedJobsOverview() {
- if (!localStorage.getItem(NAV_JOBS_OVERVIEW)) {
- localStorage.setItem(NAV_JOBS_OVERVIEW, "true");
- this._ctx.neverVisitedJobsOverview = false;
- this._notify();
- }
- },
-
- markOpenedRunModal() {
- if (!localStorage.getItem(OPENED_RUN_MODAL)) {
- localStorage.setItem(OPENED_RUN_MODAL, "true");
- this._ctx.neverOpenedRunModal = false;
- this._notify();
- }
- },
-
- markVisitedJobDetail() {
- if (!localStorage.getItem(NAV_JOB_DETAIL)) {
- localStorage.setItem(NAV_JOB_DETAIL, "true");
- this._ctx.neverVisitedJobDetail = false;
- this._notify();
- }
- },
-
- markVisitedSchedule() {
- if (!localStorage.getItem(NAV_SCHEDULE)) {
- localStorage.setItem(NAV_SCHEDULE, "true");
- this._ctx.neverVisitedSchedule = false;
- this._notify();
- }
- },
-
clearJustCreatedFlag() {
this._ctx.justCreatedJob = false;
sessionStorage.removeItem("tour_just_created");
this._notify();
},
- // Direct element refs for reliable spotlight targeting
+ // Direct element ref for reliable spotlight targeting of the run button
_runBtnEl: null as HTMLElement | null,
setRunButtonEl(el: HTMLElement | null) {
this._runBtnEl = el;
@@ -160,19 +115,6 @@ export const TourStore = {
return this._runBtnEl;
},
- // Track whether user has any spider jobs
- _hasAnyJobs: false,
- setHasAnyJobs(val: boolean) {
- this._hasAnyJobs = val;
- if (val) {
- localStorage.setItem(`${getTourKey()}_has_jobs`, "true");
- }
- },
- getHasAnyJobs(): boolean {
- if (this._hasAnyJobs) return true;
- return !!localStorage.getItem(`${getTourKey()}_has_jobs`);
- },
-
subscribe(fn: Listener): () => void {
this._listeners.add(fn);
return () => {
diff --git a/estela-web/src/tour/types.ts b/estela-web/src/tour/types.ts
index 91494b8e..0c09c007 100644
--- a/estela-web/src/tour/types.ts
+++ b/estela-web/src/tour/types.ts
@@ -25,31 +25,24 @@ export interface TourStep {
export interface TourContext {
currentRoute: string;
deploys: Deploy[];
- jobs: unknown[];
newJobModalOpen: boolean;
seenSteps: string[];
lastCompletedStepAt: string | null;
+ /** True when the project has at least one spider job (fetched lazily in DeployListPage) */
+ projectHasJobs: boolean;
/** Set from sessionStorage when arriving at job detail from Create */
justCreatedJob: boolean;
/** Set when step-4 is completed to trigger step-5 on same page */
step4Completed: boolean;
- neverVisitedJobsOverview: boolean;
- neverOpenedRunModal: boolean;
- neverVisitedJobDetail: boolean;
- neverVisitedSchedule: boolean;
}
export const defaultTourContext: TourContext = {
currentRoute: "",
deploys: [],
- jobs: [],
newJobModalOpen: false,
seenSteps: [],
lastCompletedStepAt: null,
+ projectHasJobs: false,
justCreatedJob: false,
step4Completed: false,
- neverVisitedJobsOverview: true,
- neverOpenedRunModal: true,
- neverVisitedJobDetail: true,
- neverVisitedSchedule: true,
};
From ac9c4f338c203e61f1f28cfed473295dc00806f8 Mon Sep 17 00:00:00 2001
From: erick-gege
Date: Thu, 21 May 2026 10:07:48 -0500
Subject: [PATCH 3/7] add tooltips
---
estela-web/src/pages/JobCreateModal/help.ts | 33 +++++++++++++
estela-web/src/pages/JobCreateModal/index.tsx | 48 +++++++++++++++----
2 files changed, 71 insertions(+), 10 deletions(-)
create mode 100644 estela-web/src/pages/JobCreateModal/help.ts
diff --git a/estela-web/src/pages/JobCreateModal/help.ts b/estela-web/src/pages/JobCreateModal/help.ts
new file mode 100644
index 00000000..c28e9d7f
--- /dev/null
+++ b/estela-web/src/pages/JobCreateModal/help.ts
@@ -0,0 +1,33 @@
+export const JOB_FIELD_HELP = {
+ spider: "The spider to run. Each project can have multiple spiders, one per scraping target.",
+
+ persistence:
+ "How long the items extracted by this job will be retained before being deleted. " +
+ "Choose 'Forever' to keep them indefinitely.",
+
+ tier:
+ "CPU and memory allocated to this job. Higher tiers run faster but consume more " +
+ "credits. DEFAULT is fine for most spiders.",
+
+ args:
+ "Command-line arguments passed to your spider on start (e.g. start_url=https://example.com). " +
+ "Available in the spider via self..",
+
+ envProject:
+ "Variables defined at the project level. They are inherited by every job, in every " +
+ "spider of this project.",
+
+ envSpider:
+ "Variables defined on this spider. They are inherited by every job of this spider " +
+ "and override project variables.",
+
+ envJob:
+ "Variables for this job only. They override spider and project variables. Use the " +
+ "eye icon to mask sensitive values like API keys.",
+
+ proxy: "Route this job's requests through a proxy server. Useful for IP rotation or " + "geo-targeted scraping.",
+
+ tags:
+ "Labels for organizing and filtering jobs later (e.g. 'production', 'monitoring'). " +
+ "Pure metadata, no behaviour change.",
+};
diff --git a/estela-web/src/pages/JobCreateModal/index.tsx b/estela-web/src/pages/JobCreateModal/index.tsx
index a14780a9..2996a940 100644
--- a/estela-web/src/pages/JobCreateModal/index.tsx
+++ b/estela-web/src/pages/JobCreateModal/index.tsx
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from "react";
import { Modal, Button, message, Row, Select, Space, Input, Tag, Checkbox, Tooltip } from "antd";
-import { EyeInvisibleOutlined } from "@ant-design/icons";
+import { EyeInvisibleOutlined, QuestionCircleOutlined } from "@ant-design/icons";
import type { CheckboxChangeEvent } from "antd/es/checkbox";
import {
ApiProjectsSpidersJobsCreateRequest,
@@ -21,6 +21,7 @@ import { resourceNotAllowedNotification, invalidDataNotification, incorrectDataN
import { DEFAULT_RESOURCE_TIER, PREDEFINED_TIERS } from "../../constants";
import { checkExternalError } from "../../defaultComponents";
import { TourStore } from "../../tour";
+import { JOB_FIELD_HELP } from "./help";
import Run from "../../assets/icons/play.svg";
import Add from "../../assets/icons/add.svg";
@@ -102,6 +103,17 @@ const dataPersistenceOptions = [
{ label: "Forever", key: 7, value: 720 },
];
+function FieldLabel({ label, help, className }: { label: string; help: string; className?: string }) {
+ return (
+
+ {label}
+
+
+
+
+ );
+}
+
export default function JobCreateModal({
openModal,
spider,
@@ -607,7 +619,7 @@ export default function JobCreateModal({
footer={null}
>
- Spider
+
- Data persistence
+
- Resource Tier
+
-
Arguments
+
{jobData.args.length > 0 ? (
@@ -731,7 +747,11 @@ export default function JobCreateModal({
{/* Project ENV vars section */}
{getFilteredEnvVars(projectEnvVars).length > 0 && (
-
Project Variables
+
{getFilteredEnvVars(projectEnvVars).map(
@@ -770,7 +790,11 @@ export default function JobCreateModal({
{/* Spider ENV vars section */}
{getFilteredEnvVars(spiderEnvVars).length > 0 && (
-
Spider Variables
+
{getFilteredEnvVars(spiderEnvVars).map(
@@ -808,7 +832,11 @@ export default function JobCreateModal({
{/* Job ENV vars section */}
-
Job Variables
+
{jobData.envVars.length > 0 ? (
@@ -889,7 +917,7 @@ export default function JobCreateModal({
- Proxy
+
{noProxy ? (