Fix TimeRangeError builder and update error response docs

- Fix TimeRangeError to properly extract properties from anonymous objects
- Add suggestions field to timeseries error response examples in README
- Enhance demo app to use suggested ranges from error responses
This commit is contained in:
2025-12-17 01:24:39 +10:00
parent 92c241b26e
commit 5e31ed33c6
13 changed files with 1298 additions and 182 deletions
+33 -8
View File
@@ -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<string, object>
{
{ "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);
}
}
}
+119 -50
View File
@@ -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<string, object>
{
{ "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 {
var availableRange = new {
oldest = oldestCache,
newest = newestCache,
totalCacheFolders = cacheRange.TotalCacheFolders,
timeSpanMinutes = cacheRange.TimeSpanMinutes
},
requestedRange = new {
};
var requestedRange = new {
start = start,
end = end
},
suggestion = "Try adjusting the time range to match the available cached data."
});
};
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);
}
}
}
+340
View File
@@ -0,0 +1,340 @@
namespace BomLocalService.Models;
/// <summary>
/// Standardized error response model for API endpoints.
/// Provides consistent error structure with error codes, types, and detailed information.
/// </summary>
public class ApiErrorResponse
{
/// <summary>
/// Machine-readable error code for programmatic handling.
/// Examples: "CACHE_NOT_FOUND", "VALIDATION_ERROR", "INTERNAL_ERROR", "CACHE_UPDATE_FAILED"
/// </summary>
public string ErrorCode { get; set; } = string.Empty;
/// <summary>
/// Human-readable error message describing what went wrong.
/// </summary>
public string Message { get; set; } = string.Empty;
/// <summary>
/// Error type/category for grouping similar errors.
/// Examples: "CacheError", "ValidationError", "ServiceError", "NotFoundError"
/// </summary>
public string ErrorType { get; set; } = string.Empty;
/// <summary>
/// Additional context about the error (e.g., field name for validation errors).
/// </summary>
public Dictionary<string, object>? Details { get; set; }
/// <summary>
/// Suggested action for the client (e.g., "retry_after_seconds", "refresh_cache").
/// </summary>
public Dictionary<string, object>? Suggestions { get; set; }
/// <summary>
/// Timestamp when the error occurred (UTC).
/// </summary>
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Helper class for creating standardized error responses.
/// </summary>
public static class ApiErrorResponseBuilder
{
/// <summary>
/// Creates an error response for when cache is not found (fresh start scenario).
/// </summary>
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<string, object>
{
{ "location", new { suburb, state } },
{ "cacheExists", cacheStatus?.CacheExists ?? false },
{ "cacheIsValid", cacheStatus?.CacheIsValid ?? false },
{ "updateTriggered", updateTriggered || (cacheStatus?.UpdateTriggered ?? false) }
},
Suggestions = new Dictionary<string, object>
{
{ "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;
}
/// <summary>
/// Creates an error response for validation errors.
/// </summary>
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<string, object> { { "field", field } };
}
return response;
}
/// <summary>
/// Creates an error response for when cache update fails.
/// </summary>
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<string, object>
{
{ "location", new { suburb, state } },
{ "reason", reason }
},
Suggestions = new Dictionary<string, object>
{
{ "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;
}
/// <summary>
/// Creates an error response for internal server errors.
/// </summary>
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<string, object>
{
{ "exceptionType", exception.GetType().Name },
{ "exceptionMessage", exception.Message }
};
}
return response;
}
/// <summary>
/// Creates an error response for when a specific resource is not found.
/// </summary>
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<string, object>
{
{ "resourceType", resourceType },
{ "identifier", identifier }
}
};
if (!string.IsNullOrEmpty(suggestion))
{
response.Suggestions = new Dictionary<string, object> { { "suggestion", suggestion } };
}
return response;
}
/// <summary>
/// Creates an error response for time range validation errors.
/// </summary>
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<string, object>(),
Suggestions = new Dictionary<string, object>
{
{ "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;
}
}
+37
View File
@@ -1,5 +1,15 @@
namespace BomLocalService.Models;
/// <summary>
/// Represents the current phase of a cache update operation.
/// </summary>
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)
}
/// <summary>
/// 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)
/// </summary>
public string? Message { get; set; }
/// <summary>
/// Indicates whether the last cache update attempt failed.
/// True if an update was triggered but encountered an error.
/// </summary>
public bool UpdateFailed { get; set; }
/// <summary>
/// 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.
/// </summary>
public string? Error { get; set; }
/// <summary>
/// Error code for programmatic handling of update failures.
/// Examples: "SCRAPING_ERROR", "BROWSER_ERROR", "STORAGE_ERROR", "TIMEOUT_ERROR"
/// Null if no error occurred.
/// </summary>
public string? ErrorCode { get; set; }
/// <summary>
/// Timestamp when the last update attempt was made (UTC).
/// Null if no update has been attempted yet.
/// </summary>
public DateTime? LastUpdateAttempt { get; set; }
}
+244 -30
View File
@@ -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,25 +496,69 @@ 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.",
"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
}
},
"suggestions": {
"action": "adjust_time_range"
},
"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",
@@ -461,8 +568,17 @@ GET /api/radar/{suburb}/{state}/timeseries?startTime={iso8601}&endTime={iso8601}
"requestedRange": {
"start": "2025-01-15T00:00:00Z",
"end": "2025-01-15T10:00:00Z"
}
},
"suggestion": "Try adjusting the time range to match the available cached data."
"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) {
+41 -9
View File
@@ -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<BomRadarService> logger,
@@ -35,6 +36,10 @@ public class BomRadarService : IBomRadarService, IDisposable
_cacheManagementCheckIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
_timeSeriesWarningFolderCount = configuration.GetValue<int>("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<RadarResponse?> 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<List<RadarFrame>?> 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
+216 -2
View File
@@ -14,6 +14,13 @@ public class CacheService : ICacheService
private readonly IConfiguration _configuration;
private readonly ConcurrentDictionary<string, string> _activeCacheFolders = new(); // locationKey -> cacheFolderPath
// Progress tracking for cache updates
private readonly ConcurrentDictionary<string, (DateTime startTime, CacheUpdatePhase phase, int? currentFrame, int? totalFrames)> _updateProgress = new();
private readonly ConcurrentQueue<double> _recentTotalDurations = new(); // Overall durations in seconds
private readonly ConcurrentDictionary<CacheUpdatePhase, ConcurrentQueue<double>> _phaseDurations = new(); // Phase -> durations
private readonly object _metricsLock = new();
private const int MaxSamples = 20;
public CacheService(ILogger<CacheService> logger, IConfiguration configuration)
{
_logger = logger;
@@ -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);
}
}
/// <summary>
/// Records progress update for a cache update operation.
/// </summary>
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<double>());
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);
}
/// <summary>
/// Records completion of a cache update and stores metrics.
/// </summary>
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);
}
}
/// <summary>
/// Gets the estimated remaining seconds for an in-progress cache update.
/// Returns 0 if not updating or no metrics available.
/// </summary>
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;
}
/// <summary>
/// Gets the average total duration of cache updates from recent metrics.
/// </summary>
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;
}
}
/// <summary>
/// Gets the average duration per frame based on historical CapturingFrames phase data.
/// </summary>
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;
}
/// <summary>
/// Gets the average duration for a specific phase from historical data.
/// </summary>
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();
}
}
/// <summary>
/// Gets the locationKey from a cacheFolderPath by parsing the folder name.
/// </summary>
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;
}
/// <summary>
/// Records progress update for a cache update operation using cacheFolderPath.
/// This is useful when locationKey is not directly available (e.g., in ScrapingService).
/// </summary>
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)
{
+18
View File
@@ -195,5 +195,23 @@ public interface ICacheService
int cacheExpirationMinutes,
int cacheManagementCheckIntervalMinutes,
CancellationToken cancellationToken = default);
/// <summary>
/// Records progress update for a cache update operation using cacheFolderPath.
/// This is useful when locationKey is not directly available (e.g., in ScrapingService).
/// </summary>
/// <param name="cacheFolderPath">The cache folder path being updated</param>
/// <param name="phase">The current phase of the update</param>
/// <param name="currentFrame">The current frame being captured (if in CapturingFrames phase)</param>
/// <param name="totalFrames">The total number of frames to capture</param>
void RecordUpdateProgressByFolder(string cacheFolderPath, CacheUpdatePhase phase, int? currentFrame = null, int? totalFrames = null);
/// <summary>
/// Gets the estimated remaining seconds for an in-progress cache update.
/// Returns 0 if not updating or no metrics available.
/// </summary>
/// <param name="locationKey">The location key (suburb_state)</param>
/// <returns>Estimated remaining seconds, or 0 if not updating or no metrics</returns>
int GetEstimatedRemainingSeconds(string locationKey);
}
+106 -16
View File
@@ -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<int>("Screenshot:DynamicContentWaitMs", 2000);
_tileRenderWaitMs = configuration.GetValue<int>("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<RadarFrame>();
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
/// </summary>
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<string>("() => 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
{
+23
View File
@@ -61,5 +61,28 @@ public static class CacheHelper
{
return IsCacheFolderCompleteForDataType(cacheFolderPath, CachedDataType.Radar, configuration);
}
/// <summary>
/// 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.
/// </summary>
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<int>("Screenshot:TileRenderWaitMs", 5000);
var dynamicContentWaitMs = configuration.GetValue<int>("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;
}
}
+14 -4
View File
@@ -22,7 +22,8 @@ public static class LocationHelper
}
/// <summary>
/// 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
/// </summary>
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);
}
/// <summary>
+6 -3
View File
@@ -34,16 +34,18 @@ public static class ResponseBuilder
/// Creates a RadarResponse from a cache folder path, frames, and metadata
/// </summary>
/// <param name="cacheManagementCheckIntervalMinutes">The interval in minutes that the background cache management service checks for updates. Used to calculate NextUpdateTime when cache is invalid.</param>
/// <param name="estimatedUpdateDurationSeconds">Estimated duration in seconds for a cache update to complete. Used to calculate NextUpdateTime when update is in progress.</param>
public static RadarResponse CreateRadarResponse(
string cacheFolderPath,
List<RadarFrame> 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)
{
+87 -46
View File
@@ -736,7 +736,10 @@
<div class="info-card">
<h3>Cache & Update Status</h3>
<div class="value" id="cache-status">Checking...</div>
<div class="timestamp" id="update-status-detail"></div>
<div class="timestamp" id="update-status-detail" style="cursor: help;"></div>
<div style="font-size: 0.85em; color: #666; margin-top: 5px; font-style: italic;">
<span id="estimation-note" style="display: none;">Estimates improve as metrics are collected from completed updates</span>
</div>
</div>
<div class="info-card">
@@ -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';
}
}
}