From a5197b4e7ee933cf22f5bb427b5e935d03ebfb53 Mon Sep 17 00:00:00 2001 From: wicky <130177258+wicky-zipstack@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:02:57 +0530 Subject: [PATCH 01/19] feat: redesign Run History + row counts per model (OR-1477) Run History Redesign: - Stats cards: success rate (7d), avg duration with sparkline, failures (24h) with trend, last successful run - New GET /jobs/run-stats/{id} endpoint with aggregation queries - Filter bar: search, date range (presets + custom RangePicker), status, trigger, environment, active filter count badge - Job info bar: name, run count, environment dot, schedule tag, view job - Table: Run #, Status (with icons), Trigger (with user name), Scope (with model count), Changes column, Duration (with comparison bar), retry icon per row - Expanded detail (failure): stats grid, error card with styled model name + stack trace, execution timeline - Expanded detail (success): green banner, row stats (processed/added/ modified/deleted), per-model changes table - Collapsed by default on load - All colors use Ant Design theme tokens (light + dark mode) - Custom expand icons (chevrons) Row Counts Per Model (OR-1477 / ADR-002): - ExecutionMetrics dataclass in adapters/model.py - BaseModel.execute() returns row count via get_table_row_count() fallback - Adapter.run_model() returns ExecutionMetrics - BaseResult gains rows_affected, materialization, duration_ms fields - execute_graph() captures metrics from run_model() - celery_tasks.py serializes rows_affected, type, duration_ms per model + total rows_processed in result JSON Enhanced Serializer: - run_number (sequential per job), triggered_by (user name resolution), duration_ms, model_count, failed_models, skipped_count Enhanced Filters: - date_from/date_to, search (error text), existing status/trigger/scope - project_id added to run history response Co-Authored-By: Claude Opus 4.6 (1M context) --- .../backend/core/scheduler/celery_tasks.py | 7 + backend/backend/core/scheduler/serializer.py | 87 +- backend/backend/core/scheduler/urls.py | 2 + backend/backend/core/scheduler/views.py | 134 +- backend/visitran/adapters/adapter.py | 4 +- backend/visitran/adapters/model.py | 34 +- backend/visitran/events/printer.py | 3 + backend/visitran/visitran.py | 13 +- frontend/src/ide/run-history/RunHistory.css | 123 +- frontend/src/ide/run-history/Runhistory.jsx | 1144 ++++++++--------- 10 files changed, 914 insertions(+), 637 deletions(-) diff --git a/backend/backend/core/scheduler/celery_tasks.py b/backend/backend/core/scheduler/celery_tasks.py index 1ce72d88..66b539e0 100644 --- a/backend/backend/core/scheduler/celery_tasks.py +++ b/backend/backend/core/scheduler/celery_tasks.py @@ -354,6 +354,7 @@ def _clean_name(raw): r for r in results_snapshot if not _clean_name(r.node_name).startswith("Source") ] + total_rows = 0 run.result = { "models": [ { @@ -361,12 +362,18 @@ def _clean_name(raw): "status": r.status, "end_status": r.end_status, "sequence": r.sequence_num, + "rows_affected": getattr(r, "rows_affected", None), + "type": getattr(r, "materialization", "") or "", + "duration_ms": getattr(r, "duration_ms", None), } for r in user_results ], "total": len(user_results), "passed": sum(1 for r in user_results if r.end_status == "OK"), "failed": sum(1 for r in user_results if r.end_status == "FAIL"), + "rows_processed": sum( + getattr(r, "rows_affected", 0) or 0 for r in user_results + ) or None, } except Exception: _clear_base_result() diff --git a/backend/backend/core/scheduler/serializer.py b/backend/backend/core/scheduler/serializer.py index 7a45ac5b..1c79ac00 100644 --- a/backend/backend/core/scheduler/serializer.py +++ b/backend/backend/core/scheduler/serializer.py @@ -1,17 +1,96 @@ +from django.contrib.auth import get_user_model from rest_framework import serializers from backend.core.scheduler.models import TaskRunHistory +User = get_user_model() + class TaskRunHistorySerializer(serializers.ModelSerializer): duration = serializers.SerializerMethodField() + duration_ms = serializers.SerializerMethodField() + run_number = serializers.SerializerMethodField() + triggered_by = serializers.SerializerMethodField() + model_count = serializers.SerializerMethodField() + failed_models = serializers.SerializerMethodField() + skipped_count = serializers.SerializerMethodField() class Meta: model = TaskRunHistory - fields = "__all__" # Include all fields or specify fields like ['id', 'start_time', 'end_time', 'status'] + fields = "__all__" def get_duration(self, obj): - """Calculate duration (end_time - start_time)""" + """Human-readable duration string.""" + if obj.start_time and obj.end_time: + delta = obj.end_time - obj.start_time + total_ms = int(delta.total_seconds() * 1000) + if total_ms < 1000: + return f"{total_ms}ms" + elif total_ms < 60000: + return f"{total_ms / 1000:.1f}s" + else: + minutes = total_ms // 60000 + seconds = (total_ms % 60000) / 1000 + return f"{minutes}m {seconds:.0f}s" + return None + + def get_duration_ms(self, obj): + """Duration in milliseconds for sorting/comparison.""" if obj.start_time and obj.end_time: - return str(obj.end_time - obj.start_time) # Convert timedelta to string - return None # If end_time is missing, return None + return int((obj.end_time - obj.start_time).total_seconds() * 1000) + return None + + def get_run_number(self, obj): + """Sequential run number within the job (1 = oldest).""" + if not hasattr(self, "_run_number_cache"): + self._run_number_cache = {} + task_detail_id = obj.user_task_detail_id + if task_detail_id not in self._run_number_cache: + # Get all run IDs for this job ordered by start_time ASC + run_ids = list( + TaskRunHistory.objects.filter(user_task_detail_id=task_detail_id) + .order_by("start_time") + .values_list("id", flat=True) + ) + self._run_number_cache[task_detail_id] = { + rid: idx + 1 for idx, rid in enumerate(run_ids) + } + return self._run_number_cache[task_detail_id].get(obj.id, 0) + + def get_triggered_by(self, obj): + """Resolve user_id from kwargs to username.""" + if not obj.kwargs: + return None + user_id = obj.kwargs.get("user_id") + if not user_id: + return None + try: + user = User.objects.get(id=user_id) + return { + "id": str(user.id), + "username": user.get_full_name() or user.username or user.email, + } + except (User.DoesNotExist, ValueError): + return {"id": str(user_id), "username": str(user_id)} + + def get_model_count(self, obj): + """Total model count from result.""" + if obj.result and isinstance(obj.result, dict): + return obj.result.get("total", 0) + return 0 + + def get_failed_models(self, obj): + """List of failed model names.""" + if obj.result and isinstance(obj.result, dict): + models = obj.result.get("models", []) + return [m["name"] for m in models if m.get("end_status") == "FAIL" or m.get("status") == "failure"] + return [] + + def get_skipped_count(self, obj): + """Count of skipped models (total - passed - failed).""" + if obj.result and isinstance(obj.result, dict): + total = obj.result.get("total", 0) + passed = obj.result.get("passed", 0) + failed = obj.result.get("failed", 0) + return max(0, total - passed - failed) + return 0 diff --git a/backend/backend/core/scheduler/urls.py b/backend/backend/core/scheduler/urls.py index c9e32a4f..1f0d8fe5 100644 --- a/backend/backend/core/scheduler/urls.py +++ b/backend/backend/core/scheduler/urls.py @@ -7,6 +7,7 @@ delete_periodic_task, update_periodic_task, task_run_history, + run_stats, trigger_task_once, trigger_task_once_for_model, list_deploy_candidates, @@ -30,6 +31,7 @@ name="get_periodic_task", ), path("/run-history/", task_run_history, name="task_run_history"), + path("/run-stats/", run_stats, name="run_stats"), path( "/trigger-periodic-task/", trigger_task_once, diff --git a/backend/backend/core/scheduler/views.py b/backend/backend/core/scheduler/views.py index 195fea57..d5ecb710 100644 --- a/backend/backend/core/scheduler/views.py +++ b/backend/backend/core/scheduler/views.py @@ -583,6 +583,112 @@ def delete_periodic_task(request, project_id, task_id): ) +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def run_stats(request, project_id, user_task_id): + """Get aggregated run statistics for a job — stats cards data.""" + try: + from django.db.models import Avg, Count, Q, F + from django.db.models.functions import ExtractDay + + query = {"id": user_task_id} + if _is_valid_project_id(project_id): + query["project__project_uuid"] = project_id + task = UserTaskDetails.objects.get(**query) + runs = TaskRunHistory.objects.filter(user_task_detail=task) + + now = timezone.now() + last_7d = now - timedelta(days=7) + last_24h = now - timedelta(hours=24) + prev_24h_start = now - timedelta(hours=48) + + # Success rate (7 days) + runs_7d = runs.filter(start_time__gte=last_7d) + total_7d = runs_7d.count() + success_7d = runs_7d.filter(status="SUCCESS").count() + success_rate = round((success_7d / total_7d * 100), 1) if total_7d > 0 else None + + # Average duration (successful runs, 7 days) + successful_runs_7d = runs_7d.filter(status="SUCCESS", start_time__isnull=False, end_time__isnull=False) + avg_duration_ms = None + if successful_runs_7d.exists(): + durations = [(r.end_time - r.start_time).total_seconds() * 1000 for r in successful_runs_7d] + avg_duration_ms = int(sum(durations) / len(durations)) + + # Failures (24h) + comparison with previous 24h + failures_24h = runs.filter(start_time__gte=last_24h, status="FAILURE").count() + failures_prev_24h = runs.filter( + start_time__gte=prev_24h_start, start_time__lt=last_24h, status="FAILURE" + ).count() + + # Last successful run + last_success = runs.filter(status="SUCCESS").order_by("-end_time").first() + last_success_time = last_success.end_time if last_success else None + + # Expected duration (avg of last 5 successful runs) + recent_successes = runs.filter( + status="SUCCESS", start_time__isnull=False, end_time__isnull=False + ).order_by("-end_time")[:5] + expected_duration_ms = None + if recent_successes.exists(): + durations = [(r.end_time - r.start_time).total_seconds() * 1000 for r in recent_successes] + expected_duration_ms = int(sum(durations) / len(durations)) + + # Duration trend (last 10 completed runs for sparkline) + recent_runs = runs.filter( + start_time__isnull=False, end_time__isnull=False + ).order_by("end_time")[:10] + duration_trend = [ + int((r.end_time - r.start_time).total_seconds() * 1000) for r in recent_runs + ] + + # Schedule info + schedule_type = None + schedule_label = None + try: + periodic = task.periodic_task + if periodic: + if periodic.crontab: + schedule_type = "cron" + c = periodic.crontab + schedule_label = f"{c.minute} {c.hour} {c.day_of_week}" + elif periodic.interval: + schedule_type = "interval" + schedule_label = f"Every {periodic.interval.every} {periodic.interval.period}" + except Exception: + pass + + return Response({ + "success": True, + "data": { + "success_rate_7d": success_rate, + "success_count_7d": success_7d, + "total_count_7d": total_7d, + "avg_duration_ms": avg_duration_ms, + "failures_24h": failures_24h, + "failures_prev_24h": failures_prev_24h, + "failures_change": failures_24h - failures_prev_24h, + "last_successful_run": last_success_time, + "expected_duration_ms": expected_duration_ms, + "duration_trend": duration_trend, + "total_runs": runs.count(), + "job_name": task.task_name, + "environment": { + "name": task.environment.environment_name if task.environment else None, + "type": task.environment.deployment_type if task.environment else None, + }, + "schedule_type": schedule_type, + "schedule_label": schedule_label, + "schedule_enabled": task.periodic_task.enabled if task.periodic_task else False, + }, + }, status=status.HTTP_200_OK) + except UserTaskDetails.DoesNotExist: + return Response({"error": "Task not found"}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + logger.error(f"Error getting run stats: {e}") + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @api_view(["GET"]) @permission_classes([IsAuthenticated]) def task_run_history(request, project_id, user_task_id): @@ -600,12 +706,32 @@ def task_run_history(request, project_id, user_task_id): trigger_filter = request.GET.get("trigger") scope_filter = request.GET.get("scope") status_filter = request.GET.get("status") + date_from = request.GET.get("date_from") + date_to = request.GET.get("date_to") + search = request.GET.get("search") + if trigger_filter: runs = runs.filter(trigger=trigger_filter) if scope_filter: runs = runs.filter(scope=scope_filter) if status_filter: runs = runs.filter(status=status_filter) + if date_from: + from django.utils.dateparse import parse_datetime + dt = parse_datetime(date_from) + if dt: + runs = runs.filter(start_time__gte=dt) + if date_to: + from django.utils.dateparse import parse_datetime as parse_dt + dt = parse_dt(date_to) + if dt: + runs = runs.filter(start_time__lte=dt) + if search: + from django.db.models import Q + runs = runs.filter( + Q(error_message__icontains=search) | + Q(result__icontains=search) + ) runs = runs.order_by("-start_time") total = runs.count() @@ -620,6 +746,7 @@ def task_run_history(request, project_id, user_task_id): "page_items": { "id": task.id, "job_name": task.task_name, + "project_id": str(task.project.project_uuid) if task.project else None, "env_type": task.environment.deployment_type if task.environment else None, @@ -705,9 +832,10 @@ def trigger_task_once(request, project_id, user_task_id): synchronous (in-process) execution so local dev works without Redis. """ try: - task = UserTaskDetails.objects.get( - id=user_task_id, project__project_uuid=project_id - ) + query = {"id": user_task_id} + if _is_valid_project_id(project_id): + query["project__project_uuid"] = project_id + task = UserTaskDetails.objects.get(**query) except UserTaskDetails.DoesNotExist: return Response( {"error": "Task not found"}, status=status.HTTP_404_NOT_FOUND diff --git a/backend/visitran/adapters/adapter.py b/backend/visitran/adapters/adapter.py index 295361c1..111a1fe6 100644 --- a/backend/visitran/adapters/adapter.py +++ b/backend/visitran/adapters/adapter.py @@ -67,10 +67,10 @@ def db_scd(self) -> BaseSCD: def db_reader(self) -> BaseDBReader: return self._db_reader - def run_model(self, visitran_model: VisitranModel) -> None: + def run_model(self, visitran_model: VisitranModel): self.load_model(model=visitran_model) fire_event(MaterializationType(materialization=str(visitran_model.materialization))) - self.db_model.execute() + return self.db_model.execute() def run_seeds(self, schema: str, abs_path: str) -> None: seed_obj = self.load_seed(schema, abs_path) diff --git a/backend/visitran/adapters/model.py b/backend/visitran/adapters/model.py index 2a7b46a5..13bad528 100644 --- a/backend/visitran/adapters/model.py +++ b/backend/visitran/adapters/model.py @@ -2,13 +2,21 @@ import logging from abc import ABC, abstractmethod -from typing import Any +from dataclasses import dataclass, field +from typing import Any, Optional from visitran.adapters.connection import BaseConnection from visitran.materialization import Materialization from visitran.templates.model import VisitranModel +@dataclass +class ExecutionMetrics: + """Metrics returned from model execution.""" + rows_affected: Optional[int] = None + materialization: str = "" + + class BaseModel(ABC): def __init__(self, db_connection: BaseConnection, model: VisitranModel) -> None: super().__init__() @@ -26,20 +34,42 @@ def model(self) -> VisitranModel: def materialization(self) -> Materialization: return self.model.materialization - def execute(self) -> None: + def execute(self) -> ExecutionMetrics: + mat_name = self.materialization.value if hasattr(self.materialization, "value") else str(self.materialization) + if self.materialization == Materialization.EPHEMERAL: self.execute_ephemeral() + return ExecutionMetrics(rows_affected=None, materialization="ephemeral") self.model.select_statement = self.model.select() if self.materialization == Materialization.TABLE: self.execute_table() + # Get row count after table creation + rows = self._get_row_count_safe() + return ExecutionMetrics(rows_affected=rows, materialization="table") elif self.materialization == Materialization.VIEW: self.execute_view() + return ExecutionMetrics(rows_affected=None, materialization="view") elif self.materialization == Materialization.INCREMENTAL: self.execute_incremental() + rows = self._get_row_count_safe() + return ExecutionMetrics(rows_affected=rows, materialization="incremental") + + return ExecutionMetrics(materialization=mat_name) + + def _get_row_count_safe(self) -> Optional[int]: + """Get row count after execution, return None on failure.""" + try: + return self._db_connection.get_table_row_count( + schema_name=self.model.destination_schema_name, + table_name=self.model.destination_table_name, + ) + except Exception as e: + logging.debug(f"Could not get row count for {self.model.destination_table_name}: {e}") + return None @abstractmethod def execute_ephemeral(self) -> None: diff --git a/backend/visitran/events/printer.py b/backend/visitran/events/printer.py index 19f1a19a..5ba1b434 100644 --- a/backend/visitran/events/printer.py +++ b/backend/visitran/events/printer.py @@ -27,6 +27,9 @@ class BaseResult: ending_time: datetime.datetime sequence_num: int end_status: str + rows_affected: int | None = None + materialization: str = "" + duration_ms: int | None = None @dataclass diff --git a/backend/visitran/visitran.py b/backend/visitran/visitran.py index 4ccee4c3..e2f2122f 100644 --- a/backend/visitran/visitran.py +++ b/backend/visitran/visitran.py @@ -345,9 +345,10 @@ def execute_graph(self) -> None: ) ) self.db_adapter.db_connection.create_schema(node.destination_schema_name) # create if not exists - self.db_adapter.run_model(visitran_model=node) + exec_metrics = self.db_adapter.run_model(visitran_model=node) _elapsed = time.monotonic() - start_time + _elapsed_ms = int(_elapsed * 1000) fire_event( ModelRunSucceeded( model_name=_model_display, @@ -360,6 +361,13 @@ def execute_graph(self) -> None: run_duration=_elapsed, ) + # Extract row count from execution metrics + _rows = None + _mat = "" + if exec_metrics is not None: + _rows = getattr(exec_metrics, "rows_affected", None) + _mat = getattr(exec_metrics, "materialization", "") + base_result = BaseResult( node_name=str(node_name), sequence_num=sequence_number, @@ -368,6 +376,9 @@ def execute_graph(self) -> None: info_message=f"Running {node_name}", status=ExecStatus.Success.value, end_status=ExecStatus.OK.value, + rows_affected=_rows, + materialization=_mat, + duration_ms=_elapsed_ms, ) sequence_number += 1 BASE_RESULT.append(base_result) diff --git a/frontend/src/ide/run-history/RunHistory.css b/frontend/src/ide/run-history/RunHistory.css index 7e7e8956..8d1267fa 100644 --- a/frontend/src/ide/run-history/RunHistory.css +++ b/frontend/src/ide/run-history/RunHistory.css @@ -1,56 +1,119 @@ -/* RunHistory.css */ +/* RunHistory.css — Matches design HTML exactly */ .runhistory-container { height: 100%; display: flex; flex-direction: column; + overflow: auto; } -.runhistory-title { - font-weight: bold; - padding: 12px 20px 0; -} - -.runhistory-filters { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 20px; -} - -.runhistory-job-select { - width: 240px; -} - -.runhistory-status-select { - width: 160px; -} - -.runhistory-job-info { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0 20px 10px; +/* ── Header ── */ +.runhistory-header { + padding: 20px 24px 8px; } +/* ── Table ── */ .runhistory-table-container { flex: 1; - padding: 0 20px 20px; + padding: 0 24px 24px; overflow: auto; } -/* Visually bind an expanded error row to its parent run row so the - * error panel reads as a continuation of that row, not a sibling. */ .runhistory-table-container .runhistory-row-expanded > td { border-bottom-color: transparent !important; } .runhistory-table-container .ant-table-expanded-row > td { border-top: 0 !important; - padding: 0 !important; + padding: 0 8px !important; background: transparent !important; } .runhistory-table-container .ant-table-expanded-row:hover > td { background: transparent !important; } + +/* ── Trigger icon circle ── */ +.rh-trigger-icon { + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--bg-color-3, rgba(0, 0, 0, 0.05)); + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--font-color-3, #8c8c8c); +} + +/* ── Duration bar ── */ +.rh-dur-bar { + width: 60px; + height: 3px; + background: var(--border-color-1, rgba(0, 0, 0, 0.08)); + border-radius: 2px; + overflow: hidden; + margin-top: 3px; +} + +.rh-dur-bar-fill { + height: 100%; + border-radius: 2px; +} + +.rh-dur-bar-fill.fail { + background: var(--error-color, #ff4d4f); +} + +.rh-dur-bar-fill.ok { + background: var(--success-color, #52c41a); +} + +/* ── Error box (expanded detail) ── */ +.rh-error-box { + border-left: 3px solid var(--error-color, #ff4d4f); + padding: 12px 14px; + background: var(--error-bg, rgba(255, 77, 79, 0.06)); + border-radius: 6px; + margin-bottom: 14px; +} + +.rh-error-box-title { + font-weight: 600; + color: var(--error-color, #ff4d4f); + font-size: 13px; + margin-bottom: 8px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.rh-error-msg { + font-family: var(--font-family-code, "SF Mono", Consolas, monospace); + font-size: 12px; + background: var(--bg-color-3, rgba(0, 0, 0, 0.04)); + padding: 8px 10px; + border-radius: 4px; + margin-bottom: 8px; +} + +.rh-error-stack { + font-family: var(--font-family-code, "SF Mono", Consolas, monospace); + font-size: 11px; + padding: 8px 10px; + background: var(--bg-color-3, rgba(0, 0, 0, 0.04)); + border-radius: 4px; + color: var(--font-color-3, #8c8c8c); + line-height: 1.6; + max-height: 100px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +/* ── Responsive ── */ +@media (max-width: 1000px) { + .runhistory-container .ant-row > .ant-col[class*="ant-col-6"] { + flex: 0 0 50%; + max-width: 50%; + } +} diff --git a/frontend/src/ide/run-history/Runhistory.jsx b/frontend/src/ide/run-history/Runhistory.jsx index 2d4cf0a6..5a787397 100644 --- a/frontend/src/ide/run-history/Runhistory.jsx +++ b/frontend/src/ide/run-history/Runhistory.jsx @@ -1,6 +1,5 @@ import { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { - Alert, Select, Table, Tag, @@ -10,55 +9,105 @@ import { Button, Space, Tooltip, + Input, + Card, + Badge, + Row, + Col, + Timeline, + DatePicker, } from "antd"; import { ReloadOutlined, CalendarOutlined, - DatabaseOutlined, CheckCircleFilled, CloseCircleFilled, ClockCircleOutlined, SyncOutlined, + SearchOutlined, + CopyOutlined, + EyeOutlined, + RedoOutlined, + ArrowUpOutlined, + ArrowDownOutlined, + UserOutlined, + EditOutlined, + UpOutlined, + DownOutlined, + MinusOutlined, } from "@ant-design/icons"; -import { useSearchParams } from "react-router-dom"; +import { useSearchParams, useNavigate } from "react-router-dom"; import { useAxiosPrivate } from "../../service/axios-service"; import { orgStore } from "../../store/org-store"; import { useNotificationService } from "../../service/notification-service"; import { runHistoryTagColor } from "../../common/constants"; import { usePagination } from "../../widgets/hooks/usePagination"; -import { - getTooltipText, - getRelativeTime, - formatDateTime, -} from "../../common/helpers"; +import { getTooltipText, getRelativeTime, formatDateTime } from "../../common/helpers"; import "./RunHistory.css"; -/* ─── Parse duration string to milliseconds for sorting ─── */ -const parseDurationMs = (duration) => { - if (!duration) return 0; - const parts = duration.split(":"); - if (parts.length !== 3) return 0; - const hours = parseInt(parts[0], 10); - const mins = parseInt(parts[1], 10); - const secs = parseFloat(parts[2]); - const h = hours * 3600; - const m = mins * 60; - return (h + m + secs) * 1000; +const { Text, Title } = Typography; +const { RangePicker } = DatePicker; + +/* ── Duration helpers ── */ +const formatDurationMs = (ms) => { + if (!ms && ms !== 0) return "—"; + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + const m = Math.floor(ms / 60000); + const s = ((ms % 60000) / 1000).toFixed(0); + return `${m}m ${s}s`; +}; + +const parseDurationMs = (d) => { + if (!d) return 0; + if (typeof d === "number") return d; + const p = d.split(":"); + if (p.length !== 3) return 0; + return (parseInt(p[0], 10) * 3600 + parseInt(p[1], 10) * 60 + parseFloat(p[2])) * 1000; +}; + +/* ── Sparkline SVG — plots real duration data points ── */ +const Sparkline = ({ color = "#3b82f6", data = [] }) => { + if (!data || data.length < 2) { + // Fallback — flat line + return ( + + + + ); + } + const max = Math.max(...data); + const min = Math.min(...data); + const range = max - min || 1; + const points = data.map((v, i) => { + const x = (i / (data.length - 1)) * 100; + const y = 26 - ((v - min) / range) * 24; // 2px top padding, 26px range + return `${x},${y}`; + }).join(" "); + return ( + + + + ); }; -/* ─── Duration formatter: "HH:MM:SS.sss" → human-readable ─── */ -const formatDuration = (duration) => { - if (!duration) return "—"; - const parts = duration.split(":"); - if (parts.length !== 3) return duration; - const hours = parseInt(parts[0], 10); - const mins = parseInt(parts[1], 10); - const secs = parseFloat(parts[2]); - if (hours > 0) return `${hours}h ${mins}m ${Math.round(secs)}s`; - if (mins > 0) return `${mins}m ${secs.toFixed(1)}s`; - if (secs >= 1) return `${secs.toFixed(1)}s`; - return `${Math.round(secs * 1000)}ms`; +/* ── StatCard ── */ +const StatCard = ({ label, icon, value, valueColor, subtext, spark }) => { + return ( + + + + {icon}{icon ? " " : ""}{label} + +
+ {value} +
+ {spark} + {subtext && {subtext}} +
+
+ ); }; const STATUS_OPTIONS = [ @@ -69,621 +118,526 @@ const STATUS_OPTIONS = [ { label: "Revoked", value: "REVOKED" }, ]; -const getRunTriggerScope = (row) => { - const kw = row?.kwargs || {}; - const legacyQuick = kw.source === "quick_deploy"; - const models = kw.models_override || []; - const trigger = - row?.trigger || kw.trigger || (legacyQuick ? "manual" : "scheduled"); - const scope = - row?.scope || - kw.scope || - (models.length > 0 || legacyQuick ? "model" : "job"); - return { trigger, scope, models }; -}; - const Runhistory = () => { const axios = useAxiosPrivate(); - const { - currentPage, - pageSize, - totalCount, - setTotalCount, - setCurrentPage, - setPageSize, - } = usePagination(); + const navigate = useNavigate(); + const { token } = theme.useToken(); + const { notify } = useNotificationService(); + const { selectedOrgId } = orgStore(); + const [searchParams, setSearchParams] = useSearchParams(); + const { currentPage, pageSize, totalCount, setTotalCount, setCurrentPage, setPageSize } = usePagination(); + const [jobListItems, setJobListItems] = useState([]); - const [backUpData, setBackUpData] = useState([]); - const [JobHistoryData, setJobHistoryData] = useState([]); + const [jobHistoryData, setJobHistoryData] = useState([]); const [jobSchedule, setJobSchedule] = useState({}); const [expandedRowKeys, setExpandedRowKeys] = useState([]); - const [filterQueries, setFilterQuery] = useState({ - status: "", - job: "", - trigger: "", - scope: "", - }); - - const [envInfo, setEnvInfo] = useState({ - env_type: "", - job_name: "", - id: "", - }); const [loading, setLoading] = useState(false); - const { selectedOrgId } = orgStore(); - const { token } = theme.useToken(); - const { notify } = useNotificationService(); - const [searchParams, setSearchParams] = useSearchParams(); + const [stats, setStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(false); + const [filters, setFilters] = useState({ job: "", status: "", trigger: "", search: "" }); + const [datePreset, setDatePreset] = useState("24h"); + const [customDateRange, setCustomDateRange] = useState(null); + const [showCustomDate, setShowCustomDate] = useState(false); + const [envInfo, setEnvInfo] = useState({ env_type: "", job_name: "", id: "", project_id: "" }); + const deepLinkConsumed = useRef(false); + const orgId = selectedOrgId || "default_org"; - /* ─── API calls ─── */ - const getRunHistoryList = useCallback( - async (Id, page = currentPage, limit = pageSize, filters = {}) => { - setLoading(true); - try { - const params = { page, limit }; - if (filters.status) params.status = filters.status; - if (filters.trigger) params.trigger = filters.trigger; - if (filters.scope) params.scope = filters.scope; - const res = await axios({ - method: "GET", - url: `/api/v1/visitran/${ - selectedOrgId || "default_org" - }/project/_all/jobs/run-history/${Id}`, - params, - }); - const { page_items, total_items, current_page } = res.data.data; - setTotalCount(total_items); - setCurrentPage(current_page); - const { env_type, job_name, run_history, id } = page_items; - setEnvInfo({ env_type, job_name, id }); - setJobHistoryData(run_history); - setBackUpData(run_history); - } catch (error) { - notify({ error }); - } finally { - setLoading(false); - } - }, - [axios, selectedOrgId, notify] - ); + /* ── APIs ── */ + const fetchStats = useCallback(async (taskId) => { + setStatsLoading(true); + try { const res = await axios.get(`/api/v1/visitran/${orgId}/project/_all/jobs/run-stats/${taskId}`); setStats(res.data.data); } + catch { setStats(null); } + finally { setStatsLoading(false); } + }, [axios, orgId]); - const getJobList = async () => { + const fetchHistory = useCallback(async (taskId, page = 1, limit = pageSize, f = filters) => { setLoading(true); try { - const res = await axios({ - method: "GET", - url: `/api/v1/visitran/${ - selectedOrgId || "default_org" - }/project/_all/jobs/list-periodic-tasks`, - }); + const params = { page, limit }; + if (f.status) params.status = f.status; + if (f.trigger) params.trigger = f.trigger; + if (f.search) params.search = f.search; + // Date filter — preset or custom range + if (datePreset === "custom" && customDateRange?.[0]) { + params.date_from = customDateRange[0].toISOString(); + if (customDateRange[1]) params.date_to = customDateRange[1].toISOString(); + } else if (datePreset && datePreset !== "all") { + const now = new Date(); + const presetMs = { "24h": 86400000, "7d": 604800000, "30d": 2592000000 }; + if (presetMs[datePreset]) params.date_from = new Date(now - presetMs[datePreset]).toISOString(); + } + const res = await axios.get(`/api/v1/visitran/${orgId}/project/_all/jobs/run-history/${taskId}`, { params }); + const { page_items, total_items, current_page } = res.data.data; + setTotalCount(total_items); + setCurrentPage(current_page); + setEnvInfo({ env_type: page_items.env_type, job_name: page_items.job_name, id: page_items.id, project_id: page_items.project_id }); + setJobHistoryData(page_items.run_history || []); + } catch (error) { notify({ error }); } + finally { setLoading(false); } + }, [axios, orgId, pageSize, notify, datePreset, customDateRange]); + + const fetchJobs = async () => { + try { + const res = await axios.get(`/api/v1/visitran/${orgId}/project/_all/jobs/list-periodic-tasks`); const { page_items } = res.data.data; - const scheduledObj = {}; - const jobIds = page_items.map((el) => { - const taskDetails = el.periodic_task_details?.[el.task_type]; - if (taskDetails) { - scheduledObj[el.user_task_id] = getTooltipText( - taskDetails, - el.task_type - ); - } + const schedObj = {}; + const jobs = page_items.map((el) => { + const td = el.periodic_task_details?.[el.task_type]; + if (td) schedObj[el.user_task_id] = getTooltipText(td, el.task_type); return { label: el.task_name, value: el.user_task_id }; }); - setJobSchedule(scheduledObj); - setJobListItems(jobIds); - if (jobIds.length) { - const taskFromUrl = searchParams.get("task"); - const taskFromUrlNum = taskFromUrl ? Number(taskFromUrl) : NaN; - const matchedFromUrl = !Number.isNaN(taskFromUrlNum) - ? jobIds.find((j) => j.value === taskFromUrlNum) - : null; - const initial = matchedFromUrl?.value ?? jobIds[0].value; - setFilterQuery((prev) => ({ ...prev, job: initial })); + setJobSchedule(schedObj); + setJobListItems(jobs); + if (jobs.length) { + const fromUrl = searchParams.get("task"); + const matched = fromUrl ? jobs.find((j) => j.value === Number(fromUrl)) : null; + setFilters((p) => ({ ...p, job: matched?.value ?? jobs[0].value })); } - } catch (error) { - console.error("Failed to load jobs", error); - } finally { - setLoading(false); - } + } catch (error) { console.error("Failed to load jobs", error); } }; - useEffect(() => { - getJobList(); - }, []); - - const deepLinkConsumed = useRef(false); - - /* ─── server-side filtering: refetch when filters change ─── */ - useEffect(() => { - if (!filterQueries.job) return; - getRunHistoryList(filterQueries.job, 1, pageSize, { - status: filterQueries.status, - trigger: filterQueries.trigger, - scope: filterQueries.scope, - }); - }, [ - filterQueries.status, - filterQueries.trigger, - filterQueries.scope, - filterQueries.job, - ]); - - /* ─── auto-expand on fresh data load ─── */ - useEffect(() => { - const ids = []; - if ( - !deepLinkConsumed.current && - searchParams.has("task") && - backUpData.length > 0 - ) { - ids.push(backUpData[0].id); - deepLinkConsumed.current = true; - } - (backUpData || []) - .filter((r) => r.status === "FAILURE" && r.error_message) - .forEach((r) => { - if (!ids.includes(r.id)) ids.push(r.id); - }); - setExpandedRowKeys(ids); - }, [backUpData]); - - /* ─── handlers ─── */ - const handleJobChange = useCallback( - (value) => { - setFilterQuery({ status: "", job: value, trigger: "", scope: "" }); - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - if (value) next.set("task", String(value)); - else next.delete("task"); - return next; - }, - { replace: true } - ); - }, - [setSearchParams] - ); + useEffect(() => { fetchJobs(); }, []); + useEffect(() => { if (!filters.job) return; fetchHistory(filters.job, 1, pageSize, filters); fetchStats(filters.job); }, [filters.job, filters.status, filters.trigger, filters.search, datePreset, customDateRange]); - const handleTriggerChange = useCallback((value) => { - setFilterQuery((prev) => ({ ...prev, trigger: value || "" })); - }, []); + // Don't auto-expand — keep collapsed on load + useEffect(() => { setExpandedRowKeys([]); }, [jobHistoryData]); - const handleScopeChange = useCallback((value) => { - setFilterQuery((prev) => ({ ...prev, scope: value || "" })); - }, []); + const handleFilterChange = (key, value) => { + setFilters((p) => ({ ...p, [key]: value || "" })); + if (key === "job") setSearchParams((prev) => { const next = new URLSearchParams(prev); if (value) next.set("task", String(value)); else next.delete("task"); return next; }, { replace: true }); + }; + const handleRefresh = () => { if (filters.job) { fetchHistory(filters.job, currentPage, pageSize, filters); fetchStats(filters.job); } }; + const handleRetry = async (taskId) => { + try { await axios.post(`/api/v1/visitran/${orgId}/project/_all/jobs/trigger-periodic-task/${taskId}`, {}, { headers: { "X-CSRFToken": document.cookie.match(/csrftoken=([^;]+)/)?.[1] } }); notify({ type: "success", message: "Job submitted" }); setTimeout(handleRefresh, 2000); } + catch (error) { notify({ error }); } + }; + const handleCopyError = (text) => { navigator.clipboard.writeText(text); notify({ type: "success", message: "Copied to clipboard" }); }; - const handleStatusChange = useCallback((value) => { - setFilterQuery((prev) => ({ ...prev, status: value || "" })); - }, []); + const activeFilterCount = [filters.status, filters.trigger, filters.search, datePreset !== "24h" ? datePreset : null].filter(Boolean).length; - const handleRefresh = useCallback(() => { - if (filterQueries.job) { - getRunHistoryList(filterQueries.job, currentPage, pageSize, { - status: filterQueries.status, - trigger: filterQueries.trigger, - scope: filterQueries.scope, - }); - } - }, [filterQueries, currentPage, pageSize, getRunHistoryList]); - - const handlePagination = useCallback( - (newPage, newPageSize) => { - if (currentPage !== newPage || pageSize !== newPageSize) { - setCurrentPage(newPage); - setPageSize(newPageSize); - getRunHistoryList(filterQueries.job, newPage, newPageSize, { - status: filterQueries.status, - trigger: filterQueries.trigger, - scope: filterQueries.scope, - }); - } + /* ── Table columns ── */ + const columns = useMemo(() => [ + { + title: "Run", dataIndex: "run_number", key: "run_number", width: 70, + render: (n) => #{n || "—"}, }, - [currentPage, pageSize, filterQueries, getRunHistoryList] - ); - - const handleExpand = useCallback((expanded, record) => { - setExpandedRowKeys((prev) => - expanded ? [...prev, record.id] : prev.filter((k) => k !== record.id) - ); - }, []); - - /* ─── table columns ─── */ - const columns = useMemo( - () => [ - { - title: "Status", - dataIndex: "status", - key: "status", - width: 120, - render: (status) => ( - - {status === "FAILURE" ? "FAILED" : status} - - ), + { + title: "Status", dataIndex: "status", key: "status", width: 110, + render: (s) => { + if (s === "FAILURE") return } color="error">Failed; + if (s === "SUCCESS") return } color="success">Success; + if (s === "STARTED" || s === "RUNNING") return } color="processing">Running; + return {s}; }, - { - title: "Trigger", - key: "trigger", - width: 120, - render: (_, record) => { - const { trigger } = getRunTriggerScope(record); - return trigger === "manual" ? ( - Manual - ) : ( - Scheduled - ); - }, + }, + { + title: "Trigger", key: "trigger", width: 170, + render: (_, r) => { + const trigger = r.trigger || r.kwargs?.trigger || "scheduled"; + const user = r.triggered_by; + return ( + + + {trigger === "manual" ? "Manual" : "Scheduled"}{user ? ` · ${user.username}` : ""} + + ); }, - { - title: "Scope", - key: "scope", - width: 220, - render: (_, record) => { - const { scope, models } = getRunTriggerScope(record); - if (scope === "model") { - return ( - - Single model - {models.length > 0 && ( - - {models.join(", ")} - - )} - - ); - } - return Full job; - }, + }, + { + title: "Scope", key: "scope", width: 150, + render: (_, r) => { + const scope = r.scope || "job"; + const count = r.model_count || r.result?.total || 0; + const models = r.kwargs?.models_override || []; + return ( + + {scope === "model" ? models.join(", ") : "Full job"} + {count} models + + ); }, - { - title: "Triggered", - dataIndex: "start_time", - key: "start_time", - sorter: (a, b) => { - if (!a.start_time) return -1; - if (!b.start_time) return 1; - return new Date(a.start_time) - new Date(b.start_time); - }, - defaultSortOrder: "descend", - render: (text) => { - if (!text) { - return ( - - Not started yet - - ); - } - return ( - - - {formatDateTime(text)} - - {getRelativeTime(text)} - - - - ); - }, + }, + { + title: "Changes", key: "changes", width: 200, + render: (_, r) => { + if (r.status !== "SUCCESS" || !r.result) return ; + const added = r.result?.rows_added ?? null; + const modified = r.result?.rows_modified ?? null; + const deleted = r.result?.rows_deleted ?? null; + if (added === null && modified === null && deleted === null) return ; + return ( + + {added !== null && + +{added.toLocaleString()}} + {modified !== null && ✎ ~{modified.toLocaleString()}} + {deleted !== null && ⊟ −{deleted.toLocaleString()}} + + ); + }, + }, + { + title: "Triggered", dataIndex: "start_time", key: "start_time", + sorter: (a, b) => new Date(a.start_time || 0) - new Date(b.start_time || 0), + defaultSortOrder: "descend", + render: (t) => t ? ( + + {formatDateTime(t)} + {getRelativeTime(t)} + + ) : Not started, + }, + { + title: "Duration", key: "duration", width: 130, + sorter: (a, b) => (a.duration_ms || 0) - (b.duration_ms || 0), + render: (_, r) => { + const ms = r.duration_ms || parseDurationMs(r.duration); + const isFail = r.status === "FAILURE"; + const pct = stats?.expected_duration_ms ? Math.min((ms / stats.expected_duration_ms) * 100, 100) : (isFail ? 70 : 35); + return ( +
+ {r.duration || formatDurationMs(ms)} +
+
+
+
+ ); }, + }, + { + title: "", key: "actions", width: 50, + render: () => + )} + + + + - /* ─── empty text ─── */ - const emptyDescription = useMemo(() => { - if (!jobListItems.length) return "No jobs created yet"; - if (!filterQueries.job) return "Select a job to view run history"; - if (filterQueries.status || filterQueries.trigger || filterQueries.scope) - return "No matching runs found"; - return "No run history available"; - }, [ - jobListItems.length, - filterQueries.job, - filterQueries.status, - filterQueries.trigger, - filterQueries.scope, - ]); + {/* Success banner */} + {isSuccess && ( +
+ + +
+ All {total} models built successfully +
+ + {totalRowsProcessed != null ? `${totalRowsProcessed.toLocaleString()} rows processed · ` : ""}{dur} total runtime + +
+
+ {(totalAdded != null || totalModified != null || totalDeleted != null) && ( + + {totalAdded != null && + +{totalAdded.toLocaleString()}} + {totalModified != null && ✎ ~{totalModified.toLocaleString()}} + {totalDeleted != null && ⊟ −{totalDeleted.toLocaleString()}} + + )} +
+ )} + + {/* Stats grid — different for success vs failure */} + {isSuccess ? ( + <> + {/* Row stats cards for success */} + + + +
ROWS PROCESSED
+
{totalRowsProcessed != null ? totalRowsProcessed.toLocaleString() : "—"}
+
+ + + +
+ ADDED
+
{totalAdded != null ? `+${totalAdded.toLocaleString()}` : "—"}
+
+ + + +
✎ MODIFIED
+
{totalModified != null ? `~${totalModified.toLocaleString()}` : "—"}
+
+ + + +
⊟ DELETED
+
{totalDeleted != null ? `−${totalDeleted.toLocaleString()}` : "—"}
+
+ +
+ {/* Per-model changes table */} + {models.length > 0 && ( + <> + Per-model changes + ({ ...m, key: i }))} + columns={modelColumns} + pagination={false} + showHeader + /> + + )} + + ) : ( + <> + {/* Failure stats grid */} + + + +
{passed}
Succeeded
of {total} total
+
+ + + +
0 ? token.colorError : undefined }}>{failed}
Failed
{failedModels.length > 0 && {failedModels.join(", ")}}
+
+ + + +
{skipped}
Skipped
{skipped > 0 && downstream of fail}
+
+ + + +
{dur}
Runtime
{expected && expected {expected}}
+
+ + + + {/* Error box */} + {run.error_message && ( +
+
+ Error in {errorModelName || "execution"} + + + + +
+
+ {errorModelName && <>{errorModelName}{" · "}} + {errorMsg.replace(errorModelName ? `${errorModelName} · ` : "", "")} +
+ {errorStack &&
{errorStack}
} +
+ )} + + {/* Execution Timeline for failures */} + Execution timeline + , children: (Setup) }, + ...models.map((m, i) => { + const isOk = m.end_status === "OK" || m.status === "success"; + const isFail = m.end_status === "FAIL" || m.status === "failure"; + return { + key: i, + dot: isOk ? : isFail ? : , + color: isFail ? "red" : isOk ? "green" : "gray", + children: ({m.name}{m.duration_ms ? formatDurationMs(m.duration_ms) : "—"}), + }; + }), + ...(skipped > 0 ? [{ dot: , color: "gray", children: ({skipped} downstream modelsskipped) }] : []), + ]} /> + + )} + + ); + }; + + /* ═══════════════ RENDER ═══════════════ */ return (
- {/* ─── Page Title ─── */} - - Run History - - - {/* ─── Filters ─── */} -
- - - - -
+ } + value={statsLoading ? "..." : stats?.success_rate_7d != null ? `${stats.success_rate_7d}%` : "— %"} + subtext={!statsLoading && {stats?.success_count_7d || 0} of {stats?.total_count_7d || 0} succeeded} + /> + + + } + /> + + + 0 ? token.colorError : undefined} + subtext={!statsLoading && + {stats?.failures_change > 0 ? `↑ from ${stats.failures_24h - stats.failures_change} yesterday` : "↑ from 0 yesterday"} + } + /> + + + {getRelativeTime(stats.last_successful_run)} : Never} + subtext={!stats?.last_successful_run && !statsLoading && Since job created} + /> + + + )} + + {/* Filter bar */} +
+ + +
} value={filters.search} onChange={(e) => handleFilterChange("search", e.target.value)} allowClear /> + + handleFilterChange("status", v)} options={STATUS_OPTIONS} /> + + + + {activeFilterCount > 0 && ( + <> + 1 ? "s" : ""}`} style={{ backgroundColor: token.colorPrimary }} /> + + + )} + {envInfo.job_name}· {totalCount} runs + + + {envInfo.env_type && ● {envInfo.env_type?.toUpperCase()}} + {stats?.schedule_enabled && } style={{ fontWeight: 600 }}>SCHEDULED {stats.schedule_label?.toUpperCase() || "DAILY"}} + + + + )} - {/* ─── History Table ─── */} + {/* Table */}
- expandedRowKeys.includes(record.id) ? "runhistory-row-expanded" : "" - } - onRow={(record) => - expandedRowKeys.includes(record.id) - ? { - style: { - boxShadow: `inset 3px 0 0 0 ${token.colorError}`, - }, - } - : {} - } + size="middle" expandable={{ - expandedRowRender: (record) => { - const meta = STATUS_META[record.status] || STATUS_META.PENDING; - const { scope, models } = getRunTriggerScope(record); - const isFailure = record.status === "FAILURE"; - return ( -
-
- {meta.icon} - - {meta.label} - - {record.start_time && ( - - - · {formatDateTime(record.start_time)} ( - {getRelativeTime(record.start_time)}) - - - )} - {record.duration && ( - - · {formatDuration(record.duration)} - - )} -
- - - {scope === "model" ? "Single model" : "Full job"} - - - {models.length > 0 - ? `Models attempted: ${models.join(", ")}` - : "No model configuration recorded for this run."} - - - {record.result?.total > 0 && ( -
- - {record.result.total || 0} models - attempted - - - {record.result.passed || 0} passed - - - {record.result.failed || 0} failed - - {record.result.models?.length > 0 && ( - - {record.result.models - .map((m) => `${m.name} (${m.end_status})`) - .join(", ")} - - )} -
- )} - {isFailure && record.error_message && ( - - {record.error_message} - - } - /> - )} -
- ); - }, - rowExpandable: (record) => - ["SUCCESS", "FAILURE", "RETRY", "REVOKED"].includes( - record.status - ), expandedRowKeys, - onExpand: handleExpand, - }} - locale={{ - emptyText: ( - + onExpandedRowsChange: (keys) => setExpandedRowKeys([...keys]), + expandedRowRender: (record) => , + expandRowByClick: false, + expandIcon: ({ expanded, onExpand, record }) => ( + ["SUCCESS", "FAILURE", "RETRY", "REVOKED"].includes(record.status) ? ( +
({ ...m, key: i }))} + dataSource={models.filter((m) => m.type !== "ephemeral").map((m, i) => ({ ...m, key: i }))} columns={modelColumns} pagination={false} showHeader @@ -492,7 +499,7 @@ const Runhistory = () => { Execution timeline, children: (Setup) }, - ...models.map((m, i) => { + ...models.filter((m) => m.type !== "ephemeral").map((m, i) => { const isOk = m.end_status === "OK" || m.status === "success"; const isFail = m.end_status === "FAIL" || m.status === "failure"; return { @@ -527,7 +534,7 @@ const Runhistory = () => { } value={statsLoading ? "..." : stats?.success_rate_7d != null ? `${stats.success_rate_7d}%` : "— %"} - subtext={!statsLoading && {stats?.success_count_7d || 0} of {stats?.total_count_7d || 0} succeeded} + subtext={!statsLoading && 0 ? token.colorWarning : token.colorError, fontSize: 11 }}>{stats?.success_count_7d || 0} of {stats?.total_count_7d || 0} succeeded} /> @@ -540,8 +547,8 @@ const Runhistory = () => { 0 ? token.colorError : undefined} - subtext={!statsLoading && - {stats?.failures_change > 0 ? `↑ from ${stats.failures_24h - stats.failures_change} yesterday` : "↑ from 0 yesterday"} + subtext={!statsLoading && 0 ? token.colorError : token.colorSuccess, fontSize: 11 }}> + {stats?.failures_change > 0 ? `↑ from ${stats.failures_24h - stats.failures_change} yesterday` : stats?.failures_24h === 0 ? "No failures" : `↑ from 0 yesterday`} } /> From 35e1353fdb1bf954a9880184b9352779abdc1c80 Mon Sep 17 00:00:00 2001 From: wicky <130177258+wicky-zipstack@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:05:04 +0530 Subject: [PATCH 04/19] fix: initialize row count variables before try block (Trino, Snowflake, Databricks) Bug fixes from code review: - Trino: inserted/deleted were only defined inside try/if blocks, causing NameError in APPEND mode (no key_columns) or on exception - Snowflake: rowcount only defined inside try, fragile dir() check - Databricks: same pattern as Snowflake Fix: initialize all variables to None before try block, remove dir() checks. --- backend/visitran/adapters/databricks/connection.py | 4 +++- backend/visitran/adapters/snowflake/connection.py | 5 +++-- backend/visitran/adapters/trino/connection.py | 9 ++++++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/backend/visitran/adapters/databricks/connection.py b/backend/visitran/adapters/databricks/connection.py index 07bea9f0..99be1c8e 100644 --- a/backend/visitran/adapters/databricks/connection.py +++ b/backend/visitran/adapters/databricks/connection.py @@ -228,6 +228,8 @@ def upsert_into_table( """Efficient upsert using Databricks Delta Lake's MERGE INTO statement. Returns dict with rows_affected from cursor.rowcount. """ + rowcount = None + # Handle both single column and composite keys if isinstance(primary_key, str): key_columns = [primary_key] @@ -292,4 +294,4 @@ def upsert_into_table( cursor.close() except Exception: pass - return {"rows_affected": rowcount if 'rowcount' in dir() else None} + return {"rows_affected": rowcount} diff --git a/backend/visitran/adapters/snowflake/connection.py b/backend/visitran/adapters/snowflake/connection.py index a0d02076..5044906a 100644 --- a/backend/visitran/adapters/snowflake/connection.py +++ b/backend/visitran/adapters/snowflake/connection.py @@ -281,9 +281,10 @@ def upsert_into_table( primary_key: Union[str, list[str]], ) -> dict: """Efficient upsert using Snowflake's MERGE INTO statement. - Returns dict with rows_affected from cursor.rowcount. """ + rowcount = None + # Handle both single column and composite keys if isinstance(primary_key, str): key_columns = [primary_key] @@ -342,4 +343,4 @@ def upsert_into_table( self.connection.raw_sql(f"DROP TABLE IF EXISTS {qi(schema_name)}.{qi(temp_table_name)}") except Exception: pass # Ignore cleanup errors - return {"rows_affected": rowcount if 'rowcount' in dir() else None} + return {"rows_affected": rowcount} diff --git a/backend/visitran/adapters/trino/connection.py b/backend/visitran/adapters/trino/connection.py index 94412a8a..111b66e2 100644 --- a/backend/visitran/adapters/trino/connection.py +++ b/backend/visitran/adapters/trino/connection.py @@ -151,6 +151,9 @@ def upsert_into_table( """Efficient upsert using DELETE + INSERT strategy for Trino. Returns dict with rows_deleted and rows_inserted from cursors. """ + inserted = None + deleted = None + # Normalize primary key(s) if isinstance(primary_key, str): key_columns = [primary_key] @@ -214,7 +217,7 @@ def upsert_into_table( except Exception: pass return { - "rows_affected": (inserted or 0) + (deleted or 0) if 'inserted' in dir() else None, - "rows_inserted": inserted if 'inserted' in dir() else None, - "rows_deleted": deleted if 'deleted' in dir() else None, + "rows_affected": (inserted or 0) + (deleted or 0) if inserted is not None else None, + "rows_inserted": inserted, + "rows_deleted": deleted, } From 9aaad8e3fb3ced5eb697eee88ce6478b143da689 Mon Sep 17 00:00:00 2001 From: wicky <130177258+wicky-zipstack@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:11:40 +0530 Subject: [PATCH 05/19] chore: cleanup unused imports, fix JSONField search, logging level - Remove unused 'field' import from dataclasses - Remove unused django.db.models imports (Avg, Count, F, ExtractDay) - Remove result__icontains on JSONField (not supported), keep error_message search only - Change _get_row_count_safe logging from debug to warning --- backend/backend/core/scheduler/views.py | 9 +-------- backend/visitran/adapters/model.py | 4 ++-- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/backend/backend/core/scheduler/views.py b/backend/backend/core/scheduler/views.py index d5ecb710..924dc8a6 100644 --- a/backend/backend/core/scheduler/views.py +++ b/backend/backend/core/scheduler/views.py @@ -588,9 +588,6 @@ def delete_periodic_task(request, project_id, task_id): def run_stats(request, project_id, user_task_id): """Get aggregated run statistics for a job — stats cards data.""" try: - from django.db.models import Avg, Count, Q, F - from django.db.models.functions import ExtractDay - query = {"id": user_task_id} if _is_valid_project_id(project_id): query["project__project_uuid"] = project_id @@ -727,11 +724,7 @@ def task_run_history(request, project_id, user_task_id): if dt: runs = runs.filter(start_time__lte=dt) if search: - from django.db.models import Q - runs = runs.filter( - Q(error_message__icontains=search) | - Q(result__icontains=search) - ) + runs = runs.filter(error_message__icontains=search) runs = runs.order_by("-start_time") total = runs.count() diff --git a/backend/visitran/adapters/model.py b/backend/visitran/adapters/model.py index 79595b18..35abb6b6 100644 --- a/backend/visitran/adapters/model.py +++ b/backend/visitran/adapters/model.py @@ -2,7 +2,7 @@ import logging from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Optional from visitran.adapters.connection import BaseConnection @@ -86,7 +86,7 @@ def _get_row_count_safe(self) -> Optional[int]: table_name=self.model.destination_table_name, ) except Exception as e: - logging.debug(f"Could not get row count for {self.model.destination_table_name}: {e}") + logging.warning(f"Could not get row count for {self.model.destination_table_name}: {e}") return None @abstractmethod From 7e5ecc45ffd2ecce1b0f1985a4c6b8dffc750c7f Mon Sep 17 00:00:00 2001 From: wicky <130177258+wicky-zipstack@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:32:26 +0530 Subject: [PATCH 06/19] feat: job switcher bar in Run History + View job config opens drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Job switcher: dropdown with status dot, env tag, project, last run time - Previous/Next arrows with "X / Y" counter - Env + Schedule tags + "View job config →" link - JobList handles ?task= param to auto-open drawer - Cleanup: removed unused imports (runHistoryTagColor, getTooltipText, useRef, ArrowUpOutlined, ArrowDownOutlined, UserOutlined, EditOutlined, jobSchedule, deepLinkConsumed) --- frontend/src/ide/run-history/RunHistory.css | 29 +++++ frontend/src/ide/run-history/Runhistory.jsx | 115 ++++++++++++++------ frontend/src/ide/scheduler/JobList.jsx | 7 ++ 3 files changed, 118 insertions(+), 33 deletions(-) diff --git a/frontend/src/ide/run-history/RunHistory.css b/frontend/src/ide/run-history/RunHistory.css index 8d1267fa..0b8f6204 100644 --- a/frontend/src/ide/run-history/RunHistory.css +++ b/frontend/src/ide/run-history/RunHistory.css @@ -7,6 +7,35 @@ overflow: auto; } +/* ── Job Switcher ── */ +.rh-job-switcher { + padding: 12px 14px; + background: var(--bg-color-3, rgba(59, 130, 246, 0.04)); + border: 1px solid var(--border-color-1, rgba(59, 130, 246, 0.15)); + border-radius: 10px; +} + +.rh-job-option { + display: flex; + align-items: center; + gap: 8px; + padding: 2px 0; +} + +.rh-job-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.rh-job-dot.success { background: var(--success-color, #52c41a); } +.rh-job-dot.failed { background: var(--error-color, #ff4d4f); } +.rh-job-dot.paused { background: var(--font-color-3, #8c8c8c); } + +.rh-job-option-name { font-weight: 500; } +.rh-job-option-meta { font-size: 11px; color: var(--font-color-3, #8c8c8c); } + /* ── Header ── */ .runhistory-header { padding: 20px 24px 8px; diff --git a/frontend/src/ide/run-history/Runhistory.jsx b/frontend/src/ide/run-history/Runhistory.jsx index 5d84e2e0..8bea0687 100644 --- a/frontend/src/ide/run-history/Runhistory.jsx +++ b/frontend/src/ide/run-history/Runhistory.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo, useCallback, useRef } from "react"; +import { useEffect, useState, useMemo, useCallback } from "react"; import { Select, Table, @@ -29,22 +29,20 @@ import { CopyOutlined, EyeOutlined, RedoOutlined, - ArrowUpOutlined, - ArrowDownOutlined, - UserOutlined, - EditOutlined, UpOutlined, DownOutlined, MinusOutlined, + LeftOutlined, + RightOutlined, + SwapOutlined, } from "@ant-design/icons"; import { useSearchParams, useNavigate } from "react-router-dom"; import { useAxiosPrivate } from "../../service/axios-service"; import { orgStore } from "../../store/org-store"; import { useNotificationService } from "../../service/notification-service"; -import { runHistoryTagColor } from "../../common/constants"; import { usePagination } from "../../widgets/hooks/usePagination"; -import { getTooltipText, getRelativeTime, formatDateTime } from "../../common/helpers"; +import { getRelativeTime, formatDateTime } from "../../common/helpers"; import "./RunHistory.css"; const { Text, Title } = Typography; @@ -129,8 +127,8 @@ const Runhistory = () => { const { currentPage, pageSize, totalCount, setTotalCount, setCurrentPage, setPageSize } = usePagination(); const [jobListItems, setJobListItems] = useState([]); + const [jobListFull, setJobListFull] = useState([]); const [jobHistoryData, setJobHistoryData] = useState([]); - const [jobSchedule, setJobSchedule] = useState({}); const [expandedRowKeys, setExpandedRowKeys] = useState([]); const [loading, setLoading] = useState(false); const [stats, setStats] = useState(null); @@ -140,7 +138,6 @@ const Runhistory = () => { const [customDateRange, setCustomDateRange] = useState(null); const [showCustomDate, setShowCustomDate] = useState(false); const [envInfo, setEnvInfo] = useState({ env_type: "", job_name: "", id: "", project_id: "" }); - const deepLinkConsumed = useRef(false); const orgId = selectedOrgId || "default_org"; /* ── APIs ── */ @@ -181,14 +178,12 @@ const Runhistory = () => { try { const res = await axios.get(`/api/v1/visitran/${orgId}/project/_all/jobs/list-periodic-tasks`); const { page_items } = res.data.data; - const schedObj = {}; - const jobs = page_items.map((el) => { - const td = el.periodic_task_details?.[el.task_type]; - if (td) schedObj[el.user_task_id] = getTooltipText(td, el.task_type); - return { label: el.task_name, value: el.user_task_id }; - }); - setJobSchedule(schedObj); + const jobs = page_items.map((el) => ({ + label: el.task_name, + value: el.user_task_id, + })); setJobListItems(jobs); + setJobListFull(page_items); if (jobs.length) { const fromUrl = searchParams.get("task"); const matched = fromUrl ? jobs.find((j) => j.value === Number(fromUrl)) : null; @@ -523,9 +518,77 @@ const Runhistory = () => { {/* Header */}
Run History - {filters.job && envInfo.job_name && ( - All runs for job {envInfo.job_name}. Sorted by most recent. - )} + Pick any job below to see its runs. +
+ + {/* Job Switcher */} +
+
+ +
+ + Viewing runs for + + + +
{ setPrefillProject(searchParams.get("project") || null); setOpenJobDeploy(true); setSearchParams({}, { replace: true }); + } else if (searchParams.get("task")) { + const taskId = Number(searchParams.get("task")); + if (!Number.isNaN(taskId)) { + setSelectedJobId(taskId); + setOpenJobDeploy(true); + setSearchParams({}, { replace: true }); + } } }, [searchParams, setSearchParams]); From e719d6df3549780c0a2762a5b34fa47bed92a055 Mon Sep 17 00:00:00 2001 From: wicky <130177258+wicky-zipstack@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:10:24 +0530 Subject: [PATCH 07/19] fix: reduce job switcher select + arrow buttons from large to middle --- frontend/src/ide/run-history/Runhistory.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/ide/run-history/Runhistory.jsx b/frontend/src/ide/run-history/Runhistory.jsx index 8bea0687..0a5be006 100644 --- a/frontend/src/ide/run-history/Runhistory.jsx +++ b/frontend/src/ide/run-history/Runhistory.jsx @@ -536,7 +536,7 @@ const Runhistory = () => { value={filters.job || undefined} onChange={(v) => handleFilterChange("job", v)} style={{ width: "100%", maxWidth: 480 }} - size="large" + size="middle" optionFilterProp="label" placeholder="Search for a job..." popupMatchSelectWidth={false} @@ -564,7 +564,7 @@ const Runhistory = () => { - Run #{run.run_number} details - {isFailure ? `failed after ${passed} of ${total} models` : `${total} models built successfully`} + {isFailure + ? `failed after ${passed} of ${total} models` + : `${total} models built successfully`} {dur !== "—" && ` · ${dur} total runtime`} @@ -371,31 +803,93 @@ const Runhistory = () => { {isSuccess && envInfo.project_id && ( - + )} - + {/* Success banner */} {isSuccess && ( -
+
- +
- All {total} models built successfully + + All {total} models built successfully +
- {totalRowsProcessed != null ? `${totalRowsProcessed.toLocaleString()} rows processed · ` : ""}{dur} total runtime + {totalRowsProcessed != null + ? `${totalRowsProcessed.toLocaleString()} rows processed · ` + : ""} + {dur} total runtime
- {(totalAdded != null || totalModified != null || totalDeleted != null) && ( + {(totalAdded != null || + totalModified != null || + totalDeleted != null) && ( - {totalAdded != null && + +{totalAdded.toLocaleString()}} - {totalModified != null && ✎ ~{totalModified.toLocaleString()}} - {totalDeleted != null && ⊟ −{totalDeleted.toLocaleString()}} + {totalAdded != null && ( + + + +{totalAdded.toLocaleString()} + + )} + {totalModified != null && ( + + ✎ ~{totalModified.toLocaleString()} + + )} + {totalDeleted != null && ( + + ⊟ −{totalDeleted.toLocaleString()} + + )} )}
@@ -408,26 +902,104 @@ const Runhistory = () => {
-
ROWS PROCESSED
-
{totalRowsProcessed != null ? totalRowsProcessed.toLocaleString() : "—"}
+
+ ROWS PROCESSED +
+
+ {totalRowsProcessed != null + ? totalRowsProcessed.toLocaleString() + : "—"} +
-
+ ADDED
-
{totalAdded != null ? `+${totalAdded.toLocaleString()}` : "—"}
+
+ + ADDED +
+
+ {totalAdded != null + ? `+${totalAdded.toLocaleString()}` + : "—"} +
-
✎ MODIFIED
-
{totalModified != null ? `~${totalModified.toLocaleString()}` : "—"}
+
+ ✎ MODIFIED +
+
+ {totalModified != null + ? `~${totalModified.toLocaleString()}` + : "—"} +
-
⊟ DELETED
-
{totalDeleted != null ? `−${totalDeleted.toLocaleString()}` : "—"}
+
+ ⊟ DELETED +
+
+ {totalDeleted != null + ? `−${totalDeleted.toLocaleString()}` + : "—"} +
@@ -435,10 +1007,23 @@ const Runhistory = () => { {/* Per-model changes table */} {models.length > 0 && ( <> - Per-model changes + + Per-model changes +
m.type !== "ephemeral").map((m, i) => ({ ...m, key: i }))} + dataSource={models + .filter((m) => m.type !== "ephemeral") + .map((m, i) => ({ ...m, key: i }))} columns={modelColumns} pagination={false} showHeader @@ -452,22 +1037,92 @@ const Runhistory = () => { -
{passed}
Succeeded
of {total} total
+ +
+ {passed} +
+
+
+ Succeeded +
+ + of {total} total + +
+
-
0 ? token.colorError : undefined }}>{failed}
Failed
{failedModels.length > 0 && {failedModels.join(", ")}}
+ +
0 ? token.colorError : undefined, + }} + > + {failed} +
+
+
+ Failed +
+ {failedModels.length > 0 && ( + + {failedModels.join(", ")} + + )} +
+
-
{skipped}
Skipped
{skipped > 0 && downstream of fail}
+ +
+ {skipped} +
+
+
+ Skipped +
+ {skipped > 0 && ( + + downstream of fail + + )} +
+
-
{dur}
Runtime
{expected && expected {expected}}
+ +
{dur}
+
+
+ Runtime +
+ {expected && ( + + expected {expected} + + )} +
+
@@ -476,36 +1131,172 @@ const Runhistory = () => { {run.error_message && (
- Error in {errorModelName || "execution"} + + + Error in {errorModelName || "execution"} + - - + +
- {errorModelName && <>{errorModelName}{" · "}} - {errorMsg.replace(errorModelName ? `${errorModelName} · ` : "", "")} + {errorModelName && ( + <> + + {errorModelName} + + {" · "} + + )} + {errorMsg.replace( + errorModelName ? `${errorModelName} · ` : "", + "" + )}
- {errorStack &&
{errorStack}
} + {errorStack && ( +
{errorStack}
+ )}
)} {/* Execution Timeline for failures */} - Execution timeline - , children: (Setup) }, - ...models.filter((m) => m.type !== "ephemeral").map((m, i) => { - const isOk = m.end_status === "OK" || m.status === "success"; - const isFail = m.end_status === "FAIL" || m.status === "failure"; - return { - key: i, - dot: isOk ? : isFail ? : , - color: isFail ? "red" : isOk ? "green" : "gray", - children: ({m.name}{m.duration_ms ? formatDurationMs(m.duration_ms) : "—"}), - }; - }), - ...(skipped > 0 ? [{ dot: , color: "gray", children: ({skipped} downstream modelsskipped) }] : []), - ]} /> + + Execution timeline + + + ), + children: ( + + + Setup + + + + — + + + + ), + }, + ...models + .filter((m) => m.type !== "ephemeral") + .map((m, i) => { + const isOk = + m.end_status === "OK" || m.status === "success"; + const isFail = + m.end_status === "FAIL" || m.status === "failure"; + return { + key: i, + dot: isOk ? ( + + ) : isFail ? ( + + ) : ( + + ), + color: isFail ? "red" : isOk ? "green" : "gray", + children: ( + + + + {m.name} + + + + + {m.duration_ms + ? formatDurationMs(m.duration_ms) + : "—"} + + + + ), + }; + }), + ...(skipped > 0 + ? [ + { + dot: , + color: "gray", + children: ( + + + + {skipped} downstream models + + + + + skipped + + + + ), + }, + ] + : []), + ]} + /> )} @@ -517,7 +1308,9 @@ const Runhistory = () => {
{/* Header */}
- Run History + + Run History + Pick any job below to see its runs.
@@ -526,7 +1319,15 @@ const Runhistory = () => {
- + Viewing runs for @@ -541,20 +1342,48 @@ const Runhistory = () => { placeholder="Search for a job..." popupMatchSelectWidth={false} options={jobListItems.map((j) => { - const job = jobListFull.find((f) => f.user_task_id === j.value) || {}; + const job = + jobListFull.find((f) => f.user_task_id === j.value) || {}; const envType = job.environment?.type || ""; const project = job.project?.name || ""; - const lastRun = job.task_completion_time ? getRelativeTime(job.task_completion_time) : "never"; - const isFailed = ["FAILED", "FAILURE"].includes(job.task_status); + const lastRun = job.task_completion_time + ? getRelativeTime(job.task_completion_time) + : "never"; + const isFailed = ["FAILED", "FAILURE"].includes( + job.task_status + ); const isSuccess = job.task_status === "SUCCESS"; return { value: j.value, label: (
- + {j.label} - {envType && {envType}} - · {project} · last run {lastRun} + {envType && ( + + {envType} + + )} + + · {project} · last run {lastRun} +
), }; @@ -564,27 +1393,96 @@ const Runhistory = () => {
- - - {envInfo.env_type && {envInfo.env_type}} - {stats?.schedule_enabled && } style={{ margin: 0 }}>{stats.schedule_label || "Scheduled"}} - + + {envInfo.env_type && ( + + {envInfo.env_type} + + )} + {stats?.schedule_enabled && ( + } + style={{ margin: 0 }} + > + {stats.schedule_label || "Scheduled"} + + )} + @@ -595,30 +1493,106 @@ const Runhistory = () => { {filters.job && ( - } - value={statsLoading ? "..." : stats?.success_rate_7d != null ? `${stats.success_rate_7d}%` : "— %"} - subtext={!statsLoading && 0 ? token.colorWarning : token.colorError, fontSize: 11 }}>{stats?.success_count_7d || 0} of {stats?.total_count_7d || 0} succeeded} + } + value={ + statsLoading + ? "..." + : stats?.success_rate_7d != null + ? `${stats.success_rate_7d}%` + : "— %" + } + subtext={ + !statsLoading && ( + 0 + ? token.colorWarning + : token.colorError, + fontSize: 11, + }} + > + {stats?.success_count_7d || 0} of{" "} + {stats?.total_count_7d || 0} succeeded + + ) + } /> - } + + } /> - 0 ? token.colorError : undefined} - subtext={!statsLoading && 0 ? token.colorError : token.colorSuccess, fontSize: 11 }}> - {stats?.failures_change > 0 ? `↑ from ${stats.failures_24h - stats.failures_change} yesterday` : stats?.failures_24h === 0 ? "No failures" : `↑ from 0 yesterday`} - } + valueColor={ + stats?.failures_24h > 0 ? token.colorError : undefined + } + subtext={ + !statsLoading && ( + 0 + ? token.colorError + : token.colorSuccess, + fontSize: 11, + }} + > + {stats?.failures_change > 0 + ? `↑ from ${ + stats.failures_24h - stats.failures_change + } yesterday` + : stats?.failures_24h === 0 + ? "No failures" + : `↑ from 0 yesterday`} + + ) + } /> - {getRelativeTime(stats.last_successful_run)} : Never} - subtext={!stats?.last_successful_run && !statsLoading && Since job created} + + {getRelativeTime(stats.last_successful_run)} + + ) : ( + Never + ) + } + subtext={ + !stats?.last_successful_run && + !statsLoading && ( + + Since job created + + ) + } /> @@ -628,10 +1602,26 @@ const Runhistory = () => {
-
} value={filters.search} onChange={(e) => handleFilterChange("search", e.target.value)} allowClear /> + + } + value={filters.search} + onChange={(e) => handleFilterChange("search", e.target.value)} + allowClear + /> + - { + setDatePreset(v); + setShowCustomDate(v === "custom"); + if (v !== "custom") setCustomDateRange(null); + }} options={[ { label: "Last 24 hours", value: "24h" }, { label: "Last 7 days", value: "7d" }, @@ -643,23 +1633,89 @@ const Runhistory = () => { /> {showCustomDate && ( - setCustomDateRange(dates)} /> + + setCustomDateRange(dates)} + /> + )} - handleFilterChange("trigger", v)} options={[{ label: "Manual", value: "manual" }, { label: "Scheduled", value: "scheduled" }]} /> - handleFilterChange("status", v)} + options={STATUS_OPTIONS} + /> + + + + {activeFilterCount > 0 && ( <> - 1 ? "s" : ""}`} style={{ backgroundColor: token.colorPrimary }} /> - + 1 ? "s" : "" + }`} + style={{ backgroundColor: token.colorPrimary }} + /> + )} -