diff --git a/gateway/.envs/example/django.env b/gateway/.envs/example/django.env index 8be928cc8..1287a26fa 100644 --- a/gateway/.envs/example/django.env +++ b/gateway/.envs/example/django.env @@ -2,6 +2,7 @@ # ====================== LOCAL ENV ====================== # GENERAL # ------------------------------------------------------------------------------ +DJANGO_DEBUG=true USE_DOCKER=yes HOSTNAME=localhost:8000 API_VERSION=v1 diff --git a/gateway/.envs/example/django.prod-example.env b/gateway/.envs/example/django.prod-example.env index 3f5a7c053..c4c55cd98 100644 --- a/gateway/.envs/example/django.prod-example.env +++ b/gateway/.envs/example/django.prod-example.env @@ -3,6 +3,7 @@ # GENERAL # ------------------------------------------------------------------------------ # DJANGO_READ_DOT_ENV_FILE=true +DJANGO_DEBUG=false DJANGO_SETTINGS_MODULE=config.settings.production DJANGO_SECRET_KEY= DJANGO_ADMIN_URL= diff --git a/gateway/config/settings/base.py b/gateway/config/settings/base.py index 5c49c220e..fe11d9606 100644 --- a/gateway/config/settings/base.py +++ b/gateway/config/settings/base.py @@ -10,6 +10,7 @@ from environs import env from config.settings.logs import ColoredFormatter +from config.settings.utils import guess_admin_console_env from config.settings.utils import guess_max_web_download_size __rng = random.SystemRandom() @@ -591,6 +592,24 @@ def __get_random_token(length: int) -> str: SDS_PROGRAMMATIC_SITE_NAME: str = env.str("SDS_PROGRAMMATIC_SITE_NAME", default="sds") SDS_SITE_FQDN: str = env.str("SDS_SITE_FQDN", default="localhost") +# ADMIN_CONSOLE_ENV is used to visually distinguish between different environments +# (production, staging, local) in the admin console and error emails. It does not affect +# any functionality and it is meant to prevent changes in production meant for testing +# or development environments. +ADMIN_CONSOLE_ENV: str = ( + env.str( + "SDS_ADMIN_CONSOLE_ENV", + default=guess_admin_console_env(is_debug=DEBUG), + ) + .strip() + .lower() +) +# cast to known values +if ADMIN_CONSOLE_ENV in {"dev", "development"}: + ADMIN_CONSOLE_ENV = "local" +elif ADMIN_CONSOLE_ENV not in {"production", "staging", "local"}: + ADMIN_CONSOLE_ENV = "local" if DEBUG else "production" + def _get_brand_image_url() -> str | None: """Resolve brand image path from static files only. diff --git a/gateway/config/settings/utils.py b/gateway/config/settings/utils.py index a19d340ab..b895a12b3 100644 --- a/gateway/config/settings/utils.py +++ b/gateway/config/settings/utils.py @@ -1,14 +1,33 @@ import os from socket import gethostname +from loguru import logger as log from sentry_sdk.types import Event from sentry_sdk.types import Hint +def _is_staging_guess() -> bool: + """Determines if the current environment is staging based on hostname.""" + # any of these substrings in hostname or SENTRY_ENVIRONMENT + # is enough to hint a staging environment + staging_hints_lower = { + "staging", + "-qa", + ".qa", + } + hostname: str = gethostname().lower() + is_staging_hostname = any(hint in hostname for hint in staging_hints_lower) + sentry_env = os.getenv("SENTRY_ENVIRONMENT", "").lower() + is_staging_sentry = any(hint in sentry_env for hint in staging_hints_lower) + + is_staging = is_staging_hostname or is_staging_sentry + log.debug(f"{is_staging=}") + + return is_staging + + def guess_best_sentry_env() -> str: - _hostname: str = gethostname() - _is_staging: bool = "-qa" in _hostname or "-dev" in _hostname - return "staging" if _is_staging else "production" + return "staging" if _is_staging_guess() else "production" def before_send(event: Event, hint: Hint) -> Event | None: @@ -39,7 +58,21 @@ def guess_max_web_download_size() -> int: - Production: 20GB - Dev/QA (staging): 5GB """ - _hostname: str = gethostname() - _is_staging: bool = "-qa" in _hostname or "-dev" in _hostname # Production: 20GB, Staging (dev/qa): 5GB - return 20 * 1024 * 1024 * 1024 if not _is_staging else 5 * 1024 * 1024 * 1024 + return ( + 20 * 1024 * 1024 * 1024 if not _is_staging_guess() else 5 * 1024 * 1024 * 1024 + ) + + +def guess_admin_console_env(*, is_debug: bool) -> str: + """Determine the admin console environment label. + + Returns: + str: One of "production", "staging", or "local". + """ + if _is_staging_guess(): + return "staging" + if is_debug: + return "local" + # safer default + return "production" diff --git a/gateway/sds_gateway/api_methods/tests/test_capture_endpoints.py b/gateway/sds_gateway/api_methods/tests/test_capture_endpoints.py index 0b9a475f0..00e0567b0 100644 --- a/gateway/sds_gateway/api_methods/tests/test_capture_endpoints.py +++ b/gateway/sds_gateway/api_methods/tests/test_capture_endpoints.py @@ -275,21 +275,25 @@ def _stub(*_args, **_kwargs): return _stub + def _delete_owned_files(self) -> None: + """Remove files owned by the primary test user after clearing relations.""" + + owned_files = list(File.objects.filter(owner=self.user)) + for owned_file in owned_files: + owned_file.captures.clear() + owned_file.datasets.clear() + + File.objects.filter(owner=self.user).update( + capture=None, + dataset=None, + ) + File.objects.filter(owner=self.user).delete() + def tearDown(self) -> None: """Clean up test data.""" - # remove temporary files linked during tests - for temp_file in getattr(self, "_temp_files", []): - temp_file_obj = File.objects.get(pk=temp_file.pk) - # Clear M2M relationships - temp_file_obj.captures.clear() - temp_file_obj.datasets.clear() - # Clear FK relationships - File.objects.filter(pk=temp_file.pk).update( - capture=None, - dataset=None, - ) - File.objects.filter(pk=temp_file.pk).delete() + # remove files linked during tests, including ones created directly by tests + self._delete_owned_files() self._temp_files.clear() # Clean up OpenSearch documents @@ -2294,8 +2298,7 @@ def test_waterfall_slices_stream_success( assert data["end_index"] == 5 # noqa: PLR2004 mock_reconstruct_drf_files.assert_called_once() mock_compute_slices_on_demand.assert_called_once() - # Avoid unused variable linter warning - assert stream_file.capture_id == capture.id + assert stream_file.capture_id == capture.pk class OpenSearchErrorTestCases(APITestCase): diff --git a/gateway/sds_gateway/context_processors.py b/gateway/sds_gateway/context_processors.py index 648550d9c..c8cf2427f 100644 --- a/gateway/sds_gateway/context_processors.py +++ b/gateway/sds_gateway/context_processors.py @@ -9,10 +9,11 @@ from django.http import HttpRequest -def app_settings(_request: HttpRequest) -> dict[str, bool]: +def app_settings(_request: HttpRequest) -> dict[str, bool | str]: """Expose application-wide settings in templates.""" return { "VISUALIZATIONS_ENABLED": settings.VISUALIZATIONS_ENABLED, + "ADMIN_CONSOLE_ENV": settings.ADMIN_CONSOLE_ENV, } diff --git a/gateway/sds_gateway/static/css/admin_environment.css b/gateway/sds_gateway/static/css/admin_environment.css new file mode 100644 index 000000000..bc7a2e555 --- /dev/null +++ b/gateway/sds_gateway/static/css/admin_environment.css @@ -0,0 +1,27 @@ +body.admin-env-production #header { + background: #800020; +} + +body.admin-env-staging #header { + background: #ff8c00; +} + +body.admin-env-local #header { + background: #198754; +} + +body.admin-env-production #header, +body.admin-env-production #header a:link, +body.admin-env-production #header a:visited, +body.admin-env-staging #header, +body.admin-env-staging #header a:link, +body.admin-env-staging #header a:visited, +body.admin-env-local #header, +body.admin-env-local #header a:link, +body.admin-env-local #header a:visited { + color: #ffffff; +} + +.mono { + font-family: monospace; +} diff --git a/gateway/sds_gateway/static/js/visualizations/spectrogram/SpectrogramControls.js b/gateway/sds_gateway/static/js/visualizations/spectrogram/SpectrogramControls.js index 1ab5b731f..4c43b143a 100644 --- a/gateway/sds_gateway/static/js/visualizations/spectrogram/SpectrogramControls.js +++ b/gateway/sds_gateway/static/js/visualizations/spectrogram/SpectrogramControls.js @@ -3,11 +3,11 @@ * Manages the control panel for spectrogram generation settings */ -import { DEFAULT_SPECTROGRAM_SETTINGS, INPUT_RANGES } from "./constants.js"; - export class SpectrogramControls { constructor() { - this.settings = { ...DEFAULT_SPECTROGRAM_SETTINGS }; + this.settings = null; + this.defaultSettings = null; + this.inputRanges = {}; this.onSettingsChange = null; this.onGenerateClick = null; this.initializeControls(); @@ -40,6 +40,9 @@ export class SpectrogramControls { return; } + this.settings = this.getSettingsFromControls(); + this.defaultSettings = { ...this.settings }; + this.inputRanges = this.getInputRanges(); this.setupEventListeners(); this.updateControlValues(); } @@ -57,7 +60,7 @@ export class SpectrogramControls { // Standard Deviation change this.stdDevInput.addEventListener("change", (e) => { const value = Number.parseInt(e.target.value); - if (this.validateInput(value, INPUT_RANGES.stdDev)) { + if (this.validateInput(value, this.inputRanges.stdDev)) { this.settings.stdDev = value; this.notifySettingsChange(); } else { @@ -68,7 +71,7 @@ export class SpectrogramControls { // Hop Size change this.hopSizeInput.addEventListener("change", (e) => { const value = Number.parseInt(e.target.value); - if (this.validateInput(value, INPUT_RANGES.hopSize)) { + if (this.validateInput(value, this.inputRanges.hopSize)) { this.settings.hopSize = value; this.notifySettingsChange(); } else { @@ -83,11 +86,39 @@ export class SpectrogramControls { }); } + /** + * Read the initial settings rendered by the server + */ + getSettingsFromControls() { + return { + fftSize: Number.parseInt(this.fftSizeSelect.value, 10), + stdDev: Number.parseInt(this.stdDevInput.value, 10), + hopSize: Number.parseInt(this.hopSizeInput.value, 10), + colorMap: this.colorMapSelect.value, + }; + } + + /** + * Read numeric validation ranges from the current form inputs + */ + getInputRanges() { + return { + stdDev: { + min: Number.parseInt(this.stdDevInput.min, 10), + max: Number.parseInt(this.stdDevInput.max, 10), + }, + hopSize: { + min: Number.parseInt(this.hopSizeInput.min, 10), + max: Number.parseInt(this.hopSizeInput.max, 10), + }, + }; + } + /** * Validate input value against specified range */ validateInput(value, range) { - return value >= range.min && value <= range.max; + return Number.isInteger(value) && value >= range.min && value <= range.max; } /** @@ -95,11 +126,10 @@ export class SpectrogramControls { */ resetInputValue(input, value) { input.value = value; + const range = this.inputRanges[input.id]; this.showValidationError( input, - `Value must be between ${INPUT_RANGES[input.id].min} and ${ - INPUT_RANGES[input.id].max - }`, + `Value must be between ${range.min} and ${range.max}`, ); } @@ -201,7 +231,7 @@ export class SpectrogramControls { * Reset controls to default values */ resetToDefaults() { - this.settings = { ...DEFAULT_SPECTROGRAM_SETTINGS }; + this.settings = { ...this.defaultSettings }; this.updateControlValues(); this.notifySettingsChange(); } diff --git a/gateway/sds_gateway/static/js/visualizations/spectrogram/SpectrogramVisualization.js b/gateway/sds_gateway/static/js/visualizations/spectrogram/SpectrogramVisualization.js index a38a6f7a4..915c371ec 100644 --- a/gateway/sds_gateway/static/js/visualizations/spectrogram/SpectrogramVisualization.js +++ b/gateway/sds_gateway/static/js/visualizations/spectrogram/SpectrogramVisualization.js @@ -93,7 +93,10 @@ export class SpectrogramVisualization { } try { + this.clearStatusDisplay(); this.setGeneratingState(true); + this.showSaveButton(false); + this.showStatus(STATUS_MESSAGES.GENERATING); const settings = this.controls.getSettings(); @@ -101,7 +104,7 @@ export class SpectrogramVisualization { await this.createSpectrogramJob(settings); } catch (error) { console.error("Error generating spectrogram:", error); - this.showError(ERROR_MESSAGES.API_ERROR); + this.displayRequestError(error, ERROR_MESSAGES.API_ERROR); this.setGeneratingState(false); } } @@ -133,14 +136,18 @@ export class SpectrogramVisualization { }, ); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } + await this.throwOnFailedResponse( + response, + "Unable to create spectrogram job", + ); const data = await response.json(); if (!data.uuid) { - throw new Error("Spectrogram job ID not found"); + const missingUuidError = new Error("Spectrogram job ID not found"); + missingUuidError.userMessage = "Unable to create spectrogram job."; + missingUuidError.errorDetail = "Response did not include a job id."; + throw missingUuidError; } this.currentJobId = data.uuid; @@ -181,26 +188,34 @@ export class SpectrogramVisualization { }, ); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } + await this.throwOnFailedResponse( + response, + "Unable to check spectrogram status", + ); const data = await response.json(); + const processingStatus = data.processing_status; - if (data.processing_status === "completed") { + if (processingStatus === "completed") { // Job completed, stop polling and fetch result this.stopStatusPolling(); await this.fetchSpectrogramResult(); - } else if (data.processing_status === "failed") { + } else if (processingStatus === "failed") { // Job failed this.stopStatusPolling(); this.handleProcessingError(data); this.setGeneratingState(false); + } else if (processingStatus) { + this.showStatus( + `${STATUS_MESSAGES.GENERATING} (${this.formatProcessingStatus(processingStatus)})`, + ); } // If still processing, continue polling } catch (error) { console.error("Error checking job status:", error); - // Continue polling on error + this.stopStatusPolling(); + this.displayRequestError(error, "Failed to check spectrogram status"); + this.setGeneratingState(false); } } @@ -228,9 +243,10 @@ export class SpectrogramVisualization { }, ); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } + await this.throwOnFailedResponse( + response, + "Unable to fetch spectrogram result", + ); const blob = await response.blob(); @@ -245,21 +261,130 @@ export class SpectrogramVisualization { this.setGeneratingState(false); if (renderResult) { - this.updateStatus(STATUS_MESSAGES.SUCCESS); + this.clearStatusDisplay(); this.showSaveButton(true); } else { - this.showError("Failed to render spectrogram"); + this.showErrorWithDetails("Failed to render spectrogram"); } // Store the result for saving this.currentSpectrogramUrl = URL.createObjectURL(blob); } catch (error) { console.error("Error fetching spectrogram result:", error); - this.showError("Failed to fetch spectrogram result"); + this.displayRequestError(error, "Failed to fetch spectrogram result"); this.setGeneratingState(false); } } + /** + * Throw enriched error for failed HTTP responses + */ + async throwOnFailedResponse(response, userMessage) { + if (response.ok) { + return; + } + + const responseData = await this.safeParseJson(response); + const processingStatus = responseData?.processing_status; + const responseDetail = this.extractResponseDetail(responseData); + + const details = []; + if (processingStatus) { + details.push( + `Processing status: ${this.formatProcessingStatus(processingStatus)}`, + ); + } + if (responseDetail) { + details.push(responseDetail); + } else { + details.push(`HTTP ${response.status}: ${response.statusText}`); + } + + const requestError = new Error( + `HTTP ${response.status}: ${response.statusText}`, + ); + requestError.userMessage = `${userMessage}.`; + requestError.errorDetail = details.join(" • "); + throw requestError; + } + + /** + * Parse JSON payload safely from response + */ + async safeParseJson(response) { + try { + return await response.json(); + } catch { + return null; + } + } + + /** + * Extract a readable error detail from API response payload + */ + extractResponseDetail(responseData) { + if (!responseData) { + return null; + } + + if (typeof responseData === "string") { + return responseData; + } + + const detailFields = ["detail", "error", "message"]; + for (const fieldName of detailFields) { + if (typeof responseData[fieldName] === "string") { + return responseData[fieldName]; + } + } + + if (Array.isArray(responseData.errors)) { + return responseData.errors.join(", "); + } + + if ( + responseData.errors && + typeof responseData.errors === "object" && + !Array.isArray(responseData.errors) + ) { + const firstFieldErrors = Object.entries(responseData.errors)[0]; + if (!firstFieldErrors) { + return null; + } + + const [fieldName, fieldValue] = firstFieldErrors; + if (Array.isArray(fieldValue)) { + return `${fieldName}: ${fieldValue.join(", ")}`; + } + + if (typeof fieldValue === "string") { + return `${fieldName}: ${fieldValue}`; + } + } + + return null; + } + + /** + * Format backend processing status for user display + */ + formatProcessingStatus(processingStatus) { + if (!processingStatus || typeof processingStatus !== "string") { + return "unknown"; + } + + return processingStatus.replace(/_/g, " "); + } + + /** + * Display request error to user + */ + displayRequestError(error, fallbackMessage) { + const message = error?.userMessage || fallbackMessage; + const errorDetail = error?.errorDetail || error?.message || null; + this.showErrorWithDetails(message, errorDetail); + } + /** * Get CSRF token from form input */ @@ -286,18 +411,16 @@ export class SpectrogramVisualization { } } - // Hide error display during generation, show transparent overlay instead + // Keep status/error display visibility in sync with whether it has content if (this.errorDisplay) { - if (isGenerating) { - this.errorDisplay.classList.add("d-none"); + const hasContent = this.errorDisplay + .querySelector("p.error-message-text") + ?.textContent.trim(); + + if (hasContent) { + this.errorDisplay.classList.remove("d-none"); } else { - // Only show if it has content (error), otherwise keep hidden - const hasContent = this.errorDisplay - .querySelector("p.error-message-text") - ?.textContent.trim(); - if (hasContent) { - this.errorDisplay.classList.remove("d-none"); - } + this.errorDisplay.classList.add("d-none"); } } } @@ -350,22 +473,69 @@ export class SpectrogramVisualization { * Update status message */ updateStatus(message) { - if (this.errorDisplay) { - const statusText = this.errorDisplay.querySelector("p"); - if (statusText) { - statusText.textContent = message; - } + if (message) { + this.showStatus(message); + return; } + + this.clearStatusDisplay(); } /** * Show error message */ showError(message) { - this.updateStatus(message); - if (this.renderer) { - this.renderer.clearImage(); + this.showErrorWithDetails(message); + } + + /** + * Show non-error status message + */ + showStatus(message, detail = null) { + if (!this.errorDisplay) { + return; + } + + const messageElement = this.errorDisplay.querySelector( + "p.error-message-text", + ); + const errorDetailElement = this.errorDisplay.querySelector( + "p.error-detail-line", + ); + + setupErrorDisplay({ + messageElement, + errorDetailElement, + message, + errorDetail: detail, + }); + + this.errorDisplay.classList.remove("d-none"); + } + + /** + * Clear status/error display + */ + clearStatusDisplay() { + if (!this.errorDisplay) { + return; } + + const messageElement = this.errorDisplay.querySelector( + "p.error-message-text", + ); + const errorDetailElement = this.errorDisplay.querySelector( + "p.error-detail-line", + ); + + setupErrorDisplay({ + messageElement, + errorDetailElement, + message: "", + errorDetail: null, + }); + + this.errorDisplay.classList.add("d-none"); } /** @@ -374,11 +544,26 @@ export class SpectrogramVisualization { handleProcessingError(data) { const errorInfo = data.error_info || {}; const hasSourceDataError = data.has_source_data_error || false; + const processingStatus = data.processing_status; const { message, errorDetail } = generateErrorMessage( errorInfo, hasSourceDataError, ); - this.showErrorWithDetails(message, errorDetail); + + const detailParts = []; + if (processingStatus) { + detailParts.push( + `Processing status: ${this.formatProcessingStatus(processingStatus)}`, + ); + } + if (errorDetail) { + detailParts.push(errorDetail); + } + + this.showErrorWithDetails( + message, + detailParts.length > 0 ? detailParts.join(" • ") : null, + ); } /** diff --git a/gateway/sds_gateway/static/js/visualizations/spectrogram/constants.js b/gateway/sds_gateway/static/js/visualizations/spectrogram/constants.js index e5a9c0590..4bda26953 100644 --- a/gateway/sds_gateway/static/js/visualizations/spectrogram/constants.js +++ b/gateway/sds_gateway/static/js/visualizations/spectrogram/constants.js @@ -2,37 +2,6 @@ * Constants for Spectrogram Visualization */ -export const DEFAULT_SPECTROGRAM_SETTINGS = { - fftSize: 1024, - stdDev: 100, - hopSize: 500, - colorMap: "magma", -}; - -// FFT size options (powers of 2 from 64 to 65536) -export const FFT_SIZE_OPTIONS = [ - 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, -]; - -export const COLOR_MAP_OPTIONS = [ - "magma", - "viridis", - "plasma", - "inferno", - "cividis", - "turbo", - "jet", - "hot", - "cool", - "rainbow", -]; - -// Input validation ranges -export const INPUT_RANGES = { - stdDev: { min: 10, max: 500 }, - hopSize: { min: 100, max: 1000 }, -}; - export const DEFAULT_IMAGE_DIMENSIONS = { width: 800, height: 400, diff --git a/gateway/sds_gateway/templates/admin/base_site.html b/gateway/sds_gateway/templates/admin/base_site.html new file mode 100644 index 000000000..8256ac90c --- /dev/null +++ b/gateway/sds_gateway/templates/admin/base_site.html @@ -0,0 +1,16 @@ +{% extends "admin/base_site.html" %} + +{% load static %} + +{% block extrastyle %} + {{ block.super }} + +{% endblock extrastyle %} +{% block bodyclass %} + {{ block.super }} admin-env-{{ ADMIN_CONSOLE_ENV }} +{% endblock bodyclass %} +{% block branding %} +