mirror of
https://github.com/alexhopeoconnor/bom-local-service.git
synced 2026-10-04 03:28:11 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eecce00a6 | ||
|
|
28e8cb4fd1 | ||
|
|
a54061f757 | ||
|
|
0b6d266e79 | ||
|
|
1a257931e8 | ||
|
|
0a29c2d0c1 | ||
|
|
ffd3636d1c | ||
|
|
b30d76ddec |
@@ -4,10 +4,10 @@
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>0.0.6</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.57.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -60,8 +60,19 @@ public class RadarController : ControllerBase
|
||||
{
|
||||
// Get cache status to include in 404 response
|
||||
// Use cache service directly since this is cache management, not radar-specific
|
||||
var cacheExpirationMinutes = (int)_configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
|
||||
var cacheManagementCheckIntervalMinutes = _configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
|
||||
var cacheExpirationMinutesConfig = _configuration.GetValue<double?>("CacheExpirationMinutes");
|
||||
if (!cacheExpirationMinutesConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
|
||||
}
|
||||
var cacheExpirationMinutes = (int)cacheExpirationMinutesConfig.Value;
|
||||
|
||||
var cacheManagementCheckIntervalMinutesConfig = _configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
|
||||
if (!cacheManagementCheckIntervalMinutesConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
|
||||
}
|
||||
var cacheManagementCheckIntervalMinutes = cacheManagementCheckIntervalMinutesConfig.Value;
|
||||
var cacheStatus = await _cacheService.GetCacheStatusAsync(
|
||||
suburb,
|
||||
state,
|
||||
@@ -288,7 +299,14 @@ public class RadarController : ControllerBase
|
||||
|
||||
// Validate time range size to prevent excessive data loading
|
||||
// Default: base limit on cache retention, but allow override via config
|
||||
var cacheRetentionHours = _configuration.GetValue<int>("CacheRetentionHours", 24);
|
||||
var cacheRetentionHoursConfig = _configuration.GetValue<int?>("CacheRetentionHours");
|
||||
if (!cacheRetentionHoursConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("CacheRetentionHours configuration is required. Set it in appsettings.json or via CACHERETENTIONHOURS environment variable.");
|
||||
}
|
||||
var cacheRetentionHours = cacheRetentionHoursConfig.Value;
|
||||
|
||||
// TimeSeries:MaxTimeRangeHours is optional (nullable)
|
||||
var configuredMaxHours = _configuration.GetValue<int?>("TimeSeries:MaxTimeRangeHours");
|
||||
|
||||
// Use configured value if set, otherwise use cache retention (with minimum of 24 hours)
|
||||
@@ -331,8 +349,19 @@ public class RadarController : ControllerBase
|
||||
});
|
||||
|
||||
// Get cache status to include in 404 response
|
||||
var cacheExpirationMinutes = (int)_configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
|
||||
var cacheManagementCheckIntervalMinutes = _configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
|
||||
var cacheExpirationMinutesConfig = _configuration.GetValue<double?>("CacheExpirationMinutes");
|
||||
if (!cacheExpirationMinutesConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
|
||||
}
|
||||
var cacheExpirationMinutes = (int)cacheExpirationMinutesConfig.Value;
|
||||
|
||||
var cacheManagementCheckIntervalMinutesConfig = _configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
|
||||
if (!cacheManagementCheckIntervalMinutesConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
|
||||
}
|
||||
var cacheManagementCheckIntervalMinutes = cacheManagementCheckIntervalMinutesConfig.Value;
|
||||
var cacheStatus = await _cacheService.GetCacheStatusAsync(
|
||||
suburb,
|
||||
state,
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
using BomLocalService.Services.Interfaces;
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
using BomLocalService.Services.Scraping;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Reflection;
|
||||
|
||||
namespace BomLocalService.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures CORS with settings from configuration.
|
||||
/// Configuration values come from appsettings.json (defaults) and can be overridden via environment variables.
|
||||
/// Environment variables use double underscore for nested keys (e.g., CORS__ALLOWEDORIGINS).
|
||||
/// </summary>
|
||||
public static IServiceCollection AddCorsConfiguration(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var corsOrigins = configuration.GetValue<string>("Cors:AllowedOrigins")
|
||||
?? throw new InvalidOperationException("Cors:AllowedOrigins configuration is required. Set it in appsettings.json or via CORS__ALLOWEDORIGINS environment variable.");
|
||||
var corsMethods = configuration.GetValue<string>("Cors:AllowedMethods")
|
||||
?? throw new InvalidOperationException("Cors:AllowedMethods configuration is required. Set it in appsettings.json or via CORS__ALLOWEDMETHODS environment variable.");
|
||||
var corsHeaders = configuration.GetValue<string>("Cors:AllowedHeaders")
|
||||
?? throw new InvalidOperationException("Cors:AllowedHeaders configuration is required. Set it in appsettings.json or via CORS__ALLOWEDHEADERS environment variable.");
|
||||
|
||||
// For bool, check if the key exists in configuration (GetValue<bool> returns false if not found, which is ambiguous)
|
||||
var corsAllowCredentialsKey = configuration["Cors:AllowCredentials"];
|
||||
if (corsAllowCredentialsKey == null)
|
||||
{
|
||||
throw new InvalidOperationException("Cors:AllowCredentials configuration is required. Set it in appsettings.json or via CORS__ALLOWCREDENTIALS environment variable.");
|
||||
}
|
||||
var corsAllowCredentials = configuration.GetValue<bool>("Cors:AllowCredentials");
|
||||
|
||||
services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
if (corsOrigins == "*")
|
||||
{
|
||||
policy.AllowAnyOrigin();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Split comma-separated origins
|
||||
var origins = corsOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
policy.WithOrigins(origins);
|
||||
}
|
||||
|
||||
// Split comma-separated methods
|
||||
var methods = corsMethods.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
policy.WithMethods(methods);
|
||||
|
||||
// Split comma-separated headers or allow all
|
||||
if (corsHeaders == "*")
|
||||
{
|
||||
policy.AllowAnyHeader();
|
||||
}
|
||||
else
|
||||
{
|
||||
var headers = corsHeaders.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
policy.WithHeaders(headers);
|
||||
}
|
||||
|
||||
if (corsAllowCredentials)
|
||||
{
|
||||
policy.AllowCredentials();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans the assembly for services implementing registration interfaces and registers them automatically.
|
||||
/// Services are registered as their concrete interface (that inherits from registration interface).
|
||||
/// Properly handles generic interfaces by using concrete interfaces that hide the generics.
|
||||
/// </summary>
|
||||
public static IServiceCollection ScanAndRegisterServices(this IServiceCollection services, Assembly assembly)
|
||||
{
|
||||
var registeredTypes = new HashSet<Type>(); // Track registered types to avoid duplicates
|
||||
|
||||
// Get all types from the assembly with improved filtering
|
||||
var types = GetRegisterableTypes(assembly);
|
||||
|
||||
// Register Singleton services
|
||||
RegisterServicesByLifetime<ISingletonService>(
|
||||
services,
|
||||
types,
|
||||
registeredTypes);
|
||||
|
||||
// Register Scoped services
|
||||
RegisterServicesByLifetime<IScopedService>(
|
||||
services,
|
||||
types,
|
||||
registeredTypes);
|
||||
|
||||
// Register Transient services
|
||||
RegisterServicesByLifetime<ITransientService>(
|
||||
services,
|
||||
types,
|
||||
registeredTypes);
|
||||
|
||||
// Register Hosted Services
|
||||
RegisterHostedServices(services, types, registeredTypes);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all types that should be considered for registration.
|
||||
/// Filters out abstract classes, interfaces, generic type definitions, nested private types,
|
||||
/// compiler-generated types, and types from excluded namespaces.
|
||||
/// </summary>
|
||||
private static List<Type> GetRegisterableTypes(Assembly assembly)
|
||||
{
|
||||
return assembly.GetTypes()
|
||||
.Where(t =>
|
||||
// Must be a class (not interface, struct, enum, etc.)
|
||||
t.IsClass
|
||||
// Must be concrete (not abstract)
|
||||
&& !t.IsAbstract
|
||||
// Must not be a generic type definition (but closed generics are OK)
|
||||
&& !t.IsGenericTypeDefinition
|
||||
// Must be public (or nested public in a public type)
|
||||
&& (t.IsPublic || (t.IsNestedPublic && t.DeclaringType?.IsPublic == true))
|
||||
// Must not be compiler-generated (e.g., async state machines, iterator classes)
|
||||
&& !t.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), inherit: false)
|
||||
// Must not be a nested private/internal type
|
||||
&& !(t.IsNested && !t.IsNestedPublic)
|
||||
// Exclude test namespaces if any (optional - adjust as needed)
|
||||
&& !t.Namespace?.StartsWith("BomLocalService.Tests", StringComparison.Ordinal) == true
|
||||
)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static void RegisterServicesByLifetime<TRegistrationInterface>(
|
||||
IServiceCollection services,
|
||||
List<Type> types,
|
||||
HashSet<Type> registeredTypes)
|
||||
where TRegistrationInterface : class
|
||||
{
|
||||
foreach (var implementationType in types)
|
||||
{
|
||||
// Skip if already registered
|
||||
if (registeredTypes.Contains(implementationType))
|
||||
continue;
|
||||
|
||||
// Get all interfaces implemented by this type
|
||||
var allInterfaces = implementationType.GetInterfaces().ToList();
|
||||
|
||||
// Find interfaces that inherit from TRegistrationInterface (but not the registration interface itself)
|
||||
var registrationInterfaces = allInterfaces
|
||||
.Where(i => typeof(TRegistrationInterface).IsAssignableFrom(i)
|
||||
&& i != typeof(TRegistrationInterface)
|
||||
&& !IsGenericTypeDefinition(i))
|
||||
.ToList();
|
||||
|
||||
if (registrationInterfaces.Count == 0)
|
||||
continue;
|
||||
|
||||
// For each registration interface, find the most specific concrete interface
|
||||
// Priority: concrete interfaces (like IRadarScrapingWorkflow) over generic interfaces (like IWorkflow<T>)
|
||||
foreach (var registrationInterface in registrationInterfaces)
|
||||
{
|
||||
// Skip if this is a generic interface definition (we prefer concrete interfaces)
|
||||
if (registrationInterface.IsGenericTypeDefinition)
|
||||
continue;
|
||||
|
||||
// Check if this interface is already registered to avoid duplicates
|
||||
var alreadyRegistered = services.Any(s =>
|
||||
s.ServiceType == registrationInterface &&
|
||||
s.ImplementationType == implementationType);
|
||||
|
||||
if (alreadyRegistered)
|
||||
continue;
|
||||
|
||||
// Determine lifetime based on registration interface
|
||||
var lifetime = GetLifetime<TRegistrationInterface>();
|
||||
|
||||
// Register the service by interface
|
||||
if (lifetime == ServiceLifetime.Singleton)
|
||||
{
|
||||
services.AddSingleton(registrationInterface, implementationType);
|
||||
// Also register as concrete type for direct resolution (e.g., for ScrapingStepRegistry)
|
||||
services.AddSingleton(implementationType);
|
||||
}
|
||||
else if (lifetime == ServiceLifetime.Scoped)
|
||||
{
|
||||
services.AddScoped(registrationInterface, implementationType);
|
||||
services.AddScoped(implementationType);
|
||||
}
|
||||
else if (lifetime == ServiceLifetime.Transient)
|
||||
{
|
||||
services.AddTransient(registrationInterface, implementationType);
|
||||
services.AddTransient(implementationType);
|
||||
}
|
||||
|
||||
registeredTypes.Add(implementationType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsGenericTypeDefinition(Type type)
|
||||
{
|
||||
return type.IsGenericTypeDefinition ||
|
||||
(type.IsGenericType && type.GetGenericTypeDefinition() == type);
|
||||
}
|
||||
|
||||
private static ServiceLifetime GetLifetime<TRegistrationInterface>()
|
||||
where TRegistrationInterface : class
|
||||
{
|
||||
if (typeof(ISingletonService).IsAssignableFrom(typeof(TRegistrationInterface)))
|
||||
return ServiceLifetime.Singleton;
|
||||
if (typeof(IScopedService).IsAssignableFrom(typeof(TRegistrationInterface)))
|
||||
return ServiceLifetime.Scoped;
|
||||
if (typeof(ITransientService).IsAssignableFrom(typeof(TRegistrationInterface)))
|
||||
return ServiceLifetime.Transient;
|
||||
|
||||
throw new InvalidOperationException($"Unknown registration interface: {typeof(TRegistrationInterface).Name}");
|
||||
}
|
||||
|
||||
private static void RegisterHostedServices(
|
||||
IServiceCollection services,
|
||||
List<Type> types,
|
||||
HashSet<Type> registeredTypes)
|
||||
{
|
||||
foreach (var type in types)
|
||||
{
|
||||
// Skip if already registered
|
||||
if (registeredTypes.Contains(type))
|
||||
continue;
|
||||
|
||||
// Check if it's a BackgroundService that implements IHostedServiceRegistration
|
||||
if (typeof(Microsoft.Extensions.Hosting.BackgroundService).IsAssignableFrom(type) &&
|
||||
typeof(IHostedServiceRegistration).IsAssignableFrom(type))
|
||||
{
|
||||
// Check if already registered
|
||||
var alreadyRegistered = services.Any(s =>
|
||||
s.ServiceType == typeof(Microsoft.Extensions.Hosting.IHostedService) &&
|
||||
s.ImplementationType == type);
|
||||
|
||||
if (!alreadyRegistered)
|
||||
{
|
||||
// Use AddHostedService via reflection - this is the recommended way
|
||||
// It properly handles lifecycle management for hosted services
|
||||
// AddHostedService<T> is generic, so we need to call it via reflection
|
||||
var addHostedServiceMethod = typeof(Microsoft.Extensions.DependencyInjection.ServiceCollectionHostedServiceExtensions)
|
||||
.GetMethod(nameof(Microsoft.Extensions.DependencyInjection.ServiceCollectionHostedServiceExtensions.AddHostedService),
|
||||
new[] { typeof(IServiceCollection) })!
|
||||
.MakeGenericMethod(type);
|
||||
addHostedServiceMethod.Invoke(null, new object[] { services });
|
||||
registeredTypes.Add(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ public class FrameMetadata
|
||||
public int FrameIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of minutes ago this frame represents (40, 35, 30, 25, 20, 15, 10).
|
||||
/// The absolute UTC observation time for this frame.
|
||||
/// Parsed directly from the frame display label during capture.
|
||||
/// </summary>
|
||||
public int MinutesAgo { get; set; }
|
||||
public DateTime ObservationTime { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ public class JavaScriptTemplatesConfig
|
||||
|
||||
public string CheckActiveFrameSegment { get; set; } = @"() => {
|
||||
const segments = Array.from(document.querySelectorAll('[data-testid=""bom-scrub-segment""]'));
|
||||
if (segments.length === 0) return true;
|
||||
const activeSegment = segments.find(s => {
|
||||
const style = window.getComputedStyle(s);
|
||||
return style.backgroundColor !== 'rgb(148, 148, 148)' && style.backgroundColor !== 'rgb(148, 148, 148)';
|
||||
@@ -73,7 +74,7 @@ public class JavaScriptTemplatesConfig
|
||||
let minutes = null;
|
||||
|
||||
// Parse timestamp format: ""Wednesday 17 Dec, 11:05 pm"" or ""17 Dec, 11:05 pm""
|
||||
const timestampMatch = ariaLabel.match(/(?:[A-Za-z]+\s+)?(\d{1,2})\s+([A-Za-z]{3}),?\s+(\d{1,2}):(\d{2})\s+(am|pm)/i);
|
||||
const timestampMatch = ariaLabel.match(/(?:[A-Za-z]+\s+)?(\d{1,2})\s+([A-Za-z]+),?\s+(\d{1,2}):(\d{2})\s*(am|pm)/i);
|
||||
if (timestampMatch) {
|
||||
try {
|
||||
const day = parseInt(timestampMatch[1]);
|
||||
|
||||
+2
-10
@@ -2,22 +2,14 @@ namespace BomLocalService.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single radar frame (historical precipitation data).
|
||||
/// Frame 0 is oldest (40 minutes ago), Frame 6 is newest (10 minutes ago).
|
||||
/// Radar frames can be joined across cache folders to create extended historical slideshows.
|
||||
/// </summary>
|
||||
public class RadarFrame : CachedFrame
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of minutes ago this frame represents (40, 35, 30, 25, 20, 15, 10).
|
||||
/// Frame 0 = 40 minutes ago, Frame 6 = 10 minutes ago.
|
||||
/// This is relative to the cache folder's observation time.
|
||||
/// </summary>
|
||||
public int MinutesAgo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The absolute UTC observation time for this frame.
|
||||
/// Calculated as: ObservationTime - MinutesAgo.
|
||||
/// This is set when frames are joined across cache folders in timeseries responses.
|
||||
/// Parsed directly from the frame display label during capture.
|
||||
/// Client calculates "minutes ago" dynamically from this timestamp.
|
||||
/// </summary>
|
||||
public DateTime? AbsoluteObservationTime { get; set; }
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ public class RadarResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// List of all captured frames (typically 7 frames: 0-6).
|
||||
/// Frame 0 is oldest (40 minutes ago), Frame 6 is newest (10 minutes ago).
|
||||
/// Each frame contains an absoluteObservationTime UTC timestamp.
|
||||
/// Client should calculate "minutes ago" dynamically from the timestamp.
|
||||
/// </summary>
|
||||
public List<RadarFrame> Frames { get; set; } = new();
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace BomLocalService.Models;
|
||||
public class TextPatternsConfig
|
||||
{
|
||||
public string ResultsCountPattern { get; set; } = @"(\d+)\s+of\s+(\d+)";
|
||||
public string TimestampPattern { get; set; } = @"(?:[A-Za-z]+\s+)?\d{1,2}\s+[A-Za-z]{3},?\s+\d{1,2}:\d{2}\s+(?:am|pm)";
|
||||
public string TimestampPattern { get; set; } = @"(?:[A-Za-z]+\s+)?\d{1,2}\s+[A-Za-z]+,?\s+\d{1,2}:\d{2}\s*(?:am|pm)";
|
||||
public string ObservationTimePattern { get; set; } = @"Observations:\s*(\d+)\s*minutes?\s*ago";
|
||||
public string ForecastTimePattern { get; set; } = @"Forecast:\s*(\d+)\s*minutes?\s+ago";
|
||||
public string ForecastHourAgoPattern { get; set; } = @"Forecast:\s*an\s+hour\s+ago";
|
||||
|
||||
+9
-146
@@ -1,12 +1,5 @@
|
||||
using BomLocalService.Services;
|
||||
using BomLocalService.Extensions;
|
||||
using BomLocalService.Services.Interfaces;
|
||||
using BomLocalService.Services.Scraping;
|
||||
using BomLocalService.Services.Scraping.Steps.Navigation;
|
||||
using BomLocalService.Services.Scraping.Steps.Search;
|
||||
using BomLocalService.Services.Scraping.Steps.Map;
|
||||
using BomLocalService.Services.Scraping.Steps.Metadata;
|
||||
using BomLocalService.Services.Scraping.Steps.Capture;
|
||||
using BomLocalService.Services.Scraping.Workflows;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -14,135 +7,20 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
// AddControllersWithViews includes AddControllers, so we use it for both MVC and API controllers
|
||||
builder.Services.AddControllersWithViews();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// Configure CORS - MUST be added before other services
|
||||
var corsOrigins = builder.Configuration.GetValue<string>("Cors:AllowedOrigins", "*");
|
||||
var corsMethods = builder.Configuration.GetValue<string>("Cors:AllowedMethods", "GET,POST,OPTIONS");
|
||||
var corsHeaders = builder.Configuration.GetValue<string>("Cors:AllowedHeaders", "*");
|
||||
var corsAllowCredentials = builder.Configuration.GetValue<bool>("Cors:AllowCredentials", false);
|
||||
builder.Services.AddCorsConfiguration(builder.Configuration);
|
||||
|
||||
// Support environment variable override (comma-separated for multiple origins)
|
||||
var corsOriginsEnv = Environment.GetEnvironmentVariable("CORS__ALLOWEDORIGINS");
|
||||
if (!string.IsNullOrEmpty(corsOriginsEnv))
|
||||
{
|
||||
corsOrigins = corsOriginsEnv;
|
||||
}
|
||||
|
||||
var corsMethodsEnv = Environment.GetEnvironmentVariable("CORS__ALLOWEDMETHODS");
|
||||
if (!string.IsNullOrEmpty(corsMethodsEnv))
|
||||
{
|
||||
corsMethods = corsMethodsEnv;
|
||||
}
|
||||
|
||||
var corsHeadersEnv = Environment.GetEnvironmentVariable("CORS__ALLOWEDHEADERS");
|
||||
if (!string.IsNullOrEmpty(corsHeadersEnv))
|
||||
{
|
||||
corsHeaders = corsHeadersEnv;
|
||||
}
|
||||
|
||||
var corsAllowCredentialsEnv = Environment.GetEnvironmentVariable("CORS__ALLOWCREDENTIALS");
|
||||
if (!string.IsNullOrEmpty(corsAllowCredentialsEnv) && bool.TryParse(corsAllowCredentialsEnv, out var parsedCredentials))
|
||||
{
|
||||
corsAllowCredentials = parsedCredentials;
|
||||
}
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
if (corsOrigins == "*")
|
||||
{
|
||||
policy.AllowAnyOrigin();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Split comma-separated origins
|
||||
var origins = corsOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
policy.WithOrigins(origins);
|
||||
}
|
||||
|
||||
// Split comma-separated methods
|
||||
var methods = corsMethods.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
policy.WithMethods(methods);
|
||||
|
||||
// Split comma-separated headers or allow all
|
||||
if (corsHeaders == "*")
|
||||
{
|
||||
policy.AllowAnyHeader();
|
||||
}
|
||||
else
|
||||
{
|
||||
var headers = corsHeaders.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
policy.WithHeaders(headers);
|
||||
}
|
||||
|
||||
if (corsAllowCredentials)
|
||||
{
|
||||
policy.AllowCredentials();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Register core services via interfaces (order matters - dependencies must be registered first)
|
||||
builder.Services.AddSingleton<IDebugService, DebugService>();
|
||||
builder.Services.AddSingleton<ICacheService, CacheService>();
|
||||
builder.Services.AddSingleton<ITimeParsingService, TimeParsingService>();
|
||||
builder.Services.AddSingleton<IBrowserService, BrowserService>();
|
||||
builder.Services.AddSingleton<ISelectorService, SelectorService>();
|
||||
|
||||
// Register scraping step registry
|
||||
builder.Services.AddSingleton<IScrapingStepRegistry, ScrapingStepRegistry>();
|
||||
|
||||
// Register all scraping steps
|
||||
builder.Services.AddScoped<NavigateHomepageStep>();
|
||||
builder.Services.AddScoped<ClickSearchButtonStep>();
|
||||
builder.Services.AddScoped<FillSearchInputStep>();
|
||||
builder.Services.AddScoped<WaitForSearchResultsStep>();
|
||||
builder.Services.AddScoped<SelectSearchResultStep>();
|
||||
builder.Services.AddScoped<ClickRadarLinkStep>();
|
||||
builder.Services.AddScoped<WaitForMapReadyStep>();
|
||||
builder.Services.AddScoped<PauseRadarStep>();
|
||||
builder.Services.AddScoped<ResetToFirstFrameStep>();
|
||||
builder.Services.AddScoped<ExtractMetadataStep>();
|
||||
builder.Services.AddScoped<CalculateMapBoundsStep>();
|
||||
builder.Services.AddScoped<CaptureFramesStep>();
|
||||
|
||||
// Register workflows
|
||||
builder.Services.AddScoped<RadarScrapingWorkflow>();
|
||||
builder.Services.AddScoped<TemperatureMapWorkflow>();
|
||||
|
||||
// Register workflow factory
|
||||
builder.Services.AddSingleton<IWorkflowFactory, WorkflowFactory>();
|
||||
|
||||
// Register scraping service (depends on workflow factory)
|
||||
builder.Services.AddSingleton<IScrapingService, ScrapingService>();
|
||||
|
||||
// Register BOM Radar Service as singleton (orchestrator, depends on all above services)
|
||||
builder.Services.AddSingleton<IBomRadarService, BomLocalService.Services.BomRadarService>();
|
||||
|
||||
// Register background services (order matters - management service needs radar service)
|
||||
builder.Services.AddHostedService<CacheCleanupService>();
|
||||
builder.Services.AddHostedService<CacheManagementService>();
|
||||
|
||||
// Add configuration - appsettings.json provides default values
|
||||
// All values can be overridden via environment variables
|
||||
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
|
||||
// Register all services via assembly scanning
|
||||
builder.Services.ScanAndRegisterServices(typeof(Program).Assembly);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
// HTTPS redirection is optional and disabled by default for Docker flexibility
|
||||
// Users can enable it by setting ENABLE_HTTPS_REDIRECTION=true environment variable
|
||||
// or EnableHttpsRedirection=true in appsettings.json
|
||||
var enableHttpsRedirection = builder.Configuration.GetValue<bool>("EnableHttpsRedirection", false) ||
|
||||
Environment.GetEnvironmentVariable("ENABLE_HTTPS_REDIRECTION")?.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
|
||||
// Users can enable it by setting EnableHttpsRedirection=true in appsettings.json or ENABLEHTTPSREDIRECTION=true environment variable
|
||||
var enableHttpsRedirection = builder.Configuration.GetValue<bool>("EnableHttpsRedirection", false);
|
||||
if (enableHttpsRedirection)
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
@@ -151,6 +29,9 @@ if (enableHttpsRedirection)
|
||||
// Enable routing (required for CORS to work with controllers)
|
||||
app.UseRouting();
|
||||
|
||||
// Static files (for wwwroot assets like CSS, JS, images)
|
||||
app.UseStaticFiles();
|
||||
|
||||
// CORS middleware - MUST be after UseRouting but before MapControllers
|
||||
app.UseCors();
|
||||
|
||||
@@ -167,24 +48,6 @@ app.MapControllers();
|
||||
// Map health check endpoint for Docker health monitoring
|
||||
app.MapHealthChecks("/api/health");
|
||||
|
||||
// Auto-register all scraping steps in the registry
|
||||
var stepRegistry = app.Services.GetRequiredService<IScrapingStepRegistry>();
|
||||
var stepTypes = typeof(IScrapingStep).Assembly.GetTypes()
|
||||
.Where(t => typeof(IScrapingStep).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract && !t.IsGenericType);
|
||||
foreach (var stepType in stepTypes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var step = (IScrapingStep)ActivatorUtilities.CreateInstance(app.Services, stepType);
|
||||
stepRegistry.RegisterStep(step);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||
logger.LogWarning(ex, "Failed to register step {StepType}", stepType.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup incomplete cache folders from previous crashes/restarts before starting services
|
||||
var cacheService = app.Services.GetRequiredService<ICacheService>();
|
||||
var deletedCount = cacheService.CleanupIncompleteCacheFolders();
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<p align="center">
|
||||
<img
|
||||
src="https://github.com/user-attachments/assets/684b73cd-a374-49e6-aaf1-ccc4c5697b47"
|
||||
width="500"
|
||||
alt="f67325fe-33ae-4fd8-9e57-38b46899d09c"
|
||||
/>
|
||||
<img width="256" height="auto" alt="icon" src="https://github.com/user-attachments/assets/6f712fbd-4f42-49d4-9ddb-1767bb43551a" />
|
||||
</p>
|
||||
|
||||
# BOM Local Service
|
||||
@@ -218,7 +214,7 @@ All configuration can be done via environment variables, which override the defa
|
||||
|----------|-------------|---------|
|
||||
| `ASPNETCORE_ENVIRONMENT` | Runtime environment (Development/Production) | `Production` |
|
||||
| `ASPNETCORE_URLS` | URLs the service listens on | `http://+:8080` |
|
||||
| `ENABLE_HTTPS_REDIRECTION` | Enable HTTPS redirection | `false` |
|
||||
| `ENABLEHTTPSREDIRECTION` | Enable HTTPS redirection | `false` |
|
||||
|
||||
#### Application Configuration
|
||||
|
||||
@@ -235,8 +231,7 @@ All configuration can be done via environment variables, which override the defa
|
||||
|----------|-------------|---------|---------|
|
||||
| `CACHEMANAGEMENT__CHECKINTERVALMINUTES` | Interval between cache validity checks | `5` | `10` |
|
||||
| `CACHEMANAGEMENT__INITIALDELAYSECONDS` | Delay before first cache check on startup | `10` | `30` |
|
||||
| `CACHEMANAGEMENT__UPDATESTAGGERSECONDS` | Delay between triggering cache updates | `2` | `5` |
|
||||
| `CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS` | Delay between processing different locations | `1` | `2` |
|
||||
| `CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS` | Delay between processing different locations (used for both initial and periodic updates) | `1` | `2` |
|
||||
|
||||
#### Cache Cleanup
|
||||
|
||||
@@ -524,8 +519,7 @@ GET /api/radar/{suburb}/{state}
|
||||
{
|
||||
"frameIndex": 0,
|
||||
"imageUrl": "/api/radar/Brisbane/QLD/frame/0",
|
||||
"minutesAgo": 0,
|
||||
"observationTime": "2025-01-15T10:00:00Z"
|
||||
"absoluteObservationTime": "2025-01-15T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"observationTime": "2025-01-15T10:00:00Z",
|
||||
@@ -540,7 +534,7 @@ GET /api/radar/{suburb}/{state}
|
||||
```
|
||||
|
||||
**Response Fields:**
|
||||
- `frames`: Array of radar frame objects with image URLs and timing information
|
||||
- `frames`: Array of radar frame objects with image URLs. Each frame contains `absoluteObservationTime` (UTC timestamp). Client should calculate "minutes ago" dynamically from this timestamp.
|
||||
- `observationTime`: UTC timestamp when the observation was made
|
||||
- `forecastTime`: UTC timestamp for the forecast
|
||||
- `weatherStation`: Name of the weather station
|
||||
@@ -670,8 +664,7 @@ GET /api/radar/{suburb}/{state}/timeseries?startTime={iso8601}&endTime={iso8601}
|
||||
{
|
||||
"frameIndex": 0,
|
||||
"imageUrl": "/api/radar/Brisbane/QLD/frame/0?cacheFolder=Brisbane_QLD_20250115_100000",
|
||||
"absoluteObservationTime": "2025-01-15T10:00:00Z",
|
||||
"minutesAgo": 0
|
||||
"absoluteObservationTime": "2025-01-15T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -880,13 +873,6 @@ DELETE /api/cache/{suburb}/{state}
|
||||
}
|
||||
```
|
||||
|
||||
### OpenAPI Documentation
|
||||
|
||||
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.
|
||||
@@ -1016,7 +1002,10 @@ if (radarData.frames && radarData.frames.length > 0) {
|
||||
|
||||
// Or loop through all frames for animation
|
||||
radarData.frames.forEach((frame, index) => {
|
||||
console.log(`Frame ${index}: ${frame.imageUrl} (${frame.minutesAgo} min ago)`);
|
||||
const minutesAgo = frame.absoluteObservationTime
|
||||
? Math.round((Date.now() - new Date(frame.absoluteObservationTime).getTime()) / 60000)
|
||||
: null;
|
||||
console.log(`Frame ${index}: ${frame.imageUrl}${minutesAgo !== null ? ` (${minutesAgo} min ago)` : ''}`);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
+47
-22
@@ -32,9 +32,27 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
_scrapingService = scrapingService;
|
||||
_debugService = debugService;
|
||||
_configuration = configuration;
|
||||
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 12.5);
|
||||
_cacheManagementCheckIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
|
||||
_timeSeriesWarningFolderCount = configuration.GetValue<int>("TimeSeries:WarningFolderCount", 200);
|
||||
|
||||
var cacheExpirationMinutesConfig = configuration.GetValue<double?>("CacheExpirationMinutes");
|
||||
if (!cacheExpirationMinutesConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
|
||||
}
|
||||
_cacheExpirationMinutes = cacheExpirationMinutesConfig.Value;
|
||||
|
||||
var cacheManagementCheckIntervalMinutesConfig = configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
|
||||
if (!cacheManagementCheckIntervalMinutesConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
|
||||
}
|
||||
_cacheManagementCheckIntervalMinutes = cacheManagementCheckIntervalMinutesConfig.Value;
|
||||
|
||||
var timeSeriesWarningFolderCountConfig = configuration.GetValue<int?>("TimeSeries:WarningFolderCount");
|
||||
if (!timeSeriesWarningFolderCountConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("TimeSeries:WarningFolderCount configuration is required. Set it in appsettings.json or via TIMESERIES__WARNINGFOLDERCOUNT environment variable.");
|
||||
}
|
||||
_timeSeriesWarningFolderCount = timeSeriesWarningFolderCountConfig.Value;
|
||||
|
||||
// Calculate estimated cache update duration
|
||||
_estimatedUpdateDurationSeconds = CacheHelper.GetEstimatedUpdateDurationSeconds(configuration, CachedDataType.Radar);
|
||||
@@ -167,6 +185,9 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
if (needsUpdate)
|
||||
{
|
||||
// Trigger async update (fire and forget)
|
||||
// Do not pass the caller's cancellationToken to Task.Run: for HTTP that token is tied to
|
||||
// RequestAborted and often fires as soon as the response is sent, so the update never starts.
|
||||
// FetchAndCacheScreenshotAsync already uses CancellationToken.None for the scrape work.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
@@ -177,7 +198,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
{
|
||||
_logger.LogError(ex, "Error during background cache update for {Suburb}, {State}", suburb, state);
|
||||
}
|
||||
}, cancellationToken);
|
||||
});
|
||||
|
||||
status.UpdateTriggered = true;
|
||||
status.Message = status.CacheExists
|
||||
@@ -388,7 +409,16 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
semaphore.Release();
|
||||
// Safely release semaphore - it may be disposed during application shutdown
|
||||
try
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Semaphore was disposed during shutdown, ignore
|
||||
_logger.LogDebug("Semaphore was disposed, skipping release");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,32 +531,27 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
|
||||
if (radarFrames.Count > 0)
|
||||
{
|
||||
// Generate URLs and calculate absolute observation times for each frame
|
||||
// Generate URLs and filter frames by unique absolute observation times
|
||||
var uniqueFrames = new List<RadarFrame>();
|
||||
|
||||
foreach (var frame in radarFrames)
|
||||
{
|
||||
frame.ImageUrl = $"/api/radar/{encodedSuburb}/{encodedState}/frame/{frame.FrameIndex}?cacheFolder={Uri.EscapeDataString(folderInfo.FolderName)}";
|
||||
|
||||
// Calculate absolute observation time for this frame
|
||||
// minutesAgo is relative to the cache folder's observation time
|
||||
// Validate that ObservationTime is reasonable (not default/min value)
|
||||
if (folderInfo.ObservationTime > DateTime.MinValue.AddYears(1) && frame.MinutesAgo >= 0)
|
||||
// Use absolute observation time directly (already set during capture)
|
||||
// Only include frames with unique absolute observation times
|
||||
// Since we process folders newest-first, this ensures we keep frames from the most recent cache folder
|
||||
if (frame.AbsoluteObservationTime.HasValue &&
|
||||
frame.AbsoluteObservationTime.Value > DateTime.MinValue.AddYears(1) &&
|
||||
!seenAbsoluteTimes.Contains(frame.AbsoluteObservationTime.Value))
|
||||
{
|
||||
frame.AbsoluteObservationTime = folderInfo.ObservationTime.AddMinutes(-frame.MinutesAgo);
|
||||
|
||||
// Only include frames with unique absolute observation times
|
||||
// Since we process folders newest-first, this ensures we keep frames from the most recent cache folder
|
||||
if (frame.AbsoluteObservationTime.HasValue && !seenAbsoluteTimes.Contains(frame.AbsoluteObservationTime.Value))
|
||||
{
|
||||
seenAbsoluteTimes.Add(frame.AbsoluteObservationTime.Value);
|
||||
uniqueFrames.Add(frame);
|
||||
}
|
||||
seenAbsoluteTimes.Add(frame.AbsoluteObservationTime.Value);
|
||||
uniqueFrames.Add(frame);
|
||||
}
|
||||
else
|
||||
else if (!frame.AbsoluteObservationTime.HasValue)
|
||||
{
|
||||
_logger.LogWarning("Skipping frame {FrameIndex} from folder {FolderName}: Invalid ObservationTime ({ObservationTime}) or MinutesAgo ({MinutesAgo})",
|
||||
frame.FrameIndex, folderInfo.FolderName, folderInfo.ObservationTime, frame.MinutesAgo);
|
||||
_logger.LogWarning("Skipping frame {FrameIndex} from folder {FolderName}: Missing AbsoluteObservationTime",
|
||||
frame.FrameIndex, folderInfo.FolderName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,15 @@ public class BrowserService : IBrowserService
|
||||
public BrowserService(ILogger<BrowserService> logger, IConfiguration configuration, IDebugService debugService)
|
||||
{
|
||||
_logger = logger;
|
||||
_timezone = configuration.GetValue<string>("Timezone") ?? "Australia/Brisbane";
|
||||
// Get timezone from configuration (default from appsettings.json, can be overridden via TIMEZONE environment variable)
|
||||
var timezone = configuration.GetValue<string>("Timezone");
|
||||
|
||||
if (string.IsNullOrEmpty(timezone))
|
||||
{
|
||||
throw new InvalidOperationException("Timezone configuration is required. Set it in appsettings.json or via TIMEZONE environment variable.");
|
||||
}
|
||||
|
||||
_timezone = timezone;
|
||||
_debugService = debugService;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
using BomLocalService.Utilities;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace BomLocalService.Services;
|
||||
|
||||
public class CacheCleanupService : BackgroundService
|
||||
public class CacheCleanupService : BackgroundService, IHostedServiceRegistration
|
||||
{
|
||||
private readonly ILogger<CacheCleanupService> _logger;
|
||||
private readonly string _cacheDirectory;
|
||||
@@ -14,8 +15,20 @@ public class CacheCleanupService : BackgroundService
|
||||
{
|
||||
_logger = logger;
|
||||
_cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
|
||||
_retentionHours = configuration.GetValue<int>("CacheRetentionHours", 24);
|
||||
var cleanupIntervalHours = configuration.GetValue<int>("CacheCleanup:IntervalHours", 1);
|
||||
|
||||
var retentionHoursConfig = configuration.GetValue<int?>("CacheRetentionHours");
|
||||
if (!retentionHoursConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("CacheRetentionHours configuration is required. Set it in appsettings.json or via CACHERETENTIONHOURS environment variable.");
|
||||
}
|
||||
_retentionHours = retentionHoursConfig.Value;
|
||||
|
||||
var cleanupIntervalHoursConfig = configuration.GetValue<int?>("CacheCleanup:IntervalHours");
|
||||
if (!cleanupIntervalHoursConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("CacheCleanup:IntervalHours configuration is required. Set it in appsettings.json or via CACHECLEANUP__INTERVALHOURS environment variable.");
|
||||
}
|
||||
var cleanupIntervalHours = cleanupIntervalHoursConfig.Value;
|
||||
_cleanupInterval = TimeSpan.FromHours(cleanupIntervalHours);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
using BomLocalService.Services.Interfaces;
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
using BomLocalService.Utilities;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace BomLocalService.Services;
|
||||
|
||||
public class CacheManagementService : BackgroundService
|
||||
public class CacheManagementService : BackgroundService, IHostedServiceRegistration
|
||||
{
|
||||
private readonly ILogger<CacheManagementService> _logger;
|
||||
private readonly IBomRadarService _bomRadarService;
|
||||
private readonly IBrowserService _browserService;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly TimeSpan _checkInterval;
|
||||
private readonly TimeSpan _locationStaggerInterval;
|
||||
private readonly TimeSpan _initialDelayInterval;
|
||||
private readonly HashSet<string> _activeUpdates = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
@@ -24,14 +27,34 @@ public class CacheManagementService : BackgroundService
|
||||
_bomRadarService = bomRadarService;
|
||||
_browserService = browserService;
|
||||
_configuration = configuration;
|
||||
var checkIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
|
||||
|
||||
var checkIntervalMinutesConfig = configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
|
||||
if (!checkIntervalMinutesConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
|
||||
}
|
||||
var checkIntervalMinutes = checkIntervalMinutesConfig.Value;
|
||||
|
||||
if (checkIntervalMinutes <= 0 || checkIntervalMinutes > 60)
|
||||
{
|
||||
throw new ArgumentException("CacheManagement:CheckIntervalMinutes must be between 1 and 60", nameof(configuration));
|
||||
}
|
||||
|
||||
_checkInterval = TimeSpan.FromMinutes(checkIntervalMinutes);
|
||||
|
||||
// LocationStaggerSeconds is used for delays between processing locations (both initial and periodic)
|
||||
var locationStaggerSecondsConfig = configuration.GetValue<int?>("CacheManagement:LocationStaggerSeconds");
|
||||
if (!locationStaggerSecondsConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("CacheManagement:LocationStaggerSeconds configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS environment variable.");
|
||||
}
|
||||
_locationStaggerInterval = TimeSpan.FromSeconds(locationStaggerSecondsConfig.Value);
|
||||
|
||||
var initialDelaySecondsConfig = configuration.GetValue<int?>("CacheManagement:InitialDelaySeconds");
|
||||
if (!initialDelaySecondsConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("CacheManagement:InitialDelaySeconds configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__INITIALDELAYSECONDS environment variable.");
|
||||
}
|
||||
_initialDelayInterval = TimeSpan.FromSeconds(initialDelaySecondsConfig.Value);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -39,8 +62,7 @@ public class CacheManagementService : BackgroundService
|
||||
_logger.LogInformation("Cache management service started. Check interval: {Interval}", _checkInterval);
|
||||
|
||||
// Wait a bit for the service to fully initialize
|
||||
var initialDelaySeconds = _configuration.GetValue<int>("CacheManagement:InitialDelaySeconds", 10);
|
||||
await Task.Delay(TimeSpan.FromSeconds(initialDelaySeconds), stoppingToken);
|
||||
await Task.Delay(_initialDelayInterval, stoppingToken);
|
||||
|
||||
// Pre-warm the browser before starting cache updates
|
||||
_logger.LogInformation("Pre-warming browser before cache updates");
|
||||
@@ -58,28 +80,7 @@ public class CacheManagementService : BackgroundService
|
||||
|
||||
// Initial cache update on startup
|
||||
_logger.LogInformation("Performing initial cache update for {Count} locations", locationsToManage.Count);
|
||||
int initialUpdatesTriggered = 0;
|
||||
int initialCachesValid = 0;
|
||||
|
||||
foreach (var (suburb, state) in locationsToManage)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
var (triggered, isValid) = await UpdateCacheIfNeededAsync(suburb, state, stoppingToken);
|
||||
if (triggered)
|
||||
{
|
||||
initialUpdatesTriggered++;
|
||||
}
|
||||
else if (isValid)
|
||||
{
|
||||
initialCachesValid++;
|
||||
}
|
||||
|
||||
// Stagger updates to avoid overwhelming the system
|
||||
var updateStaggerSeconds = _configuration.GetValue<int>("CacheManagement:UpdateStaggerSeconds", 2);
|
||||
await Task.Delay(TimeSpan.FromSeconds(updateStaggerSeconds), stoppingToken);
|
||||
}
|
||||
var (initialUpdatesTriggered, initialCachesValid) = await ProcessLocationsAsync(locationsToManage, stoppingToken);
|
||||
|
||||
_logger.LogInformation("Initial cache update completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid",
|
||||
initialUpdatesTriggered, initialCachesValid);
|
||||
@@ -95,28 +96,7 @@ public class CacheManagementService : BackgroundService
|
||||
locationsToManage = GetLocationsToManage();
|
||||
_logger.LogInformation("Starting periodic cache check for {Count} locations", locationsToManage.Count);
|
||||
|
||||
int updatesTriggered = 0;
|
||||
int cachesValid = 0;
|
||||
|
||||
foreach (var (suburb, state) in locationsToManage)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
var (triggered, isValid) = await UpdateCacheIfNeededAsync(suburb, state, stoppingToken);
|
||||
if (triggered)
|
||||
{
|
||||
updatesTriggered++;
|
||||
}
|
||||
else if (isValid)
|
||||
{
|
||||
cachesValid++;
|
||||
}
|
||||
|
||||
// Small delay between locations
|
||||
var locationStaggerSeconds = _configuration.GetValue<int>("CacheManagement:LocationStaggerSeconds", 1);
|
||||
await Task.Delay(TimeSpan.FromSeconds(locationStaggerSeconds), stoppingToken);
|
||||
}
|
||||
var (updatesTriggered, cachesValid) = await ProcessLocationsAsync(locationsToManage, stoppingToken);
|
||||
|
||||
_logger.LogInformation("Periodic cache check completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid",
|
||||
updatesTriggered, cachesValid);
|
||||
@@ -128,6 +108,38 @@ public class CacheManagementService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes a list of locations, checking and updating cache as needed
|
||||
/// </summary>
|
||||
private async Task<(int updatesTriggered, int cachesValid)> ProcessLocationsAsync(
|
||||
List<(string suburb, string state)> locations,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int updatesTriggered = 0;
|
||||
int cachesValid = 0;
|
||||
|
||||
foreach (var (suburb, state) in locations)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
var (triggered, isValid) = await UpdateCacheIfNeededAsync(suburb, state, cancellationToken);
|
||||
if (triggered)
|
||||
{
|
||||
updatesTriggered++;
|
||||
}
|
||||
else if (isValid)
|
||||
{
|
||||
cachesValid++;
|
||||
}
|
||||
|
||||
// Stagger processing to avoid overwhelming the system
|
||||
await Task.Delay(_locationStaggerInterval, cancellationToken);
|
||||
}
|
||||
|
||||
return (updatesTriggered, cachesValid);
|
||||
}
|
||||
|
||||
private async Task<(bool updateTriggered, bool isValid)> UpdateCacheIfNeededAsync(string suburb, string state, CancellationToken cancellationToken)
|
||||
{
|
||||
var locationKey = LocationHelper.GetLocationKey(suburb, state);
|
||||
|
||||
+17
-12
@@ -27,7 +27,13 @@ public class CacheService : ICacheService
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
|
||||
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 12.5);
|
||||
|
||||
var cacheExpirationMinutesConfig = configuration.GetValue<double?>("CacheExpirationMinutes");
|
||||
if (!cacheExpirationMinutesConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
|
||||
}
|
||||
_cacheExpirationMinutes = cacheExpirationMinutesConfig.Value;
|
||||
|
||||
if (_cacheExpirationMinutes <= 0)
|
||||
{
|
||||
@@ -148,18 +154,18 @@ public class CacheService : ICacheService
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load frame metadata to get accurate minutesAgo
|
||||
// Load frame metadata to get observation time
|
||||
var framesMetadata = await LoadFramesMetadataAsync(cacheFolderPath, CachedDataType.Radar, cancellationToken);
|
||||
var frameMetadata = framesMetadata.FirstOrDefault(f => f.FrameIndex == frameIndex);
|
||||
var minutesAgo = frameMetadata != null
|
||||
? frameMetadata.MinutesAgo
|
||||
: 40 - (frameIndex * 5);
|
||||
var observationTime = frameMetadata?.ObservationTime > DateTime.MinValue.AddYears(1)
|
||||
? frameMetadata.ObservationTime
|
||||
: (DateTime?)null;
|
||||
|
||||
return new RadarFrame
|
||||
{
|
||||
FrameIndex = frameIndex,
|
||||
ImagePath = framePath,
|
||||
MinutesAgo = minutesAgo
|
||||
AbsoluteObservationTime = observationTime
|
||||
};
|
||||
}
|
||||
|
||||
@@ -194,7 +200,7 @@ public class CacheService : ICacheService
|
||||
var framesMetadata = frames.Select(f => new FrameMetadata
|
||||
{
|
||||
FrameIndex = f.FrameIndex,
|
||||
MinutesAgo = f.MinutesAgo
|
||||
ObservationTime = f.AbsoluteObservationTime ?? throw new InvalidOperationException($"Frame {f.FrameIndex} missing AbsoluteObservationTime")
|
||||
}).ToList();
|
||||
|
||||
var framesPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath, dataType);
|
||||
@@ -418,7 +424,8 @@ public class CacheService : ICacheService
|
||||
|
||||
// Load frame metadata from frames.json if available
|
||||
var framesMetadata = await LoadFramesMetadataAsync(folderPath, dataType, cancellationToken);
|
||||
var metadataDict = framesMetadata.ToDictionary(f => f.FrameIndex, f => f.MinutesAgo);
|
||||
var metadataDict = framesMetadata.Where(f => f.ObservationTime > DateTime.MinValue.AddYears(1))
|
||||
.ToDictionary(f => f.FrameIndex, f => f.ObservationTime);
|
||||
|
||||
// Load frames from data type subfolder
|
||||
for (int i = 0; i < frameCount; i++)
|
||||
@@ -428,15 +435,13 @@ public class CacheService : ICacheService
|
||||
{
|
||||
if (dataType == CachedDataType.Radar)
|
||||
{
|
||||
var minutesAgo = metadataDict.ContainsKey(i)
|
||||
? metadataDict[i]
|
||||
: 40 - (i * 5);
|
||||
var observationTime = metadataDict.ContainsKey(i) ? metadataDict[i] : (DateTime?)null;
|
||||
|
||||
frames.Add(new RadarFrame
|
||||
{
|
||||
FrameIndex = i,
|
||||
ImagePath = framePath,
|
||||
MinutesAgo = minutesAgo
|
||||
AbsoluteObservationTime = observationTime
|
||||
});
|
||||
}
|
||||
else
|
||||
|
||||
+10
-13
@@ -14,20 +14,17 @@ namespace BomLocalService.Services;
|
||||
public DebugService(ILogger<DebugService> logger, IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_waitMs = configuration.GetValue<int>("Debug:WaitMs", 2000);
|
||||
|
||||
var waitMsConfig = configuration.GetValue<int?>("Debug:WaitMs");
|
||||
if (!waitMsConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Debug:WaitMs configuration is required. Set it in appsettings.json or via DEBUG__WAITMS environment variable.");
|
||||
}
|
||||
_waitMs = waitMsConfig.Value;
|
||||
|
||||
// Check all possible ways the config might be set
|
||||
var debugEnabledEnv = Environment.GetEnvironmentVariable("DEBUG__ENABLED");
|
||||
var debugEnabledConfig = configuration.GetValue<bool>("Debug:Enabled", false);
|
||||
var debugEnabledFromConfig = configuration["Debug:Enabled"];
|
||||
|
||||
_logger.LogInformation("Debug configuration check - Env var DEBUG__ENABLED: {EnvVar}, Config Debug:Enabled: {ConfigValue}, Config string: {ConfigString}",
|
||||
debugEnabledEnv ?? "null", debugEnabledConfig, debugEnabledFromConfig ?? "null");
|
||||
|
||||
// Try environment variable first, then config
|
||||
_enabled = !string.IsNullOrEmpty(debugEnabledEnv)
|
||||
? bool.TryParse(debugEnabledEnv, out var envBool) && envBool
|
||||
: debugEnabledConfig;
|
||||
// Get debug enabled from configuration (default from appsettings.json, can be overridden via DEBUG__ENABLED environment variable)
|
||||
// Note: bool defaults to false if not found, which is acceptable for Debug:Enabled
|
||||
_enabled = configuration.GetValue<bool>("Debug:Enabled", false);
|
||||
|
||||
var cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
|
||||
_debugDirectory = Path.Combine(cacheDirectory, "debug");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
|
||||
namespace BomLocalService.Services.Interfaces;
|
||||
|
||||
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
|
||||
/// Main service interface for BOM radar screenshot operations.
|
||||
/// Orchestrates cache management, browser automation, and web scraping to provide radar screenshots for Australian locations.
|
||||
/// </summary>
|
||||
public interface IBomRadarService
|
||||
public interface IBomRadarService : ISingletonService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets cached radar data for a location.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace BomLocalService.Services.Interfaces;
|
||||
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
|
||||
/// Service interface for managing Playwright browser instances and automation.
|
||||
/// Handles browser lifecycle, context creation, and anti-detection measures for web scraping.
|
||||
/// </summary>
|
||||
public interface IBrowserService : IDisposable
|
||||
public interface IBrowserService : ISingletonService, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new browser context with proper configuration for BOM website scraping.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
|
||||
namespace BomLocalService.Services.Interfaces;
|
||||
|
||||
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
|
||||
/// Service interface for managing cached radar screenshot files and metadata.
|
||||
/// Handles file system operations for storing and retrieving cached BOM radar screenshots.
|
||||
/// </summary>
|
||||
public interface ICacheService
|
||||
public interface ICacheService : ISingletonService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the cached screenshot folder path and associated metadata for a location and data type.
|
||||
@@ -90,7 +91,7 @@ public interface ICacheService
|
||||
Task SaveMetadataAsync(string cacheFolderPath, LastUpdatedInfo metadata, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Saves frame metadata (frame index and minutes ago) to a frames.json file in the data type subfolder.
|
||||
/// Saves frame metadata (frame index and observation time) to a frames.json file in the data type subfolder.
|
||||
/// </summary>
|
||||
/// <param name="cacheFolderPath">Full path to the cache folder</param>
|
||||
/// <param name="dataType">The type of cached data</param>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace BomLocalService.Services.Interfaces;
|
||||
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
|
||||
/// Service interface for debug file generation during web scraping operations.
|
||||
/// When enabled, saves screenshots, HTML, console logs, and network request information for troubleshooting.
|
||||
/// </summary>
|
||||
public interface IDebugService
|
||||
public interface IDebugService : ISingletonService
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates whether debug mode is enabled.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace BomLocalService.Services.Interfaces;
|
||||
@@ -7,7 +8,7 @@ namespace BomLocalService.Services.Interfaces;
|
||||
/// Service interface for scraping radar screenshots from the BOM website.
|
||||
/// Orchestrates the multi-step process of navigating BOM, finding locations, and capturing radar screenshots.
|
||||
/// </summary>
|
||||
public interface IScrapingService
|
||||
public interface IScrapingService : ISingletonService
|
||||
{
|
||||
/// <summary>
|
||||
/// Scrapes a radar screenshot from the BOM website for a given location.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
using BomLocalService.Services.Scraping;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace BomLocalService.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the BOM scrubber time display label to UTC; shared by capture and reset steps.
|
||||
/// </summary>
|
||||
public interface IScrubDisplayTimestampParser : ISingletonService
|
||||
{
|
||||
Task<DateTime?> ReadUtcAsync(
|
||||
IPage page,
|
||||
ISelectorService selectorService,
|
||||
ScrapingContext? context = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
bool TryParseTimestamp(string timestampStr, string? timezoneAbbreviation, out DateTime timestampUtc);
|
||||
|
||||
Task WaitForDisplayLabelChangeAsync(
|
||||
IPage page,
|
||||
ISelectorService selectorService,
|
||||
DateTime currentTimestamp,
|
||||
ScrapingContext? context = null,
|
||||
int maxWaitMs = 5000,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace BomLocalService.Services.Interfaces;
|
||||
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
|
||||
/// <summary>
|
||||
/// Service for finding page elements using configured selectors
|
||||
/// </summary>
|
||||
public interface ISelectorService
|
||||
public interface ISelectorService : ISingletonService
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds an element using the configured selectors, trying each in order until one is found
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace BomLocalService.Services.Interfaces;
|
||||
@@ -7,7 +8,7 @@ namespace BomLocalService.Services.Interfaces;
|
||||
/// Service interface for parsing time and metadata information from BOM website content.
|
||||
/// Extracts observation times, forecast times, weather station names, and distances from BOM weather map pages.
|
||||
/// </summary>
|
||||
public interface ITimeParsingService
|
||||
public interface ITimeParsingService : ISingletonService
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts last updated information from a BOM weather map page.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace BomLocalService.Services.Interfaces.Registration;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for services that should be registered as Singleton
|
||||
/// </summary>
|
||||
public interface ISingletonService
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for services that should be registered as Scoped
|
||||
/// </summary>
|
||||
public interface IScopedService
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for services that should be registered as Transient
|
||||
/// </summary>
|
||||
public interface ITransientService
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for hosted services (BackgroundService implementations)
|
||||
/// </summary>
|
||||
public interface IHostedServiceRegistration
|
||||
{
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
|
||||
namespace BomLocalService.Services.Scraping;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for a single scraping step
|
||||
/// Steps are stateless (all request data is in ScrapingContext), so singleton lifetime is appropriate
|
||||
/// </summary>
|
||||
public interface IScrapingStep
|
||||
public interface IScrapingStep : ISingletonService
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique name of the step
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
|
||||
namespace BomLocalService.Services.Scraping;
|
||||
|
||||
/// <summary>
|
||||
/// Registry for managing scraping steps
|
||||
/// </summary>
|
||||
public interface IScrapingStepRegistry
|
||||
public interface IScrapingStepRegistry : ISingletonService
|
||||
{
|
||||
void RegisterStep(IScrapingStep step);
|
||||
IScrapingStep? GetStep(string name);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
|
||||
namespace BomLocalService.Services.Scraping;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating workflows
|
||||
/// </summary>
|
||||
public interface IWorkflowFactory
|
||||
public interface IWorkflowFactory : ISingletonService
|
||||
{
|
||||
IWorkflow<TResponse> GetWorkflow<TResponse>(string name);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace BomLocalService.Services.Scraping;
|
||||
|
||||
public class ScrapingStepRegistry : IScrapingStepRegistry
|
||||
@@ -5,9 +7,33 @@ public class ScrapingStepRegistry : IScrapingStepRegistry
|
||||
private readonly Dictionary<string, IScrapingStep> _steps = new();
|
||||
private readonly ILogger<ScrapingStepRegistry> _logger;
|
||||
|
||||
public ScrapingStepRegistry(ILogger<ScrapingStepRegistry> logger)
|
||||
public ScrapingStepRegistry(ILogger<ScrapingStepRegistry> logger, IServiceProvider serviceProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
AutoRegisterSteps(serviceProvider);
|
||||
}
|
||||
|
||||
private void AutoRegisterSteps(IServiceProvider serviceProvider)
|
||||
{
|
||||
var stepTypes = typeof(IScrapingStep).Assembly.GetTypes()
|
||||
.Where(t => typeof(IScrapingStep).IsAssignableFrom(t)
|
||||
&& !t.IsInterface
|
||||
&& !t.IsAbstract
|
||||
&& !t.IsGenericType);
|
||||
|
||||
foreach (var stepType in stepTypes)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use DI to resolve the step (ensures proper dependency injection)
|
||||
var step = (IScrapingStep)serviceProvider.GetRequiredService(stepType);
|
||||
RegisterStep(step);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to auto-register step {StepType}", stepType.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void RegisterStep(IScrapingStep step)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace BomLocalService.Services.Scraping;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the BOM scrubber time label (.bom-scrub-display-label) to UTC.
|
||||
/// Shared by capture and reset steps so behaviour stays in sync.
|
||||
/// </summary>
|
||||
public class ScrubDisplayTimestampParser : IScrubDisplayTimestampParser
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ScrapingSelectorsConfig _selectors;
|
||||
private readonly TextPatternsConfig _textPatterns;
|
||||
private readonly JavaScriptTemplatesConfig _javaScriptTemplates;
|
||||
private readonly ILogger<ScrubDisplayTimestampParser> _logger;
|
||||
|
||||
public ScrubDisplayTimestampParser(
|
||||
IConfiguration configuration,
|
||||
ILogger<ScrubDisplayTimestampParser> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_selectors = configuration.GetSection("Scraping:Selectors").Get<ScrapingSelectorsConfig>() ?? new();
|
||||
_textPatterns = configuration.GetSection("Scraping:TextPatterns").Get<TextPatternsConfig>() ?? new();
|
||||
_javaScriptTemplates = configuration.GetSection("Scraping:JavaScriptTemplates").Get<JavaScriptTemplatesConfig>() ?? new();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<DateTime?> ReadUtcAsync(
|
||||
IPage page,
|
||||
ISelectorService selectorService,
|
||||
ScrapingContext? context = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = cancellationToken;
|
||||
try
|
||||
{
|
||||
var timeLabelLocator = selectorService.GetLocator(page, _selectors.TimeDisplayLabel);
|
||||
var timeLabel = await timeLabelLocator.TextContentAsync();
|
||||
if (string.IsNullOrEmpty(timeLabel))
|
||||
{
|
||||
_logger.LogDebug("Time display label is empty");
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmedLabel = timeLabel.Trim();
|
||||
_logger.LogDebug("Scrub display label: '{Label}'", trimmedLabel);
|
||||
|
||||
var timestampMatch = Regex.Match(trimmedLabel, _textPatterns.TimestampPattern, RegexOptions.IgnoreCase);
|
||||
if (!timestampMatch.Success)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Display label did not match timestamp pattern. Label: '{Label}', Pattern: '{Pattern}'",
|
||||
trimmedLabel,
|
||||
_textPatterns.TimestampPattern);
|
||||
return null;
|
||||
}
|
||||
|
||||
var timestampStr = timestampMatch.Groups[0].Value;
|
||||
|
||||
string? detectedTimezone = null;
|
||||
if (trimmedLabel.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
|
||||
detectedTimezone = "AEDT";
|
||||
else if (trimmedLabel.Contains("AEST", StringComparison.OrdinalIgnoreCase))
|
||||
detectedTimezone = "AEST";
|
||||
|
||||
if (detectedTimezone == null && context != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var metadataText = await page.EvaluateAsync<string>(_javaScriptTemplates.ExtractWeatherMetadata);
|
||||
if (!string.IsNullOrEmpty(metadataText))
|
||||
{
|
||||
if (metadataText.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
|
||||
detectedTimezone = "AEDT";
|
||||
else if (metadataText.Contains("AEST", StringComparison.OrdinalIgnoreCase))
|
||||
detectedTimezone = "AEST";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to extract timezone from metadata, using configured timezone");
|
||||
}
|
||||
}
|
||||
|
||||
if (TryParseTimestamp(timestampStr, detectedTimezone, out var frameTimestampUtc))
|
||||
return frameTimestampUtc;
|
||||
|
||||
_logger.LogWarning("Failed to parse timestamp string: '{Timestamp}'", timestampStr);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to extract timestamp from display label");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryParseTimestamp(string timestampStr, string? timezoneAbbreviation, out DateTime timestampUtc)
|
||||
{
|
||||
timestampUtc = DateTime.MinValue;
|
||||
|
||||
try
|
||||
{
|
||||
TimeZoneInfo timeZoneInfo;
|
||||
|
||||
if (!string.IsNullOrEmpty(timezoneAbbreviation))
|
||||
{
|
||||
if (timezoneAbbreviation.Contains("AEST", StringComparison.OrdinalIgnoreCase))
|
||||
timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Australia/Brisbane");
|
||||
else if (timezoneAbbreviation.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
|
||||
timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Australia/Sydney");
|
||||
else
|
||||
{
|
||||
var timezone = _configuration.GetValue<string>("Timezone");
|
||||
if (string.IsNullOrEmpty(timezone))
|
||||
return false;
|
||||
timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezone);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var timezone = _configuration.GetValue<string>("Timezone");
|
||||
if (string.IsNullOrEmpty(timezone))
|
||||
return false;
|
||||
timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezone);
|
||||
}
|
||||
|
||||
timestampStr = Regex.Replace(timestampStr.Trim(), @"\s+", " ");
|
||||
|
||||
var formats = new[]
|
||||
{
|
||||
"dddd d MMM, h:mm tt",
|
||||
"d MMM, h:mm tt",
|
||||
"dddd d MMM, hh:mm tt",
|
||||
"d MMM, hh:mm tt",
|
||||
"dddd dd MMM, h:mm tt",
|
||||
"dd MMM, h:mm tt",
|
||||
"dddd d MMM, h:mmtt",
|
||||
"d MMM, h:mmtt",
|
||||
"dddd d MMM, hh:mmtt",
|
||||
"d MMM, hh:mmtt",
|
||||
"dddd dd MMM, h:mmtt",
|
||||
"dd MMM, h:mmtt",
|
||||
"dddd dd MMM, hh:mmtt",
|
||||
"dd MMM, hh:mmtt"
|
||||
};
|
||||
|
||||
var culture = new System.Globalization.CultureInfo("en-AU");
|
||||
DateTime localTime = default;
|
||||
var parsed = false;
|
||||
|
||||
foreach (var format in formats)
|
||||
{
|
||||
if (DateTime.TryParseExact(timestampStr, format, culture,
|
||||
System.Globalization.DateTimeStyles.None, out localTime))
|
||||
{
|
||||
parsed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed)
|
||||
return false;
|
||||
|
||||
if (localTime.Year == 1)
|
||||
{
|
||||
localTime = new DateTime(DateTime.UtcNow.Year, localTime.Month, localTime.Day,
|
||||
localTime.Hour, localTime.Minute, localTime.Second);
|
||||
}
|
||||
|
||||
timestampUtc = TimeZoneInfo.ConvertTimeToUtc(localTime, timeZoneInfo);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse timestamp: {Timestamp}", timestampStr);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task WaitForDisplayLabelChangeAsync(
|
||||
IPage page,
|
||||
ISelectorService selectorService,
|
||||
DateTime currentTimestamp,
|
||||
ScrapingContext? context = null,
|
||||
int maxWaitMs = 5000,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = cancellationToken;
|
||||
try
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
while ((DateTime.UtcNow - startTime).TotalMilliseconds < maxWaitMs)
|
||||
{
|
||||
var newTimestamp = await ReadUtcAsync(page, selectorService, context, cancellationToken);
|
||||
if (newTimestamp.HasValue && newTimestamp.Value != currentTimestamp)
|
||||
return;
|
||||
|
||||
await page.WaitForTimeoutAsync(200);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Display label did not change from {CurrentTimestamp} within {MaxWaitMs}ms",
|
||||
currentTimestamp,
|
||||
maxWaitMs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error waiting for display label to change");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,13 @@ using BomLocalService.Services.Interfaces;
|
||||
using BomLocalService.Services.Scraping;
|
||||
using BomLocalService.Utilities;
|
||||
using Microsoft.Playwright;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace BomLocalService.Services.Scraping.Steps.Capture;
|
||||
|
||||
public class CaptureFramesStep : BaseScrapingStep
|
||||
{
|
||||
private readonly ICacheService _cacheService;
|
||||
private readonly IScrubDisplayTimestampParser _scrubDisplayTime;
|
||||
private readonly int _tileRenderWaitMs;
|
||||
private readonly ScreenshotCropConfig _cropConfig;
|
||||
|
||||
@@ -21,19 +21,44 @@ public class CaptureFramesStep : BaseScrapingStep
|
||||
ISelectorService selectorService,
|
||||
IDebugService debugService,
|
||||
IConfiguration configuration,
|
||||
ICacheService cacheService)
|
||||
ICacheService cacheService,
|
||||
IScrubDisplayTimestampParser scrubDisplayTime)
|
||||
: base(logger, selectorService, debugService, configuration)
|
||||
{
|
||||
_cacheService = cacheService;
|
||||
_tileRenderWaitMs = configuration.GetValue<int>("Screenshot:TileRenderWaitMs", 5000);
|
||||
_scrubDisplayTime = scrubDisplayTime;
|
||||
|
||||
var tileRenderWaitMsConfig = configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
|
||||
if (!tileRenderWaitMsConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Screenshot:TileRenderWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__TILERENDERWAITMS environment variable.");
|
||||
}
|
||||
_tileRenderWaitMs = tileRenderWaitMsConfig.Value;
|
||||
|
||||
var cropSection = configuration.GetSection("Screenshot:Crop");
|
||||
|
||||
var cropXConfig = cropSection.GetValue<int?>("X");
|
||||
if (!cropXConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Screenshot:Crop:X configuration is required. Set it in appsettings.json or via SCREENSHOT__CROP__X environment variable.");
|
||||
}
|
||||
var cropYConfig = cropSection.GetValue<int?>("Y");
|
||||
if (!cropYConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Screenshot:Crop:Y configuration is required. Set it in appsettings.json or via SCREENSHOT__CROP__Y environment variable.");
|
||||
}
|
||||
var cropRightOffsetConfig = cropSection.GetValue<int?>("RightOffset");
|
||||
if (!cropRightOffsetConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Screenshot:Crop:RightOffset configuration is required. Set it in appsettings.json or via SCREENSHOT__CROP__RIGHTOFFSET environment variable.");
|
||||
}
|
||||
|
||||
_cropConfig = new ScreenshotCropConfig
|
||||
{
|
||||
X = cropSection.GetValue<int>("X", 0),
|
||||
Y = cropSection.GetValue<int>("Y", 0),
|
||||
RightOffset = cropSection.GetValue<int>("RightOffset", 0),
|
||||
Height = cropSection.GetValue<int?>("Height")
|
||||
X = cropXConfig.Value,
|
||||
Y = cropYConfig.Value,
|
||||
RightOffset = cropRightOffsetConfig.Value,
|
||||
Height = cropSection.GetValue<int?>("Height") // Height is optional (nullable)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,7 +80,7 @@ public class CaptureFramesStep : BaseScrapingStep
|
||||
|
||||
var frames = new List<RadarFrame>();
|
||||
var stepForwardButton = SelectorService.GetLocator(context.Page, Selectors.StepForwardButton);
|
||||
int? previousMinutesAgo = null;
|
||||
DateTime? previousTimestamp = null;
|
||||
|
||||
for (int frameIndex = 0; frameIndex < frameCount; frameIndex++)
|
||||
{
|
||||
@@ -67,37 +92,49 @@ public class CaptureFramesStep : BaseScrapingStep
|
||||
// This is especially important for frame 0 which was just selected in ResetToFirstFrame
|
||||
await context.Page.WaitForTimeoutAsync(300);
|
||||
|
||||
// Try extracting with a retry in case the label is still updating
|
||||
var minutesAgo = await ExtractMinutesAgoFromDisplayAsync(context.Page);
|
||||
if (minutesAgo == null)
|
||||
// Try extracting timestamp with a retry in case the label is still updating
|
||||
var frameTimestamp = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
|
||||
if (frameTimestamp == null)
|
||||
{
|
||||
// Retry once after a short wait in case label was updating
|
||||
await context.Page.WaitForTimeoutAsync(200);
|
||||
minutesAgo = await ExtractMinutesAgoFromDisplayAsync(context.Page);
|
||||
}
|
||||
if (minutesAgo == null && context.FrameInfo != null && frameIndex < context.FrameInfo.Count)
|
||||
{
|
||||
var (_, defaultMinutesAgo) = context.FrameInfo[frameIndex];
|
||||
minutesAgo = defaultMinutesAgo;
|
||||
Logger.LogWarning("Step {Step}: Failed to extract minutes from display label for frame {FrameIndex}, using default: {MinutesAgo}", Name, frameIndex, minutesAgo);
|
||||
frameTimestamp = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
|
||||
}
|
||||
|
||||
if (frameIndex > 0 && previousMinutesAgo.HasValue && minutesAgo == previousMinutesAgo.Value)
|
||||
// Fallback: calculate expected timestamp from observation time and frame index if we can't parse it
|
||||
if (frameTimestamp == null && context.LastUpdatedInfo?.ObservationTime != null && context.FrameInfo != null && frameIndex < context.FrameInfo.Count)
|
||||
{
|
||||
Logger.LogWarning("Step {Step}: Frame {FrameIndex} has same minutesAgo ({MinutesAgo}) as previous frame. Waiting for display to update...", Name, frameIndex, minutesAgo);
|
||||
await WaitForDisplayLabelToChangeAsync(context.Page, previousMinutesAgo.Value);
|
||||
minutesAgo = await ExtractMinutesAgoFromDisplayAsync(context.Page);
|
||||
if (minutesAgo == null || minutesAgo == previousMinutesAgo.Value)
|
||||
var (_, defaultMinutesAgo) = context.FrameInfo[frameIndex];
|
||||
frameTimestamp = context.LastUpdatedInfo.ObservationTime.AddMinutes(-defaultMinutesAgo);
|
||||
Logger.LogWarning("Step {Step}: Failed to extract timestamp from display label for frame {FrameIndex}, calculated from observation time: {Timestamp}",
|
||||
Name, frameIndex, frameTimestamp);
|
||||
}
|
||||
|
||||
if (frameIndex > 0 && previousTimestamp.HasValue && frameTimestamp == previousTimestamp.Value)
|
||||
{
|
||||
Logger.LogWarning("Step {Step}: Frame {FrameIndex} has same timestamp ({Timestamp}) as previous frame. Waiting for display to update...",
|
||||
Name, frameIndex, frameTimestamp);
|
||||
await _scrubDisplayTime.WaitForDisplayLabelChangeAsync(
|
||||
context.Page, SelectorService, previousTimestamp.Value, context, cancellationToken: cancellationToken);
|
||||
frameTimestamp = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
|
||||
if (frameTimestamp == null || frameTimestamp == previousTimestamp.Value)
|
||||
{
|
||||
if (context.FrameInfo != null && frameIndex < context.FrameInfo.Count)
|
||||
if (context.LastUpdatedInfo?.ObservationTime != null && context.FrameInfo != null && frameIndex < context.FrameInfo.Count)
|
||||
{
|
||||
var (_, defaultMinutesAgo) = context.FrameInfo[frameIndex];
|
||||
minutesAgo = defaultMinutesAgo;
|
||||
Logger.LogWarning("Step {Step}: Display label did not update for frame {FrameIndex}, using calculated default: {MinutesAgo}", Name, frameIndex, minutesAgo);
|
||||
frameTimestamp = context.LastUpdatedInfo.ObservationTime.AddMinutes(-defaultMinutesAgo);
|
||||
Logger.LogWarning("Step {Step}: Display label did not update for frame {FrameIndex}, calculated from observation time: {Timestamp}",
|
||||
Name, frameIndex, frameTimestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (frameTimestamp == null)
|
||||
{
|
||||
Logger.LogError("Step {Step}: Could not determine timestamp for frame {FrameIndex}, skipping", Name, frameIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
var radarFolder = FilePathHelper.GetDataTypeFolderPath(context.CacheFolderPath, CachedDataType.Radar);
|
||||
if (!Directory.Exists(radarFolder))
|
||||
{
|
||||
@@ -111,13 +148,13 @@ public class CaptureFramesStep : BaseScrapingStep
|
||||
{
|
||||
FrameIndex = frameIndex,
|
||||
ImagePath = framePath,
|
||||
MinutesAgo = minutesAgo ?? 0
|
||||
AbsoluteObservationTime = frameTimestamp.Value
|
||||
});
|
||||
|
||||
previousMinutesAgo = minutesAgo;
|
||||
previousTimestamp = frameTimestamp;
|
||||
|
||||
Logger.LogInformation("Step {Step}: Frame {FrameIndex} saved: {Path} ({MinutesAgo} minutes ago)",
|
||||
Name, frameIndex, framePath, minutesAgo ?? 0);
|
||||
Logger.LogInformation("Step {Step}: Frame {FrameIndex} saved: {Path} (timestamp: {Timestamp} UTC)",
|
||||
Name, frameIndex, framePath, frameTimestamp.Value);
|
||||
|
||||
_cacheService.RecordUpdateProgressByFolder(context.CacheFolderPath, CacheUpdatePhase.CapturingFrames, frameIndex + 1, frameCount);
|
||||
|
||||
@@ -127,13 +164,14 @@ public class CaptureFramesStep : BaseScrapingStep
|
||||
{
|
||||
await DismissModalOverlaysAsync(context.Page);
|
||||
|
||||
var currentMinutesAgo = await ExtractMinutesAgoFromDisplayAsync(context.Page);
|
||||
var currentTimestamp = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
|
||||
|
||||
await stepForwardButton.ClickAsync(new LocatorClickOptions { Force = true });
|
||||
|
||||
if (currentMinutesAgo.HasValue)
|
||||
if (currentTimestamp.HasValue)
|
||||
{
|
||||
await WaitForDisplayLabelToChangeAsync(context.Page, currentMinutesAgo.Value);
|
||||
await _scrubDisplayTime.WaitForDisplayLabelChangeAsync(
|
||||
context.Page, SelectorService, currentTimestamp.Value, context, cancellationToken: cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -164,138 +202,6 @@ public class CaptureFramesStep : BaseScrapingStep
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int?> ExtractMinutesAgoFromDisplayAsync(IPage page)
|
||||
{
|
||||
try
|
||||
{
|
||||
var timeLabelLocator = SelectorService.GetLocator(page, Selectors.TimeDisplayLabel);
|
||||
var timeLabel = await timeLabelLocator.TextContentAsync();
|
||||
if (string.IsNullOrEmpty(timeLabel))
|
||||
{
|
||||
Logger.LogDebug("Time display label is empty");
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmedLabel = timeLabel.Trim();
|
||||
Logger.LogInformation("Extracting minutes from display label: '{Label}'", trimmedLabel);
|
||||
|
||||
// Parse timestamp format (current BOM website format): "Wednesday 17 Dec, 11:05 pm" or "17 Dec, 11:05 pm"
|
||||
Logger.LogInformation("Trying timestamp pattern: '{Pattern}'", TextPatterns.TimestampPattern);
|
||||
var timestampMatch = Regex.Match(trimmedLabel, TextPatterns.TimestampPattern, RegexOptions.IgnoreCase);
|
||||
if (timestampMatch.Success)
|
||||
{
|
||||
var timestampStr = timestampMatch.Groups[0].Value;
|
||||
Logger.LogInformation("Matched timestamp pattern: '{Timestamp}' from label: '{Label}'", timestampStr, trimmedLabel);
|
||||
if (TryParseTimestamp(timestampStr, out var timestamp))
|
||||
{
|
||||
var minutesAgo = (int)(DateTime.UtcNow - timestamp).TotalMinutes;
|
||||
Logger.LogInformation("Parsed timestamp: {Timestamp} UTC, calculated minutes ago: {Minutes}", timestamp, minutesAgo);
|
||||
if (minutesAgo >= 0 && minutesAgo <= 120) // Reasonable range: 0-2 hours
|
||||
{
|
||||
Logger.LogInformation("Successfully calculated minutes ago from timestamp: {Minutes}", minutesAgo);
|
||||
return minutesAgo;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Calculated minutes ago ({Minutes}) outside reasonable range (0-120)", minutesAgo);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Failed to parse timestamp string: '{Timestamp}'", timestampStr);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Display label did not match timestamp pattern. Label: '{Label}', Pattern: '{Pattern}'", trimmedLabel, TextPatterns.TimestampPattern);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug(ex, "Failed to extract minutes from display label");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryParseTimestamp(string timestampStr, out DateTime timestamp)
|
||||
{
|
||||
timestamp = DateTime.MinValue;
|
||||
|
||||
try
|
||||
{
|
||||
// Try common Australian date formats
|
||||
// Format: "Wednesday 17 Dec, 11:05 pm" or "17 Dec, 11:05 pm"
|
||||
var formats = new[]
|
||||
{
|
||||
"dddd d MMM, h:mm tt", // Wednesday 17 Dec, 11:05 pm
|
||||
"d MMM, h:mm tt", // 17 Dec, 11:05 pm
|
||||
"dddd d MMM, hh:mm tt", // Wednesday 17 Dec, 11:05 pm (with leading zero)
|
||||
"d MMM, hh:mm tt", // 17 Dec, 11:05 pm (with leading zero)
|
||||
"dddd dd MMM, h:mm tt", // Wednesday 17 Dec, 11:05 pm (with leading zero day)
|
||||
"dd MMM, h:mm tt" // 17 Dec, 11:05 pm (with leading zero day)
|
||||
};
|
||||
|
||||
var culture = new System.Globalization.CultureInfo("en-AU");
|
||||
|
||||
foreach (var format in formats)
|
||||
{
|
||||
if (DateTime.TryParseExact(timestampStr, format, culture,
|
||||
System.Globalization.DateTimeStyles.AssumeLocal, out timestamp))
|
||||
{
|
||||
// If year is not specified, assume current year
|
||||
if (timestamp.Year == 1)
|
||||
{
|
||||
timestamp = new DateTime(DateTime.Now.Year, timestamp.Month, timestamp.Day,
|
||||
timestamp.Hour, timestamp.Minute, timestamp.Second);
|
||||
}
|
||||
|
||||
// If the parsed time is in the future (likely same day next year), adjust
|
||||
if (timestamp > DateTime.Now && timestamp < DateTime.Now.AddDays(1))
|
||||
{
|
||||
// Already correct
|
||||
}
|
||||
else if (timestamp > DateTime.Now)
|
||||
{
|
||||
// Likely parsed as next year, adjust to this year
|
||||
timestamp = timestamp.AddYears(-1);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WaitForDisplayLabelToChangeAsync(IPage page, int currentMinutesAgo, int maxWaitMs = 5000)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
while ((DateTime.UtcNow - startTime).TotalMilliseconds < maxWaitMs)
|
||||
{
|
||||
var newMinutesAgo = await ExtractMinutesAgoFromDisplayAsync(page);
|
||||
if (newMinutesAgo.HasValue && newMinutesAgo.Value != currentMinutesAgo)
|
||||
{
|
||||
return;
|
||||
}
|
||||
await page.WaitForTimeoutAsync(200);
|
||||
}
|
||||
Logger.LogDebug("Display label did not change from {CurrentMinutesAgo} within {MaxWaitMs}ms", currentMinutesAgo, maxWaitMs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug(ex, "Error waiting for display label to change");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DismissModalOverlaysAsync(IPage page)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -31,16 +31,43 @@ public class PauseRadarStep : BaseScrapingStep
|
||||
|
||||
var playPauseButton = SelectorService.GetLocator(context.Page, Selectors.PlayPauseButton);
|
||||
await playPauseButton.WaitForAsync(new LocatorWaitForOptions { Timeout = 5000 });
|
||||
|
||||
var buttonLabel = await playPauseButton.Locator(Selectors.PlayPauseLabel.Selectors[0]).TextContentAsync();
|
||||
if (buttonLabel?.Trim().Equals(TextPatterns.ExpectedTexts["PauseButtonLabel"], StringComparison.OrdinalIgnoreCase) == true)
|
||||
|
||||
// New spatial map often uses one step-through control; label may be on the button or in .bom-scrub-action__label
|
||||
string? buttonLabel = null;
|
||||
try
|
||||
{
|
||||
var labelLoc = playPauseButton.Locator(Selectors.PlayPauseLabel.Selectors[0]).First;
|
||||
if (await labelLoc.CountAsync() > 0)
|
||||
buttonLabel = (await labelLoc.TextContentAsync())?.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* use inner text */
|
||||
}
|
||||
|
||||
buttonLabel ??= (await playPauseButton.InnerTextAsync())?.Trim();
|
||||
|
||||
if (buttonLabel?.Equals(TextPatterns.ExpectedTexts["PauseButtonLabel"], StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
Logger.LogInformation("Step {Step}: Radar is playing, pausing it", Name);
|
||||
await playPauseButton.ClickAsync();
|
||||
await context.Page.WaitForTimeoutAsync(300);
|
||||
|
||||
buttonLabel = await playPauseButton.Locator(Selectors.PlayPauseLabel.Selectors[0]).TextContentAsync();
|
||||
if (buttonLabel?.Trim().Equals(TextPatterns.ExpectedTexts["PlayButtonLabel"], StringComparison.OrdinalIgnoreCase) != true)
|
||||
|
||||
// Clear so post-click read always runs; otherwise stale "Pause" skips the ??= InnerText fallback when the label child is missing
|
||||
buttonLabel = null;
|
||||
try
|
||||
{
|
||||
var labelLoc = playPauseButton.Locator(Selectors.PlayPauseLabel.Selectors[0]).First;
|
||||
if (await labelLoc.CountAsync() > 0)
|
||||
buttonLabel = (await labelLoc.TextContentAsync())?.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
buttonLabel = null;
|
||||
}
|
||||
|
||||
buttonLabel ??= (await playPauseButton.InnerTextAsync())?.Trim();
|
||||
if (buttonLabel?.Equals(TextPatterns.ExpectedTexts["PlayButtonLabel"], StringComparison.OrdinalIgnoreCase) != true)
|
||||
{
|
||||
Logger.LogWarning("Step {Step}: Radar may not be paused after click, continuing anyway", Name);
|
||||
}
|
||||
|
||||
@@ -1,94 +1,134 @@
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces;
|
||||
using BomLocalService.Services.Scraping;
|
||||
using BomLocalService.Utilities;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace BomLocalService.Services.Scraping.Steps.Map;
|
||||
|
||||
/// <summary>
|
||||
/// Aligns the radar scrubber to the oldest frame (historical frame 0) without clicking timeline segments.
|
||||
/// The BOM UI now often exposes a single step-through control; segment pills and <c>bom-scrub-tl</c> are unreliable.
|
||||
/// Strategy: while paused, read <see cref="IScrubDisplayTimestampParser"/> from the display label, step forward
|
||||
/// through one full loop to find the minimum time, then step until that time shows again.
|
||||
/// </summary>
|
||||
public class ResetToFirstFrameStep : BaseScrapingStep
|
||||
{
|
||||
private readonly IScrubDisplayTimestampParser _scrubDisplayTime;
|
||||
|
||||
public override string Name => "ResetToFirstFrame";
|
||||
public override string[] Prerequisites => new[] { "PauseRadar" };
|
||||
|
||||
public override string[] Prerequisites => new[] { "ExtractMetadata" };
|
||||
|
||||
public ResetToFirstFrameStep(
|
||||
ILogger<ResetToFirstFrameStep> logger,
|
||||
ISelectorService selectorService,
|
||||
IDebugService debugService,
|
||||
IConfiguration configuration)
|
||||
IConfiguration configuration,
|
||||
IScrubDisplayTimestampParser scrubDisplayTime)
|
||||
: base(logger, selectorService, debugService, configuration)
|
||||
{
|
||||
_scrubDisplayTime = scrubDisplayTime;
|
||||
}
|
||||
|
||||
|
||||
public override bool CanExecute(ScrapingContext context)
|
||||
{
|
||||
return context.CurrentState >= PageState.SlideshowPaused;
|
||||
return context.CurrentState >= PageState.SlideshowPaused
|
||||
&& context.LastUpdatedInfo != null;
|
||||
}
|
||||
|
||||
|
||||
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.LogInformation("Step {Step}: Resetting to first frame (frame 0)", Name);
|
||||
|
||||
try
|
||||
Logger.LogInformation("Step {Step}: Aligning to oldest frame via step-through + display label (no segment clicks)", Name);
|
||||
|
||||
var frameCount = CacheHelper.GetFrameCountForDataType(Configuration, CachedDataType.Radar);
|
||||
var elementWaitMs = Configuration.GetValue<int?>("Scraping:Timeouts:ElementWait") ?? 10000;
|
||||
|
||||
var stepBtn = SelectorService.GetLocator(context.Page, Selectors.StepForwardButton);
|
||||
await stepBtn.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = elementWaitMs });
|
||||
|
||||
// Phase 1 — sample one full loop of display times while paused; oldest = frame 0 target
|
||||
var samples = new List<DateTime>();
|
||||
for (var i = 0; i < frameCount; i++)
|
||||
{
|
||||
// Try JavaScript click first to avoid thumb interception
|
||||
var clicked = await context.Page.EvaluateAsync<bool>(@"() => {
|
||||
const segment = document.querySelector('[data-testid=""bom-scrub-segment""][data-id=""0""]');
|
||||
if (segment) {
|
||||
segment.click();
|
||||
return true;
|
||||
var t = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
|
||||
if (t.HasValue)
|
||||
samples.Add(t.Value);
|
||||
|
||||
if (i == frameCount - 1)
|
||||
break;
|
||||
|
||||
var beforeStep = t;
|
||||
await stepBtn.ClickAsync(new LocatorClickOptions { Force = true });
|
||||
if (beforeStep.HasValue)
|
||||
{
|
||||
await _scrubDisplayTime.WaitForDisplayLabelChangeAsync(
|
||||
context.Page, SelectorService, beforeStep.Value, context, maxWaitMs: 8000, cancellationToken: cancellationToken);
|
||||
}
|
||||
else
|
||||
await context.Page.WaitForTimeoutAsync(800);
|
||||
}
|
||||
|
||||
DateTime? frame0Utc = null;
|
||||
if (samples.Count > 0)
|
||||
{
|
||||
frame0Utc = samples.Min();
|
||||
Logger.LogInformation("Step {Step}: Oldest time in sampled loop (frame 0 target): {Utc:o} UTC", Name, frame0Utc);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Step {Step}: Could not read any display timestamps; tile timing may be wrong", Name);
|
||||
}
|
||||
|
||||
// Phase 2 — step until the display matches the oldest sample
|
||||
if (frame0Utc.HasValue)
|
||||
{
|
||||
const double matchMinutes = 2.0;
|
||||
for (var attempt = 0; attempt < frameCount + 4; attempt++)
|
||||
{
|
||||
var cur = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
|
||||
if (cur.HasValue && Math.Abs((cur.Value - frame0Utc.Value).TotalMinutes) <= matchMinutes)
|
||||
{
|
||||
Logger.LogInformation("Step {Step}: Display matches frame 0 target (≤{MatchMinutes:F0} min)", Name, matchMinutes);
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}");
|
||||
|
||||
if (clicked)
|
||||
{
|
||||
await context.Page.WaitForTimeoutAsync(500);
|
||||
Logger.LogInformation("Step {Step}: Successfully clicked frame 0 segment via JavaScript", Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback to locator click with force
|
||||
var firstFrameSegment = context.Page.Locator("[data-testid='bom-scrub-segment'][data-id='0']").First;
|
||||
await firstFrameSegment.WaitForAsync(new LocatorWaitForOptions { Timeout = 5000 });
|
||||
await firstFrameSegment.ClickAsync(new LocatorClickOptions { Force = true });
|
||||
await context.Page.WaitForTimeoutAsync(500);
|
||||
Logger.LogInformation("Step {Step}: Successfully clicked frame 0 segment via force click", Name);
|
||||
|
||||
await stepBtn.ClickAsync(new LocatorClickOptions { Force = true });
|
||||
if (cur.HasValue)
|
||||
{
|
||||
await _scrubDisplayTime.WaitForDisplayLabelChangeAsync(
|
||||
context.Page, SelectorService, cur.Value, context, maxWaitMs: 8000, cancellationToken: cancellationToken);
|
||||
}
|
||||
else
|
||||
await context.Page.WaitForTimeoutAsync(800);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Step {Step}: Failed to click first frame segment, continuing anyway", Name);
|
||||
}
|
||||
|
||||
await SaveDebugAsync(context, 9, "frame_0_selected", cancellationToken);
|
||||
|
||||
// Verify scrubber is at position 0
|
||||
Logger.LogInformation("Step {Step}: Verifying scrubber is at position 0", Name);
|
||||
|
||||
await SaveDebugAsync(context, 9, "frame_0_aligned", cancellationToken);
|
||||
|
||||
// Best-effort only: segment-based checks are unreliable on the new spatial map layout
|
||||
try
|
||||
{
|
||||
var activeSegment = await context.Page.EvaluateAsync<bool>(JavaScriptTemplates.CheckActiveFrameSegment);
|
||||
|
||||
if (activeSegment)
|
||||
{
|
||||
Logger.LogInformation("Step {Step}: Scrubber confirmed at position 0", Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogDebug("Step {Step}: Could not confirm scrubber position, but continuing", Name);
|
||||
}
|
||||
var onSegmentZero = await context.Page.EvaluateAsync<bool>(JavaScriptTemplates.CheckActiveFrameSegment);
|
||||
if (onSegmentZero)
|
||||
Logger.LogDebug("Step {Step}: Segment check reports frame 0 (informational)", Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug(ex, "Step {Step}: Scrubber position verification failed, continuing anyway", Name);
|
||||
Logger.LogDebug(ex, "Step {Step}: Segment position check skipped", Name);
|
||||
}
|
||||
|
||||
await SaveDebugAsync(context, 10, "scrubber_at_position_0", cancellationToken);
|
||||
|
||||
// Wait for frame 0 tiles to fully render
|
||||
var tileRenderWaitMs = Configuration.GetValue<int>("Screenshot:TileRenderWaitMs", 5000);
|
||||
await context.Page.WaitForTimeoutAsync(tileRenderWaitMs);
|
||||
|
||||
|
||||
await SaveDebugAsync(context, 10, "scrubber_frame0", cancellationToken);
|
||||
|
||||
var tileRenderWaitMsConfig = Configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
|
||||
if (!tileRenderWaitMsConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Screenshot:TileRenderWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__TILERENDERWAITMS environment variable.");
|
||||
}
|
||||
|
||||
await context.Page.WaitForTimeoutAsync(tileRenderWaitMsConfig.Value);
|
||||
|
||||
context.CurrentState = PageState.Frame0Selected;
|
||||
return ScrapingStepResult.Successful();
|
||||
}
|
||||
@@ -100,4 +140,3 @@ public class ResetToFirstFrameStep : BaseScrapingStep
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,12 @@ public class WaitForMapReadyStep : BaseScrapingStep
|
||||
IConfiguration configuration)
|
||||
: base(logger, selectorService, debugService, configuration)
|
||||
{
|
||||
_tileRenderWaitMs = configuration.GetValue<int>("Screenshot:TileRenderWaitMs", 5000);
|
||||
var tileRenderWaitMsConfig = configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
|
||||
if (!tileRenderWaitMsConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Screenshot:TileRenderWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__TILERENDERWAITMS environment variable.");
|
||||
}
|
||||
_tileRenderWaitMs = tileRenderWaitMsConfig.Value;
|
||||
}
|
||||
|
||||
public override bool CanExecute(ScrapingContext context)
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
using System.Text.Json;
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces;
|
||||
using BomLocalService.Services.Scraping;
|
||||
using BomLocalService.Utilities;
|
||||
|
||||
namespace BomLocalService.Services.Scraping.Steps.Metadata;
|
||||
|
||||
public class ExtractMetadataStep : BaseScrapingStep
|
||||
{
|
||||
private readonly ITimeParsingService _timeParsingService;
|
||||
|
||||
|
||||
public override string Name => "ExtractMetadata";
|
||||
public override string[] Prerequisites => new[] { "ResetToFirstFrame" };
|
||||
|
||||
public override string[] Prerequisites => new[] { "PauseRadar" };
|
||||
|
||||
public ExtractMetadataStep(
|
||||
ILogger<ExtractMetadataStep> logger,
|
||||
ISelectorService selectorService,
|
||||
@@ -20,41 +23,67 @@ public class ExtractMetadataStep : BaseScrapingStep
|
||||
{
|
||||
_timeParsingService = timeParsingService;
|
||||
}
|
||||
|
||||
|
||||
public override bool CanExecute(ScrapingContext context)
|
||||
{
|
||||
return context.IsMapReady;
|
||||
}
|
||||
|
||||
|
||||
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.LogInformation("Step {Step}: Extracting metadata and frame information", Name);
|
||||
|
||||
|
||||
var lastUpdatedInfo = await _timeParsingService.ExtractLastUpdatedInfoAsync(context.Page);
|
||||
context.LastUpdatedInfo = lastUpdatedInfo;
|
||||
|
||||
// Extract frame info
|
||||
|
||||
var frameCount = CacheHelper.GetFrameCountForDataType(Configuration, CachedDataType.Radar);
|
||||
var defaults = Enumerable.Range(0, frameCount)
|
||||
.Select(i => (index: i, minutesAgo: 40 - i * 5))
|
||||
.ToList();
|
||||
|
||||
try
|
||||
{
|
||||
var frameInfo = await context.Page.EvaluateAsync<object[]>(JavaScriptTemplates.ExtractFrameInfo);
|
||||
var result = new List<(int index, int minutesAgo)>();
|
||||
for (int i = 0; i < 7; i++)
|
||||
var element = await context.Page.EvaluateAsync<JsonElement>(JavaScriptTemplates.ExtractFrameInfo);
|
||||
if (element.ValueKind == JsonValueKind.Array && element.GetArrayLength() > 0)
|
||||
{
|
||||
var minutesAgo = 40 - (i * 5);
|
||||
result.Add((i, minutesAgo));
|
||||
var byIndex = new Dictionary<int, int>();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object)
|
||||
continue;
|
||||
|
||||
var idx = item.GetProperty("index").GetInt32();
|
||||
if (item.TryGetProperty("minutesAgo", out var m) && m.ValueKind == JsonValueKind.Number)
|
||||
byIndex[idx] = m.GetInt32();
|
||||
}
|
||||
|
||||
if (byIndex.Count > 0)
|
||||
{
|
||||
context.FrameInfo = Enumerable.Range(0, frameCount)
|
||||
.Select(i => (i, byIndex.TryGetValue(i, out var mm) ? mm : defaults[i].minutesAgo))
|
||||
.ToList();
|
||||
Logger.LogInformation("Step {Step}: Parsed frame offsets from segment aria-labels where available", Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.FrameInfo = defaults;
|
||||
Logger.LogInformation("Step {Step}: Segment times not parsed; using default ladder", Name);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
context.FrameInfo = defaults;
|
||||
Logger.LogInformation("Step {Step}: No segment array from page; using default ladder", Name);
|
||||
}
|
||||
context.FrameInfo = result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Step {Step}: Failed to extract frame info, using defaults", Name);
|
||||
context.FrameInfo = Enumerable.Range(0, 7)
|
||||
.Select(i => (i, 40 - (i * 5)))
|
||||
.ToList();
|
||||
context.FrameInfo = defaults;
|
||||
}
|
||||
|
||||
|
||||
return ScrapingStepResult.Successful();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -65,4 +94,3 @@ public class ExtractMetadataStep : BaseScrapingStep
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ public class NavigateHomepageStep : BaseScrapingStep
|
||||
IConfiguration configuration)
|
||||
: base(logger, selectorService, debugService, configuration)
|
||||
{
|
||||
_baseUrl = configuration.GetValue<string>("Scraping:BaseUrl") ?? "https://www.bom.gov.au/";
|
||||
_baseUrl = configuration.GetValue<string>("Scraping:BaseUrl")
|
||||
?? throw new InvalidOperationException("Scraping:BaseUrl configuration is required. Set it in appsettings.json or via SCRAPING__BASEURL environment variable.");
|
||||
}
|
||||
|
||||
public override bool CanExecute(ScrapingContext context)
|
||||
|
||||
@@ -174,7 +174,12 @@ public class SelectSearchResultStep : BaseScrapingStep
|
||||
|
||||
// Wait for forecast page to load
|
||||
await context.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded, new PageWaitForLoadStateOptions { Timeout = 15000 });
|
||||
var dynamicContentWaitMs = Configuration.GetValue<int>("Screenshot:DynamicContentWaitMs", 2000);
|
||||
var dynamicContentWaitMsConfig = Configuration.GetValue<int?>("Screenshot:DynamicContentWaitMs");
|
||||
if (!dynamicContentWaitMsConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Screenshot:DynamicContentWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__DYNAMICCONTENTWAITMS environment variable.");
|
||||
}
|
||||
var dynamicContentWaitMs = dynamicContentWaitMsConfig.Value;
|
||||
await context.Page.WaitForTimeoutAsync(dynamicContentWaitMs);
|
||||
await SaveDebugAsync(context, 5, "forecast_page_loaded", cancellationToken);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using BomLocalService.Services.Scraping.Workflows;
|
||||
|
||||
namespace BomLocalService.Services.Scraping;
|
||||
|
||||
public class WorkflowFactory : IWorkflowFactory
|
||||
@@ -13,8 +15,10 @@ public class WorkflowFactory : IWorkflowFactory
|
||||
{
|
||||
return name switch
|
||||
{
|
||||
"RadarScraping" => (IWorkflow<TResponse>)_serviceProvider.GetRequiredService<Workflows.RadarScrapingWorkflow>(),
|
||||
"TemperatureMap" => (IWorkflow<TResponse>)_serviceProvider.GetRequiredService<Workflows.TemperatureMapWorkflow>(),
|
||||
"RadarScraping" => _serviceProvider.GetRequiredService<IRadarScrapingWorkflow>() as IWorkflow<TResponse>
|
||||
?? throw new InvalidOperationException($"IRadarScrapingWorkflow is not IWorkflow<{typeof(TResponse).Name}>"),
|
||||
"TemperatureMap" => _serviceProvider.GetRequiredService<ITemperatureMapWorkflow>() as IWorkflow<TResponse>
|
||||
?? throw new InvalidOperationException($"ITemperatureMapWorkflow is not IWorkflow<{typeof(TResponse).Name}>"),
|
||||
_ => throw new ArgumentException($"Unknown workflow: {name}")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
|
||||
namespace BomLocalService.Services.Scraping.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete interface for radar scraping workflow (hides generic IWorkflow<RadarResponse>)
|
||||
/// </summary>
|
||||
public interface IRadarScrapingWorkflow : IWorkflow<RadarResponse>, IScopedService
|
||||
{
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
|
||||
namespace BomLocalService.Services.Scraping.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete interface for temperature map workflow (hides generic IWorkflow<RadarResponse>)
|
||||
/// </summary>
|
||||
public interface ITemperatureMapWorkflow : IWorkflow<RadarResponse>, IScopedService
|
||||
{
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ using BomLocalService.Utilities;
|
||||
|
||||
namespace BomLocalService.Services.Scraping.Workflows;
|
||||
|
||||
public class RadarScrapingWorkflow : IWorkflow<RadarResponse>
|
||||
public class RadarScrapingWorkflow : IRadarScrapingWorkflow
|
||||
{
|
||||
private readonly ILogger<RadarScrapingWorkflow> _logger;
|
||||
private readonly IScrapingStepRegistry _stepRegistry;
|
||||
@@ -28,8 +28,8 @@ public class RadarScrapingWorkflow : IWorkflow<RadarResponse>
|
||||
"ClickRadarLink",
|
||||
"WaitForMapReady",
|
||||
"PauseRadar",
|
||||
"ResetToFirstFrame",
|
||||
"ExtractMetadata",
|
||||
"ResetToFirstFrame",
|
||||
"CalculateMapBounds",
|
||||
"CaptureFrames"
|
||||
};
|
||||
@@ -44,8 +44,20 @@ public class RadarScrapingWorkflow : IWorkflow<RadarResponse>
|
||||
_stepRegistry = stepRegistry;
|
||||
_configuration = configuration;
|
||||
_cacheService = cacheService;
|
||||
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 12.5);
|
||||
_cacheManagementCheckIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
|
||||
|
||||
var cacheExpirationMinutesConfig = configuration.GetValue<double?>("CacheExpirationMinutes");
|
||||
if (!cacheExpirationMinutesConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
|
||||
}
|
||||
_cacheExpirationMinutes = cacheExpirationMinutesConfig.Value;
|
||||
|
||||
var cacheManagementCheckIntervalMinutesConfig = configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
|
||||
if (!cacheManagementCheckIntervalMinutesConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
|
||||
}
|
||||
_cacheManagementCheckIntervalMinutes = cacheManagementCheckIntervalMinutesConfig.Value;
|
||||
}
|
||||
|
||||
public async Task<RadarResponse> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace BomLocalService.Services.Scraping.Workflows;
|
||||
/// <summary>
|
||||
/// Future workflow for temperature map scraping (not yet implemented)
|
||||
/// </summary>
|
||||
public class TemperatureMapWorkflow : IWorkflow<RadarResponse>
|
||||
public class TemperatureMapWorkflow : ITemperatureMapWorkflow
|
||||
{
|
||||
private readonly ILogger<TemperatureMapWorkflow> _logger;
|
||||
|
||||
|
||||
@@ -52,9 +52,17 @@ public class SelectorService : ISelectorService
|
||||
{
|
||||
throw new ArgumentException($"No selectors configured for {config.Name}");
|
||||
}
|
||||
|
||||
// Returns first selector as locator (for cases where we know it exists)
|
||||
return page.Locator(config.Selectors[0]).First;
|
||||
|
||||
// Match any configured selector (BOM occasionally renames test ids; list is tried in order)
|
||||
var combined = page.Locator(config.Selectors[0]).First;
|
||||
for (var i = 1; i < config.Selectors.Length; i++)
|
||||
{
|
||||
combined = combined.Or(page.Locator(config.Selectors[i]).First);
|
||||
}
|
||||
|
||||
// Or() unions alternatives; BOM sometimes renders duplicates (e.g. responsive + hidden).
|
||||
// Pin to a single element so Playwright strict mode does not reject clicks.
|
||||
return combined.First;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+97
-108
@@ -14,7 +14,13 @@ public class TimeParsingService : ITimeParsingService
|
||||
public TimeParsingService(ILogger<TimeParsingService> logger, IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
var timezone = configuration.GetValue<string>("Timezone") ?? "Australia/Brisbane";
|
||||
// Get timezone from configuration (default from appsettings.json, can be overridden via TIMEZONE environment variable)
|
||||
var timezone = configuration.GetValue<string>("Timezone");
|
||||
|
||||
if (string.IsNullOrEmpty(timezone))
|
||||
{
|
||||
throw new InvalidOperationException("Timezone configuration is required. Set it in appsettings.json or via TIMEZONE environment variable.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -23,8 +29,8 @@ public class TimeParsingService : ITimeParsingService
|
||||
}
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
_logger.LogWarning("Timezone {Timezone} not found, defaulting to Australia/Brisbane", timezone);
|
||||
_timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Australia/Brisbane");
|
||||
_logger.LogError("Timezone {Timezone} not found. Please use a valid IANA timezone identifier.", timezone);
|
||||
throw new InvalidOperationException($"Invalid timezone '{timezone}'. Please use a valid IANA timezone identifier (e.g., Australia/Sydney, Australia/Brisbane).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,139 +41,111 @@ public class TimeParsingService : ITimeParsingService
|
||||
{
|
||||
try
|
||||
{
|
||||
// Look for the weather metadata section using the correct selector
|
||||
// HTML structure: <section data-testid="weatherMetadata"> with aria-label="Last updated"
|
||||
var metadataSection = page.Locator("section[data-testid='weatherMetadata'], section[aria-label='Last updated']").First;
|
||||
|
||||
// Extract text from each div separately to preserve structure
|
||||
// Structure: <div>Observations: X minutes ago, 9:40 pm AEST</div><div>at Gympie...</div><div>Forecast: ...</div>
|
||||
// BOM layouts: legacy map used inner divs; location/spatial pages often use C07_WeatherMetadata with
|
||||
// a single section and visible text in the section (no div split). Prefer normalized innerText of the
|
||||
// section so both shapes parse the same.
|
||||
var metadataSection = page.Locator(
|
||||
"section[data-testid='weatherMetadata'], " +
|
||||
"section[aria-label='Last updated'], " +
|
||||
"section[data-component='C07_WeatherMetadata']").First;
|
||||
|
||||
var lastUpdatedText = await page.EvaluateAsync<string>(@"() => {
|
||||
const section = document.querySelector('section[data-testid=""weatherMetadata""]') ||
|
||||
document.querySelector('section[aria-label=""Last updated""]');
|
||||
const section =
|
||||
document.querySelector('section[data-testid=""weatherMetadata""]') ||
|
||||
document.querySelector('section[aria-label=""Last updated""]') ||
|
||||
document.querySelector('section[data-component=""C07_WeatherMetadata""]');
|
||||
if (!section) return null;
|
||||
|
||||
// Get all divs and join with newlines to preserve structure
|
||||
const divs = section.querySelectorAll('div');
|
||||
return Array.from(divs).map(div => div.textContent.trim()).filter(text => text).join(' ');
|
||||
const raw = section.innerText || section.textContent || '';
|
||||
return raw.replace(/\s+/g, ' ').trim();
|
||||
}");
|
||||
|
||||
|
||||
if (string.IsNullOrEmpty(lastUpdatedText))
|
||||
{
|
||||
// Fallback: get all text content
|
||||
lastUpdatedText = await metadataSection.TextContentAsync();
|
||||
var fallback = await metadataSection.InnerTextAsync();
|
||||
lastUpdatedText = string.IsNullOrEmpty(fallback)
|
||||
? null
|
||||
: Regex.Replace(fallback, @"\s+", " ").Trim();
|
||||
}
|
||||
|
||||
return ParseLastUpdatedText(lastUpdatedText ?? string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to extract last updated information");
|
||||
return new LastUpdatedInfo
|
||||
{
|
||||
ObservationTime = DateTime.UtcNow,
|
||||
ForecastTime = DateTime.UtcNow
|
||||
};
|
||||
_logger.LogError(ex, "Failed to extract last updated information");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses last updated text to extract observation time, forecast time, weather station, and distance
|
||||
/// Parses actual time strings to UTC timestamps - client calculates "minutes ago" dynamically
|
||||
/// </summary>
|
||||
public LastUpdatedInfo ParseLastUpdatedText(string text)
|
||||
{
|
||||
var info = new LastUpdatedInfo();
|
||||
_logger.LogInformation("Parsing last updated text: {Text}", text);
|
||||
|
||||
// Parse observation time - use "minutes ago" as primary source for accuracy
|
||||
// "Minutes ago" is reliable and doesn't require date guessing
|
||||
// Time string is used only as fallback if "minutes ago" is unavailable
|
||||
var minutesAgoMatch = Regex.Match(text, @"Observations:\s*(\d+)\s*minutes?\s*ago", RegexOptions.IgnoreCase);
|
||||
if (minutesAgoMatch.Success && int.TryParse(minutesAgoMatch.Groups[1].Value, out var minutesAgo))
|
||||
// Parse observation time from time string (e.g., "9:40 pm AEST")
|
||||
// Ignore relative prefix — BOM uses "a minute ago", "6 minutes ago", "an hour ago", "2 hours ago", etc.
|
||||
var observationMatch = Regex.Match(text,
|
||||
@"Observations:\s*(?:a\s+minute\s+ago|\d+\s*minutes?\s+ago|an\s+hour\s+ago|\d+\s*hours?\s+ago)?[,\s]+([\d:]+(?:\s*[ap]m)?)\s+([A-Z]{3,4})(?:at|\s+at|,|$)",
|
||||
RegexOptions.IgnoreCase);
|
||||
if (observationMatch.Success && observationMatch.Groups.Count >= 3 && observationMatch.Groups[1].Success)
|
||||
{
|
||||
// Use "minutes ago" as primary source - it's accurate and doesn't require date guessing
|
||||
info.ObservationTime = DateTime.UtcNow.AddMinutes(-minutesAgo);
|
||||
_logger.LogInformation("Calculated observation time from 'minutes ago': {MinutesAgo} minutes ago = {Time} UTC",
|
||||
minutesAgo, info.ObservationTime);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback to time string parsing only if "minutes ago" is not available
|
||||
var observationMatch = Regex.Match(text, @"Observations:\s*(?:\d+\s*minutes?\s*ago)?[,\s]+([\d:]+(?:\s*[ap]m)?)\s+([A-Z]{3,4})(?:at|\s+at|,|$)", RegexOptions.IgnoreCase);
|
||||
if (observationMatch.Success && observationMatch.Groups.Count >= 3 && observationMatch.Groups[1].Success)
|
||||
var timeStr = observationMatch.Groups[1].Value.Trim();
|
||||
var timezoneStr = observationMatch.Groups[2].Success ? observationMatch.Groups[2].Value.Trim() : null;
|
||||
|
||||
// Clean up timezone string - remove "at" if it got concatenated (e.g., "AESTat" -> "AEST")
|
||||
if (!string.IsNullOrEmpty(timezoneStr) && timezoneStr.EndsWith("at", StringComparison.OrdinalIgnoreCase) && timezoneStr.Length > 2)
|
||||
{
|
||||
var timeStr = observationMatch.Groups[1].Value.Trim();
|
||||
var timezoneStr = observationMatch.Groups[2].Success ? observationMatch.Groups[2].Value.Trim() : null;
|
||||
|
||||
// Clean up timezone string - remove "at" if it got concatenated (e.g., "AESTat" -> "AEST")
|
||||
if (!string.IsNullOrEmpty(timezoneStr) && timezoneStr.EndsWith("at", StringComparison.OrdinalIgnoreCase) && timezoneStr.Length > 2)
|
||||
{
|
||||
timezoneStr = timezoneStr.Substring(0, timezoneStr.Length - 2);
|
||||
}
|
||||
|
||||
if (TryParseTimeString(timeStr, timezoneStr, out var observationTime))
|
||||
{
|
||||
info.ObservationTime = observationTime;
|
||||
_logger.LogInformation("Used time string fallback for observation time: {Time} UTC (from '{TimeStr}' {TzStr})",
|
||||
observationTime, timeStr, timezoneStr ?? "default timezone");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to parse observation time from both 'minutes ago' and time string, using current time");
|
||||
info.ObservationTime = DateTime.UtcNow;
|
||||
}
|
||||
timezoneStr = timezoneStr.Substring(0, timezoneStr.Length - 2);
|
||||
}
|
||||
|
||||
if (TryParseTimeString(timeStr, timezoneStr, out var observationTime))
|
||||
{
|
||||
info.ObservationTime = observationTime;
|
||||
_logger.LogInformation("Parsed observation time: {Time} UTC (from '{TimeStr}' {TzStr})",
|
||||
observationTime, timeStr, timezoneStr ?? "default timezone");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No observation time found in text, using current time");
|
||||
info.ObservationTime = DateTime.UtcNow;
|
||||
_logger.LogError("Failed to parse observation time from time string '{TimeStr}' with timezone '{TzStr}'", timeStr, timezoneStr ?? "default");
|
||||
throw new InvalidOperationException($"Failed to parse observation time from text: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
// Parse forecast time - use "minutes ago" or "an hour ago" as primary source
|
||||
// Handle both "X minutes ago" and "an hour ago" formats
|
||||
var forecastMinutesAgoMatch = Regex.Match(text, @"Forecast:\s*(\d+)\s*minutes?\s*ago", RegexOptions.IgnoreCase);
|
||||
var forecastHourAgoMatch = Regex.Match(text, @"Forecast:\s*an\s+hour\s+ago", RegexOptions.IgnoreCase);
|
||||
|
||||
if (forecastMinutesAgoMatch.Success && int.TryParse(forecastMinutesAgoMatch.Groups[1].Value, out var forecastMinutesAgo))
|
||||
{
|
||||
// Use "minutes ago" as primary source
|
||||
info.ForecastTime = DateTime.UtcNow.AddMinutes(-forecastMinutesAgo);
|
||||
_logger.LogInformation("Calculated forecast time from 'minutes ago': {MinutesAgo} minutes ago = {Time} UTC",
|
||||
forecastMinutesAgo, info.ForecastTime);
|
||||
}
|
||||
else if (forecastHourAgoMatch.Success)
|
||||
{
|
||||
// Handle "an hour ago" format
|
||||
info.ForecastTime = DateTime.UtcNow.AddHours(-1);
|
||||
_logger.LogInformation("Calculated forecast time from 'an hour ago': {Time} UTC", info.ForecastTime);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback to time string parsing only if "minutes ago" or "an hour ago" is not available
|
||||
var forecastMatch = Regex.Match(text, @"Forecast:\s*(?:an\s+hour\s+ago|\d+\s*minutes?\s*ago)?[,\s]*([\d:]+(?:\s*[ap]m)?)\s+([A-Z]+)(?:\s+at|$)", RegexOptions.IgnoreCase);
|
||||
if (forecastMatch.Success && forecastMatch.Groups[1].Success)
|
||||
_logger.LogError("No observation time found in text: {Text}", text);
|
||||
throw new InvalidOperationException($"Could not find observation time in text: {text}");
|
||||
}
|
||||
|
||||
// Parse forecast time from time string (e.g., "7:50 pm AEST")
|
||||
// Ignore relative prefix (minutes/hours ago) — client derives "ago" from the UTC timestamp
|
||||
var forecastMatch = Regex.Match(text,
|
||||
@"Forecast:\s*(?:\d+\s*hours?\s+ago|an\s+hour\s+ago|\d+\s*minutes?\s*ago)?[,\s]*([\d:]+(?:\s*[ap]m)?)\s+([A-Z]+)(?:\s+at|$)",
|
||||
RegexOptions.IgnoreCase);
|
||||
if (forecastMatch.Success && forecastMatch.Groups[1].Success)
|
||||
{
|
||||
var timeStr = forecastMatch.Groups[1].Value.Trim();
|
||||
var timezoneStr = forecastMatch.Groups[2].Success ? forecastMatch.Groups[2].Value.Trim() : null;
|
||||
|
||||
if (TryParseTimeString(timeStr, timezoneStr, out var forecastTime))
|
||||
{
|
||||
var timeStr = forecastMatch.Groups[1].Value.Trim();
|
||||
var timezoneStr = forecastMatch.Groups[2].Success ? forecastMatch.Groups[2].Value.Trim() : null;
|
||||
|
||||
if (TryParseTimeString(timeStr, timezoneStr, out var forecastTime))
|
||||
{
|
||||
info.ForecastTime = forecastTime;
|
||||
_logger.LogInformation("Used time string fallback for forecast time: {Time} UTC (from '{TimeStr}' {TzStr})",
|
||||
forecastTime, timeStr, timezoneStr ?? "default timezone");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to parse forecast time from both 'minutes ago' and time string, using current time");
|
||||
info.ForecastTime = DateTime.UtcNow;
|
||||
}
|
||||
info.ForecastTime = forecastTime;
|
||||
_logger.LogInformation("Parsed forecast time: {Time} UTC (from '{TimeStr}' {TzStr})",
|
||||
forecastTime, timeStr, timezoneStr ?? "default timezone");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No forecast time found in text, using current time");
|
||||
info.ForecastTime = DateTime.UtcNow;
|
||||
_logger.LogError("Failed to parse forecast time from time string '{TimeStr}' with timezone '{TzStr}'", timeStr, timezoneStr ?? "default");
|
||||
throw new InvalidOperationException($"Failed to parse forecast time from text: {text}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("No forecast time found in text: {Text}", text);
|
||||
throw new InvalidOperationException($"Could not find forecast time in text: {text}");
|
||||
}
|
||||
|
||||
// Extract weather station name
|
||||
var stationMatch = Regex.Match(text, @"at\s+([^,]+)\s+weather\s+station", RegexOptions.IgnoreCase);
|
||||
@@ -220,20 +198,32 @@ public class TimeParsingService : ITimeParsingService
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the timezone - use configured timezone if not specified or if it's AEST/AEDT
|
||||
// Determine the timezone - map AEST/AEDT based on what the BOM website displays
|
||||
// Note: The BOM website shows the timezone abbreviation for the location being viewed,
|
||||
// NOT the browser's configured timezone. The browser timezone only affects JavaScript Date operations.
|
||||
TimeZoneInfo tz = _timeZoneInfo;
|
||||
if (!string.IsNullOrEmpty(timezoneStr))
|
||||
{
|
||||
// AEST/AEDT are typically the same as Australia/Brisbane or Australia/Sydney
|
||||
// Both Brisbane and Sydney use AEST/AEDT
|
||||
if (timezoneStr.Contains("AEST", StringComparison.OrdinalIgnoreCase) ||
|
||||
timezoneStr.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
|
||||
// Map timezone abbreviations to actual timezones:
|
||||
// - "AEST" = UTC+10 (Australian Eastern Standard Time)
|
||||
// Used by Brisbane year-round, and by Sydney/Melbourne during winter
|
||||
// Since both are UTC+10 when showing AEST, we use Brisbane timezone (UTC+10 year-round)
|
||||
// - "AEDT" = UTC+11 (Australian Eastern Daylight Time)
|
||||
// Used by Sydney/Melbourne during summer (Oct-Apr), never Brisbane
|
||||
// We use Sydney timezone which handles the UTC+11 offset correctly
|
||||
if (timezoneStr.Contains("AEST", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
tz = _timeZoneInfo; // Use configured timezone
|
||||
// AEST maps to Brisbane timezone (UTC+10) - works for both Brisbane and Sydney in winter
|
||||
tz = TimeZoneInfo.FindSystemTimeZoneById("Australia/Brisbane");
|
||||
}
|
||||
else if (timezoneStr.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// AEDT maps to Sydney timezone (UTC+11) - only appears in summer for Sydney/Melbourne
|
||||
tz = TimeZoneInfo.FindSystemTimeZoneById("Australia/Sydney");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try to find the timezone, fallback to configured
|
||||
// Try to find the timezone by name, fallback to configured browser timezone
|
||||
try
|
||||
{
|
||||
tz = TimeZoneInfo.FindSystemTimeZoneById(timezoneStr);
|
||||
@@ -250,8 +240,7 @@ public class TimeParsingService : ITimeParsingService
|
||||
var dateTimeInTz = nowInTz.Date.Add(localTime.TimeOfDay);
|
||||
|
||||
// Only adjust to yesterday if the time is clearly in the future
|
||||
// Don't use the 12-hour threshold - it's too aggressive for recent observations
|
||||
// This method is now only used as fallback when "minutes ago" is unavailable
|
||||
// This ensures we get the correct date for the observation/forecast time
|
||||
if (dateTimeInTz > nowInTz)
|
||||
{
|
||||
// Time is in the future, so it must be from yesterday
|
||||
|
||||
@@ -14,8 +14,12 @@ public static class CacheHelper
|
||||
public static int GetFrameCountForDataType(IConfiguration configuration, CachedDataType dataType)
|
||||
{
|
||||
var dataTypeName = dataType.ToString();
|
||||
var frameCount = configuration.GetValue<int>($"CachedDataTypes:{dataTypeName}:FrameCount", 7);
|
||||
return frameCount;
|
||||
var frameCountConfig = configuration.GetValue<int?>($"CachedDataTypes:{dataTypeName}:FrameCount");
|
||||
if (!frameCountConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException($"CachedDataTypes:{dataTypeName}:FrameCount configuration is required. Set it in appsettings.json or via CACHEDDATATYPES__{dataTypeName.ToUpperInvariant()}__FRAMECOUNT environment variable.");
|
||||
}
|
||||
return frameCountConfig.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -71,8 +75,20 @@ public static class CacheHelper
|
||||
{
|
||||
// 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);
|
||||
|
||||
var tileRenderWaitMsConfig = configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
|
||||
if (!tileRenderWaitMsConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Screenshot:TileRenderWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__TILERENDERWAITMS environment variable.");
|
||||
}
|
||||
var tileRenderWaitMs = tileRenderWaitMsConfig.Value;
|
||||
|
||||
var dynamicContentWaitMsConfig = configuration.GetValue<int?>("Screenshot:DynamicContentWaitMs");
|
||||
if (!dynamicContentWaitMsConfig.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Screenshot:DynamicContentWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__DYNAMICCONTENTWAITMS environment variable.");
|
||||
}
|
||||
var dynamicContentWaitMs = dynamicContentWaitMsConfig.Value;
|
||||
|
||||
// Rough calculation:
|
||||
// - Initial page load and navigation: ~10-15 seconds
|
||||
|
||||
@@ -5,12 +5,12 @@ namespace BomLocalService.Utilities;
|
||||
public static class FilePathHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the cache directory path from configuration or returns default
|
||||
/// Gets the cache directory path from configuration (default from appsettings.json, can be overridden via CACHEDIRECTORY environment variable)
|
||||
/// </summary>
|
||||
public static string GetCacheDirectory(IConfiguration configuration)
|
||||
{
|
||||
return configuration.GetValue<string>("CacheDirectory")
|
||||
?? Path.Combine(AppContext.BaseDirectory, "cache");
|
||||
return configuration.GetValue<string>("CacheDirectory")
|
||||
?? throw new InvalidOperationException("CacheDirectory configuration is required. Set it in appsettings.json or via CACHEDIRECTORY environment variable.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -63,19 +63,8 @@ public static class ResponseBuilder
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate AbsoluteObservationTime for all frames if metadata is available
|
||||
// AbsoluteObservationTime is already set during capture, no calculation needed
|
||||
// This ensures consistency with the time series endpoint
|
||||
if (metadata != null && metadata.ObservationTime > DateTime.MinValue.AddYears(1))
|
||||
{
|
||||
foreach (var frame in frames)
|
||||
{
|
||||
// Only calculate if not already set and MinutesAgo is valid
|
||||
if (!frame.AbsoluteObservationTime.HasValue && frame.MinutesAgo >= 0)
|
||||
{
|
||||
frame.AbsoluteObservationTime = metadata.ObservationTime.AddMinutes(-frame.MinutesAgo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate NextUpdateTime based on cache status:
|
||||
// - If cache is valid: NextUpdateTime = max(CacheExpiresAt, next background service check)
|
||||
|
||||
+4
-2127
File diff suppressed because it is too large
Load Diff
+15
-11
@@ -18,7 +18,6 @@
|
||||
"CacheManagement": {
|
||||
"CheckIntervalMinutes": 5,
|
||||
"InitialDelaySeconds": 10,
|
||||
"UpdateStaggerSeconds": 2,
|
||||
"LocationStaggerSeconds": 1
|
||||
},
|
||||
"CacheCleanup": {
|
||||
@@ -62,11 +61,9 @@
|
||||
"Name": "Search Button",
|
||||
"Selectors": [
|
||||
"button[data-testid='searchLabel']",
|
||||
"button[aria-label='Search for a location']",
|
||||
"button.search-location__trigger-button",
|
||||
"button.bom-button:has(span.bom-button__label:has-text('Search for a location'))",
|
||||
"button:has-text('Search for a location')",
|
||||
"[data-testid='showSearchModal'] button"
|
||||
"button[aria-label='Search for a location'].search-location__trigger-button",
|
||||
"button.search-location__trigger-button.bom-button:has(span.bom-button__label:has-text('Search for a location'))"
|
||||
],
|
||||
"TimeoutMs": 10000,
|
||||
"Required": true,
|
||||
@@ -138,7 +135,10 @@
|
||||
},
|
||||
"PlayPauseButton": {
|
||||
"Name": "Play Pause Button",
|
||||
"Selectors": ["button[data-testid='bom-time-scrub-play-pause']"],
|
||||
"Selectors": [
|
||||
"button[data-testid='bom-time-scrub-step-through']",
|
||||
"button[data-testid='bom-time-scrub-play-pause']"
|
||||
],
|
||||
"TimeoutMs": 5000,
|
||||
"Required": true
|
||||
},
|
||||
@@ -156,7 +156,11 @@
|
||||
},
|
||||
"StepForwardButton": {
|
||||
"Name": "Step Forward Button",
|
||||
"Selectors": ["button[data-testid='bom-scrub-utils__right__step-forward']"],
|
||||
"Selectors": [
|
||||
"button[data-testid='bom-time-scrub-step-through']",
|
||||
"button.bom-scrub-action--step-through",
|
||||
"button[data-testid='bom-scrub-utils__right__step-forward']"
|
||||
],
|
||||
"TimeoutMs": 5000,
|
||||
"Required": true
|
||||
},
|
||||
@@ -185,9 +189,9 @@
|
||||
"ExtractSearchResultsFallback": "() => { const results = Array.from(document.querySelectorAll('li.bom-linklist__item[role=\"listitem\"]')); return results.map(r => r.textContent || ''); }",
|
||||
"WaitForMapCanvas": "() => { const canvas = document.querySelector('.esri-view-surface canvas'); return canvas && canvas.width > 0 && canvas.height > 0 && canvas.offsetWidth > 0 && canvas.offsetHeight > 0; }",
|
||||
"WaitForEsriView": "() => { try { const elements = document.querySelectorAll('.esri-view'); for (let el of elements) { if (el.__view && el.__view.ready) { return true; } } } catch(e) {} return false; }",
|
||||
"CheckActiveFrameSegment": "() => { const segments = Array.from(document.querySelectorAll('[data-testid=\"bom-scrub-segment\"]')); const activeSegment = segments.find(s => { const style = window.getComputedStyle(s); return style.backgroundColor !== 'rgb(148, 148, 148)' && style.backgroundColor !== 'rgb(148, 148, 148)'; }); return activeSegment && activeSegment.getAttribute('data-id') === '0'; }",
|
||||
"CheckActiveFrameSegment": "() => { const segments = Array.from(document.querySelectorAll('[data-testid=\"bom-scrub-segment\"]')); if (segments.length === 0) return true; const activeSegment = segments.find(s => { const style = window.getComputedStyle(s); return style.backgroundColor !== 'rgb(148, 148, 148)' && style.backgroundColor !== 'rgb(148, 148, 148)'; }); return activeSegment && activeSegment.getAttribute('data-id') === '0'; }",
|
||||
"WaitForMapContainer": "() => { const container = document.querySelector('.esri-view-surface'); return container && container.offsetWidth > 0 && container.offsetHeight > 0; }",
|
||||
"ExtractFrameInfo": "() => { const segments = Array.from(document.querySelectorAll('[data-testid=\"bom-scrub-segment\"]')); const now = new Date(); return segments.map((seg, index) => { const ariaLabel = seg.getAttribute('aria-label') || ''; let minutes = null; const timestampMatch = ariaLabel.match(/(?:[A-Za-z]+\\s+)?(\\d{1,2})\\s+([A-Za-z]{3}),?\\s+(\\d{1,2}):(\\d{2})\\s+(am|pm)/i); if (timestampMatch) { try { const day = parseInt(timestampMatch[1]); const monthStr = timestampMatch[2]; const hour12 = parseInt(timestampMatch[3]); const minute = parseInt(timestampMatch[4]); const ampm = timestampMatch[5].toLowerCase(); const monthNames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const month = monthNames.indexOf(monthStr.toLowerCase()); if (month >= 0) { let hour24 = hour12; if (ampm === 'pm' && hour12 !== 12) hour24 += 12; if (ampm === 'am' && hour12 === 12) hour24 = 0; const year = now.getFullYear(); const frameTime = new Date(year, month, day, hour24, minute); if (frameTime > now) frameTime.setFullYear(year - 1); const diffMs = now - frameTime; minutes = Math.round(diffMs / (1000 * 60)); if (minutes < 0 || minutes > 120) minutes = null; } } catch(e) {} } return { index: index, minutesAgo: minutes }; }); }",
|
||||
"ExtractFrameInfo": "() => { const segments = Array.from(document.querySelectorAll('[data-testid=\"bom-scrub-segment\"]')); const now = new Date(); return segments.map((seg, index) => { const ariaLabel = seg.getAttribute('aria-label') || ''; let minutes = null; const timestampMatch = ariaLabel.match(/(?:[A-Za-z]+\\s+)?(\\d{1,2})\\s+([A-Za-z]+),?\\s+(\\d{1,2}):(\\d{2})\\s*(am|pm)/i); if (timestampMatch) { try { const day = parseInt(timestampMatch[1]); const monthStr = timestampMatch[2]; const hour12 = parseInt(timestampMatch[3]); const minute = parseInt(timestampMatch[4]); const ampm = timestampMatch[5].toLowerCase(); const monthNames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const month = monthNames.indexOf(monthStr.toLowerCase()); if (month >= 0) { let hour24 = hour12; if (ampm === 'pm' && hour12 !== 12) hour24 += 12; if (ampm === 'am' && hour12 === 12) hour24 = 0; const year = now.getFullYear(); const frameTime = new Date(year, month, day, hour24, minute); if (frameTime > now) frameTime.setFullYear(year - 1); const diffMs = now - frameTime; minutes = Math.round(diffMs / (1000 * 60)); if (minutes < 0 || minutes > 120) minutes = null; } } catch(e) {} } return { index: index, minutesAgo: minutes }; }); }",
|
||||
"CheckModalOverlay": "() => { const bomOverlay = document.querySelector('.bom-modal-overlay--after-open'); if (bomOverlay && bomOverlay.style.display !== 'none') { return true; } const recaptchaSelectors = ['.g-recaptcha', '#g-recaptcha', '.rc-anchor-container']; for (const selector of recaptchaSelectors) { const el = document.querySelector(selector); if (el) { const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); if (style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 200 && rect.height > 200) { return true; } } } const allForms = document.querySelectorAll('form'); for (const form of allForms) { const action = form.getAttribute('action') || ''; const id = form.getAttribute('id') || ''; if (action.includes('feedback') || id.includes('feedback')) { const style = window.getComputedStyle(form); const rect = form.getBoundingClientRect(); if (style.display !== 'none' && rect.width > 200 && rect.height > 200) { const text = form.textContent || ''; if (text.includes('reCAPTCHA') || text.includes('recaptcha') || text.includes('Tell us why')) { return true; } } } } return false; }",
|
||||
"CheckModalStillVisible": "() => { const bomOverlay = document.querySelector('.bom-modal-overlay--after-open'); if (bomOverlay && bomOverlay.style.display !== 'none') return true; const allForms = document.querySelectorAll('form'); for (const form of allForms) { const action = form.getAttribute('action') || ''; const id = form.getAttribute('id') || ''; if (action.includes('feedback') || id.includes('feedback')) { const style = window.getComputedStyle(form); const rect = form.getBoundingClientRect(); if (style.display !== 'none' && rect.width > 200 && rect.height > 200) { return true; } } } return false; }",
|
||||
"GetViewportSize": "() => JSON.stringify({ width: window.innerWidth, height: window.innerHeight })",
|
||||
@@ -195,7 +199,7 @@
|
||||
},
|
||||
"TextPatterns": {
|
||||
"ResultsCountPattern": "(\\d+)\\s+of\\s+(\\d+)",
|
||||
"TimestampPattern": "(?:[A-Za-z]+\\s+)?\\d{1,2}\\s+[A-Za-z]{3},?\\s+\\d{1,2}:\\d{2}\\s+(?:am|pm)",
|
||||
"TimestampPattern": "(?:[A-Za-z]+\\s+)?\\d{1,2}\\s+[A-Za-z]+,?\\s+\\d{1,2}:\\d{2}\\s*(?:am|pm)",
|
||||
"ObservationTimePattern": "Observations:\\s*(\\d+)\\s*minutes?\\s*ago",
|
||||
"ForecastTimePattern": "Forecast:\\s*(\\d+)\\s*minutes?\\s+ago",
|
||||
"ForecastHourAgoPattern": "Forecast:\\s*an\\s+hour\\s+ago",
|
||||
@@ -219,8 +223,8 @@
|
||||
"ClickRadarLink": { "Enabled": true },
|
||||
"WaitForMapReady": { "Enabled": true },
|
||||
"PauseRadar": { "Enabled": true },
|
||||
"ResetToFirstFrame": { "Enabled": true },
|
||||
"ExtractMetadata": { "Enabled": true },
|
||||
"ResetToFirstFrame": { "Enabled": true },
|
||||
"CalculateMapBounds": { "Enabled": true },
|
||||
"CaptureFrames": {
|
||||
"Enabled": true,
|
||||
|
||||
@@ -32,7 +32,6 @@ services:
|
||||
- CACHEEXPIRATIONMINUTES=${CACHEEXPIRATIONMINUTES:-12.5}
|
||||
- CACHEMANAGEMENT__CHECKINTERVALMINUTES=${CACHEMANAGEMENT__CHECKINTERVALMINUTES:-5}
|
||||
- CACHEMANAGEMENT__INITIALDELAYSECONDS=${CACHEMANAGEMENT__INITIALDELAYSECONDS:-10}
|
||||
- CACHEMANAGEMENT__UPDATESTAGGERSECONDS=${CACHEMANAGEMENT__UPDATESTAGGERSECONDS:-2}
|
||||
- CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS=${CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS:-1}
|
||||
- CACHECLEANUP__INTERVALHOURS=${CACHECLEANUP__INTERVALHOURS:-1}
|
||||
- SCREENSHOT__DYNAMICCONTENTWAITMS=${SCREENSHOT__DYNAMICCONTENTWAITMS:-2000}
|
||||
|
||||
+72
-9
@@ -1,29 +1,92 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Function to cleanup stale Xvfb processes and lock files
|
||||
cleanup_xvfb() {
|
||||
# Kill any stale Xvfb processes for display :99
|
||||
pkill -f "Xvfb :99" 2>/dev/null || true
|
||||
sleep 0.5
|
||||
|
||||
# Remove stale lock files
|
||||
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Function to check if Xvfb is actually functional
|
||||
check_xvfb_working() {
|
||||
# Check if process exists and is running (not zombie)
|
||||
local pid=$(pgrep -f "Xvfb :99" | head -n1)
|
||||
if [ -z "$pid" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if process is actually running (not a zombie)
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to start Xvfb
|
||||
start_xvfb() {
|
||||
if ! pgrep -f "Xvfb :99" > /dev/null; then
|
||||
# Clean up any stale processes/locks first
|
||||
cleanup_xvfb
|
||||
|
||||
if ! check_xvfb_working; then
|
||||
echo "Starting Xvfb on display :99..."
|
||||
Xvfb :99 -screen 0 1920x1080x24 -ac +extension GLX +render -noreset > /dev/null 2>&1 &
|
||||
sleep 1
|
||||
if ! pgrep -f "Xvfb :99" > /dev/null; then
|
||||
sleep 1.5
|
||||
|
||||
if check_xvfb_working; then
|
||||
echo "Xvfb started successfully"
|
||||
return 0
|
||||
else
|
||||
echo "ERROR: Xvfb failed to start" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Xvfb started successfully"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to monitor and restart Xvfb if it dies (runs as background process)
|
||||
monitor_xvfb() {
|
||||
local consecutive_failures=0
|
||||
local max_backoff=60 # Maximum backoff in seconds
|
||||
local backoff_seconds=5
|
||||
|
||||
# Monitor while PID 1 (dotnet after exec) is still running
|
||||
while kill -0 1 2>/dev/null; do
|
||||
sleep 5
|
||||
if ! pgrep -f "Xvfb :99" > /dev/null; then
|
||||
echo "WARNING: Xvfb process not found, restarting..." >&2
|
||||
start_xvfb || true
|
||||
sleep $backoff_seconds
|
||||
|
||||
if ! check_xvfb_working; then
|
||||
consecutive_failures=$((consecutive_failures + 1))
|
||||
|
||||
if [ $consecutive_failures -eq 1 ]; then
|
||||
echo "WARNING: Xvfb not working, attempting restart..." >&2
|
||||
elif [ $consecutive_failures -le 3 ]; then
|
||||
echo "WARNING: Xvfb restart attempt $consecutive_failures..." >&2
|
||||
elif [ $((consecutive_failures % 10)) -eq 0 ]; then
|
||||
echo "ERROR: Xvfb has failed to start $consecutive_failures times. Display may be unavailable." >&2
|
||||
fi
|
||||
|
||||
if start_xvfb; then
|
||||
consecutive_failures=0
|
||||
backoff_seconds=5
|
||||
else
|
||||
# Exponential backoff, capped at max_backoff
|
||||
backoff_seconds=$((backoff_seconds * 2))
|
||||
if [ $backoff_seconds -gt $max_backoff ]; then
|
||||
backoff_seconds=$max_backoff
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# Xvfb is working, reset failure counter
|
||||
if [ $consecutive_failures -gt 0 ]; then
|
||||
echo "Xvfb is now working" >&2
|
||||
consecutive_failures=0
|
||||
backoff_seconds=5
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
@@ -31,7 +94,7 @@ monitor_xvfb() {
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
echo "Shutting down Xvfb..."
|
||||
pkill -f "Xvfb :99" || true
|
||||
cleanup_xvfb
|
||||
}
|
||||
|
||||
# Set up signal handlers for cleanup
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
.info-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.info-section {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.info-section {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.radar-section {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-image-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 15px;
|
||||
aspect-ratio: 16/9;
|
||||
min-height: 250px; /* Smaller on mobile */
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.radar-image-container {
|
||||
margin-bottom: 20px;
|
||||
min-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
image-rendering: crisp-edges; /* Prevent blurry scaling */
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.frame-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.frame-controls {
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.frame-btn {
|
||||
padding: 10px 20px;
|
||||
border: 2px solid #667eea;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.frame-btn:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.frame-btn.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.frame-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.play-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-bottom: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.play-controls {
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s;
|
||||
min-height: 44px; /* Touch target size */
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.play-btn {
|
||||
flex: 0 1 auto;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.play-btn {
|
||||
padding: 12px 30px;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
background: #5568d3;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.play-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.info-card {
|
||||
padding: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-card h3 {
|
||||
font-size: 0.8em;
|
||||
text-transform: uppercase;
|
||||
color: #666;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.info-card h3 {
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-card .value {
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.info-card .value {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.status-valid {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-invalid {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status-updating {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin: 20px;
|
||||
border-left: 4px solid #dc3545;
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
|
||||
.error.retrying {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border-left-color: #ffc107;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.refresh-indicator {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #28a745;
|
||||
margin-right: 8px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.frame-info {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/* Frame Slider Controls */
|
||||
.frame-slider-container {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 15px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||
border-radius: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.frame-slider-container {
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.frame-slider-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.frame-slider-wrapper {
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.frame-slider-wrapper {
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.frame-nav-btn {
|
||||
padding: 10px 16px;
|
||||
border: 2px solid #667eea;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s;
|
||||
min-width: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.frame-nav-btn:hover:not(:disabled) {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.frame-nav-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.frame-nav-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
background: #f0f0f0;
|
||||
border-color: #d0d0d0;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.frame-slider {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: #e0e0e0;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
width: 100%;
|
||||
order: 3; /* Put slider in middle on mobile when wrapped */
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.frame-slider {
|
||||
min-width: 100px;
|
||||
order: 0; /* Reset order for larger screens */
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.frame-slider {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.frame-slider:hover {
|
||||
background: #d0d0d0;
|
||||
}
|
||||
|
||||
/* Webkit (Chrome, Safari, Edge) */
|
||||
.frame-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
|
||||
transition: all 0.2s;
|
||||
border: 3px solid white;
|
||||
}
|
||||
|
||||
.frame-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.frame-slider::-webkit-slider-thumb:active {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Track fill for webkit */
|
||||
.frame-slider::-webkit-slider-runnable-track {
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
.frame-slider::-moz-range-thumb {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
cursor: pointer;
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.frame-slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.frame-slider::-moz-range-thumb:active {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.frame-slider::-moz-range-track {
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.frame-slider::-moz-range-progress {
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.frame-info-display {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.frame-info-display {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.frame-info-display {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.frame-info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.frame-info-item {
|
||||
gap: 8px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
}
|
||||
|
||||
.frame-info-label {
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
font-size: 0.8em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.frame-info-label {
|
||||
font-size: 0.9em;
|
||||
min-width: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.frame-info-value {
|
||||
color: #333;
|
||||
font-weight: 700;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.frame-info-value {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
}
|
||||
|
||||
.frame-jump-input {
|
||||
width: 70px;
|
||||
padding: 10px 12px;
|
||||
border: 2px solid #667eea;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
background: white;
|
||||
transition: all 0.2s;
|
||||
min-height: 44px; /* Touch target size */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.frame-jump-input {
|
||||
padding: 8px 12px;
|
||||
font-size: 1em;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.frame-jump-input:focus {
|
||||
outline: none;
|
||||
border-color: #764ba2;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
|
||||
background: #f8f9ff;
|
||||
}
|
||||
|
||||
.frame-jump-input::-webkit-inner-spin-button,
|
||||
.frame-jump-input::-webkit-outer-spin-button {
|
||||
opacity: 1;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
body {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header {
|
||||
padding: 30px;
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 700;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.header .subtitle {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header .subtitle {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-header-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 44px; /* Touch target size */
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.settings-header-btn {
|
||||
padding: 12px 24px;
|
||||
font-size: 1em;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-header-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.content {
|
||||
gap: 20px;
|
||||
padding: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
@import url('./reset.css');
|
||||
@import url('./layout.css');
|
||||
@import url('./components.css');
|
||||
@import url('./frame-slider.css');
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
// API client for fetching radar data
|
||||
import { API_BASE } from './config.js';
|
||||
import { formatDate, isNetworkError, isNetworkErrorResponse, createCacheStatusData, triggerBackgroundRefresh } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
/**
|
||||
* Fetch cache range information
|
||||
*/
|
||||
export async function fetchCacheRange() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE.replace('/api/radar', '/api/cache')}/range`, {
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (isNetworkErrorResponse(response)) {
|
||||
throw new Error('Network error');
|
||||
}
|
||||
console.warn('Failed to fetch cache range');
|
||||
return { data: null, error: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
state.cacheRangeInfo = data;
|
||||
|
||||
// Update UI
|
||||
const rangeTextEl = document.getElementById('cache-range-text');
|
||||
if (rangeTextEl) {
|
||||
if (data.totalCacheFolders > 0) {
|
||||
const spanHours = data.timeSpanMinutes ? Math.round(data.timeSpanMinutes / 60 * 10) / 10 : 0;
|
||||
rangeTextEl.innerHTML = `
|
||||
<div>Oldest: ${formatDate(data.oldestCache.cacheTimestamp)}</div>
|
||||
<div>Newest: ${formatDate(data.newestCache.cacheTimestamp)}</div>
|
||||
<div>Total: ${data.totalCacheFolders} cache folders (${spanHours} hours)</div>
|
||||
`;
|
||||
} else {
|
||||
rangeTextEl.textContent = 'No cache data available';
|
||||
}
|
||||
}
|
||||
|
||||
return { data: data, error: null };
|
||||
} catch (error) {
|
||||
console.error('Error fetching cache range:', error);
|
||||
const rangeTextEl = document.getElementById('cache-range-text');
|
||||
if (rangeTextEl) {
|
||||
rangeTextEl.textContent = 'Unable to load cache range';
|
||||
}
|
||||
|
||||
if (isNetworkError(error)) {
|
||||
return { data: null, error: 'network' };
|
||||
}
|
||||
return { data: null, error: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch historical radar data for extended timespan
|
||||
*/
|
||||
export async function fetchHistoricalRadar(startTime, endTime) {
|
||||
try {
|
||||
let url = `${API_BASE}/timeseries`;
|
||||
const params = new URLSearchParams();
|
||||
if (startTime) params.append('startTime', startTime.toISOString());
|
||||
if (endTime) params.append('endTime', endTime.toISOString());
|
||||
if (params.toString()) url += '?' + params.toString();
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(30000) // Longer timeout for potentially large responses
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: `HTTP ${response.status}` }));
|
||||
|
||||
// Handle 400 Bad Request
|
||||
if (response.status === 400) {
|
||||
const errorMessage = errorData.message || 'Invalid request';
|
||||
let 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);
|
||||
}
|
||||
|
||||
// Handle 404 Not Found
|
||||
if (response.status === 404) {
|
||||
if (errorData.errorCode === 'CACHE_NOT_FOUND') {
|
||||
triggerBackgroundRefresh(errorData.suggestions?.refreshEndpoint);
|
||||
return {
|
||||
frames: null,
|
||||
error: 'location_missing',
|
||||
cacheStatus: errorData.details || errorData
|
||||
};
|
||||
} else if (errorData.errorCode === 'TIME_RANGE_ERROR' && errorData.details?.availableRange) {
|
||||
const availableRange = errorData.details.availableRange;
|
||||
let rangeMessage = '';
|
||||
|
||||
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.`;
|
||||
}
|
||||
|
||||
if (errorData.suggestions?.suggestion) {
|
||||
rangeMessage += ` ${errorData.suggestions.suggestion}`;
|
||||
}
|
||||
|
||||
throw new Error(errorData.message + rangeMessage);
|
||||
} else {
|
||||
throw new Error(errorData.message || 'No historical data found');
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(errorData.message || 'Failed to fetch historical radar');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
state.historicalData = data;
|
||||
|
||||
// Flatten all frames from all cache folders
|
||||
const allFrames = [];
|
||||
data.cacheFolders.forEach(cacheFolder => {
|
||||
cacheFolder.frames.forEach(frame => {
|
||||
frame.cacheTimestamp = cacheFolder.cacheTimestamp;
|
||||
frame.observationTime = cacheFolder.observationTime;
|
||||
frame.cacheFolderName = cacheFolder.cacheFolderName;
|
||||
allFrames.push(frame);
|
||||
});
|
||||
});
|
||||
|
||||
// Filter out frames without absoluteObservationTime
|
||||
const invalidFrames = allFrames.filter(frame => !frame.absoluteObservationTime);
|
||||
if (invalidFrames.length > 0) {
|
||||
console.error('Backend returned frames missing absoluteObservationTime:', {
|
||||
count: invalidFrames.length,
|
||||
frames: invalidFrames.map((f, idx) => ({
|
||||
index: idx,
|
||||
frameIndex: f.frameIndex,
|
||||
cacheFolder: f.cacheFolderName,
|
||||
imageUrl: f.imageUrl
|
||||
}))
|
||||
});
|
||||
}
|
||||
const validFrames = allFrames.filter(frame => frame.absoluteObservationTime);
|
||||
|
||||
// Validate chronological order
|
||||
let previousTime = null;
|
||||
const outOfOrderFrames = [];
|
||||
validFrames.forEach((frame, idx) => {
|
||||
const currentTime = new Date(frame.absoluteObservationTime).getTime();
|
||||
if (previousTime !== null && currentTime < previousTime) {
|
||||
outOfOrderFrames.push({
|
||||
index: idx,
|
||||
frameIndex: frame.frameIndex,
|
||||
cacheFolder: frame.cacheFolderName,
|
||||
absoluteTime: frame.absoluteObservationTime,
|
||||
previousTime: new Date(previousTime).toISOString(),
|
||||
timeDiffMinutes: (currentTime - previousTime) / 1000 / 60
|
||||
});
|
||||
}
|
||||
previousTime = currentTime;
|
||||
});
|
||||
|
||||
if (outOfOrderFrames.length > 0) {
|
||||
console.error('Backend returned frames out of chronological order:', {
|
||||
count: outOfOrderFrames.length,
|
||||
issues: outOfOrderFrames
|
||||
});
|
||||
}
|
||||
|
||||
// Re-index frames sequentially for display
|
||||
validFrames.forEach((frame, idx) => {
|
||||
frame.sequentialIndex = idx;
|
||||
});
|
||||
|
||||
return { frames: validFrames, error: null };
|
||||
} catch (error) {
|
||||
console.error('Error fetching historical radar:', error);
|
||||
if (isNetworkError(error)) {
|
||||
return { frames: null, error: 'network' };
|
||||
}
|
||||
return { frames: [], error: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch metadata separately
|
||||
*/
|
||||
export async function fetchMetadata() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/metadata`, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Could not fetch metadata:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch latest radar data (for latest mode)
|
||||
*/
|
||||
export async function fetchLatestRadar() {
|
||||
const response = await fetch(API_BASE, {
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: `HTTP ${response.status}: ${response.statusText}` }));
|
||||
|
||||
if (response.status === 404) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
state.lastRefreshTime = new Date();
|
||||
|
||||
const refreshEndpoint = errorData.suggestions?.refreshEndpoint || errorData.refreshEndpoint;
|
||||
triggerBackgroundRefresh(refreshEndpoint);
|
||||
|
||||
const cacheStatusData = createCacheStatusData(errorData);
|
||||
const retryAfter = errorData.suggestions?.retryAfter || errorData.retryAfter || 30;
|
||||
const message = errorData.message || `Cache is being generated. Please wait ${retryAfter} seconds and refresh.`;
|
||||
|
||||
return {
|
||||
error: 'cache_generating',
|
||||
cacheStatus: cacheStatusData,
|
||||
message: message,
|
||||
refreshEndpoint: refreshEndpoint
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(error.message || error.error || 'Failed to fetch radar data');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
data.isExtendedMode = false;
|
||||
return { data: data, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to fetch radar data (handles both latest and extended modes)
|
||||
*/
|
||||
export async function fetchRadarData() {
|
||||
try {
|
||||
// Extended mode
|
||||
if (state.settings.timespan !== 'latest') {
|
||||
// Fetch cache range first if needed
|
||||
if (!state.cacheRangeInfo) {
|
||||
const rangeResult = await fetchCacheRange();
|
||||
if (rangeResult.error === 'network') {
|
||||
throw new Error('Network error - API unavailable');
|
||||
}
|
||||
state.cacheRangeInfo = rangeResult.data;
|
||||
}
|
||||
|
||||
if (!state.cacheRangeInfo || state.cacheRangeInfo.totalCacheFolders === 0) {
|
||||
const refreshEndpoint = `${API_BASE.replace('/api/radar', '/api/cache')}/refresh`;
|
||||
triggerBackgroundRefresh(refreshEndpoint);
|
||||
|
||||
// Try to fetch cache status
|
||||
try {
|
||||
const statusResponse = await fetch(`${API_BASE}`, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
if (statusResponse.status === 404) {
|
||||
const errorData = await statusResponse.json().catch(() => ({}));
|
||||
return {
|
||||
error: 'no_cache_data',
|
||||
cacheStatus: createCacheStatusData(errorData),
|
||||
message: 'No historical cache data available yet. Cache update has been triggered in the background.'
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug('Could not fetch cache status:', err);
|
||||
}
|
||||
|
||||
return {
|
||||
error: 'no_cache_data',
|
||||
message: 'No historical cache data available yet. Cache update has been triggered in the background.'
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate time range
|
||||
let startTime = null;
|
||||
let endTime = new Date();
|
||||
|
||||
if (state.settings.timespan === 'custom') {
|
||||
const startInput = document.getElementById('start-time-input');
|
||||
const endInput = document.getElementById('end-time-input');
|
||||
if (startInput && startInput.value) startTime = new Date(startInput.value);
|
||||
if (endInput && endInput.value) endTime = new Date(endInput.value);
|
||||
} else {
|
||||
const hours = parseInt(state.settings.timespan.replace('h', '')) || 1;
|
||||
startTime = new Date(endTime.getTime() - (hours * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
// Fetch historical data
|
||||
const result = await fetchHistoricalRadar(startTime, endTime);
|
||||
if (result.error === 'network') {
|
||||
throw new Error('Network error - API unavailable');
|
||||
}
|
||||
|
||||
if (result.error === 'location_missing') {
|
||||
return {
|
||||
error: 'location_missing',
|
||||
cacheStatus: createCacheStatusData(result.cacheStatus || {}),
|
||||
message: 'No cached data found for this location. Cache update has been triggered in background.'
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.frames || result.frames.length === 0) {
|
||||
return {
|
||||
error: 'no_frames',
|
||||
message: 'No frames found for selected timespan. Try a different range or wait for more cache data.'
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch metadata
|
||||
let metadata = null;
|
||||
try {
|
||||
const metadataResponse = await fetch(API_BASE, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
if (metadataResponse.ok) {
|
||||
const latestData = await metadataResponse.json();
|
||||
metadata = {
|
||||
weatherStation: latestData.weatherStation,
|
||||
distance: latestData.distance,
|
||||
observationTime: latestData.observationTime,
|
||||
cacheExpiresAt: latestData.cacheExpiresAt,
|
||||
nextUpdateTime: latestData.nextUpdateTime,
|
||||
cacheIsValid: latestData.cacheIsValid,
|
||||
isUpdating: latestData.isUpdating
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug('Could not fetch metadata for extended mode:', err);
|
||||
}
|
||||
|
||||
// Create response object
|
||||
const newestCacheFolder = state.historicalData.cacheFolders[state.historicalData.cacheFolders.length - 1];
|
||||
return {
|
||||
data: {
|
||||
frames: result.frames,
|
||||
lastUpdated: endTime.toISOString(),
|
||||
observationTime: metadata?.observationTime || newestCacheFolder?.observationTime || endTime.toISOString(),
|
||||
forecastTime: endTime.toISOString(),
|
||||
weatherStation: metadata?.weatherStation || null,
|
||||
distance: metadata?.distance || null,
|
||||
cacheIsValid: metadata?.cacheIsValid ?? true,
|
||||
cacheExpiresAt: metadata?.cacheExpiresAt || endTime.toISOString(),
|
||||
isUpdating: metadata?.isUpdating || false,
|
||||
nextUpdateTime: metadata?.nextUpdateTime || endTime.toISOString(),
|
||||
totalFrames: result.frames.length,
|
||||
isExtendedMode: true
|
||||
},
|
||||
error: null
|
||||
};
|
||||
} else {
|
||||
// Latest mode
|
||||
const result = await fetchLatestRadar();
|
||||
if (result.error) {
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching radar data:', error);
|
||||
if (isNetworkError(error)) {
|
||||
return { error: 'network', originalError: error };
|
||||
}
|
||||
return { error: 'unknown', message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Main application entry point
|
||||
import { loadSettings, saveSettings, updateSettingsUI, applySettings } from './settings.js';
|
||||
import { fetchRadarData } from './api-client.js';
|
||||
import { updateUI, updateRelativeTimes } from './ui-updater.js';
|
||||
import { showError, showNoFramesMessage, clearError } from './ui-updater.js';
|
||||
import { handleApiDown, resetApiRetryState } from './error-handler.js';
|
||||
import { buildFrameControls, showFrame, findFrameToShowAfterRefresh } from './frame-navigation.js';
|
||||
import { play, pause, togglePlay, previousFrame, nextFrame } from './slideshow.js';
|
||||
import { setupKeyboardNavigation } from './keyboard.js';
|
||||
import { state } from './state.js';
|
||||
import { isNetworkError } from './utils.js';
|
||||
|
||||
/**
|
||||
* Settings modal management
|
||||
*/
|
||||
function showSettings() {
|
||||
const modal = document.getElementById('settings-modal');
|
||||
if (modal) modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideSettings() {
|
||||
const modal = document.getElementById('settings-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-refresh data
|
||||
*/
|
||||
async function refreshData() {
|
||||
// Don't clear error if API is down (keep retry message visible)
|
||||
if (!state.isApiDown) {
|
||||
clearError();
|
||||
}
|
||||
|
||||
const result = await fetchRadarData();
|
||||
|
||||
if (result.error) {
|
||||
if (result.error === 'network') {
|
||||
handleApiDown(result.originalError || new Error('Network error'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
if (result.error === 'cache_generating') {
|
||||
updateUI(result.cacheStatus);
|
||||
showNoFramesMessage(result.message, result.refreshEndpoint);
|
||||
} else if (result.error === 'location_missing' || result.error === 'no_cache_data' || result.error === 'no_frames') {
|
||||
if (result.cacheStatus) {
|
||||
updateUI(result.cacheStatus);
|
||||
}
|
||||
showNoFramesMessage(result.message, result.refreshEndpoint);
|
||||
} else {
|
||||
showError(result.message || 'An error occurred');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
state.lastRefreshTime = new Date();
|
||||
resetApiRetryState();
|
||||
updateUI(result.data);
|
||||
|
||||
// Build frame controls after UI update
|
||||
buildFrameControls();
|
||||
|
||||
// Find and show appropriate frame
|
||||
const frameToShow = findFrameToShowAfterRefresh();
|
||||
showFrame(frameToShow);
|
||||
|
||||
// Restart auto-refresh if it was stopped
|
||||
if (!state.refreshInterval && !state.isApiDown) {
|
||||
state.refreshInterval = setInterval(refreshData, state.settings.refreshInterval * 1000);
|
||||
}
|
||||
|
||||
// Restart play if it was playing before refresh
|
||||
const wasPlaying = state.isPlaying;
|
||||
if (wasPlaying && result.data.frames && result.data.frames.length > 0) {
|
||||
setTimeout(() => {
|
||||
if (!state.isPlaying) {
|
||||
play();
|
||||
}
|
||||
}, 100);
|
||||
} else if (state.settings.autoPlay && !state.isPlaying && result.data.frames && result.data.frames.length > 0) {
|
||||
// Auto-play on initial load only
|
||||
setTimeout(() => play(), 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize application
|
||||
*/
|
||||
async function init() {
|
||||
// Load settings from cookies
|
||||
loadSettings();
|
||||
|
||||
// Set up event listeners
|
||||
const playBtn = document.getElementById('play-btn');
|
||||
const prevBtn = document.getElementById('prev-btn');
|
||||
const nextBtn = document.getElementById('next-btn');
|
||||
|
||||
if (playBtn) playBtn.addEventListener('click', togglePlay);
|
||||
if (prevBtn) prevBtn.addEventListener('click', previousFrame);
|
||||
if (nextBtn) nextBtn.addEventListener('click', nextFrame);
|
||||
|
||||
// Settings button
|
||||
const settingsBtn = document.getElementById('settings-btn-header');
|
||||
if (settingsBtn) {
|
||||
settingsBtn.addEventListener('click', showSettings);
|
||||
}
|
||||
|
||||
// Settings modal handlers
|
||||
const saveSettingsBtn = document.getElementById('save-settings-btn');
|
||||
if (saveSettingsBtn) {
|
||||
saveSettingsBtn.addEventListener('click', () => {
|
||||
const frameIntervalValue = parseFloat(document.getElementById('frame-interval-input').value);
|
||||
state.settings.frameInterval = (frameIntervalValue > 0) ? frameIntervalValue : 2.0;
|
||||
|
||||
const refreshIntervalValue = parseInt(document.getElementById('refresh-interval-input').value);
|
||||
state.settings.refreshInterval = (refreshIntervalValue >= 5) ? refreshIntervalValue : 30;
|
||||
state.settings.autoPlay = document.getElementById('auto-play-input').checked;
|
||||
state.settings.timespan = document.getElementById('timespan-select').value;
|
||||
|
||||
if (state.settings.timespan === 'custom') {
|
||||
const startInput = document.getElementById('start-time-input');
|
||||
const endInput = document.getElementById('end-time-input');
|
||||
if (startInput && startInput.value) {
|
||||
state.settings.customStartTime = startInput.value;
|
||||
}
|
||||
if (endInput && endInput.value) {
|
||||
state.settings.customEndTime = endInput.value;
|
||||
}
|
||||
}
|
||||
|
||||
saveSettings();
|
||||
hideSettings();
|
||||
|
||||
// Apply settings and restart refresh interval
|
||||
applySettings();
|
||||
if (state.refreshInterval) {
|
||||
clearInterval(state.refreshInterval);
|
||||
state.refreshInterval = null;
|
||||
}
|
||||
|
||||
// Reload data with new timespan
|
||||
refreshData();
|
||||
});
|
||||
}
|
||||
|
||||
// Timespan select change handler
|
||||
const timespanSelect = document.getElementById('timespan-select');
|
||||
if (timespanSelect) {
|
||||
timespanSelect.addEventListener('change', (e) => {
|
||||
state.settings.timespan = e.target.value;
|
||||
const customSection = document.getElementById('custom-range-section');
|
||||
if (customSection) {
|
||||
customSection.style.display = e.target.value === 'custom' ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel settings button
|
||||
const cancelSettingsBtn = document.getElementById('cancel-settings-btn');
|
||||
if (cancelSettingsBtn) {
|
||||
cancelSettingsBtn.addEventListener('click', () => {
|
||||
updateSettingsUI(); // Reset to current settings
|
||||
hideSettings();
|
||||
});
|
||||
}
|
||||
|
||||
// Close button handler
|
||||
const closeSettingsBtn = document.getElementById('close-settings-btn');
|
||||
if (closeSettingsBtn) {
|
||||
closeSettingsBtn.addEventListener('click', hideSettings);
|
||||
}
|
||||
|
||||
// Close modal on background click
|
||||
const settingsModal = document.getElementById('settings-modal');
|
||||
if (settingsModal) {
|
||||
settingsModal.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'settings-modal') {
|
||||
hideSettings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Setup keyboard navigation
|
||||
setupKeyboardNavigation();
|
||||
|
||||
// Initial load
|
||||
await refreshData();
|
||||
|
||||
// Auto-refresh based on settings (only if API is up)
|
||||
if (!state.isApiDown) {
|
||||
state.refreshInterval = setInterval(refreshData, state.settings.refreshInterval * 1000);
|
||||
}
|
||||
|
||||
// Update relative times every second
|
||||
setInterval(updateRelativeTimes, 1000);
|
||||
}
|
||||
|
||||
// Start when page loads
|
||||
init();
|
||||
@@ -0,0 +1,15 @@
|
||||
// Configuration constants
|
||||
export const MAX_RETRY_ATTEMPTS = 10;
|
||||
export const RETRY_DELAY_MS = 5000; // 5 seconds between retries
|
||||
|
||||
// Get API base URL from global variable set by Razor view
|
||||
export const API_BASE = window.API_BASE || '';
|
||||
|
||||
// Default settings
|
||||
export const DEFAULT_SETTINGS = {
|
||||
frameInterval: 2.0, // seconds between frames
|
||||
refreshInterval: 30, // seconds between API refreshes
|
||||
autoPlay: true, // auto-play on load
|
||||
timespan: 'latest' // 'latest', '1h', '3h', '6h', '12h', '24h', 'custom'
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Error handling and API retry logic
|
||||
import { MAX_RETRY_ATTEMPTS, RETRY_DELAY_MS } from './config.js';
|
||||
import { state } from './state.js';
|
||||
import { updateApiStatus, showError, clearError } from './ui-updater.js';
|
||||
import { fetchRadarData } from './api-client.js';
|
||||
import { updateUI } from './ui-updater.js';
|
||||
|
||||
/**
|
||||
* Reset API retry state when connection is restored
|
||||
*/
|
||||
export function resetApiRetryState() {
|
||||
if (state.isApiDown) {
|
||||
state.isApiDown = false;
|
||||
state.retryAttempts = 0;
|
||||
clearError();
|
||||
if (state.retryInterval) {
|
||||
clearInterval(state.retryInterval);
|
||||
state.retryInterval = null;
|
||||
}
|
||||
updateApiStatus('connected');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API being down
|
||||
*/
|
||||
export function handleApiDown(error) {
|
||||
if (!state.isApiDown) {
|
||||
state.isApiDown = true;
|
||||
state.retryAttempts = 0;
|
||||
updateApiStatus('disconnected');
|
||||
}
|
||||
|
||||
state.retryAttempts++;
|
||||
showError(`API connection lost. Retrying... (Attempt ${state.retryAttempts}/${MAX_RETRY_ATTEMPTS})`);
|
||||
|
||||
// Stop auto-refresh while retrying
|
||||
if (state.refreshInterval) {
|
||||
clearInterval(state.refreshInterval);
|
||||
state.refreshInterval = null;
|
||||
}
|
||||
|
||||
// Start retry mechanism
|
||||
if (!state.retryInterval && state.retryAttempts < MAX_RETRY_ATTEMPTS) {
|
||||
state.retryInterval = setInterval(async () => {
|
||||
if (state.retryAttempts >= MAX_RETRY_ATTEMPTS) {
|
||||
clearInterval(state.retryInterval);
|
||||
state.retryInterval = null;
|
||||
showError('API connection failed after multiple attempts. Please check if the service is running.');
|
||||
updateApiStatus('failed');
|
||||
return;
|
||||
}
|
||||
|
||||
state.retryAttempts++;
|
||||
showError(`API connection lost. Retrying... (Attempt ${state.retryAttempts}/${MAX_RETRY_ATTEMPTS})`);
|
||||
|
||||
// Try to fetch data
|
||||
const result = await fetchRadarData();
|
||||
if (result.data) {
|
||||
updateUI(result.data);
|
||||
}
|
||||
}, RETRY_DELAY_MS);
|
||||
} else if (state.retryAttempts >= MAX_RETRY_ATTEMPTS) {
|
||||
showError('API connection failed after multiple attempts. Please check if the service is running.');
|
||||
updateApiStatus('failed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
// Frame navigation and display
|
||||
import { formatDate, formatFrameTime, getMinutesAgo, preloadImages } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
/**
|
||||
* Calculate jump amount for frame navigation
|
||||
* Returns 10% of total frames (min 1, max 50) for extended mode, 0 otherwise
|
||||
*/
|
||||
export function getJumpAmount() {
|
||||
if (!state.isExtendedMode || state.frames.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(1, Math.min(50, Math.round(state.frames.length / 10)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build frame controls HTML and attach event listeners
|
||||
*/
|
||||
export function buildFrameControls() {
|
||||
const controlsEl = document.getElementById('frame-controls');
|
||||
if (!controlsEl) return;
|
||||
|
||||
// Calculate jump amount - 10% of total frames, minimum 1, maximum 50
|
||||
// Only used in extended mode (timeseries)
|
||||
const jumpAmount = getJumpAmount();
|
||||
|
||||
// Build jump buttons HTML only for extended mode
|
||||
const prevJumpButtonHtml = state.isExtendedMode && jumpAmount > 0
|
||||
? `<button class="frame-nav-btn" id="frame-prev-jump-btn" title="Go back ${jumpAmount} frames" aria-label="Go back ${jumpAmount} frames">-${jumpAmount}</button>`
|
||||
: '';
|
||||
const nextJumpButtonHtml = state.isExtendedMode && jumpAmount > 0
|
||||
? `<button class="frame-nav-btn" id="frame-next-jump-btn" title="Go forward ${jumpAmount} frames" aria-label="Go forward ${jumpAmount} frames">+${jumpAmount}</button>`
|
||||
: '';
|
||||
|
||||
controlsEl.innerHTML = `
|
||||
<div class="frame-slider-container">
|
||||
<div class="frame-slider-wrapper">
|
||||
<button class="frame-nav-btn" id="frame-first-btn" title="First frame" aria-label="First frame">⏮</button>
|
||||
${prevJumpButtonHtml}
|
||||
<input type="range" class="frame-slider" id="frame-slider" min="0" max="${state.frames.length - 1}" value="${state.currentFrameIndex}" aria-label="Frame slider">
|
||||
${nextJumpButtonHtml}
|
||||
<button class="frame-nav-btn" id="frame-last-btn" title="Last frame" aria-label="Last frame">⏭</button>
|
||||
</div>
|
||||
<div class="frame-info-display">
|
||||
<div class="frame-info-item">
|
||||
<span class="frame-info-label">Frame:</span>
|
||||
<input type="number" class="frame-jump-input" id="frame-jump-input" min="0" max="${state.frames.length - 1}" value="0">
|
||||
<span class="frame-info-value">/ ${state.frames.length - 1}</span>
|
||||
</div>
|
||||
<div class="frame-info-item">
|
||||
<span class="frame-info-label">Time:</span>
|
||||
<span class="frame-info-value" id="current-frame-time">-</span>
|
||||
</div>
|
||||
<div class="frame-info-item">
|
||||
<span class="frame-info-label">Progress:</span>
|
||||
<span class="frame-info-value" id="frame-progress">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Attach event listeners
|
||||
const firstBtn = document.getElementById('frame-first-btn');
|
||||
const prevJumpBtn = document.getElementById('frame-prev-jump-btn');
|
||||
const nextJumpBtn = document.getElementById('frame-next-jump-btn');
|
||||
const lastBtn = document.getElementById('frame-last-btn');
|
||||
const slider = document.getElementById('frame-slider');
|
||||
const jumpInput = document.getElementById('frame-jump-input');
|
||||
|
||||
if (firstBtn) firstBtn.addEventListener('click', () => showFrame(0));
|
||||
if (prevJumpBtn && jumpAmount > 0) {
|
||||
prevJumpBtn.addEventListener('click', () => jumpFrame(-jumpAmount));
|
||||
}
|
||||
if (nextJumpBtn && jumpAmount > 0) {
|
||||
nextJumpBtn.addEventListener('click', () => jumpFrame(jumpAmount));
|
||||
}
|
||||
if (lastBtn) lastBtn.addEventListener('click', () => showFrame(state.frames.length - 1));
|
||||
if (slider) {
|
||||
slider.addEventListener('input', (e) => updateFrameFromSlider(parseInt(e.target.value)));
|
||||
}
|
||||
if (jumpInput) {
|
||||
jumpInput.addEventListener('change', (e) => jumpToFrame(parseInt(e.target.value)));
|
||||
jumpInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') jumpToFrame(parseInt(e.target.value));
|
||||
});
|
||||
}
|
||||
|
||||
// Update initial display
|
||||
updateFrameSliderInfo(state.currentFrameIndex);
|
||||
|
||||
// Preload all frame images
|
||||
preloadImages(state.frames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update frame slider info display
|
||||
*/
|
||||
export function updateFrameSliderInfo(index) {
|
||||
if (index < 0 || index >= state.frames.length) return;
|
||||
|
||||
const frame = state.frames[index];
|
||||
const progress = Math.round(((index + 1) / state.frames.length) * 100);
|
||||
|
||||
// Update jump input
|
||||
const jumpInput = document.getElementById('frame-jump-input');
|
||||
if (jumpInput) jumpInput.value = index;
|
||||
|
||||
// Update slider
|
||||
const slider = document.getElementById('frame-slider');
|
||||
if (slider) slider.value = index;
|
||||
|
||||
// Update frame number display
|
||||
const frameNumDisplay = document.getElementById('current-frame-num-display');
|
||||
if (frameNumDisplay) frameNumDisplay.textContent = index + 1;
|
||||
|
||||
// Update time display
|
||||
const timeDisplay = document.getElementById('current-frame-time');
|
||||
if (timeDisplay) {
|
||||
timeDisplay.textContent = formatFrameTime(frame, state.isExtendedMode);
|
||||
}
|
||||
|
||||
// Update progress
|
||||
const progressDisplay = document.getElementById('frame-progress');
|
||||
if (progressDisplay) progressDisplay.textContent = `${progress}%`;
|
||||
|
||||
// Update navigation buttons
|
||||
const firstBtn = document.getElementById('frame-first-btn');
|
||||
const prevJumpBtn = document.getElementById('frame-prev-jump-btn');
|
||||
const nextJumpBtn = document.getElementById('frame-next-jump-btn');
|
||||
const lastBtn = document.getElementById('frame-last-btn');
|
||||
|
||||
if (firstBtn) firstBtn.disabled = index === 0;
|
||||
if (prevJumpBtn) prevJumpBtn.disabled = index === 0;
|
||||
if (nextJumpBtn) nextJumpBtn.disabled = index >= state.frames.length - 1;
|
||||
if (lastBtn) lastBtn.disabled = index >= state.frames.length - 1;
|
||||
}
|
||||
|
||||
let sliderUpdateTimeout;
|
||||
/**
|
||||
* Update frame from slider (with debouncing)
|
||||
*/
|
||||
export function updateFrameFromSlider(value) {
|
||||
clearTimeout(sliderUpdateTimeout);
|
||||
sliderUpdateTimeout = setTimeout(() => {
|
||||
showFrame(value);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to specific frame number
|
||||
*/
|
||||
export function jumpToFrame(frameNum) {
|
||||
const index = Math.max(0, Math.min(state.frames.length - 1, frameNum));
|
||||
showFrame(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump forward/backward by N frames
|
||||
*/
|
||||
export function jumpFrame(offset) {
|
||||
const newIndex = Math.max(0, Math.min(state.frames.length - 1, state.currentFrameIndex + offset));
|
||||
showFrame(newIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build alt text for frame image
|
||||
*/
|
||||
function buildFrameAltText(frame, index) {
|
||||
if (state.isExtendedMode) {
|
||||
const frameNum = frame.sequentialIndex !== undefined ? frame.sequentialIndex : index;
|
||||
const timeInfo = frame.absoluteObservationTime
|
||||
? formatDate(frame.absoluteObservationTime)
|
||||
: (frame.cacheTimestamp ? formatDate(frame.cacheTimestamp) : '');
|
||||
return `Radar frame ${frameNum}${timeInfo ? ' (' + timeInfo + ')' : ''}`;
|
||||
} else {
|
||||
const minutesAgo = getMinutesAgo(frame.absoluteObservationTime);
|
||||
if (minutesAgo !== null) {
|
||||
return `Radar frame ${frame.frameIndex} (${minutesAgo} minutes ago)`;
|
||||
} else {
|
||||
// Fall back to formatted date if minutes ago calculation fails
|
||||
const timeInfo = frame.absoluteObservationTime ? formatDate(frame.absoluteObservationTime) : '';
|
||||
return `Radar frame ${frame.frameIndex}${timeInfo ? ' (' + timeInfo + ')' : ''}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build frame info text
|
||||
*/
|
||||
function buildFrameInfoText(frame, index) {
|
||||
if (state.isExtendedMode) {
|
||||
const frameNum = frame.sequentialIndex !== undefined ? frame.sequentialIndex : index;
|
||||
const timeInfo = frame.absoluteObservationTime
|
||||
? formatDate(frame.absoluteObservationTime)
|
||||
: (frame.cacheTimestamp ? formatDate(frame.cacheTimestamp) : '');
|
||||
return `Frame ${frameNum} of ${state.frames.length - 1}${timeInfo ? ' • ' + timeInfo : ''}`;
|
||||
} else {
|
||||
const minutesAgo = getMinutesAgo(frame.absoluteObservationTime);
|
||||
if (minutesAgo !== null) {
|
||||
return `Frame ${frame.frameIndex} of ${state.frames.length - 1} • ${minutesAgo} minutes ago`;
|
||||
} else {
|
||||
// Fall back to formatted date if minutes ago calculation fails
|
||||
const timeInfo = frame.absoluteObservationTime ? formatDate(frame.absoluteObservationTime) : '';
|
||||
return `Frame ${frame.frameIndex} of ${state.frames.length - 1}${timeInfo ? ' • ' + timeInfo : ''}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show specific frame
|
||||
*/
|
||||
export function showFrame(index) {
|
||||
if (index < 0 || index >= state.frames.length) {
|
||||
console.error(`showFrame: Invalid index ${index}, frames.length = ${state.frames.length}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.frames[index]) {
|
||||
console.error(`showFrame: Frame at index ${index} is undefined`);
|
||||
return;
|
||||
}
|
||||
|
||||
state.currentFrameIndex = index;
|
||||
const frame = state.frames[index];
|
||||
|
||||
// Update slider info
|
||||
updateFrameSliderInfo(index);
|
||||
|
||||
// Update active button state
|
||||
document.querySelectorAll('.frame-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
if (parseInt(btn.getAttribute('data-frame')) === index) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Update image
|
||||
const imgEl = document.getElementById('radar-image');
|
||||
const loadingEl = document.getElementById('loading');
|
||||
const altText = buildFrameAltText(frame, index);
|
||||
|
||||
// Check if image is already loaded (from preload)
|
||||
const preloadedImg = new Image();
|
||||
preloadedImg.onload = () => {
|
||||
if (imgEl) {
|
||||
imgEl.src = frame.imageUrl;
|
||||
imgEl.alt = altText;
|
||||
}
|
||||
if (loadingEl) loadingEl.style.display = 'none';
|
||||
if (imgEl) imgEl.style.display = 'block';
|
||||
};
|
||||
preloadedImg.onerror = () => {
|
||||
if (imgEl) {
|
||||
imgEl.src = frame.imageUrl;
|
||||
imgEl.alt = altText;
|
||||
}
|
||||
};
|
||||
preloadedImg.src = frame.imageUrl;
|
||||
|
||||
// Update frame info
|
||||
const frameInfoEl = document.getElementById('frame-info');
|
||||
if (frameInfoEl) {
|
||||
frameInfoEl.textContent = buildFrameInfoText(frame, index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find frame to show after refresh (preserves position)
|
||||
*/
|
||||
export function findFrameToShowAfterRefresh() {
|
||||
if (state.frames.length === 0) return 0;
|
||||
|
||||
const previousFrameIndex = state.currentFrameIndex;
|
||||
if (previousFrameIndex < 0 || !state.radarData?.frames) return 0;
|
||||
|
||||
const previousFrame = state.radarData.frames[previousFrameIndex];
|
||||
if (!previousFrame) return 0;
|
||||
|
||||
if (previousFrame.absoluteObservationTime) {
|
||||
// Find frame with same absolute observation time
|
||||
const matchingIndex = state.frames.findIndex(f =>
|
||||
f.absoluteObservationTime === previousFrame.absoluteObservationTime
|
||||
);
|
||||
if (matchingIndex >= 0) return matchingIndex;
|
||||
|
||||
// Find closest frame by time
|
||||
const previousTime = new Date(previousFrame.absoluteObservationTime).getTime();
|
||||
let closestIndex = 0;
|
||||
let closestDiff = Infinity;
|
||||
state.frames.forEach((f, idx) => {
|
||||
if (f.absoluteObservationTime) {
|
||||
const diff = Math.abs(new Date(f.absoluteObservationTime).getTime() - previousTime);
|
||||
if (diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
closestIndex = idx;
|
||||
}
|
||||
}
|
||||
});
|
||||
return closestIndex;
|
||||
}
|
||||
|
||||
// Fallback: keep same index if valid
|
||||
return previousFrameIndex < state.frames.length ? previousFrameIndex : 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Keyboard navigation
|
||||
import { state } from './state.js';
|
||||
import { showFrame, jumpFrame, getJumpAmount } from './frame-navigation.js';
|
||||
import { togglePlay } from './slideshow.js';
|
||||
|
||||
/**
|
||||
* Setup keyboard navigation
|
||||
*/
|
||||
export function setupKeyboardNavigation() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Don't handle keyboard shortcuts if user is typing in an input
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow keys for frame navigation
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
if (e.key === 'ArrowLeft') {
|
||||
if (e.shiftKey) {
|
||||
// Shift+Left: jump back by proportional amount (only in extended mode)
|
||||
const jumpAmount = getJumpAmount();
|
||||
if (jumpAmount > 0) {
|
||||
jumpFrame(-jumpAmount);
|
||||
}
|
||||
} else {
|
||||
showFrame(Math.max(0, state.currentFrameIndex - 1)); // Left: previous frame
|
||||
}
|
||||
} else {
|
||||
if (e.shiftKey) {
|
||||
// Shift+Right: jump forward by proportional amount (only in extended mode)
|
||||
const jumpAmount = getJumpAmount();
|
||||
if (jumpAmount > 0) {
|
||||
jumpFrame(jumpAmount);
|
||||
}
|
||||
} else {
|
||||
showFrame(Math.min(state.frames.length - 1, state.currentFrameIndex + 1)); // Right: next frame
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Home/End for first/last frame
|
||||
if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
showFrame(0);
|
||||
}
|
||||
if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
showFrame(state.frames.length - 1);
|
||||
}
|
||||
|
||||
// Spacebar for play/pause
|
||||
if (e.key === ' ' && state.frames.length > 0) {
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Settings management
|
||||
import { getCookie, setCookie } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
import { fetchCacheRange } from './api-client.js';
|
||||
import { pause, play } from './slideshow.js';
|
||||
|
||||
/**
|
||||
* Load settings from cookies
|
||||
*/
|
||||
export function loadSettings() {
|
||||
const saved = getCookie('radarTestSettings');
|
||||
if (saved) {
|
||||
state.settings = { ...state.settings, ...saved };
|
||||
}
|
||||
updateSettingsUI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings to cookies
|
||||
*/
|
||||
export function saveSettings() {
|
||||
setCookie('radarTestSettings', state.settings);
|
||||
updateSettingsUI();
|
||||
applySettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings UI elements
|
||||
*/
|
||||
export function updateSettingsUI() {
|
||||
document.getElementById('frame-interval-input').value = state.settings.frameInterval;
|
||||
document.getElementById('refresh-interval-input').value = state.settings.refreshInterval;
|
||||
document.getElementById('auto-play-input').checked = state.settings.autoPlay;
|
||||
document.getElementById('timespan-select').value = state.settings.timespan || 'latest';
|
||||
|
||||
// Show/hide custom range section
|
||||
const customSection = document.getElementById('custom-range-section');
|
||||
if (customSection) {
|
||||
customSection.style.display = state.settings.timespan === 'custom' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Load cache range info when settings modal opens
|
||||
fetchCacheRange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply settings (restart intervals if needed)
|
||||
*/
|
||||
export function applySettings() {
|
||||
// Restart play interval if playing
|
||||
if (state.isPlaying) {
|
||||
pause();
|
||||
play();
|
||||
}
|
||||
|
||||
// Restart refresh interval (will be handled by refreshData caller)
|
||||
if (state.refreshInterval) {
|
||||
clearInterval(state.refreshInterval);
|
||||
state.refreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Slideshow controls (play/pause)
|
||||
import { state } from './state.js';
|
||||
import { showFrame } from './frame-navigation.js';
|
||||
|
||||
/**
|
||||
* Play slideshow
|
||||
*/
|
||||
export function play() {
|
||||
if (state.frames.length === 0) return;
|
||||
|
||||
state.isPlaying = true;
|
||||
const playBtn = document.getElementById('play-btn');
|
||||
if (playBtn) playBtn.textContent = '⏸ Pause';
|
||||
|
||||
if (state.playInterval) {
|
||||
clearInterval(state.playInterval);
|
||||
}
|
||||
|
||||
state.playInterval = setInterval(() => {
|
||||
state.currentFrameIndex = (state.currentFrameIndex + 1) % state.frames.length;
|
||||
showFrame(state.currentFrameIndex);
|
||||
}, state.settings.frameInterval * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause slideshow
|
||||
*/
|
||||
export function pause() {
|
||||
state.isPlaying = false;
|
||||
const playBtn = document.getElementById('play-btn');
|
||||
if (playBtn) playBtn.textContent = '▶ Play';
|
||||
|
||||
if (state.playInterval) {
|
||||
clearInterval(state.playInterval);
|
||||
state.playInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle play/pause
|
||||
*/
|
||||
export function togglePlay() {
|
||||
if (state.isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to previous frame
|
||||
*/
|
||||
export function previousFrame() {
|
||||
pause();
|
||||
const newIndex = state.currentFrameIndex > 0 ? state.currentFrameIndex - 1 : state.frames.length - 1;
|
||||
showFrame(newIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to next frame
|
||||
*/
|
||||
export function nextFrame() {
|
||||
pause();
|
||||
const newIndex = (state.currentFrameIndex + 1) % state.frames.length;
|
||||
showFrame(newIndex);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Application state management
|
||||
import { DEFAULT_SETTINGS } from './config.js';
|
||||
|
||||
// Export a mutable state object - properties can be mutated even though the object binding is read-only
|
||||
export const state = {
|
||||
currentFrameIndex: 0,
|
||||
frames: [],
|
||||
radarData: null,
|
||||
playInterval: null,
|
||||
isPlaying: false,
|
||||
lastRefreshTime: null,
|
||||
refreshInterval: null,
|
||||
nextClientCheckTime: null,
|
||||
retryInterval: null,
|
||||
isApiDown: false,
|
||||
retryAttempts: 0,
|
||||
cacheRangeInfo: null,
|
||||
historicalData: null,
|
||||
isExtendedMode: false,
|
||||
settings: { ...DEFAULT_SETTINGS }
|
||||
};
|
||||
|
||||
// Reset state (for testing/debugging)
|
||||
export function resetState() {
|
||||
state.currentFrameIndex = 0;
|
||||
state.frames = [];
|
||||
state.radarData = null;
|
||||
state.playInterval = null;
|
||||
state.isPlaying = false;
|
||||
state.lastRefreshTime = null;
|
||||
state.refreshInterval = null;
|
||||
state.nextClientCheckTime = null;
|
||||
state.retryInterval = null;
|
||||
state.isApiDown = false;
|
||||
state.retryAttempts = 0;
|
||||
state.cacheRangeInfo = null;
|
||||
state.historicalData = null;
|
||||
state.isExtendedMode = false;
|
||||
state.settings = { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
// UI update functions
|
||||
import { formatDate, getRelativeTime, setElementText, setElementHTML, getElement } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
/**
|
||||
* Update API connection status indicator
|
||||
*/
|
||||
export function updateApiStatus(status) {
|
||||
const refreshStatusEl = getElement('refresh-status');
|
||||
const refreshIndicatorEl = getElement('refresh-indicator');
|
||||
|
||||
if (!refreshStatusEl || !refreshIndicatorEl) return;
|
||||
|
||||
if (status === 'connected') {
|
||||
refreshStatusEl.textContent = 'Active';
|
||||
refreshIndicatorEl.style.background = '#28a745';
|
||||
refreshIndicatorEl.style.animation = 'pulse 2s infinite';
|
||||
} else if (status === 'disconnected') {
|
||||
refreshStatusEl.textContent = 'Reconnecting...';
|
||||
refreshIndicatorEl.style.background = '#ffc107';
|
||||
refreshIndicatorEl.style.animation = 'pulse 1s infinite';
|
||||
} else if (status === 'failed') {
|
||||
refreshStatusEl.textContent = 'Connection Failed';
|
||||
refreshIndicatorEl.style.background = '#dc3545';
|
||||
refreshIndicatorEl.style.animation = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message
|
||||
*/
|
||||
export function showError(message) {
|
||||
const container = getElement('error-container');
|
||||
if (!container) return;
|
||||
|
||||
const isRetrying = message.includes('Retrying');
|
||||
const errorClass = isRetrying ? 'error retrying' : 'error';
|
||||
container.innerHTML = `<div class="${errorClass}">${isRetrying ? '⚠️ ' : '❌ Error: '}${message}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show graceful "no frames" message
|
||||
*/
|
||||
export function showNoFramesMessage(message, refreshEndpoint = null) {
|
||||
const container = getElement('error-container');
|
||||
if (!container) return;
|
||||
|
||||
let html = `<div class="error retrying" style="background: #fff3cd; color: #856404; border-left-color: #ffc107;">
|
||||
<div style="margin-bottom: 10px;">⏳ ${message}</div>`;
|
||||
|
||||
if (refreshEndpoint) {
|
||||
html += `<button class="play-btn" onclick="window.location.reload()" style="margin-top: 10px; padding: 8px 16px; font-size: 0.9em;">
|
||||
🔄 Refresh Now
|
||||
</button>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
container.innerHTML = html;
|
||||
|
||||
// Hide loading and show message in image container
|
||||
const loadingEl = getElement('loading');
|
||||
const imageEl = getElement('radar-image');
|
||||
if (loadingEl) loadingEl.textContent = message;
|
||||
if (imageEl) imageEl.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error message
|
||||
*/
|
||||
export function clearError() {
|
||||
const container = getElement('error-container');
|
||||
if (container) container.innerHTML = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update relative times periodically
|
||||
*/
|
||||
export function updateRelativeTimes() {
|
||||
if (!state.radarData) return;
|
||||
|
||||
const expiresEl = getElement('cache-expires-relative');
|
||||
const nextUpdateEl = getElement('next-update-relative');
|
||||
const updateStatusDetailEl = getElement('update-status-detail');
|
||||
const nextCheckEl = getElement('next-client-check');
|
||||
const estimationNoteEl = getElement('estimation-note');
|
||||
|
||||
if (state.radarData.cacheExpiresAt && expiresEl) {
|
||||
expiresEl.textContent = getRelativeTime(state.radarData.cacheExpiresAt);
|
||||
}
|
||||
|
||||
if (state.radarData.nextUpdateTime && nextUpdateEl) {
|
||||
nextUpdateEl.textContent = getRelativeTime(state.radarData.nextUpdateTime);
|
||||
}
|
||||
|
||||
// Update update status detail with relative time
|
||||
if (updateStatusDetailEl) {
|
||||
if (state.radarData.isUpdating && state.radarData.nextUpdateTime) {
|
||||
const relativeTime = getRelativeTime(state.radarData.nextUpdateTime);
|
||||
updateStatusDetailEl.textContent = 'Update in progress • Estimated completion: ' + formatDate(state.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 (!state.radarData.cacheIsValid && !state.radarData.isUpdating && state.radarData.nextUpdateTime) {
|
||||
const relativeTime = getRelativeTime(state.radarData.nextUpdateTime);
|
||||
updateStatusDetailEl.textContent = 'Update scheduled: ' + formatDate(state.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 (state.radarData.cacheIsValid) {
|
||||
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
|
||||
if (state.nextClientCheckTime && nextCheckEl) {
|
||||
nextCheckEl.textContent = 'Next check: ' + formatDate(state.nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(state.nextClientCheckTime.toISOString()) + ')';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status cards with cache update information
|
||||
*/
|
||||
export function updateStatusForCacheUpdate(retryAfter) {
|
||||
const statusEl = getElement('cache-status');
|
||||
const updateStatusDetailEl = getElement('update-status-detail');
|
||||
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = 'Generating <span class="status-badge status-updating">IN PROGRESS</span>';
|
||||
}
|
||||
|
||||
if (updateStatusDetailEl) {
|
||||
if (retryAfter) {
|
||||
const estimatedTime = new Date(Date.now() + (retryAfter * 1000));
|
||||
updateStatusDetailEl.textContent = `Cache generation in progress. Estimated ready in ${retryAfter} seconds.`;
|
||||
setElementText('next-update', formatDate(estimatedTime.toISOString()));
|
||||
} else {
|
||||
updateStatusDetailEl.textContent = 'Cache generation in progress, please wait...';
|
||||
setElementText('next-update', '-');
|
||||
}
|
||||
}
|
||||
|
||||
// Set other fields to indicate cache is being generated
|
||||
setElementText('observation-time', '-');
|
||||
setElementText('cache-expires', '-');
|
||||
setElementText('weather-station', '-');
|
||||
setElementText('distance', '-');
|
||||
|
||||
// Update last refresh
|
||||
if (state.lastRefreshTime) {
|
||||
setElementText('last-refresh', 'Last checked: ' + formatDate(state.lastRefreshTime.toISOString()));
|
||||
|
||||
// Calculate and display next client check time
|
||||
state.nextClientCheckTime = new Date(state.lastRefreshTime.getTime() + (state.settings.refreshInterval * 1000));
|
||||
const nextCheckEl = getElement('next-client-check');
|
||||
if (nextCheckEl) {
|
||||
nextCheckEl.textContent = 'Next check: ' + formatDate(state.nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(state.nextClientCheckTime.toISOString()) + ')';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI with radar data (main update function)
|
||||
*/
|
||||
export function updateUI(data) {
|
||||
// Update cache status even if no frames are available
|
||||
if (data) {
|
||||
const statusEl = getElement('cache-status');
|
||||
if (statusEl && (data.isUpdating !== undefined || data.cacheIsValid !== undefined)) {
|
||||
let statusHtml = '';
|
||||
if (data.isUpdating) {
|
||||
statusHtml = 'Updating <span class="status-badge status-updating">IN PROGRESS</span>';
|
||||
} else if (data.cacheIsValid) {
|
||||
statusHtml = 'Valid <span class="status-badge status-valid">ACTIVE</span>';
|
||||
} else {
|
||||
statusHtml = 'Invalid <span class="status-badge status-invalid">EXPIRED</span>';
|
||||
}
|
||||
statusEl.innerHTML = statusHtml;
|
||||
}
|
||||
|
||||
// Update update status detail
|
||||
const updateStatusDetailEl = getElement('update-status-detail');
|
||||
const estimationNoteEl = getElement('estimation-note');
|
||||
if (updateStatusDetailEl && (data.isUpdating !== undefined || data.cacheIsValid !== undefined)) {
|
||||
if (data.isUpdating) {
|
||||
if (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 = 'Cache is valid, no update needed';
|
||||
updateStatusDetailEl.title = '';
|
||||
if (estimationNoteEl) estimationNoteEl.style.display = 'none';
|
||||
} else {
|
||||
if (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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update other metadata fields
|
||||
if (data.observationTime) setElementText('observation-time', formatDate(data.observationTime));
|
||||
if (data.cacheExpiresAt) setElementText('cache-expires', formatDate(data.cacheExpiresAt));
|
||||
if (data.nextUpdateTime) setElementText('next-update', formatDate(data.nextUpdateTime));
|
||||
if (data.weatherStation !== undefined) setElementText('weather-station', data.weatherStation || '-');
|
||||
if (data.distance !== undefined) setElementText('distance', data.distance || '-');
|
||||
}
|
||||
|
||||
// Handle no frames case
|
||||
if (!data || !data.frames || data.frames.length === 0) {
|
||||
const currentStatus = getElement('cache-status')?.innerHTML;
|
||||
if (!currentStatus || currentStatus === 'Checking...' || currentStatus.trim() === '') {
|
||||
const statusEl = getElement('cache-status');
|
||||
const updateStatusDetailEl = getElement('update-status-detail');
|
||||
if (statusEl) statusEl.innerHTML = 'Generating <span class="status-badge status-updating">IN PROGRESS</span>';
|
||||
if (updateStatusDetailEl) updateStatusDetailEl.textContent = 'Cache generation in progress, please wait...';
|
||||
}
|
||||
|
||||
showNoFramesMessage('No radar frames available yet. Cache is being generated in the background. Please wait a moment and refresh.');
|
||||
state.frames = [];
|
||||
const controlsEl = getElement('frame-controls');
|
||||
if (controlsEl) controlsEl.innerHTML = '';
|
||||
setElementText('play-btn', '▶ Play');
|
||||
const playBtn = getElement('play-btn');
|
||||
const prevBtn = getElement('prev-btn');
|
||||
const nextBtn = getElement('next-btn');
|
||||
if (playBtn) playBtn.disabled = true;
|
||||
if (prevBtn) prevBtn.disabled = true;
|
||||
if (nextBtn) nextBtn.disabled = true;
|
||||
setElementText('frame-info', 'Waiting for cache to be generated...');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update state
|
||||
state.radarData = data;
|
||||
state.isExtendedMode = data.isExtendedMode || false;
|
||||
|
||||
// Enable controls
|
||||
const playBtn = getElement('play-btn');
|
||||
const prevBtn = getElement('prev-btn');
|
||||
const nextBtn = getElement('next-btn');
|
||||
if (playBtn) playBtn.disabled = false;
|
||||
if (prevBtn) prevBtn.disabled = false;
|
||||
if (nextBtn) nextBtn.disabled = false;
|
||||
|
||||
// Sort frames appropriately
|
||||
if (state.isExtendedMode) {
|
||||
state.frames = data.frames;
|
||||
} else {
|
||||
state.frames = data.frames.sort((a, b) => a.frameIndex - b.frameIndex);
|
||||
}
|
||||
|
||||
// Update metadata fields (redundant updates removed since already done above)
|
||||
// Update last refresh
|
||||
if (state.lastRefreshTime) {
|
||||
setElementText('last-refresh', 'Last checked: ' + formatDate(state.lastRefreshTime.toISOString()));
|
||||
|
||||
state.nextClientCheckTime = new Date(state.lastRefreshTime.getTime() + (state.settings.refreshInterval * 1000));
|
||||
const nextCheckEl = getElement('next-client-check');
|
||||
if (nextCheckEl) {
|
||||
nextCheckEl.textContent = 'Next check: ' + formatDate(state.nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(state.nextClientCheckTime.toISOString()) + ')';
|
||||
}
|
||||
}
|
||||
|
||||
// Frame controls will be built by the caller (to avoid circular dependencies)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// Utility functions
|
||||
|
||||
// Cookie management
|
||||
export function setCookie(name, value, days = 365) {
|
||||
const expires = new Date();
|
||||
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||
document.cookie = `${name}=${encodeURIComponent(JSON.stringify(value))};expires=${expires.toUTCString()};path=/`;
|
||||
}
|
||||
|
||||
export function getCookie(name) {
|
||||
const nameEQ = name + "=";
|
||||
const ca = document.cookie.split(';');
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i];
|
||||
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
|
||||
if (c.indexOf(nameEQ) === 0) {
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(c.substring(nameEQ.length, c.length)));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Date formatting utilities
|
||||
export function formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-AU', {
|
||||
timeZone: 'Australia/Brisbane',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
export function getRelativeTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = date - now;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffSecs = Math.floor((diffMs % 60000) / 1000);
|
||||
|
||||
if (diffMs < 0) {
|
||||
return Math.abs(diffMins) + ' minute(s) ago';
|
||||
} else if (diffMins > 0) {
|
||||
return 'in ' + diffMins + ' minute(s)';
|
||||
} else {
|
||||
return 'in ' + diffSecs + ' second(s)';
|
||||
}
|
||||
}
|
||||
|
||||
export function getMinutesAgo(dateString) {
|
||||
if (!dateString) return null;
|
||||
|
||||
// Handle both string and Date object inputs
|
||||
let date;
|
||||
if (typeof dateString === 'string') {
|
||||
date = new Date(dateString);
|
||||
} else if (dateString instanceof Date) {
|
||||
date = dateString;
|
||||
} else {
|
||||
console.warn('getMinutesAgo: Expected string or Date, got:', typeof dateString, dateString);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
console.warn('getMinutesAgo: Invalid date:', dateString, 'parsed as:', date);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate difference in milliseconds
|
||||
// Date.now() is UTC-based, date.getTime() is UTC-based, so this should be correct
|
||||
const now = Date.now();
|
||||
const dateTime = date.getTime();
|
||||
const diffMs = now - dateTime;
|
||||
|
||||
// Convert to minutes and round
|
||||
const minutes = Math.round(diffMs / 60000);
|
||||
|
||||
// If negative (future date), return null
|
||||
if (minutes < 0) {
|
||||
console.warn('getMinutesAgo: Future date detected:', dateString, 'parsed as:', date.toISOString());
|
||||
return null;
|
||||
}
|
||||
|
||||
// Safeguard against unreasonably large values (likely parsing errors)
|
||||
// But don't cap at 60 - radar frames can legitimately be older in some cases
|
||||
if (minutes > 100000) { // e.g., more than ~69 days ago
|
||||
console.warn('getMinutesAgo: Unreasonably large minutes value:', minutes, 'for date:', dateString, 'parsed as:', date.toISOString());
|
||||
return null;
|
||||
}
|
||||
|
||||
return minutes;
|
||||
}
|
||||
|
||||
// Format frame time for display (consolidates duplicate logic)
|
||||
export function formatFrameTime(frame, isExtendedMode) {
|
||||
if (!frame.absoluteObservationTime) return '-';
|
||||
|
||||
if (isExtendedMode) {
|
||||
return formatDate(frame.absoluteObservationTime);
|
||||
} else {
|
||||
const minutesAgo = getMinutesAgo(frame.absoluteObservationTime);
|
||||
// If minutesAgo is null (invalid date or edge case), fall back to formatted date
|
||||
if (minutesAgo === null) {
|
||||
return formatDate(frame.absoluteObservationTime);
|
||||
}
|
||||
return `${minutesAgo} min ago`;
|
||||
}
|
||||
}
|
||||
|
||||
// Network error detection (consolidates duplicate checks)
|
||||
export function isNetworkError(error) {
|
||||
return error.name === 'TypeError' ||
|
||||
error.name === 'AbortError' ||
|
||||
error.message.includes('fetch') ||
|
||||
error.message.includes('network') ||
|
||||
error.message.includes('Failed to fetch') ||
|
||||
error.message === 'Network error';
|
||||
}
|
||||
|
||||
export function isNetworkErrorResponse(response) {
|
||||
return response.status === 0 || response.status >= 500;
|
||||
}
|
||||
|
||||
// DOM helpers
|
||||
export function getElement(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
export function setElementText(id, text) {
|
||||
const el = getElement(id);
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
export function setElementHTML(id, html) {
|
||||
const el = getElement(id);
|
||||
if (el) el.innerHTML = html;
|
||||
}
|
||||
|
||||
// Create cache status data object (consolidates duplicate creation)
|
||||
export function createCacheStatusData(errorData) {
|
||||
const details = errorData.details || errorData;
|
||||
const isUpdating = details.updateTriggered === true ||
|
||||
(errorData.message && errorData.message.includes('in progress')) ||
|
||||
(details.statusMessage && details.statusMessage.includes('in progress'));
|
||||
|
||||
return {
|
||||
frames: [],
|
||||
isUpdating: isUpdating,
|
||||
cacheIsValid: details.cacheIsValid || false,
|
||||
cacheExpiresAt: details.cacheExpiresAt || null,
|
||||
nextUpdateTime: details.nextUpdateTime || null
|
||||
};
|
||||
}
|
||||
|
||||
// Trigger background cache refresh (consolidates duplicate calls)
|
||||
export function triggerBackgroundRefresh(refreshEndpoint) {
|
||||
if (refreshEndpoint) {
|
||||
fetch(refreshEndpoint, {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(5000)
|
||||
}).catch(err => {
|
||||
console.debug('Background cache refresh trigger failed (non-critical):', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Preload all frame images to prevent jiggle
|
||||
export function preloadImages(frames) {
|
||||
frames.forEach(frame => {
|
||||
const img = new Image();
|
||||
img.src = frame.imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user