diff --git a/gateway/sds_gateway/api_methods/helpers/temporal_filtering.py b/gateway/sds_gateway/api_methods/helpers/temporal_filtering.py new file mode 100644 index 00000000..e56eea89 --- /dev/null +++ b/gateway/sds_gateway/api_methods/helpers/temporal_filtering.py @@ -0,0 +1,152 @@ +import re + +from django.db.models import QuerySet + +from opensearchpy.exceptions import NotFoundError as OpenSearchNotFoundError +from sds_gateway.api_methods.models import CaptureType, Capture, File +from sds_gateway.api_methods.utils.opensearch_client import get_opensearch_client +from sds_gateway.api_methods.utils.relationship_utils import get_capture_files +from loguru import logger as log + +# Digital RF spec: rf@SECONDS.MILLISECONDS.h5 (e.g. rf@1396379502.000.h5) +# https://github.com/MITHaystack/digital_rf +DRF_RF_FILENAME_PATTERN = re.compile( + r"^rf@(\d+)\.(\d+)\.h5$", + re.IGNORECASE, +) +DRF_RF_FILENAME_REGEX_STR = r"^rf@\d+\.\d+\.h5$" + + +def drf_rf_filename_from_ms(ms: int) -> str: + """Format ms as DRF rf data filename (canonical for range queries).""" + return f"rf@{ms // 1000}.{ms % 1000:03d}.h5" + + +def drf_rf_filename_to_ms(file_name: str) -> int | None: + """ + Parse DRF rf data filename to milliseconds. + Handles rf@SECONDS.MILLISECONDS.h5; fractional part padded to 3 digits. + """ + name = file_name.strip() + match = DRF_RF_FILENAME_PATTERN.match(name) + if not match: + return None + try: + seconds = int(match.group(1)) + frac = match.group(2).ljust(3, "0")[:3] + return seconds * 1000 + int(frac) + except (ValueError, TypeError): + return None + + +def _catch_capture_type_error(capture_type: CaptureType) -> None: + if capture_type != CaptureType.DigitalRF: + msg = "Only DigitalRF captures are supported for temporal filtering." + log.error(msg) + raise ValueError(msg) + + +def get_capture_bounds(capture_type: CaptureType, capture_uuid: str) -> tuple[int, int]: + """Get start and end bounds for capture from opensearch.""" + + _catch_capture_type_error(capture_type) + + client = get_opensearch_client() + index = f"captures-{capture_type}" + + try: + response = client.get(index=index, id=capture_uuid) + except OpenSearchNotFoundError as e: + raise ValueError( + f"Capture {capture_uuid} not found in OpenSearch index {index}" + ) from e + + if not response.get("found"): + raise ValueError( + f"Capture {capture_uuid} not found in OpenSearch index {index}" + ) + + source = response.get("_source", {}) + search_props = source.get("search_props", {}) + start_time = search_props.get("start_time", 0) + end_time = search_props.get("end_time", 0) + return start_time, end_time + + +def get_data_files(capture_type: CaptureType, capture: Capture) -> QuerySet[File]: + """Get the data files in the capture.""" + _catch_capture_type_error(capture_type) + + return get_capture_files(capture).filter(name__regex=DRF_RF_FILENAME_REGEX_STR) + + +def get_file_cadence(capture_type: CaptureType, capture: Capture) -> int: + """Get the file cadence in milliseconds. OpenSearch bounds are in seconds.""" + _catch_capture_type_error(capture_type) + + capture_uuid = str(capture.uuid) + try: + start_time, end_time = get_capture_bounds(capture_type, capture_uuid) + except ValueError as e: + log.error(e) + raise e + + data_files = get_data_files(capture_type, capture) + count = data_files.count() + + # the first file represents the beginning of the capture + # exclude it from the count to get the correct file cadence + # the count - 1 gives us the number of "spaces" between the files + count -= 1 + if count == 0: + return 0 + duration_sec = end_time - start_time + duration_ms = duration_sec * 1000 + return max(1, int(duration_ms / count)) + + +def filter_capture_data_files_selection_bounds( + capture_type: CaptureType, + capture: Capture, + start_time: int, # relative ms from start of capture (from UI) + end_time: int, # relative ms from start of capture (from UI) +) -> QuerySet[File]: + """Filter the capture file selection bounds to the given start and end times.""" + _catch_capture_type_error(capture_type) + epoch_start_sec, _ = get_capture_bounds(capture_type, str(capture.uuid)) + epoch_start_ms = epoch_start_sec * 1000 + start_ms = epoch_start_ms + start_time + end_ms = epoch_start_ms + end_time + + start_file_name = drf_rf_filename_from_ms(start_ms) + end_file_name = drf_rf_filename_from_ms(end_ms) + + data_files = get_data_files(capture_type, capture) + return data_files.filter( + name__gte=start_file_name, + name__lte=end_file_name, + ).order_by("name") + +def get_capture_files_with_temporal_filter( + capture_type: CaptureType, + capture: Capture, + start_time: int | None = None, # milliseconds since start of capture + end_time: int | None = None, +) -> QuerySet[File]: + """Get the capture files with temporal filtering.""" + _catch_capture_type_error(capture_type) + + if start_time is None or end_time is None: + log.warning("Start or end time is None, returning all capture files without temporal filtering") + return get_capture_files(capture) + + # get non-data files + non_data_files = get_capture_files(capture).exclude(name__regex=DRF_RF_FILENAME_REGEX_STR) + + # get data files with temporal filtering + data_files = filter_capture_data_files_selection_bounds( + capture_type, capture, start_time, end_time + ) + + # return all files + return non_data_files.union(data_files) \ No newline at end of file diff --git a/gateway/sds_gateway/api_methods/serializers/capture_serializers.py b/gateway/sds_gateway/api_methods/serializers/capture_serializers.py index 037ebafd..5391d3c3 100644 --- a/gateway/sds_gateway/api_methods/serializers/capture_serializers.py +++ b/gateway/sds_gateway/api_methods/serializers/capture_serializers.py @@ -1,5 +1,6 @@ """Capture serializers for the SDS Gateway API methods.""" +import logging from typing import Any from typing import cast @@ -9,6 +10,9 @@ from rest_framework.utils.serializer_helpers import ReturnList from sds_gateway.api_methods.helpers.index_handling import retrieve_indexed_metadata +from sds_gateway.api_methods.helpers.temporal_filtering import get_capture_bounds +from sds_gateway.api_methods.helpers.temporal_filtering import get_file_cadence +from sds_gateway.api_methods.helpers.temporal_filtering import get_data_files from sds_gateway.api_methods.models import Capture from sds_gateway.api_methods.models import CaptureType from sds_gateway.api_methods.models import DEPRECATEDPostProcessedData @@ -70,7 +74,12 @@ class CaptureGetSerializer(serializers.ModelSerializer[Capture]): files = serializers.SerializerMethodField() center_frequency_ghz = serializers.SerializerMethodField() sample_rate_mhz = serializers.SerializerMethodField() - files_count = serializers.SerializerMethodField() + length_of_capture_ms = serializers.SerializerMethodField() + file_cadence_ms = serializers.SerializerMethodField() + capture_start_epoch_sec = serializers.SerializerMethodField() + data_files_count = serializers.SerializerMethodField() + data_files_total_size = serializers.SerializerMethodField() + per_data_file_size = serializers.SerializerMethodField() total_file_size = serializers.SerializerMethodField() formatted_created_at = serializers.SerializerMethodField() capture_type_display = serializers.SerializerMethodField() @@ -94,23 +103,89 @@ def get_files(self, capture: Capture) -> ReturnList[File]: def get_center_frequency_ghz(self, capture: Capture) -> float | None: """Get the center frequency in GHz from the capture model property.""" return capture.center_frequency_ghz - - @extend_schema_field(serializers.FloatField) + + @extend_schema_field(serializers.FloatField(allow_null=True)) def get_sample_rate_mhz(self, capture: Capture) -> float | None: - """Get the sample rate in MHz from the capture model property.""" + """Get the sample rate in MHz from the capture model property. None if not indexed in OpenSearch.""" return capture.sample_rate_mhz + @extend_schema_field(serializers.IntegerField(allow_null=True)) + def get_length_of_capture_ms(self, capture: Capture) -> int | None: + """Get the length of the capture in milliseconds. OpenSearch bounds are in seconds.""" + try: + start_time, end_time = get_capture_bounds(capture.capture_type, str(capture.uuid)) + return (end_time - start_time) * 1000 + except (ValueError, IndexError, KeyError): + return None + + @extend_schema_field(serializers.IntegerField(allow_null=True)) + def get_file_cadence_ms(self, capture: Capture) -> int | None: + """Get the file cadence in milliseconds. None if not indexed in OpenSearch.""" + try: + return get_file_cadence(capture.capture_type, capture) + except (ValueError, IndexError, KeyError): + return None + + @extend_schema_field(serializers.IntegerField(allow_null=True)) + def get_capture_start_epoch_sec(self, capture: Capture) -> int | None: + """Get the capture start time as Unix epoch seconds. None if not indexed in OpenSearch.""" + try: + start_time, _ = get_capture_bounds(capture.capture_type, str(capture.uuid)) + return start_time + except (ValueError, IndexError, KeyError): + return None + @extend_schema_field(serializers.IntegerField) - def get_files_count(self, capture: Capture) -> int: + def get_data_files_count(self, capture: Capture) -> int | None: """Get the count of files associated with this capture.""" - return get_capture_files(capture, include_deleted=False).count() + if capture.capture_type != CaptureType.DigitalRF: + return None + + return get_data_files(capture.capture_type, capture).count() + + @extend_schema_field(serializers.IntegerField) + def get_data_files_total_size(self, capture: Capture) -> int | None: + """Exact sum of data file sizes; use this for consistent totals with total_file_size.""" + if capture.capture_type != CaptureType.DigitalRF: + return None + data_files = get_data_files(capture.capture_type, capture) + result = data_files.aggregate(total_size=Sum("size")) + return result.get("total_size") or 0 + + @extend_schema_field(serializers.FloatField) + def get_per_data_file_size(self, capture: Capture) -> float | None: + """Get the size of each data file in the capture.""" + if capture.capture_type != CaptureType.DigitalRF: + return None + + data_files = get_data_files(capture.capture_type, capture) + + if data_files.count() == 0: + return None + + data_file_sizes = data_files.aggregate(total_size=Sum("size")) + total_size = data_file_sizes.get("total_size") + + if not total_size: + return None + return float(total_size) / data_files.count() + @extend_schema_field(serializers.IntegerField) def get_total_file_size(self, capture: Capture) -> int: """Get the total file size of all files associated with this capture.""" all_files = get_capture_files(capture, include_deleted=False) result = all_files.aggregate(total_size=Sum("size")) - return result["total_size"] or 0 + total = result["total_size"] or 0 + if capture.capture_type == CaptureType.DigitalRF: + data_total = self.get_data_files_total_size(capture) or 0 + if total < data_total: + logging.getLogger(__name__).warning( + "Capture %s: total_file_size (%s) < data_files_total_size (%s); using data total.", + str(capture.uuid), total, data_total, + ) + total = data_total + return total @extend_schema_field(serializers.DictField) def get_capture_props(self, capture: Capture) -> dict[str, Any]: @@ -301,9 +376,13 @@ class CompositeCaptureSerializer(serializers.Serializer): # Computed fields files = serializers.SerializerMethodField() - files_count = serializers.SerializerMethodField() + data_files_count = serializers.SerializerMethodField() + data_files_total_size = serializers.SerializerMethodField() total_file_size = serializers.SerializerMethodField() formatted_created_at = serializers.SerializerMethodField() + length_of_capture_ms = serializers.SerializerMethodField() + file_cadence_ms = serializers.SerializerMethodField() + capture_start_epoch_sec = serializers.SerializerMethodField() def get_files(self, obj: dict[str, Any]) -> ReturnList[File]: """Get all files from all channels in the composite capture.""" @@ -321,18 +400,38 @@ def get_files(self, obj: dict[str, Any]) -> ReturnList[File]: return cast("ReturnList[File]", all_files) @extend_schema_field(serializers.IntegerField) - def get_files_count(self, obj: dict[str, Any]) -> int: + def get_data_files_count(self, obj: dict[str, Any]) -> int | None: """Get the total count of files across all channels.""" + if obj["capture_type"] != CaptureType.DigitalRF: + return None + total_count = 0 for channel_data in obj["channels"]: capture_uuid = channel_data["uuid"] capture = Capture.objects.get(uuid=capture_uuid) - total_count += get_capture_files(capture, include_deleted=False).count() + total_count += get_data_files(capture.capture_type, capture).count() return total_count + @extend_schema_field(serializers.IntegerField) + def get_data_files_total_size(self, obj: dict[str, Any]) -> int | None: + """Exact sum of data file sizes across all channels.""" + if obj["capture_type"] != CaptureType.DigitalRF: + return None + total = 0 + for channel_data in obj["channels"]: + capture_uuid = channel_data["uuid"] + capture = Capture.objects.get(uuid=capture_uuid) + data_files = get_data_files(capture.capture_type, capture) + result = data_files.aggregate(total_size=Sum("size")) + total += result.get("total_size") or 0 + return total + @extend_schema_field(serializers.IntegerField) def get_total_file_size(self, obj: dict[str, Any]) -> int: """Get the total file size across all channels.""" + if obj["capture_type"] != CaptureType.DigitalRF: + return None + total_size = 0 for channel_data in obj["channels"]: capture_uuid = channel_data["uuid"] @@ -340,6 +439,13 @@ def get_total_file_size(self, obj: dict[str, Any]) -> int: all_files = get_capture_files(capture, include_deleted=False) result = all_files.aggregate(total_size=Sum("size")) total_size += result["total_size"] or 0 + data_total = self.get_data_files_total_size(obj) or 0 + if total_size < data_total: + logging.getLogger(__name__).warning( + "Composite capture: total_file_size (%s) < data_files_total_size (%s); using data total.", + total_size, data_total, + ) + total_size = data_total return total_size @extend_schema_field(serializers.CharField) @@ -350,6 +456,48 @@ def get_formatted_created_at(self, obj: dict[str, Any]) -> str: return created_at.strftime("%m/%d/%Y %I:%M:%S %p") return "" + @extend_schema_field(serializers.IntegerField(allow_null=True)) + def get_length_of_capture_ms(self, obj: dict[str, Any]) -> int | None: + """Use first channel's bounds for composite capture duration.""" + channels = obj.get("channels") or [] + if not channels: + return None + try: + capture = Capture.objects.get(uuid=channels[0]["uuid"]) + start_time, end_time = get_capture_bounds( + capture.capture_type, str(capture.uuid) + ) + return (end_time - start_time) * 1000 + except (ValueError, IndexError, KeyError): + return None + + @extend_schema_field(serializers.IntegerField(allow_null=True)) + def get_file_cadence_ms(self, obj: dict[str, Any]) -> int | None: + """Use first channel's file cadence for composite capture.""" + channels = obj.get("channels") or [] + if not channels: + return None + try: + capture = Capture.objects.get(uuid=channels[0]["uuid"]) + return get_file_cadence(capture.capture_type, capture) + except (ValueError, IndexError, KeyError): + return None + + @extend_schema_field(serializers.IntegerField(allow_null=True)) + def get_capture_start_epoch_sec(self, obj: dict[str, Any]) -> int | None: + """Use first channel's start time for composite capture.""" + channels = obj.get("channels") or [] + if not channels: + return None + try: + capture = Capture.objects.get(uuid=channels[0]["uuid"]) + start_time, _ = get_capture_bounds( + capture.capture_type, str(capture.uuid) + ) + return start_time + except (ValueError, IndexError, KeyError): + return None + def build_composite_capture_data(captures: list[Capture]) -> dict[str, Any]: """Build composite capture data from a list of captures with the same top_level_dir. diff --git a/gateway/sds_gateway/api_methods/tasks.py b/gateway/sds_gateway/api_methods/tasks.py index db752163..51f5698b 100644 --- a/gateway/sds_gateway/api_methods/tasks.py +++ b/gateway/sds_gateway/api_methods/tasks.py @@ -20,6 +20,7 @@ from redis import Redis from sds_gateway.api_methods.models import Capture +from sds_gateway.api_methods.models import CaptureType from sds_gateway.api_methods.models import Dataset from sds_gateway.api_methods.models import File from sds_gateway.api_methods.models import ItemType @@ -676,15 +677,26 @@ def _process_item_files( item_type: ItemType, item_uuid: UUID, temp_zip: TemporaryZipFile, + start_time: int | None = None, + end_time: int | None = None, ) -> tuple[Mapping[str, UUID | int | str] | None, str | None, int | None, int | None]: # pyright: ignore[reportMissingTypeArgument] """ Process files for an item and create a zip file. + Args: + user: The user requesting the files + item: The item object (Dataset or Capture) + item_type: Type of item (dataset or capture) + item_uuid: UUID of the item to download + temp_zip: The temporary zip file to create + start_time: Optional start time for temporal filtering + end_time: Optional end time for temporal filtering + Returns: tuple: (error_response, zip_file_path, total_size, files_processed) If error_response is not None, the other values are None """ - files = _get_item_files(user, item, item_type) + files = _get_item_files(user, item, item_type, start_time, end_time) if not files: log.warning(f"No files found for {item_type} {item_uuid}") error_message = f"No files found in {item_type}" @@ -929,7 +941,6 @@ def _send_download_email( getattr(item, "name", str(item)) or f"{item_type.capitalize()} {item.uuid}" ) subject = f"Your {item_type} '{item_display_name}' is ready for download" - context = { "item_type": item_type, "item_name": item_display_name, @@ -979,7 +990,11 @@ def _handle_timeout_exception( time_limit=30 * 60, soft_time_limit=25 * 60 ) # 30 min hard limit, 25 min soft limit def send_item_files_email( # noqa: C901, PLR0911, PLR0912, PLR0915 - item_uuid: UUID, user_id: str, item_type: str | ItemType + item_uuid: UUID, + user_id: str, + item_type: str | ItemType, + start_time: int | None = None, + end_time: int | None = None, ) -> Mapping[str, UUID | str | int]: """ Unified Celery task to create a zip file of item files and send it via email. @@ -990,6 +1005,8 @@ def send_item_files_email( # noqa: C901, PLR0911, PLR0912, PLR0915 item_uuid: UUID of the item to process user_id: ID of the user requesting the download item_type: Type of item (dataset or capture) + start_time: Optional start time for temporal filtering + end_time: Optional end time for temporal filtering Returns: dict: Task result with status and details """ @@ -1053,6 +1070,8 @@ def send_item_files_email( # noqa: C901, PLR0911, PLR0912, PLR0915 item_type=item_type_enum, item_uuid=item_uuid, temp_zip=temp_zip, + start_time=start_time, + end_time=end_time, ) ) if error_response: @@ -1251,7 +1270,13 @@ def _validate_item_download_request( return None, user, item -def _get_item_files(user: User, item: Any, item_type: ItemType) -> list[File]: +def _get_item_files( + user: User, + item: Any, + item_type: ItemType, + start_time: int | None = None, + end_time: int | None = None, +) -> list[File]: """ Get all files for an item based on its type. @@ -1259,10 +1284,14 @@ def _get_item_files(user: User, item: Any, item_type: ItemType) -> list[File]: user: The user requesting the files item: The item object (Dataset or Capture) item_type: Type of item (dataset or capture) - + start_time: Optional start time for temporal filtering + end_time: Optional end time for temporal filtering Returns: List of files associated with the item """ + from sds_gateway.api_methods.helpers.temporal_filtering import ( + get_capture_files_with_temporal_filter, + ) from sds_gateway.api_methods.utils.relationship_utils import get_capture_files from sds_gateway.api_methods.utils.relationship_utils import ( get_dataset_files_including_captures, @@ -1272,14 +1301,35 @@ def _get_item_files(user: User, item: Any, item_type: ItemType) -> list[File]: files_queryset = get_dataset_files_including_captures( item, include_deleted=False ) - files = list(files_queryset) # Convert to list before len() to avoid SQL issues + files = list(files_queryset) log.info(f"Found {len(files)} files for dataset {item.uuid}") return files if item_type == ItemType.CAPTURE: - files = get_capture_files(item, include_deleted=False) + capture_type = item.capture_type + # temporal filtering is only supported for DigitalRF captures + if capture_type == CaptureType.DigitalRF: + files = get_capture_files_with_temporal_filter( + capture_type=capture_type, + capture=item, + start_time=start_time, + end_time=end_time, + ) + else: + if start_time is not None or end_time is not None: + log.warning( + "Temporal filtering is only supported for DigitalRF captures, " + "ignoring start_time and end_time" + ) + + files = get_capture_files( + capture=item, + include_deleted=False, + ) + + files = list(files) log.info(f"Found {len(files)} files for capture {item.uuid}") - return list(files) + return files log.warning(f"Unknown item type: {item_type}") return [] diff --git a/gateway/sds_gateway/api_methods/tests/factories.py b/gateway/sds_gateway/api_methods/tests/factories.py index f71d222f..3192b03d 100644 --- a/gateway/sds_gateway/api_methods/tests/factories.py +++ b/gateway/sds_gateway/api_methods/tests/factories.py @@ -15,8 +15,11 @@ from django.core.files.base import ContentFile from factory import Faker from factory import post_generation +from factory import Sequence from factory.django import DjangoModelFactory +from sds_gateway.api_methods.helpers.temporal_filtering import drf_rf_filename_from_ms +from sds_gateway.api_methods.models import Capture from sds_gateway.api_methods.models import Dataset from sds_gateway.api_methods.models import File from sds_gateway.api_methods.models import ItemType @@ -223,6 +226,53 @@ class Meta: model = File +class CaptureFactory(DjangoModelFactory): + class Meta: + model = Capture + + channel = Faker("word") + capture_type = "drf" + top_level_dir = Faker("file_path", depth=2).replace("/", "_") + owner = Faker("subfactory", factory=UserFactory) + name = Faker("slug") + index_name = "captures-drf" + + +class DRFDataFileFactory(DjangoModelFactory): + """Factory for creating DRF data file instances for testing. + + This factory creates realistic DRF data file objects that represent files stored in the system. + It generates test data for file metadata and creates a Django ContentFile for the actual file content. + + The factory creates files with realistic metadata including size, checksums, and proper file extensions. + It also handles the creation of the Django file field with test content. + """ + + uuid = Faker("uuid4") + directory = f"/files/{self.owner.email}/{self.capture.top_level_dir}/" + name = Sequence(lambda n: drf_rf_filename_from_ms(1000 + n * 1000)) + media_type = "application/x-hdf5" + permissions = "rw-r----" + size = Faker("random_int", min=1000, max=1000000) + sum_blake3 = Faker("sha256") + owner = Faker("subfactory", factory=UserFactory) + capture = Faker("subfactory", factory=CaptureFactory) + is_deleted = False + + @post_generation + def file(self, create, extracted, **kwargs): + if not create: + return + if extracted: + self.file = extracted + else: + content = b"test drf file content" + self.file = ContentFile(content, name=self.name) + + class Meta: + model = File + + class UserSharePermissionFactory(DjangoModelFactory): """Factory for creating UserSharePermission instances for testing. diff --git a/gateway/sds_gateway/api_methods/tests/test_celery_tasks.py b/gateway/sds_gateway/api_methods/tests/test_celery_tasks.py index d5c6fba3..7f5f16d1 100644 --- a/gateway/sds_gateway/api_methods/tests/test_celery_tasks.py +++ b/gateway/sds_gateway/api_methods/tests/test_celery_tasks.py @@ -36,6 +36,7 @@ from sds_gateway.api_methods.tasks import get_user_task_status from sds_gateway.api_methods.tasks import is_user_locked from sds_gateway.api_methods.tasks import release_user_lock +from sds_gateway.api_methods.tasks import _get_item_files from sds_gateway.api_methods.tasks import send_item_files_email from sds_gateway.api_methods.utils.disk_utils import estimate_disk_size @@ -1232,3 +1233,51 @@ def test_large_file_download_redirects_to_sdk(self): assert result["status"] == "error" assert "SDK" in result["message"] assert "GB" in result["message"] # Check for GB in general + + def test_get_item_files_with_temporal_bounds_returns_expected_rf_subset(self): + """ + Task-level test: start_time/end_time flow into _get_item_files and the helper + returns only the expected DRF data files in range (temporal_filtering logic + is unit-tested in test_temporal_filtering.py). + """ + # Create DRF-named files for self.capture (epoch 1s..5s) + epoch_start_sec = 1 + epoch_end_sec = 6 + for i in range(epoch_start_sec, epoch_end_sec): + name = f"rf@{i}.000.h5" + content = ContentFile(b"x", name=name) + File.objects.create( + name=name, + size=100, + directory=self.top_level_dir, + owner=self.user, + capture=self.capture, + file=content, + sum_blake3="a" * 64, + ) + # Link to capture via FK (get_capture_files uses both M2M and FK) + mock_response = { + "found": True, + "_source": { + "search_props": { + "start_time": epoch_start_sec, + "end_time": epoch_end_sec, + } + }, + } + with patch( + "sds_gateway.api_methods.helpers.temporal_filtering.get_opensearch_client" + ) as m: + m.return_value.get.return_value = mock_response + # Relative ms: 1000–4000 ms from start → files rf@1.000.h5 .. rf@4.000.h5 + result = _get_item_files( + self.user, + self.capture, + ItemType.CAPTURE, + start_time=1000, + end_time=4000, + ) + names = [f.name for f in result] + # start_time=1000, end_time=4000 → absolute 2s..5s → rf@2.000.h5 .. rf@5.000.h5 + expected = [f"rf@{i}.000.h5" for i in range(2, 6)] + assert names == expected, f"Expected {expected}, got {names}" diff --git a/gateway/sds_gateway/api_methods/tests/test_temporal_filtering.py b/gateway/sds_gateway/api_methods/tests/test_temporal_filtering.py new file mode 100644 index 00000000..a9d5d8c5 --- /dev/null +++ b/gateway/sds_gateway/api_methods/tests/test_temporal_filtering.py @@ -0,0 +1,112 @@ +import time + +from unittest.mock import patch +from django.test import TestCase + +import sds_gateway.api_methods.helpers.temporal_filtering as temporal_filtering +from sds_gateway.api_methods.models import Capture, CaptureType + +from sds_gateway.api_methods.tests.factories import CaptureFactory, DRFDataFileFactory, UserFactory + + +class TemporalFilteringTestCase(TestCase): + def setUp(self): + # get unix timestamp for now + self.now = int(time.time()) + self.file_count = 10 + self.user = UserFactory() + self.capture = CaptureFactory(owner=self.user, capture_type="drf") + + # Create 5 DRF data files in sequence with 1 second interval + self.files = [ + DRFDataFileFactory( + capture=self.capture, + owner=self.user, + name=f"rf@{self.now + i}.000.h5", + ) + for i in range(self.file_count) + ] + + def _get_test_capture_bounds(self): + start_sec = int(self.now) + end_sec = start_sec + 10 # 10 second span + return start_sec, end_sec + + def test_rf_filename_ms_conversion(self): + for i in range(10): + expected_ms = (self.now + i) * 1000 + filename_to_ms = temporal_filtering.drf_rf_filename_to_ms(self.files[i].name) + assert filename_to_ms is not None + assert filename_to_ms == expected_ms + + ms_to_filename = temporal_filtering.drf_rf_filename_from_ms(expected_ms) + assert ms_to_filename is not None + assert ms_to_filename == self.files[i].name + + def test_get_capture_bounds(self): + start_sec, end_sec = self._get_test_capture_bounds() + # mock response, opensearch calls are tested in test_opensearch.py + mock_response = { + "found": True, + "_source": { + "search_props": { + "start_time": start_sec, + "end_time": end_sec, + } + }, + } + with patch("sds_gateway.api_methods.helpers.temporal_filtering.get_opensearch_client") as m: + m.return_value.get.return_value = mock_response + start_time, end_time = temporal_filtering.get_capture_bounds( + self.capture.capture_type, str(self.capture.uuid) + ) + assert start_time is not None + assert end_time is not None + assert start_time == start_sec + assert end_time == end_sec + + def test_get_file_cadence(self): + start_sec, end_sec = self._get_test_capture_bounds() + # mock response, opensearch calls are tested in test_opensearch.py + mock_response = { + "found": True, + "_source": { + "search_props": { + "start_time": start_sec, + "end_time": end_sec, + } + }, + } + with patch("sds_gateway.api_methods.helpers.temporal_filtering.get_opensearch_client") as m: + m.return_value.get.return_value = mock_response + file_cadence = temporal_filtering.get_file_cadence( + self.capture.capture_type, self.capture + ) + expected_cadence = max(1, int((end_sec - start_sec) * 1000 / (self.file_count - 1))) + assert file_cadence is not None + assert file_cadence == expected_cadence + + def test_file_filtering(self): + start_ms = 1000 + end_ms = 5000 + # Inclusive range: 1s, 2s, 3s, 4s, 5s -> 5 files + expected_count = (end_ms - start_ms) // 1000 + 1 + start_sec, end_sec = self._get_test_capture_bounds() + mock_response = { + "found": True, + "_source": { + "search_props": { + "start_time": start_sec, + "end_time": end_sec, + } + }, + } + with patch("sds_gateway.api_methods.helpers.temporal_filtering.get_opensearch_client") as m: + m.return_value.get.return_value = mock_response + filtered_files = temporal_filtering.filter_capture_data_files_selection_bounds( + self.capture.capture_type, self.capture, start_ms, end_ms + ) + assert filtered_files is not None + assert len(filtered_files) == expected_count + for i in range(expected_count): + assert filtered_files[i].name == self.files[1 + i].name diff --git a/gateway/sds_gateway/static/js/actions/DownloadActionManager.js b/gateway/sds_gateway/static/js/actions/DownloadActionManager.js index 314b5aca..7e096480 100644 --- a/gateway/sds_gateway/static/js/actions/DownloadActionManager.js +++ b/gateway/sds_gateway/static/js/actions/DownloadActionManager.js @@ -2,6 +2,81 @@ * Download Action Manager * Handles all download-related actions */ + +function msToHms(ms) { + const n = Number(ms); + if (!Number.isFinite(n) || n < 0) return "0:00:00.000"; + const totalSec = Math.floor(n / 1000); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + const decimalMs = n % 1000; + const hms = [h, m, s].map((v) => String(v).padStart(2, "0")).join(":"); + return hms + "." + String(decimalMs).padStart(3, "0"); +} + +function formatBytes(bytes) { + const n = Number(bytes); + if (!Number.isFinite(n) || n < 0) return "0 bytes"; + if (n === 0) return "0 bytes"; + const units = ["bytes", "KB", "MB", "GB"]; + let i = 0; + let v = n; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return (i === 0 ? v : v.toFixed(2)) + " " + units[i]; +} + +function formatUtcRange(startEpochSec, startMs, endMs) { + if (!Number.isFinite(startEpochSec)) return "—"; + const startDate = new Date(startEpochSec * 1000 + startMs); + const endDate = new Date(startEpochSec * 1000 + endMs); + const pad2 = (x) => String(x).padStart(2, "0"); + const fmt = (d) => + pad2(d.getUTCHours()) + + ":" + + pad2(d.getUTCMinutes()) + + ":" + + pad2(d.getUTCSeconds()) + + " " + + pad2(d.getUTCMonth() + 1) + + "/" + + pad2(d.getUTCDate()) + + "/" + + d.getUTCFullYear(); + return fmt(startDate) + " - " + fmt(endDate) + " (UTC)"; +} + +/** Format ms from capture start as UTC string for display (Y-m-d H:i:s). */ +function msToUtcString(captureStartEpochSec, ms) { + if (!Number.isFinite(captureStartEpochSec) || !Number.isFinite(ms)) return ""; + const d = new Date(captureStartEpochSec * 1000 + ms); + const pad2 = (x) => String(x).padStart(2, "0"); + return ( + d.getUTCFullYear() + + "-" + + pad2(d.getUTCMonth() + 1) + + "-" + + pad2(d.getUTCDate()) + + " " + + pad2(d.getUTCHours()) + + ":" + + pad2(d.getUTCMinutes()) + + ":" + + pad2(d.getUTCSeconds()) + ); +} + +/** Parse UTC date string (Y-m-d H:i:s or Y-m-d H:i) to epoch ms. */ +function parseUtcStringToEpochMs(str) { + if (!str || !str.trim()) return NaN; + const s = str.trim(); + const d = new Date(s.endsWith("Z") ? s : s.replace(" ", "T") + "Z"); + return Number.isFinite(d.getTime()) ? d.getTime() : NaN; +} + class DownloadActionManager { /** * Initialize download action manager @@ -21,6 +96,9 @@ class DownloadActionManager { // Initialize download buttons for captures this.initializeCaptureDownloadButtons(); + + // Web download modal (dataset + capture) + this.initializeWebDownloadModal(); } /** @@ -89,6 +167,391 @@ class DownloadActionManager { } } + /** + * Initialize web download modal: confirm button click and modal hidden handler. + * Exposes showWebDownloadModal on window for template callbacks. + */ + initializeWebDownloadModal() { + const webDownloadModal = document.getElementById("webDownloadModal"); + const confirmWebDownloadBtn = document.getElementById("confirmWebDownloadBtn"); + if (!webDownloadModal || !confirmWebDownloadBtn) return; + + confirmWebDownloadBtn.addEventListener("click", () => { + const itemType = confirmWebDownloadBtn.dataset.itemType || "dataset"; + const uuid = confirmWebDownloadBtn.dataset.itemUuid || confirmWebDownloadBtn.dataset.datasetUuid; + + if (!uuid) return; + + const startTimeInput = document.getElementById("startTime"); + const endTimeInput = document.getElementById("endTime"); + const startEntry = document.getElementById("startTimeEntry"); + const endEntry = document.getElementById("endTimeEntry"); + const modalEl = document.getElementById("webDownloadModal"); + + if (startEntry && endEntry && modalEl && modalEl.dataset.durationMs) { + const entryStart = startEntry.value.trim(); + const entryEnd = endEntry.value.trim(); + if (entryStart !== "" || entryEnd !== "") { + const durationMs = parseInt(modalEl.dataset.durationMs, 10); + const startMs = entryStart === "" ? 0 : parseInt(entryStart, 10); + const endMs = entryEnd === "" ? durationMs : parseInt(entryEnd, 10); + if ( + !Number.isFinite(startMs) || + !Number.isFinite(endMs) || + startMs < 0 || + endMs > durationMs || + startMs >= endMs + ) { + this.showToast( + "Please enter valid start/end times (0 ≤ start < end ≤ " + durationMs + " ms).", + "warning", + ); + return; + } + if (startTimeInput) startTimeInput.value = String(startMs); + if (endTimeInput) endTimeInput.value = String(endMs); + } + } + + const labels = this.getWebDownloadModalLabels(itemType); + confirmWebDownloadBtn.innerHTML = ' Processing...'; + confirmWebDownloadBtn.disabled = true; + + const url = "/users/download-item/" + itemType + "/" + uuid + "/"; + const headers = { + "X-CSRFToken": document.querySelector("[name=csrfmiddlewaretoken]")?.value, + }; + let body = null; + if (startTimeInput && endTimeInput && startTimeInput.value && endTimeInput.value) { + headers["Content-Type"] = "application/x-www-form-urlencoded"; + body = new URLSearchParams({ + start_time: startTimeInput.value, + end_time: endTimeInput.value, + }); + } else { + headers["Content-Type"] = "application/json"; + } + + fetch(url, { method: "POST", headers, body }) + .then((response) => { + const contentType = response.headers.get("content-type"); + if (contentType && contentType.includes("application/json")) { + return response.json(); + } + return response.text().then((text) => { + throw new Error("Server returned non-JSON response: " + text); + }); + }) + .then((data) => { + if (data.success === true) { + this.showToast( + data.message || + "Download request submitted successfully! You will receive an email when ready.", + "success", + ); + const modal = bootstrap.Modal.getInstance(webDownloadModal); + if (modal) modal.hide(); + } else { + this.showToast( + "Error requesting download: " + (data.message || "Unknown error"), + "danger", + ); + } + }) + .catch((error) => { + console.error("Download error:", error); + this.showToast( + error.message || "An error occurred while processing your request.", + "danger", + ); + }) + .finally(() => { + confirmWebDownloadBtn.innerHTML = + ' ' + labels.confirmText; + confirmWebDownloadBtn.disabled = false; + }); + }); + + webDownloadModal.addEventListener("hidden.bs.modal", () => { + confirmWebDownloadBtn.dataset.itemType = ""; + confirmWebDownloadBtn.dataset.itemUuid = ""; + confirmWebDownloadBtn.dataset.itemName = ""; + confirmWebDownloadBtn.dataset.datasetUuid = ""; + confirmWebDownloadBtn.dataset.datasetName = ""; + const nameEl = document.getElementById("webDownloadDatasetName"); + if (nameEl) nameEl.textContent = ""; + }); + + window.showWebDownloadModal = (a1, a2) => { + const options = + typeof a1 === "string" && a2 !== undefined + ? { itemType: "dataset", uuid: a1, name: a2 } + : a1; + this.showWebDownloadModal(options); + }; + } + + /** + * Open web download modal for a dataset or capture. + * @param {{ itemType?: string, uuid: string, name?: string }} options - itemType 'dataset'|'capture', uuid, name + */ + showWebDownloadModal(options) { + const { itemType = "dataset", uuid, name } = options || {}; + const nameEl = document.getElementById("webDownloadDatasetName"); + const confirmBtn = document.getElementById("confirmWebDownloadBtn"); + const modalEl = document.getElementById("webDownloadModal"); + const titleEl = document.getElementById("webDownloadModalLabel"); + + if (nameEl) nameEl.textContent = name || ""; + if (confirmBtn) { + confirmBtn.dataset.itemType = itemType; + confirmBtn.dataset.itemUuid = uuid || ""; + confirmBtn.dataset.itemName = name || ""; + } + // Update title and button text from item type + const labels = this.getWebDownloadModalLabels(itemType); + if (titleEl && window.DOMUtils) { + window.DOMUtils.renderContent(titleEl, { icon: "download", text: labels.title }); + } + if (confirmBtn && window.DOMUtils) { + window.DOMUtils.renderContent(confirmBtn, { icon: "download", text: labels.confirmText }); + } + if (modalEl && window.bootstrap) { + new bootstrap.Modal(modalEl).show(); + } + } + + /** + * Initialize or update the capture download temporal slider. Call before + * showing the modal when opening for a capture with known bounds. + * @param {number} durationMs - Total capture duration in milliseconds + * @param {number} fileCadenceMs - File cadence in milliseconds (step) + * @param {Object} opts - Optional: { perDataFileSize, totalSize, dataFilesCount, totalFilesCount, dataFilesTotalSize, captureUuid, captureStartEpochSec } + */ + initializeCaptureDownloadSlider(durationMs, fileCadenceMs, opts) { + opts = opts || {}; + var sliderEl = document.getElementById('temporalFilterSlider'); + var rangeLabel = document.getElementById('temporalFilterRangeLabel'); + var totalFilesLabel = document.getElementById('totalFilesLabel'); + var metadataFilesLabel = document.getElementById('metadataFilesLabel'); + var totalSizeLabel = document.getElementById('totalSizeLabel'); + var dateTimeLabel = document.getElementById('dateTimeLabel'); + var startTimeInput = document.getElementById('startTime'); + var endTimeInput = document.getElementById('endTime'); + var startTimeEntry = document.getElementById('startTimeEntry'); + var endTimeEntry = document.getElementById('endTimeEntry'); + var startDateTimeEntry = document.getElementById('startDateTimeEntry'); + var endDateTimeEntry = document.getElementById('endDateTimeEntry'); + var rangeHintEl = document.getElementById('temporalRangeHint'); + var sizeWarningEl = document.getElementById('temporalFilterSizeWarning'); + var webDownloadModal = document.getElementById('webDownloadModal'); + if (!sliderEl || typeof noUiSlider === 'undefined') return; + durationMs = Number(durationMs); + if (!Number.isFinite(durationMs) || durationMs < 0) durationMs = 0; + fileCadenceMs = Number(fileCadenceMs); + if (!Number.isFinite(fileCadenceMs) || fileCadenceMs < 1) fileCadenceMs = 1000; + var perDataFileSize = Number(opts.perDataFileSize) || 0; + var totalSize = Number(opts.totalSize) || 0; + var dataFilesCount = Number(opts.dataFilesCount) || 0; + var totalFilesCount = Number(opts.totalFilesCount) || 0; + var dataFilesTotalSize = Number(opts.dataFilesTotalSize); + if (!Number.isFinite(dataFilesTotalSize) || dataFilesTotalSize < 0) { + dataFilesTotalSize = perDataFileSize * dataFilesCount; + } + var metadataFilesTotalSize = totalSize - dataFilesTotalSize; + if (metadataFilesTotalSize < 0) metadataFilesTotalSize = 0; + var metadataFilesCount = Math.max(0, totalFilesCount - dataFilesCount); + var captureUuid = opts.captureUuid != null ? String(opts.captureUuid) : ''; + var captureStartEpochSec = Number(opts.captureStartEpochSec); + if (totalSize > 0 && dataFilesTotalSize > totalSize) { + console.warn( + '[DownloadActionManager] data files total size exceeds total size (backend/query inconsistency).', + { captureUuid: captureUuid || '(unknown)', totalSize, dataFilesTotalSize, perDataFileSize, dataFilesCount } + ); + if (sizeWarningEl) { + sizeWarningEl.classList.remove('d-none'); + } + dataFilesTotalSize = totalSize; + metadataFilesTotalSize = 0; + } else if (sizeWarningEl) { + sizeWarningEl.classList.add('d-none'); + } + if (webDownloadModal) { + webDownloadModal.dataset.durationMs = String(Math.round(durationMs)); + webDownloadModal.dataset.fileCadenceMs = String(fileCadenceMs); + webDownloadModal.dataset.captureStartEpochSec = Number.isFinite(captureStartEpochSec) ? String(captureStartEpochSec) : ''; + } + if (rangeHintEl) rangeHintEl.textContent = '0 – ' + Math.round(durationMs) + ' ms'; + if (sliderEl.noUiSlider) { + sliderEl.noUiSlider.destroy(); + } + if (rangeLabel) rangeLabel.textContent = '—'; + if (totalFilesLabel) totalFilesLabel.textContent = '0 files'; + if (totalSizeLabel) totalSizeLabel.textContent = formatBytes(totalSize); + if (dateTimeLabel) dateTimeLabel.textContent = '—'; + if (startTimeInput) startTimeInput.value = ''; + if (endTimeInput) endTimeInput.value = ''; + if (startTimeEntry) startTimeEntry.value = ''; + if (endTimeEntry) endTimeEntry.value = ''; + var hasEpoch = Number.isFinite(captureStartEpochSec); + if (startDateTimeEntry) { + startDateTimeEntry.value = ''; + startDateTimeEntry.disabled = !hasEpoch; + } + if (endDateTimeEntry) { + endDateTimeEntry.value = ''; + endDateTimeEntry.disabled = !hasEpoch; + } + if (durationMs <= 0) return; + var fpStart = null, fpEnd = null; + var epochStart = captureStartEpochSec * 1000; + var epochEnd = epochStart + durationMs; + if (hasEpoch && typeof flatpickr !== 'undefined' && startDateTimeEntry && endDateTimeEntry) { + var fpOpts = { + enableTime: true, + enableSeconds: true, + utc: true, + dateFormat: 'Y-m-d H:i:S', + time_24hr: true, + minDate: epochStart, + maxDate: epochEnd, + allowInput: true, + static: true, + appendTo: webDownloadModal || undefined, + }; + flatpickr(startDateTimeEntry, Object.assign({}, fpOpts, { + onChange: function() { syncFromDateTimeEntries(); } + })); + flatpickr(endDateTimeEntry, Object.assign({}, fpOpts, { + onChange: function() { syncFromDateTimeEntries(); } + })); + fpStart = startDateTimeEntry._flatpickr; + fpEnd = endDateTimeEntry._flatpickr; + startDateTimeEntry.disabled = false; + endDateTimeEntry.disabled = false; + } + noUiSlider.create(sliderEl, { + start: [0, durationMs], + connect: true, + step: fileCadenceMs, + range: { min: 0, max: durationMs }, + }); + sliderEl.noUiSlider.on('update', function(values) { + var startMs = Number(values[0]); + var endMs = Number(values[1]); + // the + 1 is to include the first file in the selection + // as file cadence is the time between files, not the time of the file + var filesInSelection = Math.round((endMs - startMs) / fileCadenceMs) + 1; + if (rangeLabel) { + rangeLabel.textContent = msToHms(startMs) + ' - ' + msToHms(endMs); + } + if (totalFilesLabel) { + totalFilesLabel.textContent = dataFilesCount > 0 + ? filesInSelection + ' of ' + dataFilesCount + ' files' + : filesInSelection + ' files'; + } + if (totalSizeLabel) { + totalSizeLabel.textContent = formatBytes( + (perDataFileSize * filesInSelection) + metadataFilesTotalSize + ); + } + if (dateTimeLabel && Number.isFinite(captureStartEpochSec)) { + dateTimeLabel.textContent = formatUtcRange(captureStartEpochSec, startMs, endMs); + } + if (startTimeInput) startTimeInput.value = String(Math.round(startMs)); + if (endTimeInput) endTimeInput.value = String(Math.round(endMs)); + if (startTimeEntry) startTimeEntry.value = String(Math.round(startMs)); + if (endTimeEntry) endTimeEntry.value = String(Math.round(endMs)); + if (hasEpoch) { + if (fpStart && typeof fpStart.setDate === 'function') fpStart.setDate(epochStart + startMs); + else if (startDateTimeEntry) startDateTimeEntry.value = msToUtcString(captureStartEpochSec, startMs); + if (fpEnd && typeof fpEnd.setDate === 'function') fpEnd.setDate(epochStart + endMs); + else if (endDateTimeEntry) endDateTimeEntry.value = msToUtcString(captureStartEpochSec, endMs); + } + }); + if (rangeLabel) { + rangeLabel.textContent = '0:00:00.000 - ' + msToHms(durationMs); + } + if (totalFilesLabel) { + totalFilesLabel.textContent = dataFilesCount > 0 + ? dataFilesCount + ' files' + : '0 files'; + } + if (metadataFilesLabel) { + metadataFilesLabel.textContent = metadataFilesCount > 0 + ? metadataFilesCount + ' files' + : '0 files'; + } + if (dateTimeLabel && Number.isFinite(captureStartEpochSec)) { + dateTimeLabel.textContent = formatUtcRange(captureStartEpochSec, 0, durationMs); + } + var startVal = '0'; + var endVal = String(durationMs); + if (startTimeInput) startTimeInput.value = startVal; + if (endTimeInput) endTimeInput.value = endVal; + if (startTimeEntry) startTimeEntry.value = startVal; + if (endTimeEntry) endTimeEntry.value = endVal; + if (hasEpoch && startDateTimeEntry && endDateTimeEntry) { + if (fpStart && typeof fpStart.setDate === 'function') fpStart.setDate(epochStart); + else startDateTimeEntry.value = msToUtcString(captureStartEpochSec, 0); + if (fpEnd && typeof fpEnd.setDate === 'function') fpEnd.setDate(epochEnd); + else endDateTimeEntry.value = msToUtcString(captureStartEpochSec, durationMs); + if (!fpStart) { startDateTimeEntry.disabled = false; endDateTimeEntry.disabled = false; } + } + + function syncSliderFromEntries() { + if (!sliderEl.noUiSlider || !startTimeEntry || !endTimeEntry) return; + var s = startTimeEntry.value.trim(); + var e = endTimeEntry.value.trim(); + var startMs = s === '' ? 0 : parseInt(s, 10); + var endMs = e === '' ? durationMs : parseInt(e, 10); + if (!Number.isFinite(startMs)) startMs = 0; + if (!Number.isFinite(endMs)) endMs = durationMs; + startMs = Math.max(0, Math.min(startMs, durationMs)); + endMs = Math.max(0, Math.min(endMs, durationMs)); + if (startMs >= endMs) endMs = Math.min(startMs + fileCadenceMs, durationMs); + sliderEl.noUiSlider.set([startMs, endMs]); + } + function syncFromDateTimeEntries() { + if (!hasEpoch || !sliderEl.noUiSlider || !startDateTimeEntry || !endDateTimeEntry) return; + var startMs, endMs; + if (startDateTimeEntry._flatpickr && endDateTimeEntry._flatpickr) { + var dStart = startDateTimeEntry._flatpickr.selectedDates[0]; + var dEnd = endDateTimeEntry._flatpickr.selectedDates[0]; + startMs = dStart ? dStart.getTime() - epochStart : 0; + endMs = dEnd ? dEnd.getTime() - epochStart : durationMs; + } else { + startMs = parseUtcStringToEpochMs(startDateTimeEntry.value) - epochStart; + endMs = parseUtcStringToEpochMs(endDateTimeEntry.value) - epochStart; + } + if (Number.isNaN(startMs) || Number.isNaN(endMs)) return; + startMs = Math.max(0, Math.min(startMs, durationMs)); + endMs = Math.max(0, Math.min(endMs, durationMs)); + if (startMs >= endMs) endMs = Math.min(startMs + fileCadenceMs, durationMs); + var cur = sliderEl.noUiSlider.get(); + if (Math.round(Number(cur[0])) === Math.round(startMs) && Math.round(Number(cur[1])) === Math.round(endMs)) return; + sliderEl.noUiSlider.set([startMs, endMs]); + } + if (startTimeEntry) startTimeEntry.addEventListener('change', syncSliderFromEntries); + if (endTimeEntry) endTimeEntry.addEventListener('change', syncSliderFromEntries); + if (startDateTimeEntry && !startDateTimeEntry._flatpickr) startDateTimeEntry.addEventListener('change', syncFromDateTimeEntries); + if (endDateTimeEntry && !endDateTimeEntry._flatpickr) endDateTimeEntry.addEventListener('change', syncFromDateTimeEntries); + } + + /** + * Labels for web download modal by item type (dataset vs capture). + * @param {string} itemType - 'dataset' or 'capture' + * @returns {{ title: string, confirmText: string }} + */ + getWebDownloadModalLabels(itemType) { + const t = (itemType || "dataset").toLowerCase(); + return { + title: t === "capture" ? "Download Capture" : "Download Dataset", + confirmText: + t === "capture" ? "Yes, Download Capture" : "Yes, Download Dataset", + }; + } + /** * Handle dataset download * @param {string} datasetUuid - Dataset UUID @@ -188,7 +651,7 @@ class DownloadActionManager { return; } - // Update modal content for capture + const labels = this.getWebDownloadModalLabels("capture"); const modalTitleElement = document.getElementById("webDownloadModalLabel"); const modalNameElement = document.getElementById("webDownloadDatasetName"); const confirmBtn = document.getElementById("confirmWebDownloadBtn"); @@ -196,7 +659,7 @@ class DownloadActionManager { if (modalTitleElement) { await window.DOMUtils.renderContent(modalTitleElement, { icon: "download", - text: "Download Capture", + text: labels.title, }); } @@ -205,38 +668,41 @@ class DownloadActionManager { } if (confirmBtn) { - // Update button text for capture await window.DOMUtils.renderContent(confirmBtn, { icon: "download", - text: "Yes, Download Capture", + text: labels.confirmText, }); - - // Update the dataset UUID to capture UUID for the API call - confirmBtn.dataset.datasetUuid = captureUuid; - confirmBtn.dataset.datasetName = captureName; - - // Override the API endpoint for captures by temporarily modifying the fetch URL - const originalFetch = window.fetch; - window.fetch = (url, options) => { - const modifiedUrl = url.includes( - `/users/download-item/dataset/${captureUuid}/`, - ) - ? `/users/download-item/capture/${captureUuid}/` - : url; - return originalFetch(modifiedUrl, options); - }; - - // Restore fetch after modal is hidden - const modal = document.getElementById("webDownloadModal"); - const restoreFetch = () => { - window.fetch = originalFetch; - modal.removeEventListener("hidden.bs.modal", restoreFetch); - }; - modal.addEventListener("hidden.bs.modal", restoreFetch); + confirmBtn.dataset.itemType = "capture"; + confirmBtn.dataset.itemUuid = captureUuid; + confirmBtn.dataset.itemName = captureName || "Unnamed Capture"; } + // Initialize temporal slider from button data attributes (clears or builds slider) + const durationMs = parseInt(button.getAttribute("data-length-of-capture-ms"), 10); + const fileCadenceMs = parseInt(button.getAttribute("data-file-cadence-ms"), 10); + const perDataFileSize = parseFloat(button.getAttribute("data-per-data-file-size"), 10); + const totalSize = parseInt(button.getAttribute("data-total-size"), 10); + const dataFilesCount = parseInt(button.getAttribute("data-data-files-count"), 10); + const totalFilesCount = parseInt(button.getAttribute("data-total-files-count"), 10); + const dataFilesTotalSizeRaw = button.getAttribute("data-data-files-total-size"); + const dataFilesTotalSize = dataFilesTotalSizeRaw !== null && dataFilesTotalSizeRaw !== '' ? parseInt(dataFilesTotalSizeRaw, 10) : NaN; + const captureStartEpochSec = parseInt(button.getAttribute("data-capture-start-epoch-sec"), 10); + this.initializeCaptureDownloadSlider( + Number.isNaN(durationMs) ? 0 : durationMs, + Number.isNaN(fileCadenceMs) ? 1000 : fileCadenceMs, + { + perDataFileSize: Number.isNaN(perDataFileSize) ? 0 : perDataFileSize, + totalSize: Number.isNaN(totalSize) ? 0 : totalSize, + dataFilesCount: Number.isNaN(dataFilesCount) ? 0 : dataFilesCount, + totalFilesCount: Number.isNaN(totalFilesCount) ? 0 : totalFilesCount, + dataFilesTotalSize: Number.isNaN(dataFilesTotalSize) ? undefined : dataFilesTotalSize, + captureUuid: captureUuid || undefined, + captureStartEpochSec: Number.isNaN(captureStartEpochSec) ? undefined : captureStartEpochSec, + }, + ); + // Show the modal - window.showWebDownloadModal(captureUuid, captureName); + this.openCustomModal("webDownloadModal"); } /** diff --git a/gateway/sds_gateway/static/js/actions/__tests__/DownloadActionManager.test.js b/gateway/sds_gateway/static/js/actions/__tests__/DownloadActionManager.test.js index 0293a32c..4abdbaef 100644 --- a/gateway/sds_gateway/static/js/actions/__tests__/DownloadActionManager.test.js +++ b/gateway/sds_gateway/static/js/actions/__tests__/DownloadActionManager.test.js @@ -352,6 +352,103 @@ describe("DownloadActionManager", () => { }); }); + describe("initializeCaptureDownloadSlider", () => { + let mockSliderEl; + let mockNoUiSliderCreate; + let mockSliderInstance; + + beforeEach(() => { + downloadManager = new DownloadActionManager({ + permissions: mockPermissions, + }); + mockSliderInstance = { + on: jest.fn(), + destroy: jest.fn(), + set: jest.fn(), + }; + mockSliderEl = { + noUiSlider: null, + dataset: {}, + }; + mockNoUiSliderCreate = jest.fn(() => { + mockSliderEl.noUiSlider = mockSliderInstance; + }); + }); + + test("should return early when temporalFilterSlider element is missing", () => { + document.getElementById = jest.fn((id) => { + if (id === "temporalFilterSlider") return null; + return {}; + }); + global.noUiSlider = { create: mockNoUiSliderCreate }; + + expect(() => { + downloadManager.initializeCaptureDownloadSlider(10000, 1000, {}); + }).not.toThrow(); + expect(mockNoUiSliderCreate).not.toHaveBeenCalled(); + }); + + test("should return early when noUiSlider is undefined", () => { + const originalNoUiSlider = global.noUiSlider; + global.noUiSlider = undefined; + document.getElementById = jest.fn((id) => { + if (id === "temporalFilterSlider") return mockSliderEl; + return {}; + }); + + expect(() => { + downloadManager.initializeCaptureDownloadSlider(10000, 1000, {}); + }).not.toThrow(); + + global.noUiSlider = originalNoUiSlider; + }); + + test("should create slider and set modal dataset and range hint when slider and noUiSlider exist", () => { + const rangeHintEl = { textContent: "" }; + const webDownloadModal = { dataset: {} }; + document.getElementById = jest.fn((id) => { + if (id === "temporalFilterSlider") return mockSliderEl; + if (id === "temporalRangeHint") return rangeHintEl; + if (id === "webDownloadModal") return webDownloadModal; + return { textContent: "", value: "", dataset: {}, classList: { add: jest.fn(), remove: jest.fn() }, disabled: false }; + }); + global.noUiSlider = { create: mockNoUiSliderCreate }; + + downloadManager.initializeCaptureDownloadSlider(5000, 500, { + dataFilesCount: 10, + totalFilesCount: 12, + totalSize: 1000000, + }); + + expect(mockNoUiSliderCreate).toHaveBeenCalledWith( + mockSliderEl, + expect.objectContaining({ + start: [0, 5000], + connect: true, + step: 500, + range: { min: 0, max: 5000 }, + }) + ); + expect(webDownloadModal.dataset.durationMs).toBe("5000"); + expect(webDownloadModal.dataset.fileCadenceMs).toBe("500"); + expect(rangeHintEl.textContent).toBe("0 – 5000 ms"); + }); + + test("should not create slider when durationMs is 0", () => { + const rangeHintEl = { textContent: "" }; + document.getElementById = jest.fn((id) => { + if (id === "temporalFilterSlider") return mockSliderEl; + if (id === "temporalRangeHint") return rangeHintEl; + return {}; + }); + global.noUiSlider = { create: mockNoUiSliderCreate }; + + downloadManager.initializeCaptureDownloadSlider(0, 1000, {}); + + expect(mockNoUiSliderCreate).not.toHaveBeenCalled(); + }); + }); + describe("Web Download Modal", () => { beforeEach(() => { downloadManager = new DownloadActionManager({ diff --git a/gateway/sds_gateway/static/js/file-list.js b/gateway/sds_gateway/static/js/file-list.js index 9997466c..3b50b368 100644 --- a/gateway/sds_gateway/static/js/file-list.js +++ b/gateway/sds_gateway/static/js/file-list.js @@ -575,6 +575,24 @@ class FileListCapturesTableManager extends CapturesTableManager { this.searchButtonLoading = document.getElementById("search-btn-loading"); } + /** + * Use web download modal (with temporal slider) when DownloadActionManager is available. + */ + handleDownloadCapture(button) { + if (window.currentDownloadManager && document.getElementById("webDownloadModal")) { + const captureUuid = button.getAttribute("data-capture-uuid"); + const captureName = button.getAttribute("data-capture-name") || captureUuid; + if (captureUuid) { + window.currentDownloadManager.handleCaptureDownload( + captureUuid, + captureName, + button, + ); + } + return; + } + } + /** * Override showLoading to toggle button contents instead of showing separate indicator */ @@ -711,6 +729,14 @@ class FileListCapturesTableManager extends CapturesTableManager { centerFrequencyGhz: ComponentUtils.escapeHtml( capture.center_frequency_ghz || "", ), + lengthOfCaptureMs: capture.length_of_capture_ms ?? 0, + fileCadenceMs: capture.file_cadence_ms ?? 1000, + perDataFileSize: capture.per_data_file_size ?? 0, + totalSize: capture.total_file_size ?? 0, + dataFilesCount: capture.data_files_count ?? 0, + dataFilesTotalSize: capture.data_files_total_size ?? 0, + totalFilesCount: capture.files.length ?? 0, + captureStartEpochSec: capture.capture_start_epoch_sec ?? 0, }; let typeDisplay = safeData.captureTypeDisplay || safeData.captureType; @@ -835,7 +861,15 @@ class FileListCapturesTableManager extends CapturesTableManager { diff --git a/gateway/sds_gateway/templates/base.html b/gateway/sds_gateway/templates/base.html index efa63a09..8a459407 100644 --- a/gateway/sds_gateway/templates/base.html +++ b/gateway/sds_gateway/templates/base.html @@ -19,6 +19,10 @@ href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" /> + + {% block css %} @@ -42,6 +46,7 @@ + {% endblock javascript %} {# djlint:off H021 #} @@ -215,6 +220,7 @@ {# Removed JS that was hiding/showing the body #} {% endblock inline_javascript %} + diff --git a/gateway/sds_gateway/templates/users/published_datasets_list.html b/gateway/sds_gateway/templates/users/published_datasets_list.html index 55d44920..cfe352f7 100644 --- a/gateway/sds_gateway/templates/users/published_datasets_list.html +++ b/gateway/sds_gateway/templates/users/published_datasets_list.html @@ -26,7 +26,7 @@