diff --git a/Controllers/CacheController.cs b/Controllers/CacheController.cs index 26579c1..89ba6b0 100644 --- a/Controllers/CacheController.cs +++ b/Controllers/CacheController.cs @@ -35,14 +35,21 @@ public class CacheController : ControllerBase var validationError = ValidationHelper.ValidateLocation(suburb, state); if (validationError != null) { - return BadRequest(new { error = validationError }); + return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError)); } var result = await _bomRadarService.GetCacheRangeAsync(suburb, state, cancellationToken); if (result.TotalCacheFolders == 0) { - return NotFound(new { error = "No cached data found for this location." }); + var errorResponse = ApiErrorResponseBuilder.CacheNotFound(suburb, state); + errorResponse.Message = "No cached data found for this location."; + errorResponse.Suggestions = new Dictionary + { + { "action", "refresh_cache" }, + { "refreshEndpoint", $"/api/cache/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}/refresh" } + }; + return NotFound(errorResponse); } return Ok(result); @@ -50,7 +57,11 @@ public class CacheController : ControllerBase catch (Exception ex) { _logger.LogError(ex, "Error getting cache range for suburb: {Suburb}, state: {State}", suburb, state); - return StatusCode(500, new { error = "An error occurred while getting cache range", message = ex.Message }); + var errorResponse = ApiErrorResponseBuilder.InternalError( + "An error occurred while getting cache range", + ex); + errorResponse.Details!["location"] = new { suburb, state }; + return StatusCode(500, errorResponse); } } @@ -65,7 +76,7 @@ public class CacheController : ControllerBase var validationError = ValidationHelper.ValidateLocation(suburb, state); if (validationError != null) { - return BadRequest(new { error = validationError }); + return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError)); } var status = await _bomRadarService.TriggerCacheUpdateAsync(suburb, state, cancellationToken); @@ -74,7 +85,12 @@ public class CacheController : ControllerBase catch (Exception ex) { _logger.LogError(ex, "Error triggering cache update for suburb: {Suburb}, state: {State}", suburb, state); - return StatusCode(500, new { error = "An error occurred while triggering cache update", message = ex.Message }); + var errorResponse = ApiErrorResponseBuilder.CacheUpdateFailed( + suburb, + state, + "Failed to trigger cache update", + ex); + return StatusCode(500, errorResponse); } } @@ -89,7 +105,7 @@ public class CacheController : ControllerBase var validationError = ValidationHelper.ValidateLocation(suburb, state); if (validationError != null) { - return BadRequest(new { error = validationError }); + return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError)); } var deleted = await _bomRadarService.DeleteCachedLocationAsync(suburb, state, cancellationToken); @@ -100,13 +116,22 @@ public class CacheController : ControllerBase } else { - return NotFound(new { error = $"No cached data found for {suburb}, {state}" }); + var errorResponse = ApiErrorResponseBuilder.NotFound( + "Cache", + $"{suburb}, {state}", + "No cached data exists for this location."); + errorResponse.Details!["location"] = new { suburb, state }; + return NotFound(errorResponse); } } catch (Exception ex) { _logger.LogError(ex, "Error deleting cached location for suburb: {Suburb}, state: {State}", suburb, state); - return StatusCode(500, new { error = "An error occurred while deleting cached location", message = ex.Message }); + var errorResponse = ApiErrorResponseBuilder.InternalError( + "An error occurred while deleting cached location", + ex); + errorResponse.Details!["location"] = new { suburb, state }; + return StatusCode(500, errorResponse); } } } diff --git a/Controllers/RadarController.cs b/Controllers/RadarController.cs index 71aa007..26078c9 100644 --- a/Controllers/RadarController.cs +++ b/Controllers/RadarController.cs @@ -37,7 +37,7 @@ public class RadarController : ControllerBase var validationError = ValidationHelper.ValidateLocation(suburb, state); if (validationError != null) { - return BadRequest(new { error = validationError }); + return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError)); } // Check if cache needs updating and trigger if needed (non-blocking) @@ -70,17 +70,37 @@ public class RadarController : ControllerBase cacheManagementCheckIntervalMinutes, cancellationToken); - return NotFound(new { - error = "Screenshots not found in cache. Cache update has been triggered in background. Please retry in a few moments.", - retryAfter = 30, // seconds - refreshEndpoint = $"/api/cache/{suburb}/{state}/refresh", - updateTriggered = cacheStatus.UpdateTriggered, - cacheExists = cacheStatus.CacheExists, - cacheIsValid = cacheStatus.CacheIsValid, - cacheExpiresAt = cacheStatus.CacheExpiresAt, - nextUpdateTime = cacheStatus.NextUpdateTime, - message = cacheStatus.Message - }); + // Determine if update was triggered (check if it's already in progress or was just triggered) + var updateTriggered = cacheStatus.UpdateTriggered || + (cacheStatus.Message?.Contains("in progress") ?? false) || + (!cacheStatus.CacheExists && !cacheStatus.CacheIsValid); + + var errorResponse = ApiErrorResponseBuilder.CacheNotFound(suburb, state, cacheStatus, updateTriggered); + + // Enhance message based on cache status + if (cacheStatus.UpdateFailed) + { + errorResponse.Message = $"No cached data found. Previous update attempt failed: {cacheStatus.Error ?? "Unknown error"}. Retrying..."; + errorResponse.Details!["previousUpdateFailed"] = true; + if (cacheStatus.Error != null) + { + errorResponse.Details!["previousError"] = cacheStatus.Error; + } + if (cacheStatus.ErrorCode != null) + { + errorResponse.Details!["previousErrorCode"] = cacheStatus.ErrorCode; + } + } + else if (cacheStatus.Message?.Contains("in progress") ?? false) + { + errorResponse.Message = "No cached data found. Cache update is currently in progress. Please retry shortly."; + } + else if (!cacheStatus.CacheExists) + { + errorResponse.Message = "No cached data found for this location (fresh start). Cache update has been triggered in background. Please retry in a few moments."; + } + + return NotFound(errorResponse); } // Generate URLs for frames if not already set @@ -97,7 +117,11 @@ public class RadarController : ControllerBase catch (Exception ex) { _logger.LogError(ex, "Error getting cached radar screenshots for suburb: {Suburb}, state: {State}", suburb, state); - return StatusCode(500, new { error = "An error occurred while getting the radar screenshots", message = ex.Message }); + var errorResponse = ApiErrorResponseBuilder.InternalError( + "An error occurred while getting the radar screenshots", + ex); + errorResponse.Details!["location"] = new { suburb, state }; + return StatusCode(500, errorResponse); } } @@ -112,7 +136,15 @@ public class RadarController : ControllerBase var validationError = ValidationHelper.ValidateLocation(suburb, state); if (validationError != null) { - return BadRequest(new { error = validationError }); + return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError)); + } + + // Validate frame index + if (frameIndex < 0 || frameIndex > 6) + { + return BadRequest(ApiErrorResponseBuilder.ValidationError( + $"Frame index must be between 0 and 6, got {frameIndex}", + "frameIndex")); } RadarFrame? frame; @@ -130,7 +162,17 @@ public class RadarController : ControllerBase if (frame == null || !System.IO.File.Exists(frame.ImagePath)) { - return NotFound(new { error = $"Frame {frameIndex} not found for {suburb}, {state}" }); + var errorResponse = ApiErrorResponseBuilder.NotFound( + "Frame", + $"Frame {frameIndex} for {suburb}, {state}", + "The frame may not exist yet. Try refreshing the cache or checking if cache update is in progress."); + errorResponse.Details!["frameIndex"] = frameIndex; + errorResponse.Details!["location"] = new { suburb, state }; + if (!string.IsNullOrEmpty(cacheFolder)) + { + errorResponse.Details["cacheFolder"] = cacheFolder; + } + return NotFound(errorResponse); } var imageBytes = await System.IO.File.ReadAllBytesAsync(frame.ImagePath, cancellationToken); @@ -139,7 +181,12 @@ public class RadarController : ControllerBase catch (Exception ex) { _logger.LogError(ex, "Error getting frame {FrameIndex} for suburb: {Suburb}, state: {State}", frameIndex, suburb, state); - return StatusCode(500, new { error = "An error occurred while getting the frame", message = ex.Message }); + var errorResponse = ApiErrorResponseBuilder.InternalError( + "An error occurred while getting the frame", + ex); + errorResponse.Details!["frameIndex"] = frameIndex; + errorResponse.Details!["location"] = new { suburb, state }; + return StatusCode(500, errorResponse); } } @@ -154,14 +201,24 @@ public class RadarController : ControllerBase var validationError = ValidationHelper.ValidateLocation(suburb, state); if (validationError != null) { - return BadRequest(new { error = validationError }); + return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError)); } var result = await _bomRadarService.GetLastUpdatedInfoAsync(suburb, state, cancellationToken); if (result == null) { - return NotFound(new { error = "No cached data found. Use POST /api/cache/{suburb}/{state}/refresh to trigger cache update." }); + var errorResponse = ApiErrorResponseBuilder.NotFound( + "Metadata", + $"{suburb}, {state}", + "Use POST /api/cache/{suburb}/{state}/refresh to trigger cache update."); + errorResponse.Details!["location"] = new { suburb, state }; + errorResponse.Suggestions = new Dictionary + { + { "action", "refresh_cache" }, + { "refreshEndpoint", $"/api/cache/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}/refresh" } + }; + return NotFound(errorResponse); } return Ok(result); @@ -169,7 +226,11 @@ public class RadarController : ControllerBase catch (Exception ex) { _logger.LogError(ex, "Error getting metadata for suburb: {Suburb}, state: {State}", suburb, state); - return StatusCode(500, new { error = "An error occurred while getting metadata", message = ex.Message }); + var errorResponse = ApiErrorResponseBuilder.InternalError( + "An error occurred while getting metadata", + ex); + errorResponse.Details!["location"] = new { suburb, state }; + return StatusCode(500, errorResponse); } } @@ -190,7 +251,7 @@ public class RadarController : ControllerBase var validationError = ValidationHelper.ValidateLocation(suburb, state); if (validationError != null) { - return BadRequest(new { error = validationError }); + return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError)); } DateTime? start = null; @@ -200,7 +261,9 @@ public class RadarController : ControllerBase { if (!DateTime.TryParse(startTime, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsedStart)) { - return BadRequest(new { error = "Invalid startTime format. Use ISO 8601 format (e.g., 2025-12-07T00:00:00Z)" }); + return BadRequest(ApiErrorResponseBuilder.ValidationError( + "Invalid startTime format. Use ISO 8601 format (e.g., 2025-12-07T00:00:00Z)", + "startTime")); } start = parsedStart.ToUniversalTime(); } @@ -209,14 +272,18 @@ public class RadarController : ControllerBase { if (!DateTime.TryParse(endTime, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsedEnd)) { - return BadRequest(new { error = "Invalid endTime format. Use ISO 8601 format (e.g., 2025-12-07T12:00:00Z)" }); + return BadRequest(ApiErrorResponseBuilder.ValidationError( + "Invalid endTime format. Use ISO 8601 format (e.g., 2025-12-07T12:00:00Z)", + "endTime")); } end = parsedEnd.ToUniversalTime(); } if (start.HasValue && end.HasValue && start.Value > end.Value) { - return BadRequest(new { error = "startTime must be before or equal to endTime" }); + return BadRequest(ApiErrorResponseBuilder.ValidationError( + "startTime must be before or equal to endTime", + "timeRange")); } // Validate time range size to prevent excessive data loading @@ -236,13 +303,10 @@ public class RadarController : ControllerBase ? $"configured limit: {configuredMaxHours} hours" : $"cache retention: {cacheRetentionHours} hours"; - return BadRequest(new { - error = $"Time range exceeds maximum allowed duration of {maxTimeRangeHours} hours (based on {reason}). Please specify a smaller range.", - requestedHours = timeRange.TotalHours, - maxHours = maxTimeRangeHours, - cacheRetentionHours = cacheRetentionHours, - configuredMaxHours = configuredMaxHours - }); + return BadRequest(ApiErrorResponseBuilder.TimeRangeError( + $"Time range exceeds maximum allowed duration of {maxTimeRangeHours} hours (based on {reason}). Please specify a smaller range.", + null, + new { start = start.Value, end = end.Value, requestedHours = timeRange.TotalHours })); } } @@ -277,17 +341,15 @@ public class RadarController : ControllerBase cacheManagementCheckIntervalMinutes, cancellationToken); - return NotFound(new { - error = "No cached data found for this location. Cache update has been triggered in background. Please retry in a few moments.", - retryAfter = 30, // seconds - refreshEndpoint = $"/api/cache/{suburb}/{state}/refresh", - updateTriggered = cacheStatus.UpdateTriggered, - cacheExists = cacheStatus.CacheExists, - cacheIsValid = cacheStatus.CacheIsValid, - cacheExpiresAt = cacheStatus.CacheExpiresAt, - nextUpdateTime = cacheStatus.NextUpdateTime, - message = cacheStatus.Message - }); + var updateTriggered = cacheStatus.UpdateTriggered || + (cacheStatus.Message?.Contains("in progress") ?? false) || + (!cacheStatus.CacheExists && !cacheStatus.CacheIsValid); + + var errorResponse = ApiErrorResponseBuilder.CacheNotFound(suburb, state, cacheStatus, updateTriggered); + errorResponse.Message = "No cached data found for this location. Cache update has been triggered in background. Please retry in a few moments."; + errorResponse.Details!["requestedTimeRange"] = new { start, end }; + + return NotFound(errorResponse); } // Location has cache, check if there's data in the requested time range @@ -299,20 +361,22 @@ public class RadarController : ControllerBase var oldestCache = cacheRange.OldestCache?.CacheTimestamp; var newestCache = cacheRange.NewestCache?.CacheTimestamp; - return NotFound(new { - error = "No historical data found for the specified time range.", - availableRange = new { - oldest = oldestCache, - newest = newestCache, - totalCacheFolders = cacheRange.TotalCacheFolders, - timeSpanMinutes = cacheRange.TimeSpanMinutes - }, - requestedRange = new { - start = start, - end = end - }, - suggestion = "Try adjusting the time range to match the available cached data." - }); + var availableRange = new { + oldest = oldestCache, + newest = newestCache, + totalCacheFolders = cacheRange.TotalCacheFolders, + timeSpanMinutes = cacheRange.TimeSpanMinutes + }; + + var requestedRange = new { + start = start, + end = end + }; + + return NotFound(ApiErrorResponseBuilder.TimeRangeError( + "No historical data found for the specified time range.", + availableRange, + requestedRange)); } return Ok(result); @@ -320,7 +384,12 @@ public class RadarController : ControllerBase catch (Exception ex) { _logger.LogError(ex, "Error getting radar time series for suburb: {Suburb}, state: {State}", suburb, state); - return StatusCode(500, new { error = "An error occurred while getting radar time series", message = ex.Message }); + var errorResponse = ApiErrorResponseBuilder.InternalError( + "An error occurred while getting radar time series", + ex); + errorResponse.Details!["location"] = new { suburb, state }; + errorResponse.Details!["requestedTimeRange"] = new { startTime, endTime }; + return StatusCode(500, errorResponse); } } } diff --git a/Models/ApiErrorResponse.cs b/Models/ApiErrorResponse.cs new file mode 100644 index 0000000..9fc6e8b --- /dev/null +++ b/Models/ApiErrorResponse.cs @@ -0,0 +1,340 @@ +namespace BomLocalService.Models; + +/// +/// Standardized error response model for API endpoints. +/// Provides consistent error structure with error codes, types, and detailed information. +/// +public class ApiErrorResponse +{ + /// + /// Machine-readable error code for programmatic handling. + /// Examples: "CACHE_NOT_FOUND", "VALIDATION_ERROR", "INTERNAL_ERROR", "CACHE_UPDATE_FAILED" + /// + public string ErrorCode { get; set; } = string.Empty; + + /// + /// Human-readable error message describing what went wrong. + /// + public string Message { get; set; } = string.Empty; + + /// + /// Error type/category for grouping similar errors. + /// Examples: "CacheError", "ValidationError", "ServiceError", "NotFoundError" + /// + public string ErrorType { get; set; } = string.Empty; + + /// + /// Additional context about the error (e.g., field name for validation errors). + /// + public Dictionary? Details { get; set; } + + /// + /// Suggested action for the client (e.g., "retry_after_seconds", "refresh_cache"). + /// + public Dictionary? Suggestions { get; set; } + + /// + /// Timestamp when the error occurred (UTC). + /// + public DateTime Timestamp { get; set; } = DateTime.UtcNow; +} + +/// +/// Helper class for creating standardized error responses. +/// +public static class ApiErrorResponseBuilder +{ + /// + /// Creates an error response for when cache is not found (fresh start scenario). + /// + public static ApiErrorResponse CacheNotFound( + string suburb, + string state, + CacheUpdateStatus? cacheStatus = null, + bool updateTriggered = false) + { + var response = new ApiErrorResponse + { + ErrorCode = "CACHE_NOT_FOUND", + ErrorType = "CacheError", + Message = "No cached data found for this location. Cache update has been triggered in background.", + Details = new Dictionary + { + { "location", new { suburb, state } }, + { "cacheExists", cacheStatus?.CacheExists ?? false }, + { "cacheIsValid", cacheStatus?.CacheIsValid ?? false }, + { "updateTriggered", updateTriggered || (cacheStatus?.UpdateTriggered ?? false) } + }, + Suggestions = new Dictionary + { + { "action", "retry_after_seconds" }, + { "refreshEndpoint", $"/api/cache/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}/refresh" }, + { "statusEndpoint", $"/api/cache/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}/range" } + } + }; + + // Determine meaningful retryAfter based on cache status + int retryAfter; + bool isUpdateInProgress = cacheStatus?.Message?.Contains("in progress") ?? false; + + if (cacheStatus != null) + { + if (cacheStatus.CacheExpiresAt.HasValue) + { + response.Details!["cacheExpiresAt"] = cacheStatus.CacheExpiresAt.Value; + } + if (cacheStatus.NextUpdateTime.HasValue) + { + response.Details!["nextUpdateTime"] = cacheStatus.NextUpdateTime.Value; + + // Calculate retryAfter from NextUpdateTime if available + var secondsUntilUpdate = (int)(cacheStatus.NextUpdateTime.Value - DateTime.UtcNow).TotalSeconds; + + if (isUpdateInProgress) + { + // Update is in progress - retry when it completes (typically ~2 minutes) + // Use NextUpdateTime if it's reasonable (1-3 minutes), otherwise default to 2 minutes + retryAfter = secondsUntilUpdate > 0 && secondsUntilUpdate <= 180 + ? secondsUntilUpdate + : 120; // Default to 2 minutes for in-progress updates + } + else if (updateTriggered || cacheStatus.UpdateTriggered) + { + // Update was just triggered - allow time for it to complete (60-90 seconds) + // Use NextUpdateTime if reasonable, otherwise default to 90 seconds + retryAfter = secondsUntilUpdate > 0 && secondsUntilUpdate <= 120 + ? secondsUntilUpdate + : 90; // Default to 90 seconds for newly triggered updates + } + else + { + // No update triggered yet - suggest waiting for background service check + // Cap at reasonable maximum (5 minutes) + retryAfter = secondsUntilUpdate > 0 && secondsUntilUpdate <= 300 + ? secondsUntilUpdate + : 60; // Default to 60 seconds if NextUpdateTime is too far or in past + } + } + else + { + // No NextUpdateTime available - use defaults based on scenario + if (isUpdateInProgress) + { + retryAfter = 120; // 2 minutes for in-progress updates + } + else if (updateTriggered || cacheStatus.UpdateTriggered) + { + retryAfter = 90; // 90 seconds for newly triggered updates + } + else + { + retryAfter = 60; // 60 seconds default + } + } + + response.Details!["statusMessage"] = cacheStatus.Message ?? "Unknown status"; + + // Add update failure information if available + if (cacheStatus.UpdateFailed) + { + response.Details!["previousUpdateFailed"] = true; + if (cacheStatus.Error != null) + { + response.Details!["previousError"] = cacheStatus.Error; + } + if (cacheStatus.ErrorCode != null) + { + response.Details!["previousErrorCode"] = cacheStatus.ErrorCode; + } + // Suggest manual refresh if previous update failed + response.Suggestions!["action"] = "manual_refresh_recommended"; + } + } + else + { + // No cache status - default to 90 seconds for fresh start + retryAfter = 90; + } + + // Ensure retryAfter is within reasonable bounds (30 seconds to 5 minutes) + retryAfter = Math.Max(30, Math.Min(retryAfter, 300)); + response.Suggestions!["retryAfter"] = retryAfter; + + return response; + } + + /// + /// Creates an error response for validation errors. + /// + public static ApiErrorResponse ValidationError(string message, string? field = null) + { + var response = new ApiErrorResponse + { + ErrorCode = "VALIDATION_ERROR", + ErrorType = "ValidationError", + Message = message + }; + + if (!string.IsNullOrEmpty(field)) + { + response.Details = new Dictionary { { "field", field } }; + } + + return response; + } + + /// + /// Creates an error response for when cache update fails. + /// + public static ApiErrorResponse CacheUpdateFailed(string suburb, string state, string reason, Exception? exception = null) + { + var response = new ApiErrorResponse + { + ErrorCode = "CACHE_UPDATE_FAILED", + ErrorType = "ServiceError", + Message = $"Failed to update cache for {suburb}, {state}: {reason}", + Details = new Dictionary + { + { "location", new { suburb, state } }, + { "reason", reason } + }, + Suggestions = new Dictionary + { + { "action", "retry_after_seconds" }, + { "retryAfter", 60 }, // Wait 1 minute before retrying failed update + { "refreshEndpoint", $"/api/cache/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}/refresh" }, + { "statusEndpoint", $"/api/cache/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}/range" } + } + }; + + if (exception != null) + { + response.Details["exceptionType"] = exception.GetType().Name; + response.Details["exceptionMessage"] = exception.Message; + + // Adjust retry suggestion based on exception type + if (exception is TimeoutException || exception.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase)) + { + response.Suggestions!["retryAfter"] = 30; // Shorter wait for timeouts + response.Suggestions!["action"] = "retry_soon"; + } + else if (exception.Message.Contains("network", StringComparison.OrdinalIgnoreCase) || + exception.Message.Contains("connection", StringComparison.OrdinalIgnoreCase)) + { + response.Suggestions!["retryAfter"] = 120; // Longer wait for network issues + response.Suggestions!["action"] = "check_network_and_retry"; + } + } + + return response; + } + + /// + /// Creates an error response for internal server errors. + /// + public static ApiErrorResponse InternalError(string message, Exception? exception = null) + { + var response = new ApiErrorResponse + { + ErrorCode = "INTERNAL_ERROR", + ErrorType = "ServiceError", + Message = message + }; + + if (exception != null) + { + response.Details = new Dictionary + { + { "exceptionType", exception.GetType().Name }, + { "exceptionMessage", exception.Message } + }; + } + + return response; + } + + /// + /// Creates an error response for when a specific resource is not found. + /// + public static ApiErrorResponse NotFound(string resourceType, string identifier, string? suggestion = null) + { + var response = new ApiErrorResponse + { + ErrorCode = "NOT_FOUND", + ErrorType = "NotFoundError", + Message = $"{resourceType} not found: {identifier}", + Details = new Dictionary + { + { "resourceType", resourceType }, + { "identifier", identifier } + } + }; + + if (!string.IsNullOrEmpty(suggestion)) + { + response.Suggestions = new Dictionary { { "suggestion", suggestion } }; + } + + return response; + } + + /// + /// Creates an error response for time range validation errors. + /// + public static ApiErrorResponse TimeRangeError(string message, object? availableRange = null, object? requestedRange = null) + { + var response = new ApiErrorResponse + { + ErrorCode = "TIME_RANGE_ERROR", + ErrorType = "ValidationError", + Message = message, + Details = new Dictionary(), + Suggestions = new Dictionary + { + { "action", "adjust_time_range" } + } + }; + + if (availableRange != null) + { + response.Details["availableRange"] = availableRange; + + // Add suggestion to use available range if provided + // Try to extract oldest/newest from the availableRange object using reflection + try + { + var rangeType = availableRange.GetType(); + var oldestProp = rangeType.GetProperty("oldest"); + var newestProp = rangeType.GetProperty("newest"); + + if (oldestProp != null && newestProp != null) + { + var oldest = oldestProp.GetValue(availableRange); + var newest = newestProp.GetValue(availableRange); + + if (oldest != null && newest != null) + { + response.Suggestions!["suggestedRange"] = new + { + start = oldest, + end = newest + }; + response.Suggestions!["suggestion"] = $"Try querying data between {oldest} and {newest}"; + } + } + } + catch + { + // Ignore if we can't extract range info + } + } + + if (requestedRange != null) + { + response.Details["requestedRange"] = requestedRange; + } + + return response; + } +} + diff --git a/Models/CacheUpdateStatus.cs b/Models/CacheUpdateStatus.cs index c938ae6..9f6c215 100644 --- a/Models/CacheUpdateStatus.cs +++ b/Models/CacheUpdateStatus.cs @@ -1,5 +1,15 @@ namespace BomLocalService.Models; +/// +/// Represents the current phase of a cache update operation. +/// +public enum CacheUpdatePhase +{ + Initializing, // Browser setup, navigation (0-20% of time) + CapturingFrames, // Frame capture loop (20-95% of time) + Saving // Metadata, cleanup (95-100% of time) +} + /// /// Status information about a cache update operation for a location. /// Returned when manually triggering a cache refresh via the refresh endpoint. @@ -51,7 +61,34 @@ public class CacheUpdateStatus /// - "Cache is stale, update triggered" - Cache exists but expired, update initiated /// - "No cache exists, update triggered" - No cache found, update initiated /// - "Cache update already in progress" - An update is currently running + /// - "Cache update failed" - An update was attempted but failed (check Error property) /// public string? Message { get; set; } + + /// + /// Indicates whether the last cache update attempt failed. + /// True if an update was triggered but encountered an error. + /// + public bool UpdateFailed { get; set; } + + /// + /// Error information if the cache update failed. + /// Contains error code, message, and details about what went wrong. + /// Null if no error occurred or if update hasn't been attempted yet. + /// + public string? Error { get; set; } + + /// + /// Error code for programmatic handling of update failures. + /// Examples: "SCRAPING_ERROR", "BROWSER_ERROR", "STORAGE_ERROR", "TIMEOUT_ERROR" + /// Null if no error occurred. + /// + public string? ErrorCode { get; set; } + + /// + /// Timestamp when the last update attempt was made (UTC). + /// Null if no update has been attempted yet. + /// + public DateTime? LastUpdateAttempt { get; set; } } diff --git a/README.md b/README.md index 0924520..4e451fd 100644 --- a/README.md +++ b/README.md @@ -350,10 +350,56 @@ GET /api/radar/{suburb}/{state} } ``` +**Response Fields:** +- `frames`: Array of radar frame objects with image URLs and timing information +- `observationTime`: UTC timestamp when the observation was made +- `forecastTime`: UTC timestamp for the forecast +- `weatherStation`: Name of the weather station +- `distance`: Distance from location to weather station +- `cacheIsValid`: Whether the cache is still valid (not expired) +- `cacheExpiresAt`: UTC timestamp when the cache expires +- `isUpdating`: Whether a cache update is currently in progress +- `nextUpdateTime`: **Estimated** UTC timestamp for when the cache will be updated or when an in-progress update will complete. This value is calculated using: + - **Metrics-based estimation** (preferred): When historical data is available, uses median durations from previous cache updates to provide hardware-adaptive estimates + - **Calculated estimation** (fallback): When no metrics are available yet (e.g., first update), calculates based on configured wait times and frame count + - **Progress-aware**: During active updates, estimates improve as progress is tracked through phases (Initializing → CapturingFrames → Saving) + **Status Codes:** - `200 OK`: Radar data available - `404 Not Found`: Cache is being generated (check response for retry information) + ```json + { + "errorCode": "CACHE_NOT_FOUND", + "errorType": "CacheError", + "message": "No cached data found for this location (fresh start). Cache update has been triggered in background.", + "details": { + "location": { "suburb": "Brisbane", "state": "QLD" }, + "cacheExists": false, + "cacheIsValid": false, + "updateTriggered": true, + "nextUpdateTime": "2025-01-15T10:12:30Z" + }, + "suggestions": { + "action": "retry_after_seconds", + "retryAfter": 30, + "refreshEndpoint": "/api/cache/Brisbane/QLD/refresh" + }, + "note": "The retryAfter value is dynamically calculated based on the estimated cache update duration. On first startup with no cache, it uses a calculated estimate. After metrics are collected from completed updates, it uses hardware-adaptive estimates based on actual performance." + "timestamp": "2025-01-15T10:00:00Z" + } + ``` - `400 Bad Request`: Invalid location parameters + ```json + { + "errorCode": "VALIDATION_ERROR", + "errorType": "ValidationError", + "message": "Invalid state abbreviation. Use: NSW, VIC, QLD, SA, WA, TAS, NT, ACT", + "details": { + "field": "state" + }, + "timestamp": "2025-01-15T10:00:00Z" + } + ``` #### Get Frame Image @@ -372,6 +418,23 @@ GET /api/radar/{suburb}/{state}/frame/{frameIndex} **Response:** - `200 OK`: PNG image - `404 Not Found`: Frame not found + ```json + { + "errorCode": "NOT_FOUND", + "errorType": "NotFoundError", + "message": "Frame 3 not found for Brisbane, QLD", + "details": { + "resourceType": "Frame", + "identifier": "Frame 3 for Brisbane, QLD", + "frameIndex": 3, + "location": { "suburb": "Brisbane", "state": "QLD" } + }, + "suggestions": { + "suggestion": "The frame may not exist yet. Try refreshing the cache or checking if cache update is in progress." + }, + "timestamp": "2025-01-15T10:00:00Z" + } + ``` #### Get Metadata @@ -433,36 +496,89 @@ GET /api/radar/{suburb}/{state}/timeseries?startTime={iso8601}&endTime={iso8601} **Status Codes:** - `200 OK`: Historical data available - `400 Bad Request`: Invalid request (e.g., time range exceeds maximum allowed duration, invalid time format, startTime after endTime) -- `404 Not Found`: - - **Location not cached**: No cache exists for this location. Cache update is triggered in background. Response includes cache status: + - **Invalid time format**: ```json { - "error": "No cached data found for this location. Cache update has been triggered in background. Please retry in a few moments.", - "retryAfter": 30, - "refreshEndpoint": "/api/cache/Brisbane/QLD/refresh", - "updateTriggered": true, - "cacheExists": false, - "cacheIsValid": false, - "cacheExpiresAt": null, - "nextUpdateTime": "2025-01-15T10:12:30Z", - "message": "No cache exists, update triggered" + "errorCode": "VALIDATION_ERROR", + "errorType": "ValidationError", + "message": "Invalid startTime format. Use ISO 8601 format (e.g., 2025-12-07T00:00:00Z)", + "details": { + "field": "startTime" + }, + "timestamp": "2025-01-15T10:00:00Z" } ``` - - **No data in range**: Cache exists but no data in the requested time range. Response includes available cache range: + - **Time range exceeds maximum**: ```json { - "error": "No historical data found for the specified time range.", - "availableRange": { - "oldest": "2025-01-15T08:00:00Z", - "newest": "2025-01-15T10:00:00Z", - "totalCacheFolders": 10, - "timeSpanMinutes": 120 + "errorCode": "TIME_RANGE_ERROR", + "errorType": "ValidationError", + "message": "Time range exceeds maximum allowed duration of 24 hours (based on cache retention: 24 hours). Please specify a smaller range.", + "details": { + "requestedRange": { + "start": "2025-01-15T00:00:00Z", + "end": "2025-01-15T25:00:00Z", + "requestedHours": 25.0 + } }, - "requestedRange": { - "start": "2025-01-15T00:00:00Z", - "end": "2025-01-15T10:00:00Z" + "suggestions": { + "action": "adjust_time_range" }, - "suggestion": "Try adjusting the time range to match the available cached data." + "timestamp": "2025-01-15T10:00:00Z" + } + ``` +- `404 Not Found`: + - **Location not cached**: No cache exists for this location. Cache update is triggered in background: + ```json + { + "errorCode": "CACHE_NOT_FOUND", + "errorType": "CacheError", + "message": "No cached data found for this location. Cache update has been triggered in background.", + "details": { + "location": { "suburb": "Brisbane", "state": "QLD" }, + "cacheExists": false, + "cacheIsValid": false, + "updateTriggered": true, + "cacheExpiresAt": null, + "nextUpdateTime": "2025-01-15T10:12:30Z", + "statusMessage": "No cache exists, update triggered" + }, + "suggestions": { + "action": "retry_after_seconds", + "retryAfter": 30, + "refreshEndpoint": "/api/cache/Brisbane/QLD/refresh", + "statusEndpoint": "/api/cache/Brisbane/QLD/range" + }, + "timestamp": "2025-01-15T10:00:00Z" + } + ``` + - **No data in range**: Cache exists but no data in the requested time range: + ```json + { + "errorCode": "TIME_RANGE_ERROR", + "errorType": "ValidationError", + "message": "No historical data found for the specified time range.", + "details": { + "availableRange": { + "oldest": "2025-01-15T08:00:00Z", + "newest": "2025-01-15T10:00:00Z", + "totalCacheFolders": 10, + "timeSpanMinutes": 120 + }, + "requestedRange": { + "start": "2025-01-15T00:00:00Z", + "end": "2025-01-15T10:00:00Z" + } + }, + "suggestions": { + "action": "adjust_time_range", + "suggestedRange": { + "start": "2025-01-15T08:00:00Z", + "end": "2025-01-15T10:00:00Z" + }, + "suggestion": "Try querying data between 2025-01-15T08:00:00Z and 2025-01-15T10:00:00Z" + }, + "timestamp": "2025-01-15T10:00:00Z" } ``` @@ -470,6 +586,45 @@ GET /api/radar/{suburb}/{state}/timeseries?startTime={iso8601}&endTime={iso8601} - Maximum time range is configurable via `TimeSeries:MaxTimeRangeHours` (defaults to `CacheRetentionHours` or minimum 24 hours) - If `TimeSeries:MaxTimeRangeHours` is not set, the limit automatically matches your `CacheRetentionHours` setting - This ensures you can always query all available cached data (e.g., if retention is 72 hours, you can query up to 72 hours) + +### Error Response Format + +All API endpoints return standardized error responses using the `ApiErrorResponse` format: + +```json +{ + "errorCode": "CACHE_NOT_FOUND", + "errorType": "CacheError", + "message": "Human-readable error message", + "details": { + "location": { "suburb": "Brisbane", "state": "QLD" }, + "cacheExists": false, + "cacheIsValid": false + }, + "suggestions": { + "action": "retry_after_seconds", + "retryAfter": 30, + "refreshEndpoint": "/api/cache/Brisbane/QLD/refresh" + }, + "timestamp": "2025-01-15T10:00:00Z" +} +``` + +**Error Response Fields:** +- `errorCode`: Machine-readable error code (e.g., `CACHE_NOT_FOUND`, `VALIDATION_ERROR`, `TIME_RANGE_ERROR`) +- `errorType`: Error category (`CacheError`, `ValidationError`, `ServiceError`, `NotFoundError`) +- `message`: Human-readable error description +- `details`: Additional context (varies by error type) +- `suggestions`: Actionable guidance (retry times, endpoints, etc.) +- `timestamp`: UTC timestamp when error occurred + +**Common Error Codes:** +- `CACHE_NOT_FOUND`: No cached data exists for the location (fresh start scenario) +- `VALIDATION_ERROR`: Invalid request parameters +- `TIME_RANGE_ERROR`: Time range validation failed or no data in range +- `NOT_FOUND`: Specific resource not found (e.g., frame, metadata) +- `CACHE_UPDATE_FAILED`: Cache update operation failed +- `INTERNAL_ERROR`: Server-side error occurred - If no time range is specified, returns all available historical data - **Note**: `CacheRetentionHours` can be set to any positive integer value (24, 48, 72, 168, etc.) @@ -516,6 +671,11 @@ POST /api/cache/{suburb}/{state}/refresh } ``` +**Note on `nextUpdateTime`**: The estimated completion time is calculated using: +- **Metrics-based estimation**: Uses historical median durations from previous cache updates (more accurate, hardware-adaptive) +- **Calculated estimation**: Falls back to calculated estimates based on configuration when no metrics are available yet +- Estimates improve in real-time as the update progresses through phases (Initializing → CapturingFrames → Saving) + #### Delete Cache Delete cached data for a location. @@ -538,6 +698,49 @@ When running in Development mode, OpenAPI documentation is available at: http://localhost:8082/openapi/v1.json ``` +## Cache Update Estimation + +The service uses a **metrics-based estimation system** to provide accurate estimates of cache update completion times. This ensures clients receive meaningful `nextUpdateTime` values that adapt to the actual hardware performance. + +### How It Works + +1. **Progress Tracking**: During cache updates, the service tracks progress through three phases: + - **Initializing**: Browser setup, navigation, and page loading (~0-20% of total time) + - **CapturingFrames**: Frame capture loop (~20-95% of total time) + - **Saving**: Metadata and cleanup operations (~95-100% of total time) + +2. **Metrics Collection**: After each successful cache update, the service records: + - Total duration of the update + - Duration of each phase + - Frame-level progress during capture + +3. **Estimation Strategy**: + - **Metrics-based** (preferred): Uses median durations from the last 20 completed updates to provide hardware-adaptive estimates + - **Progress-aware**: During active updates, estimates improve in real-time based on current phase and frame progress + - **Calculated fallback**: When no metrics are available (e.g., first update), falls back to calculated estimates based on configuration values + +4. **Benefits**: + - **Hardware-adaptive**: Estimates automatically adjust to slower/faster hardware + - **Improves over time**: More accurate estimates as more updates complete + - **Real-time refinement**: Estimates become more precise as updates progress + - **Works from clean start**: Provides reasonable estimates even on first run + +### Example Scenarios + +**First Update (No Metrics)**: +- Uses calculated estimate based on `Screenshot:DynamicContentWaitMs`, `Screenshot:TileRenderWaitMs`, and frame count +- Example: ~120 seconds for 7 frames with default settings + +**Subsequent Updates (With Metrics)**: +- Uses median duration from historical data +- Example: If previous updates averaged 95 seconds, estimates will use ~95 seconds (with buffer) + +**In-Progress Update**: +- If capturing frame 3 of 7, estimates remaining time based on: + - Average frame duration from historical data + - Remaining frames (4 frames × avg frame duration) + - Plus estimated time for saving phase + ## Demo SPA The service includes a built-in Single Page Application (SPA) for testing and demonstration purposes. This provides a visual interface to: @@ -590,15 +793,23 @@ async function getRadarData(suburb, state) { if (response.status === 404) { // Cache is being generated - trigger refresh and show message const error = await response.json(); - if (error.refreshEndpoint) { + + // Use standardized error response format + if (error.errorCode === 'CACHE_NOT_FOUND' && error.suggestions?.refreshEndpoint) { // Trigger cache update in background - fetch(error.refreshEndpoint, { method: 'POST' }).catch(() => {}); + fetch(error.suggestions.refreshEndpoint, { method: 'POST' }).catch(() => {}); } - return { frames: [], message: `Cache being generated. Retry in ${error.retryAfter} seconds.` }; + + const retryAfter = error.suggestions?.retryAfter || 30; + return { + frames: [], + message: error.message || `Cache being generated. Retry in ${retryAfter} seconds.` + }; } if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + const error = await response.json().catch(() => ({ message: `HTTP ${response.status}` })); + throw new Error(error.message || `HTTP ${response.status}`); } return await response.json(); @@ -633,29 +844,32 @@ async function getHistoricalRadar(suburb, state, hoursBack = 3) { if (response.status === 400) { const error = await response.json(); - // Error will include maxHours and cacheRetentionHours for context - throw new Error(error.error || 'Invalid time range request'); + // Use standardized error format + throw new Error(error.message || 'Invalid time range request'); } if (response.status === 404) { const error = await response.json(); - // Check if location doesn't exist (cache update triggered) - if (error.updateTriggered !== undefined || error.cacheExists !== undefined) { - // Trigger cache update if endpoint provided - if (error.refreshEndpoint) { - fetch(error.refreshEndpoint, { method: 'POST' }).catch(() => {}); + // Check error code to determine type + if (error.errorCode === 'CACHE_NOT_FOUND') { + // Location doesn't exist - trigger cache update if endpoint provided + if (error.suggestions?.refreshEndpoint) { + fetch(error.suggestions.refreshEndpoint, { method: 'POST' }).catch(() => {}); } - throw new Error(`No cache found. ${error.message || 'Cache update triggered, please retry in a few moments.'}`); + throw new Error(error.message || 'Cache update triggered, please retry in a few moments.'); } // Cache exists but no data in range - if (error.availableRange) { - const range = error.availableRange; - throw new Error(`${error.error} Available data: ${range.oldest} to ${range.newest}. ${error.suggestion || ''}`); + if (error.errorCode === 'TIME_RANGE_ERROR' && error.details?.availableRange) { + const range = error.details.availableRange; + const rangeMsg = range.oldest && range.newest + ? ` Available data: ${new Date(range.oldest).toLocaleString()} to ${new Date(range.newest).toLocaleString()}.` + : ''; + throw new Error(error.message + rangeMsg); } - throw new Error(error.error || 'No historical data found'); + throw new Error(error.message || 'No historical data found'); } if (!response.ok) { diff --git a/Services/BomRadarService.cs b/Services/BomRadarService.cs index 4c19ef2..34ebb81 100644 --- a/Services/BomRadarService.cs +++ b/Services/BomRadarService.cs @@ -16,6 +16,7 @@ public class BomRadarService : IBomRadarService, IDisposable private readonly double _cacheExpirationMinutes; private readonly int _cacheManagementCheckIntervalMinutes; private readonly int _timeSeriesWarningFolderCount; + private readonly int _estimatedUpdateDurationSeconds; public BomRadarService( ILogger logger, @@ -35,6 +36,10 @@ public class BomRadarService : IBomRadarService, IDisposable _cacheManagementCheckIntervalMinutes = configuration.GetValue("CacheManagement:CheckIntervalMinutes", 5); _timeSeriesWarningFolderCount = configuration.GetValue("TimeSeries:WarningFolderCount", 200); + // Calculate estimated cache update duration + _estimatedUpdateDurationSeconds = CacheHelper.GetEstimatedUpdateDurationSeconds(configuration, CachedDataType.Radar); + _logger.LogInformation("Estimated cache update duration: {Seconds} seconds", _estimatedUpdateDurationSeconds); + if (_cacheExpirationMinutes <= 0) { throw new ArgumentException("CacheExpirationMinutes must be greater than 0", nameof(configuration)); @@ -47,6 +52,10 @@ public class BomRadarService : IBomRadarService, IDisposable { throw new ArgumentException("TimeSeries:WarningFolderCount must be greater than 0", nameof(configuration)); } + if (_estimatedUpdateDurationSeconds <= 0) + { + throw new ArgumentException("Calculated estimated update duration must be greater than 0. Check Screenshot:TileRenderWaitMs, Screenshot:DynamicContentWaitMs, and CachedDataTypes:Radar:FrameCount configuration values.", nameof(configuration)); + } } public async Task GetCachedRadarAsync(string suburb, string state, CancellationToken cancellationToken = default) @@ -71,7 +80,11 @@ public class BomRadarService : IBomRadarService, IDisposable var cacheExpiresAt = cachedMetadata != null ? cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes) : (DateTime?)null; var isUpdating = _cacheService.IsLocationUpdating(locationKey); - return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating, _cacheManagementCheckIntervalMinutes); + // Use metrics-based estimate if available, otherwise fallback + var estimatedDuration = _cacheService.GetEstimatedRemainingSeconds(locationKey); + var durationToUse = estimatedDuration > 0 ? estimatedDuration : _estimatedUpdateDurationSeconds; + + return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, _cacheManagementCheckIntervalMinutes, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating, durationToUse); } public async Task?> GetCachedFramesAsync(string suburb, string state, CancellationToken cancellationToken = default) @@ -119,7 +132,19 @@ public class BomRadarService : IBomRadarService, IDisposable status.UpdateTriggered = false; status.Message = "Cache update already in progress"; - status.NextUpdateTime = status.CacheExpiresAt ?? DateTime.UtcNow.AddMinutes(_cacheExpirationMinutes); + + // Use metrics-based estimate if available, otherwise fallback + var remainingSeconds = _cacheService.GetEstimatedRemainingSeconds(locationKey); + if (remainingSeconds > 0) + { + status.NextUpdateTime = DateTime.UtcNow.AddSeconds(remainingSeconds); + } + else + { + // Fallback to calculated estimate + status.NextUpdateTime = DateTime.UtcNow.AddSeconds(_estimatedUpdateDurationSeconds); + } + return status; } @@ -159,14 +184,16 @@ public class BomRadarService : IBomRadarService, IDisposable ? "Cache is stale, update triggered" : "No cache exists, update triggered"; - // Calculate next update time - if (status.CacheExpiresAt.HasValue && status.CacheExpiresAt.Value > DateTime.UtcNow) + // Try metrics-based estimate first, then fallback to calculated + var remainingSeconds = _cacheService.GetEstimatedRemainingSeconds(locationKey); + if (remainingSeconds > 0) { - status.NextUpdateTime = status.CacheExpiresAt.Value; + status.NextUpdateTime = DateTime.UtcNow.AddSeconds(remainingSeconds); } else { - status.NextUpdateTime = DateTime.UtcNow.AddMinutes(_cacheExpirationMinutes); + // Fallback to calculated estimate + status.NextUpdateTime = DateTime.UtcNow.AddSeconds(_estimatedUpdateDurationSeconds); } } else @@ -198,7 +225,12 @@ public class BomRadarService : IBomRadarService, IDisposable var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken); var isValid = cachedMetadata != null && _cacheService.IsCacheValid(cachedMetadata); var cacheExpiresAt = cachedMetadata != null ? cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes) : (DateTime?)null; - return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating: true, _cacheManagementCheckIntervalMinutes); + + // Use metrics-based estimate if available, otherwise fallback + var estimatedDuration = _cacheService.GetEstimatedRemainingSeconds(locationKey); + var durationToUse = estimatedDuration > 0 ? estimatedDuration : _estimatedUpdateDurationSeconds; + + return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, _cacheManagementCheckIntervalMinutes, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating: true, durationToUse); } else { @@ -215,7 +247,7 @@ public class BomRadarService : IBomRadarService, IDisposable _logger.LogInformation("Returning valid cached screenshots for {Suburb}, {State} (no semaphore needed)", suburb, state); var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken); var cacheExpiresAt = cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes); - return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating: false, _cacheManagementCheckIntervalMinutes); + return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, _cacheManagementCheckIntervalMinutes, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating: false, _estimatedUpdateDurationSeconds); } else { @@ -265,7 +297,7 @@ public class BomRadarService : IBomRadarService, IDisposable _logger.LogInformation("Cache became valid while waiting for semaphore, returning cached screenshots"); var recheckFrames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken); var recheckCacheExpiresAt = recheckCachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes); - return ResponseBuilder.CreateRadarResponse(recheckCacheFolderPath, recheckFrames, recheckCachedMetadata, suburb, state, cacheIsValid: true, recheckCacheExpiresAt, isUpdating: false, _cacheManagementCheckIntervalMinutes); + return ResponseBuilder.CreateRadarResponse(recheckCacheFolderPath, recheckFrames, _cacheManagementCheckIntervalMinutes, recheckCachedMetadata, suburb, state, cacheIsValid: true, recheckCacheExpiresAt, isUpdating: false); } // Create debug folder only now diff --git a/Services/CacheService.cs b/Services/CacheService.cs index cb172fc..54b2cc2 100644 --- a/Services/CacheService.cs +++ b/Services/CacheService.cs @@ -13,6 +13,13 @@ public class CacheService : ICacheService private readonly double _cacheExpirationMinutes; private readonly IConfiguration _configuration; private readonly ConcurrentDictionary _activeCacheFolders = new(); // locationKey -> cacheFolderPath + + // Progress tracking for cache updates + private readonly ConcurrentDictionary _updateProgress = new(); + private readonly ConcurrentQueue _recentTotalDurations = new(); // Overall durations in seconds + private readonly ConcurrentDictionary> _phaseDurations = new(); // Phase -> durations + private readonly object _metricsLock = new(); + private const int MaxSamples = 20; public CacheService(ILogger logger, IConfiguration configuration) { @@ -519,6 +526,7 @@ public class CacheService : ICacheService public void SetActiveCacheFolder(string locationKey, string cacheFolderPath) { _activeCacheFolders[locationKey] = cacheFolderPath; + _updateProgress[locationKey] = (DateTime.UtcNow, CacheUpdatePhase.Initializing, null, null); _logger.LogDebug("Tracking active cache folder: {Folder} for location: {Location}", cacheFolderPath, locationKey); } @@ -526,10 +534,206 @@ public class CacheService : ICacheService { if (_activeCacheFolders.TryRemove(locationKey, out var folder)) { + RecordUpdateComplete(locationKey); + _updateProgress.TryRemove(locationKey, out _); _logger.LogDebug("Cleared active cache folder tracking: {Folder} for location: {Location}", folder, locationKey); } } + /// + /// Records progress update for a cache update operation. + /// + public void RecordUpdateProgress(string locationKey, CacheUpdatePhase phase, int? currentFrame = null, int? totalFrames = null) + { + if (_updateProgress.TryGetValue(locationKey, out var existing)) + { + // Record duration for previous phase if it changed + var previousPhase = existing.phase; + if (previousPhase != phase) + { + var phaseDuration = (DateTime.UtcNow - existing.startTime).TotalSeconds; + + lock (_metricsLock) + { + var durations = _phaseDurations.GetOrAdd(previousPhase, _ => new ConcurrentQueue()); + durations.Enqueue(phaseDuration); + + while (durations.Count > MaxSamples) + { + durations.TryDequeue(out _); + } + } + } + } + + // Update progress + var startTime = _updateProgress.TryGetValue(locationKey, out var current) ? current.startTime : DateTime.UtcNow; + _updateProgress[locationKey] = (startTime, phase, currentFrame, totalFrames); + } + + /// + /// Records completion of a cache update and stores metrics. + /// + private void RecordUpdateComplete(string locationKey) + { + if (_updateProgress.TryRemove(locationKey, out var progress)) + { + var totalDuration = (DateTime.UtcNow - progress.startTime).TotalSeconds; + + lock (_metricsLock) + { + _recentTotalDurations.Enqueue(totalDuration); + while (_recentTotalDurations.Count > MaxSamples) + { + _recentTotalDurations.TryDequeue(out _); + } + } + + _logger.LogDebug("Cache update completed in {Duration:F1} seconds for {Location}", totalDuration, locationKey); + } + } + + /// + /// Gets the estimated remaining seconds for an in-progress cache update. + /// Returns 0 if not updating or no metrics available. + /// + public int GetEstimatedRemainingSeconds(string locationKey) + { + if (!_updateProgress.TryGetValue(locationKey, out var progress)) + { + return 0; // Not updating + } + + var elapsed = (DateTime.UtcNow - progress.startTime).TotalSeconds; + + // If we have historical data, use it + var avgTotal = GetAverageTotalDuration(); + if (avgTotal > 0) + { + // Estimate based on phase and progress + double estimatedRemaining = 0; + + switch (progress.phase) + { + case CacheUpdatePhase.Initializing: + // Estimate: avg total - elapsed (with some buffer) + estimatedRemaining = Math.Max(0, avgTotal * 1.1 - elapsed); + break; + + case CacheUpdatePhase.CapturingFrames: + if (progress.currentFrame.HasValue && progress.totalFrames.HasValue) + { + // We know exactly where we are: frame X of Y + var framesRemaining = progress.totalFrames.Value - progress.currentFrame.Value - 1; + var avgFrameDuration = GetAverageFrameDuration(); + if (avgFrameDuration > 0) + { + // Time for remaining frames + saving phase + estimatedRemaining = (framesRemaining * avgFrameDuration) + GetAveragePhaseDuration(CacheUpdatePhase.Saving); + } + else + { + // Fallback: estimate based on progress through total + var progressFraction = (progress.currentFrame.Value + 1.0) / progress.totalFrames.Value; + estimatedRemaining = Math.Max(0, (avgTotal / progressFraction) - elapsed); + } + } + else + { + // No frame info - estimate based on elapsed time + estimatedRemaining = Math.Max(0, avgTotal - elapsed); + } + break; + + case CacheUpdatePhase.Saving: + // Almost done - just saving phase remaining + estimatedRemaining = GetAveragePhaseDuration(CacheUpdatePhase.Saving); + if (estimatedRemaining == 0) + { + estimatedRemaining = 5; // Default 5 seconds for saving + } + break; + } + + return (int)Math.Ceiling(Math.Max(0, estimatedRemaining)); + } + + // No historical data - return 0 to signal fallback + return 0; + } + + /// + /// Gets the average total duration of cache updates from recent metrics. + /// + private double GetAverageTotalDuration() + { + lock (_metricsLock) + { + if (_recentTotalDurations.Count == 0) return 0; + var durations = _recentTotalDurations.ToArray(); + Array.Sort(durations); + // Use median for robustness + var median = durations.Length % 2 == 0 + ? (durations[durations.Length / 2 - 1] + durations[durations.Length / 2]) / 2.0 + : durations[durations.Length / 2]; + return median; + } + } + + /// + /// Gets the average duration per frame based on historical CapturingFrames phase data. + /// + private double GetAverageFrameDuration() + { + // Estimate frame duration from CapturingFrames phase duration / frame count + var avgCapturingDuration = GetAveragePhaseDuration(CacheUpdatePhase.CapturingFrames); + var frameCount = CacheHelper.GetFrameCountForDataType(_configuration, CachedDataType.Radar); + return avgCapturingDuration > 0 && frameCount > 0 ? avgCapturingDuration / frameCount : 0; + } + + /// + /// Gets the average duration for a specific phase from historical data. + /// + private double GetAveragePhaseDuration(CacheUpdatePhase phase) + { + lock (_metricsLock) + { + if (!_phaseDurations.TryGetValue(phase, out var durations) || durations.Count == 0) + { + return 0; + } + var durationsArray = durations.ToArray(); + return durationsArray.Average(); + } + } + + /// + /// Gets the locationKey from a cacheFolderPath by parsing the folder name. + /// + private string? GetLocationKeyFromCacheFolder(string cacheFolderPath) + { + var folderName = Path.GetFileName(cacheFolderPath); + var location = LocationHelper.ParseLocationFromFilename(folderName); + if (location.HasValue) + { + return LocationHelper.GetLocationKey(location.Value.suburb, location.Value.state); + } + return null; + } + + /// + /// Records progress update for a cache update operation using cacheFolderPath. + /// This is useful when locationKey is not directly available (e.g., in ScrapingService). + /// + public void RecordUpdateProgressByFolder(string cacheFolderPath, CacheUpdatePhase phase, int? currentFrame = null, int? totalFrames = null) + { + var locationKey = GetLocationKeyFromCacheFolder(cacheFolderPath); + if (locationKey != null) + { + RecordUpdateProgress(locationKey, phase, currentFrame, totalFrames); + } + } + public string CreateCacheFolder(string suburb, string state, string timestamp) { var cacheFolderPath = FilePathHelper.GetCacheFolderPath(_cacheDirectory, suburb, state, timestamp); @@ -627,8 +831,18 @@ public class CacheService : ICacheService if (isUpdating) { status.Message = "Cache update already in progress"; - // Update in progress - estimate completion in ~2 minutes - status.NextUpdateTime = DateTime.UtcNow.AddMinutes(2); + // Try metrics-based estimate first + var remainingSeconds = GetEstimatedRemainingSeconds(locationKey); + if (remainingSeconds > 0) + { + status.NextUpdateTime = DateTime.UtcNow.AddSeconds(remainingSeconds); + } + else + { + // Fallback to calculated estimate + var estimatedDurationSeconds = CacheHelper.GetEstimatedUpdateDurationSeconds(_configuration, dataType); + status.NextUpdateTime = DateTime.UtcNow.AddSeconds(estimatedDurationSeconds); + } } else if (status.CacheIsValid && status.CacheExpiresAt.HasValue) { diff --git a/Services/Interfaces/ICacheService.cs b/Services/Interfaces/ICacheService.cs index e7bb0fb..4984026 100644 --- a/Services/Interfaces/ICacheService.cs +++ b/Services/Interfaces/ICacheService.cs @@ -195,5 +195,23 @@ public interface ICacheService int cacheExpirationMinutes, int cacheManagementCheckIntervalMinutes, CancellationToken cancellationToken = default); + + /// + /// Records progress update for a cache update operation using cacheFolderPath. + /// This is useful when locationKey is not directly available (e.g., in ScrapingService). + /// + /// The cache folder path being updated + /// The current phase of the update + /// The current frame being captured (if in CapturingFrames phase) + /// The total number of frames to capture + void RecordUpdateProgressByFolder(string cacheFolderPath, CacheUpdatePhase phase, int? currentFrame = null, int? totalFrames = null); + + /// + /// Gets the estimated remaining seconds for an in-progress cache update. + /// Returns 0 if not updating or no metrics available. + /// + /// The location key (suburb_state) + /// Estimated remaining seconds, or 0 if not updating or no metrics + int GetEstimatedRemainingSeconds(string locationKey); } diff --git a/Services/ScrapingService.cs b/Services/ScrapingService.cs index 45ecfc4..640e542 100644 --- a/Services/ScrapingService.cs +++ b/Services/ScrapingService.cs @@ -12,6 +12,7 @@ public class ScrapingService : IScrapingService private readonly ITimeParsingService _timeParsingService; private readonly ICacheService _cacheService; private readonly IDebugService _debugService; + private readonly IConfiguration _configuration; private readonly int _dynamicContentWaitMs; private readonly int _tileRenderWaitMs; private readonly ScreenshotCropConfig _cropConfig; @@ -54,6 +55,7 @@ public class ScrapingService : IScrapingService _timeParsingService = timeParsingService; _cacheService = cacheService; _debugService = debugService; + _configuration = configuration; _dynamicContentWaitMs = configuration.GetValue("Screenshot:DynamicContentWaitMs", 2000); _tileRenderWaitMs = configuration.GetValue("Screenshot:TileRenderWaitMs", 5000); @@ -485,9 +487,10 @@ public class ScrapingService : IScrapingService }", new PageWaitForFunctionOptions { Timeout = 10000 }); var boundingBox = await mapContainer.BoundingBoxAsync(); - if (boundingBox == null) + if (boundingBox == null || boundingBox.Width <= 0 || boundingBox.Height <= 0) { - throw new Exception("Could not determine map container bounds"); + _logger.LogError("Map container has invalid bounds: {BoundingBox}", boundingBox); + throw new Exception($"Map container has invalid bounds: {boundingBox?.Width ?? 0}x{boundingBox?.Height ?? 0}"); } // Convert BoundingBox to Clip for crop calculation @@ -503,14 +506,19 @@ public class ScrapingService : IScrapingService Directory.CreateDirectory(cacheFolderPath); _logger.LogInformation("Using cache folder: {Path}", cacheFolderPath); - // Step 15-21: Capture all 7 frames + // Step 15-21: Capture all frames + var frameCount = CacheHelper.GetFrameCountForDataType(_configuration, CachedDataType.Radar); + + // Track progress: map is ready, starting frame capture + _cacheService.RecordUpdateProgressByFolder(cacheFolderPath, CacheUpdatePhase.CapturingFrames, 0, frameCount); + var frames = new List(); var stepForwardButton = page.Locator("button[data-testid='bom-scrub-utils__right__step-forward']").First; int? previousMinutesAgo = null; - for (int frameIndex = 0; frameIndex < 7; frameIndex++) + for (int frameIndex = 0; frameIndex < frameCount; frameIndex++) { - _logger.LogInformation("Capturing frame {FrameIndex} of 7", frameIndex); + _logger.LogInformation("Capturing frame {FrameIndex} of {FrameCount}", frameIndex, frameCount); // Wait for map to stabilize (tiles to load for current frame) await page.WaitForTimeoutAsync(_tileRenderWaitMs); @@ -565,11 +573,14 @@ public class ScrapingService : IScrapingService _logger.LogInformation("Frame {FrameIndex} saved: {Path} ({MinutesAgo} minutes ago)", frameIndex, framePath, minutesAgo.Value); + // Track progress: frame captured + _cacheService.RecordUpdateProgressByFolder(cacheFolderPath, CacheUpdatePhase.CapturingFrames, frameIndex + 1, frameCount); + // Save debug screenshot BEFORE clicking step forward await _debugService.SaveStepDebugAsync(debugFolder, 15 + frameIndex, $"frame_{frameIndex}_captured", page, consoleMessages, networkRequests, cancellationToken); // If not the last frame, click step forward to prepare for next frame - if (frameIndex < 6) + if (frameIndex < frameCount - 1) { // Dismiss any modal overlays (BOM, reCAPTCHA, feedback forms) before clicking await DismissModalOverlaysAsync(page); @@ -593,15 +604,18 @@ public class ScrapingService : IScrapingService } } - _logger.LogInformation("All 7 frames captured successfully"); + _logger.LogInformation("All {FrameCount} frames captured successfully", frameCount); // Step 22: Save metadata and frame information + // Track progress: switching to saving phase + _cacheService.RecordUpdateProgressByFolder(cacheFolderPath, CacheUpdatePhase.Saving); + await _cacheService.SaveMetadataAsync(cacheFolderPath, lastUpdatedInfo, cancellationToken); await _cacheService.SaveFramesMetadataAsync(cacheFolderPath, CachedDataType.Radar, frames, cancellationToken); // Step 23: Return response with all frames var cacheExpiresAt = lastUpdatedInfo.ObservationTime.AddMinutes(_cacheExpirationMinutes); - return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, lastUpdatedInfo, suburb, state, cacheIsValid: true, cacheExpiresAt: cacheExpiresAt, isUpdating: false, cacheManagementCheckIntervalMinutes: _cacheManagementCheckIntervalMinutes); + return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, _cacheManagementCheckIntervalMinutes, lastUpdatedInfo, suburb, state, cacheIsValid: true, cacheExpiresAt: cacheExpiresAt, isUpdating: false); } catch (Exception ex) { @@ -888,6 +902,14 @@ public class ScrapingService : IScrapingService /// private async Task CaptureMapScreenshotAsync(IPage page, ILocator mapContainer, string outputPath, Clip containerClip) { + // First, validate container clip itself + if (containerClip == null || containerClip.Width <= 0 || containerClip.Height <= 0) + { + _logger.LogError("Invalid container bounds: X={X}, Y={Y}, Width={Width}, Height={Height}", + containerClip?.X ?? 0, containerClip?.Y ?? 0, containerClip?.Width ?? 0, containerClip?.Height ?? 0); + throw new Exception($"Invalid container bounds: {containerClip?.Width ?? 0}x{containerClip?.Height ?? 0}"); + } + Clip cropArea; try { @@ -901,24 +923,92 @@ public class ScrapingService : IScrapingService cropArea = containerClip; } - // Validate crop area is within page bounds before attempting screenshot + // Get viewport size - if null, try to get it from page evaluation as fallback var viewportSize = page.ViewportSize; - if (viewportSize == null || cropArea.X < 0 || cropArea.Y < 0 || - cropArea.X + cropArea.Width > viewportSize.Width || - cropArea.Y + cropArea.Height > viewportSize.Height) + int? viewportWidth = viewportSize?.Width; + int? viewportHeight = viewportSize?.Height; + + if (viewportWidth == null || viewportHeight == null) { - _logger.LogWarning("Crop area is outside viewport bounds, using full container. Crop: X={X}, Y={Y}, Width={Width}, Height={Height}, Viewport: {ViewportWidth}x{ViewportHeight}", - cropArea.X, cropArea.Y, cropArea.Width, cropArea.Height, viewportSize?.Width ?? 0, viewportSize?.Height ?? 0); - cropArea = containerClip; + try + { + var viewportJson = await page.EvaluateAsync("() => JSON.stringify({ width: window.innerWidth, height: window.innerHeight })"); + if (!string.IsNullOrEmpty(viewportJson)) + { + using var doc = System.Text.Json.JsonDocument.Parse(viewportJson); + var root = doc.RootElement; + if (root.TryGetProperty("width", out var widthProp) && root.TryGetProperty("height", out var heightProp)) + { + if (widthProp.TryGetInt32(out var width) && heightProp.TryGetInt32(out var height)) + { + viewportWidth = width; + viewportHeight = height; + _logger.LogDebug("Retrieved viewport size from page evaluation: {Width}x{Height}", width, height); + } + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get viewport size from page evaluation"); + } + + // If still can't get it, use container bounds as fallback for validation + if (viewportWidth == null || viewportHeight == null) + { + _logger.LogWarning("Cannot determine viewport size, using container bounds for validation"); + viewportWidth = (int)containerClip.Width; + viewportHeight = (int)containerClip.Height; + } + } + + // Validate crop area is within page bounds and adjust if necessary + if (viewportWidth.HasValue && viewportHeight.HasValue) + { + // Ensure crop area coordinates are non-negative + if (cropArea.X < 0) + { + _logger.LogWarning("Crop X is negative ({X}), adjusting to 0", cropArea.X); + cropArea = new Clip { X = 0, Y = cropArea.Y, Width = cropArea.Width + cropArea.X, Height = cropArea.Height }; + } + if (cropArea.Y < 0) + { + _logger.LogWarning("Crop Y is negative ({Y}), adjusting to 0", cropArea.Y); + cropArea = new Clip { X = cropArea.X, Y = 0, Width = cropArea.Width, Height = cropArea.Height + cropArea.Y }; + } + + // Ensure crop area doesn't exceed viewport bounds + if (cropArea.X + cropArea.Width > viewportWidth.Value) + { + var newWidth = viewportWidth.Value - cropArea.X; + _logger.LogWarning("Crop width exceeds viewport ({Requested} > {Max}), adjusting to {NewWidth}", + cropArea.Width, viewportWidth.Value, newWidth); + cropArea = new Clip { X = cropArea.X, Y = cropArea.Y, Width = newWidth, Height = cropArea.Height }; + } + if (cropArea.Y + cropArea.Height > viewportHeight.Value) + { + var newHeight = viewportHeight.Value - cropArea.Y; + _logger.LogWarning("Crop height exceeds viewport ({Requested} > {Max}), adjusting to {NewHeight}", + cropArea.Height, viewportHeight.Value, newHeight); + cropArea = new Clip { X = cropArea.X, Y = cropArea.Y, Width = cropArea.Width, Height = newHeight }; + } } // Final validation - ensure dimensions are positive if (cropArea.Width <= 0 || cropArea.Height <= 0) { - _logger.LogError("Invalid crop dimensions: {Width}x{Height}, using full container", cropArea.Width, cropArea.Height); + _logger.LogError("Invalid crop dimensions after validation: {Width}x{Height}, using full container", cropArea.Width, cropArea.Height); cropArea = containerClip; } + // Double-check container clip is still valid as final fallback + if (cropArea.Width <= 0 || cropArea.Height <= 0) + { + _logger.LogError("Cannot create valid crop area. Container: {ContainerWidth}x{ContainerHeight}, Viewport: {ViewportWidth}x{ViewportHeight}", + containerClip.Width, containerClip.Height, viewportWidth ?? 0, viewportHeight ?? 0); + throw new Exception($"Cannot create valid crop area. Container: {containerClip.Width}x{containerClip.Height}, Viewport: {viewportWidth ?? 0}x{viewportHeight ?? 0}"); + } + // Wait for fonts to be loaded to prevent text rendering artifacts try { diff --git a/Utilities/CacheHelper.cs b/Utilities/CacheHelper.cs index 23b9460..e6a6648 100644 --- a/Utilities/CacheHelper.cs +++ b/Utilities/CacheHelper.cs @@ -61,5 +61,28 @@ public static class CacheHelper { return IsCacheFolderCompleteForDataType(cacheFolderPath, CachedDataType.Radar, configuration); } + + /// + /// Calculates the estimated cache update duration in seconds. + /// This is used as a fallback when metrics-based estimation is not available yet. + /// Calculates based on frame count and configured wait times. + /// + public static int GetEstimatedUpdateDurationSeconds(IConfiguration configuration, CachedDataType dataType = CachedDataType.Radar) + { + // Calculate based on actual wait times and frame count + var frameCount = GetFrameCountForDataType(configuration, dataType); + var tileRenderWaitMs = configuration.GetValue("Screenshot:TileRenderWaitMs", 5000); + var dynamicContentWaitMs = configuration.GetValue("Screenshot:DynamicContentWaitMs", 2000); + + // Rough calculation: + // - Initial page load and navigation: ~10-15 seconds + // - Per frame: tileRenderWaitMs (default 5s) + overhead (~1-2s for clicking, waiting, etc.) + // - Final processing and metadata saving: ~5 seconds + var perFrameSeconds = (tileRenderWaitMs + 1500) / 1000.0; // Add 1.5s overhead per frame + var baseOverheadSeconds = 15; // Initial load + final processing + var estimatedSeconds = (int)Math.Ceiling(baseOverheadSeconds + (frameCount * perFrameSeconds)); + + return estimatedSeconds; + } } diff --git a/Utilities/LocationHelper.cs b/Utilities/LocationHelper.cs index 5526bc4..b7aae15 100644 --- a/Utilities/LocationHelper.cs +++ b/Utilities/LocationHelper.cs @@ -22,7 +22,8 @@ public static class LocationHelper } /// - /// Parses suburb and state from a cache filename (format: Suburb_State_YYYYMMDD_HHMMSS.png) + /// Parses suburb and state from a cache filename (format: Suburb_State_YYYYMMDD_HHMMSS) + /// Handles multi-word suburbs like "Gold Coast" which becomes "Gold_Coast_QLD_20251216_130831" /// Returns null if parsing fails /// public static (string suburb, string state)? ParseLocationFromFilename(string fileName) @@ -30,12 +31,21 @@ public static class LocationHelper var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName); var parts = fileNameWithoutExtension.Split('_'); - if (parts.Length >= 2) + // Need at least: suburb, state, date, time (4 parts minimum) + // Format: [Suburb_Parts...]_State_YYYYMMDD_HHMMSS + if (parts.Length < 4) { - return (parts[0], parts[1]); + return null; } - return null; + // Last two parts are always timestamp (YYYYMMDD, HHMMSS) + // Second-to-last part is the state + // Everything before that is the suburb (may contain underscores from multi-word suburbs) + var state = parts[^3]; // Third from end + var suburbParts = parts.Take(parts.Length - 3).ToArray(); + var suburb = string.Join(" ", suburbParts); // Join with spaces (original format had spaces converted to underscores) + + return (suburb, state); } /// diff --git a/Utilities/ResponseBuilder.cs b/Utilities/ResponseBuilder.cs index de1a36c..1e22a2f 100644 --- a/Utilities/ResponseBuilder.cs +++ b/Utilities/ResponseBuilder.cs @@ -34,16 +34,18 @@ public static class ResponseBuilder /// Creates a RadarResponse from a cache folder path, frames, and metadata /// /// The interval in minutes that the background cache management service checks for updates. Used to calculate NextUpdateTime when cache is invalid. + /// Estimated duration in seconds for a cache update to complete. Used to calculate NextUpdateTime when update is in progress. public static RadarResponse CreateRadarResponse( string cacheFolderPath, List frames, + int cacheManagementCheckIntervalMinutes, LastUpdatedInfo? metadata = null, string? suburb = null, string? state = null, bool? cacheIsValid = null, DateTime? cacheExpiresAt = null, bool isUpdating = false, - int cacheManagementCheckIntervalMinutes = 5) + int? estimatedUpdateDurationSeconds = null) { var folderInfo = new DirectoryInfo(cacheFolderPath); var lastWriteTime = folderInfo.Exists @@ -88,8 +90,9 @@ public static class ResponseBuilder if (isUpdating) { - // Update in progress - estimate completion in ~2 minutes - nextUpdateTime = now.AddMinutes(2); + // Update in progress - estimate completion based on configured/calculated duration + var durationSeconds = estimatedUpdateDurationSeconds ?? 120; // Default to 2 minutes if not provided + nextUpdateTime = now.AddSeconds(durationSeconds); } else if (cacheIsValid == true && cacheExpiresAt.HasValue) { diff --git a/Views/RadarTest/Index.cshtml b/Views/RadarTest/Index.cshtml index 6047f1c..c0ac06c 100644 --- a/Views/RadarTest/Index.cshtml +++ b/Views/RadarTest/Index.cshtml @@ -736,7 +736,10 @@

Cache & Update Status

Checking...
-
+
+
+ +
@@ -1034,10 +1037,23 @@ } // Update update status detail with relative time + const estimationNoteEl = document.getElementById('estimation-note'); if (radarData.isUpdating && radarData.nextUpdateTime && updateStatusDetailEl) { - updateStatusDetailEl.textContent = 'Estimated completion: ' + formatDate(radarData.nextUpdateTime) + ' (' + getRelativeTime(radarData.nextUpdateTime) + ')'; + const relativeTime = getRelativeTime(radarData.nextUpdateTime); + updateStatusDetailEl.textContent = 'Update in progress • Estimated completion: ' + formatDate(radarData.nextUpdateTime) + ' (' + relativeTime + ')'; + updateStatusDetailEl.title = 'Estimate is based on historical metrics and current progress. Accuracy improves as more updates complete.'; + if (estimationNoteEl) estimationNoteEl.style.display = 'inline'; } else if (!radarData.cacheIsValid && !radarData.isUpdating && radarData.nextUpdateTime && updateStatusDetailEl) { - updateStatusDetailEl.textContent = 'Update scheduled: ' + formatDate(radarData.nextUpdateTime) + ' (' + getRelativeTime(radarData.nextUpdateTime) + ')'; + const relativeTime = getRelativeTime(radarData.nextUpdateTime); + updateStatusDetailEl.textContent = 'Update scheduled: ' + formatDate(radarData.nextUpdateTime) + ' (' + relativeTime + ')'; + updateStatusDetailEl.title = 'Estimated time when cache update will be triggered or completed. Based on metrics from previous updates.'; + if (estimationNoteEl) estimationNoteEl.style.display = 'inline'; + } else if (radarData.cacheIsValid && updateStatusDetailEl) { + updateStatusDetailEl.textContent = 'Cache is valid, no update needed'; + updateStatusDetailEl.title = ''; + if (estimationNoteEl) estimationNoteEl.style.display = 'none'; + } else { + if (estimationNoteEl) estimationNoteEl.style.display = 'none'; } // Update next client check time @@ -1121,19 +1137,17 @@ }); if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: `HTTP ${response.status}` })); + const errorData = await response.json().catch(() => ({ message: `HTTP ${response.status}` })); - // Handle 400 Bad Request (e.g., time range too large) + // Handle 400 Bad Request (e.g., time range too large, validation errors) if (response.status === 400) { - const errorMessage = errorData.error || 'Invalid request'; + const errorMessage = errorData.message || 'Invalid request'; let details = ''; - if (errorData.requestedHours && errorData.maxHours) { - details = ` Requested: ${errorData.requestedHours.toFixed(1)} hours, Maximum: ${errorData.maxHours} hours.`; - if (errorData.cacheRetentionHours) { - details += ` (Limit is based on cache retention: ${errorData.cacheRetentionHours} hours)`; - } - if (errorData.configuredMaxHours) { - details += ` (Override configured: ${errorData.configuredMaxHours} hours)`; + // Check if it's a time range error with details + if (errorData.errorCode === 'TIME_RANGE_ERROR' && errorData.details?.requestedRange) { + const requested = errorData.details.requestedRange; + if (requested.requestedHours && errorData.details.maxHours) { + details = ` Requested: ${requested.requestedHours.toFixed(1)} hours, Maximum: ${errorData.details.maxHours} hours.`; } } throw new Error(errorMessage + details); @@ -1141,11 +1155,11 @@ // Handle 404 Not Found if (response.status === 404) { - // Check if this is "location doesn't exist" vs "no data in range" - if (errorData.updateTriggered !== undefined || errorData.cacheExists !== undefined) { + // Check error code to determine type + if (errorData.errorCode === 'CACHE_NOT_FOUND') { // Location doesn't exist - trigger cache update if endpoint provided - if (errorData.refreshEndpoint) { - fetch(errorData.refreshEndpoint, { + if (errorData.suggestions?.refreshEndpoint) { + fetch(errorData.suggestions.refreshEndpoint, { method: 'POST', signal: AbortSignal.timeout(5000) }).catch(err => { @@ -1157,25 +1171,37 @@ return { frames: null, error: 'location_missing', - cacheStatus: errorData + cacheStatus: errorData.details || errorData }; - } else if (errorData.availableRange) { + } else if (errorData.errorCode === 'TIME_RANGE_ERROR' && errorData.details?.availableRange) { // Cache exists but no data in requested range - const availableRange = errorData.availableRange; - const rangeMessage = availableRange.oldest && availableRange.newest ? - ` Available data: ${formatDate(availableRange.oldest)} to ${formatDate(availableRange.newest)}.` : - availableRange.totalCacheFolders > 0 ? - ` ${availableRange.totalCacheFolders} cache folders available.` : ''; + const availableRange = errorData.details.availableRange; + let rangeMessage = ''; - throw new Error((errorData.error || 'No data in requested range') + rangeMessage); + // Use suggested range from error response if available + if (errorData.suggestions?.suggestedRange) { + const suggested = errorData.suggestions.suggestedRange; + rangeMessage = ` Suggested range: ${formatDate(suggested.start)} to ${formatDate(suggested.end)}.`; + } else if (availableRange.oldest && availableRange.newest) { + rangeMessage = ` Available data: ${formatDate(availableRange.oldest)} to ${formatDate(availableRange.newest)}.`; + } else if (availableRange.totalCacheFolders > 0) { + rangeMessage = ` ${availableRange.totalCacheFolders} cache folders available.`; + } + + // Include suggestion text if provided + if (errorData.suggestions?.suggestion) { + rangeMessage += ` ${errorData.suggestions.suggestion}`; + } + + throw new Error(errorData.message + rangeMessage); } else { // Generic 404 - throw new Error(errorData.error || 'No historical data found'); + throw new Error(errorData.message || 'No historical data found'); } } // Other errors - throw new Error(errorData.error || 'Failed to fetch historical radar'); + throw new Error(errorData.message || 'Failed to fetch historical radar'); } const data = await response.json(); @@ -1427,7 +1453,7 @@ }); if (!response.ok) { - const error = await response.json().catch(() => ({ error: `HTTP ${response.status}: ${response.statusText}` })); + const error = await response.json().catch(() => ({ message: `HTTP ${response.status}: ${response.statusText}` })); // Handle 404 more gracefully - cache is being generated if (response.status === 404) { @@ -1446,10 +1472,12 @@ updateApiStatus('connected'); } + // Use standardized error response format // Explicitly trigger cache refresh if refreshEndpoint is provided - if (errorData.refreshEndpoint) { + const refreshEndpoint = errorData.suggestions?.refreshEndpoint || errorData.refreshEndpoint; + if (refreshEndpoint) { // Trigger cache update in background (fire and forget) - fetch(errorData.refreshEndpoint, { + fetch(refreshEndpoint, { method: 'POST', signal: AbortSignal.timeout(5000) }).catch(err => { @@ -1458,26 +1486,24 @@ } // Use actual cache status from API if available - // Determine if update is in progress: UpdateTriggered=true OR Message indicates update in progress - if (errorData.updateTriggered !== undefined || errorData.cacheIsValid !== undefined || errorData.cacheExists !== undefined) { + // Check both new format (details) and old format for backward compatibility + const details = errorData.details || errorData; + if (details.updateTriggered !== undefined || details.cacheIsValid !== undefined || details.cacheExists !== undefined) { // Determine if update is in progress - const isUpdating = errorData.updateTriggered === true || - (errorData.message && errorData.message.includes('in progress')); + const isUpdating = details.updateTriggered === true || + (errorData.message && errorData.message.includes('in progress')) || + (details.statusMessage && details.statusMessage.includes('in progress')); // Create a data-like object with cache status for updateUI const cacheStatusData = { frames: [], isUpdating: isUpdating, - cacheIsValid: errorData.cacheIsValid || false, - cacheExpiresAt: errorData.cacheExpiresAt || null, - nextUpdateTime: errorData.nextUpdateTime || null + cacheIsValid: details.cacheIsValid || false, + cacheExpiresAt: details.cacheExpiresAt || null, + nextUpdateTime: details.nextUpdateTime || null }; console.debug('Updating UI with cache status from 404:', cacheStatusData); updateUI(cacheStatusData); - } else if (errorData.retryAfter) { - // Fallback to old method if cache status not available - console.debug('Cache status not in 404 response, using fallback method'); - updateStatusForCacheUpdate(errorData.retryAfter, errorData.refreshEndpoint); } else { // No cache status info at all - set default updating status console.debug('No cache status info available, setting default updating status'); @@ -1489,11 +1515,13 @@ updateUI(defaultStatusData); } - showNoFramesMessage(`Cache is being generated. Please wait ${errorData.retryAfter || 30} seconds and refresh.`, errorData.refreshEndpoint); + const retryAfter = errorData.suggestions?.retryAfter || errorData.retryAfter || 30; + const message = errorData.message || `Cache is being generated. Please wait ${retryAfter} seconds and refresh.`; + showNoFramesMessage(message, refreshEndpoint); return null; } - throw new Error(error.error || 'Failed to fetch radar data'); + throw new Error(error.message || error.error || 'Failed to fetch radar data'); } const data = await response.json(); @@ -1704,20 +1732,33 @@ // Update update status detail (now shown below cache status in combined card) const updateStatusDetailEl = document.getElementById('update-status-detail'); + const estimationNoteEl = document.getElementById('estimation-note'); if (data.isUpdating !== undefined || data.cacheIsValid !== undefined) { if (data.isUpdating) { if (data.nextUpdateTime) { - updateStatusDetailEl.textContent = 'Estimated completion: ' + formatDate(data.nextUpdateTime) + ' (' + getRelativeTime(data.nextUpdateTime) + ')'; + const relativeTime = getRelativeTime(data.nextUpdateTime); + updateStatusDetailEl.textContent = 'Update in progress • Estimated completion: ' + formatDate(data.nextUpdateTime) + ' (' + relativeTime + ')'; + updateStatusDetailEl.title = 'Estimate is based on historical metrics and current progress. Accuracy improves as more updates complete.'; + if (estimationNoteEl) estimationNoteEl.style.display = 'inline'; } else { updateStatusDetailEl.textContent = 'Update in progress, please wait...'; + updateStatusDetailEl.title = 'Cache update is in progress. Completion time will be estimated once progress tracking begins.'; + if (estimationNoteEl) estimationNoteEl.style.display = 'none'; } } else if (data.cacheIsValid) { - updateStatusDetailEl.textContent = 'No update needed'; + updateStatusDetailEl.textContent = 'Cache is valid, no update needed'; + updateStatusDetailEl.title = ''; + if (estimationNoteEl) estimationNoteEl.style.display = 'none'; } else { if (data.nextUpdateTime) { - updateStatusDetailEl.textContent = 'Update scheduled: ' + formatDate(data.nextUpdateTime) + ' (' + getRelativeTime(data.nextUpdateTime) + ')'; + const relativeTime = getRelativeTime(data.nextUpdateTime); + updateStatusDetailEl.textContent = 'Update scheduled: ' + formatDate(data.nextUpdateTime) + ' (' + relativeTime + ')'; + updateStatusDetailEl.title = 'Estimated time when cache update will be triggered or completed. Based on metrics from previous updates.'; + if (estimationNoteEl) estimationNoteEl.style.display = 'inline'; } else { updateStatusDetailEl.textContent = 'Update will be triggered by background service'; + updateStatusDetailEl.title = 'The background cache management service will check and update the cache periodically.'; + if (estimationNoteEl) estimationNoteEl.style.display = 'none'; } } }