diff --git a/Controllers/RadarTestController.cs b/Controllers/RadarTestController.cs new file mode 100644 index 0000000..c819a5d --- /dev/null +++ b/Controllers/RadarTestController.cs @@ -0,0 +1,33 @@ +using BomLocalService.Utilities; +using Microsoft.AspNetCore.Mvc; + +namespace BomLocalService.Controllers; + +/// +/// MVC controller for serving the radar test/demo page. +/// This demonstrates best practices for consuming the radar API. +/// +[Route("radar")] +public class RadarTestController : Controller +{ + /// + /// Serves the test page for viewing radar data for a specific location. + /// Route: /radar/{suburb}/{state} + /// + [HttpGet("{suburb}/{state}")] + public IActionResult Index(string suburb, string state) + { + var validationError = ValidationHelper.ValidateLocation(suburb, state); + if (validationError != null) + { + return BadRequest(validationError); + } + + ViewBag.Suburb = suburb; + ViewBag.State = state; + ViewBag.ApiBaseUrl = $"/api/radar/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}"; + + return View(); + } +} + diff --git a/Program.cs b/Program.cs index 719f5f6..aadec34 100644 --- a/Program.cs +++ b/Program.cs @@ -4,7 +4,8 @@ using BomLocalService.Services.Interfaces; var builder = WebApplication.CreateBuilder(args); // Add services to the container -builder.Services.AddControllers(); +// AddControllersWithViews includes AddControllers, so we use it for both MVC and API controllers +builder.Services.AddControllersWithViews(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddOpenApi(); @@ -45,6 +46,13 @@ if (enableHttpsRedirection) } // No authorization required - service is designed to run behind a reverse proxy if auth is needed + +// Map MVC routes first (before API routes to avoid conflicts) +app.MapControllerRoute( + name: "default", + pattern: "{controller=RadarTest}/{action=Index}/{suburb?}/{state?}"); + +// Map API controllers (with /api prefix) app.MapControllers(); // Cleanup incomplete cache folders from previous crashes/restarts before starting services diff --git a/Services/BomRadarService.cs b/Services/BomRadarService.cs index 023a4f6..e83e160 100644 --- a/Services/BomRadarService.cs +++ b/Services/BomRadarService.cs @@ -14,6 +14,7 @@ public class BomRadarService : IBomRadarService, IDisposable private readonly IScrapingService _scrapingService; private readonly IDebugService _debugService; private readonly double _cacheExpirationMinutes; + private readonly int _cacheManagementCheckIntervalMinutes; private readonly ConcurrentDictionary _activeCacheFolders = new(); // locationKey -> cacheFolderPath public BomRadarService( @@ -30,6 +31,7 @@ public class BomRadarService : IBomRadarService, IDisposable _scrapingService = scrapingService; _debugService = debugService; _cacheExpirationMinutes = configuration.GetValue("CacheExpirationMinutes", 15.5); + _cacheManagementCheckIntervalMinutes = configuration.GetValue("CacheManagement:CheckIntervalMinutes", 5); } public async Task GetCachedRadarAsync(string suburb, string state, CancellationToken cancellationToken = default) @@ -54,7 +56,7 @@ public class BomRadarService : IBomRadarService, IDisposable var cacheExpiresAt = cachedMetadata != null ? cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes) : (DateTime?)null; var isUpdating = _activeCacheFolders.ContainsKey(locationKey); - return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating); + return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating, _cacheManagementCheckIntervalMinutes); } public async Task?> GetCachedFramesAsync(string suburb, string state, CancellationToken cancellationToken = default) @@ -95,6 +97,10 @@ public class BomRadarService : IBomRadarService, IDisposable status.CacheIsValid = _cacheService.IsCacheValid(cachedMetadata); status.CacheExpiresAt = cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes); } + else + { + status.CacheIsValid = false; + } status.UpdateTriggered = false; status.Message = "Cache update already in progress"; @@ -109,6 +115,11 @@ public class BomRadarService : IBomRadarService, IDisposable status.CacheIsValid = _cacheService.IsCacheValid(cachedMetadata); status.CacheExpiresAt = cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes); } + else + { + // If no metadata, cache cannot be valid + status.CacheIsValid = false; + } // Check if we need to update bool needsUpdate = !status.CacheExists || !status.CacheIsValid; @@ -172,7 +183,7 @@ 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); + return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating: true, _cacheManagementCheckIntervalMinutes); } else { @@ -189,7 +200,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); + return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating: false, _cacheManagementCheckIntervalMinutes); } else { @@ -257,7 +268,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); + return ResponseBuilder.CreateRadarResponse(recheckCacheFolderPath, recheckFrames, recheckCachedMetadata, suburb, state, cacheIsValid: true, recheckCacheExpiresAt, isUpdating: false, _cacheManagementCheckIntervalMinutes); } // Create debug folder only now diff --git a/Services/CacheManagementService.cs b/Services/CacheManagementService.cs index be7607f..4de50a8 100644 --- a/Services/CacheManagementService.cs +++ b/Services/CacheManagementService.cs @@ -147,12 +147,17 @@ public class CacheManagementService : BackgroundService suburb, state, status.CacheExpiresAt); return (true, false); } - else + else if (status.CacheIsValid) { _logger.LogInformation("Cache is valid for {Suburb}, {State} (expires at {ExpiresAt})", - suburb, state, status.CacheExpiresAt); + suburb, state, status.CacheExpiresAt?.ToString("yyyy-MM-dd HH:mm:ss UTC") ?? "unknown"); return (false, true); } + else + { + _logger.LogInformation("Cache exists but is invalid for {Suburb}, {State}", suburb, state); + return (false, false); + } } catch (Exception ex) { diff --git a/Services/ScrapingService.cs b/Services/ScrapingService.cs index 4a65f61..e2c4f5a 100644 --- a/Services/ScrapingService.cs +++ b/Services/ScrapingService.cs @@ -541,7 +541,9 @@ public class ScrapingService : IScrapingService } // Step 22: Return response with all frames - return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, lastUpdatedInfo, suburb, state); + // Note: ScrapingService doesn't have access to cache management check interval + // Default to 5 minutes (standard check interval) + return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, lastUpdatedInfo, suburb, state, cacheIsValid: true, cacheExpiresAt: null, isUpdating: false, cacheManagementCheckIntervalMinutes: 5); } catch (Exception ex) { diff --git a/Utilities/ResponseBuilder.cs b/Utilities/ResponseBuilder.cs index 7a78bac..b5d7c18 100644 --- a/Utilities/ResponseBuilder.cs +++ b/Utilities/ResponseBuilder.cs @@ -7,6 +7,7 @@ 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. public static RadarResponse CreateRadarResponse( string cacheFolderPath, List frames, @@ -15,7 +16,8 @@ public static class ResponseBuilder string? state = null, bool? cacheIsValid = null, DateTime? cacheExpiresAt = null, - bool isUpdating = false) + bool isUpdating = false, + int cacheManagementCheckIntervalMinutes = 5) { var folderInfo = new DirectoryInfo(cacheFolderPath); var lastWriteTime = folderInfo.Exists @@ -33,6 +35,35 @@ public static class ResponseBuilder } } + // Calculate NextUpdateTime based on cache status: + // - If cache is valid: NextUpdateTime = max(CacheExpiresAt, next background service check) + // (Background service checks every N minutes, so update might happen at next check after expiry) + // - If cache is invalid and updating: NextUpdateTime = estimated completion time (~2 minutes) + // - If cache is invalid and NOT updating: NextUpdateTime = next background service check (based on check interval) + DateTime? nextUpdateTime = null; + + // Calculate next background service check time (rounds up to next check interval) + var now = DateTime.UtcNow; + var minutesUntilNextCheck = cacheManagementCheckIntervalMinutes - (now.Minute % cacheManagementCheckIntervalMinutes); + var nextServiceCheck = now.AddMinutes(minutesUntilNextCheck); + + if (isUpdating) + { + // Update in progress - estimate completion in ~2 minutes + nextUpdateTime = now.AddMinutes(2); + } + else if (cacheIsValid == true && cacheExpiresAt.HasValue) + { + // Cache is valid - next update will be when cache expires OR next service check, whichever is later + // This accounts for the fact that the background service only checks every N minutes + nextUpdateTime = cacheExpiresAt.Value > nextServiceCheck ? cacheExpiresAt.Value : nextServiceCheck; + } + else if (cacheIsValid == false) + { + // Cache is invalid and not updating - next update will be at next background service check + nextUpdateTime = nextServiceCheck; + } + var response = new RadarResponse { Frames = frames, @@ -44,7 +75,7 @@ public static class ResponseBuilder CacheIsValid = cacheIsValid ?? false, CacheExpiresAt = cacheExpiresAt, IsUpdating = isUpdating, - NextUpdateTime = cacheExpiresAt ?? (isUpdating ? DateTime.UtcNow.AddMinutes(2) : null) // Estimate 2 min for update if in progress + NextUpdateTime = nextUpdateTime }; return response; diff --git a/Views/RadarTest/Index.cshtml b/Views/RadarTest/Index.cshtml new file mode 100644 index 0000000..d04457a --- /dev/null +++ b/Views/RadarTest/Index.cshtml @@ -0,0 +1,903 @@ +@{ + ViewData["Title"] = $"BOM Radar Test - {ViewBag.Suburb}, {ViewBag.State}"; + var apiBaseUrl = ViewBag.ApiBaseUrl as string ?? ""; + var suburb = ViewBag.Suburb as string ?? ""; + var state = ViewBag.State as string ?? ""; +} + + + + + + + @ViewData["Title"] + + + + + + 🌧️ BOM Radar Test + @suburb, @state + + + + + + + + Loading radar data... + + + + + + + ▶ Play + ◀ Previous + Next ▶ + + + + + + + + Cache Status + Checking... + + + + Observation Time + - + + + + Cache Expires + - + + + + + Next Update + - + + + + + Weather Station + - + + + + Distance + - + + + + Update Status + - + + + + + Auto Refresh + + + Active + + + + + + + Settings + ⚙️ Settings + + + + + + + + + Settings + + + Frame Interval (seconds) + + Time between frames in slideshow + + + + Auto Refresh Interval (seconds) + + How often to check for new radar data + + + + + + Auto-play on page load + + + + + Save + Cancel + + + + + + + +