mirror of
https://github.com/alexhopeoconnor/bom-local-service.git
synced 2026-10-22 01:02:07 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eecce00a6 | ||
|
|
28e8cb4fd1 | ||
|
|
a54061f757 | ||
|
|
0b6d266e79 | ||
|
|
1a257931e8 | ||
|
|
0a29c2d0c1 | ||
|
|
ffd3636d1c | ||
|
|
b30d76ddec |
@@ -4,10 +4,10 @@
|
|||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Version>0.0.6</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
|
||||||
<PackageReference Include="Microsoft.Playwright" Version="1.57.0" />
|
<PackageReference Include="Microsoft.Playwright" Version="1.57.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -60,8 +60,19 @@ public class RadarController : ControllerBase
|
|||||||
{
|
{
|
||||||
// Get cache status to include in 404 response
|
// Get cache status to include in 404 response
|
||||||
// Use cache service directly since this is cache management, not radar-specific
|
// Use cache service directly since this is cache management, not radar-specific
|
||||||
var cacheExpirationMinutes = (int)_configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
|
var cacheExpirationMinutesConfig = _configuration.GetValue<double?>("CacheExpirationMinutes");
|
||||||
var cacheManagementCheckIntervalMinutes = _configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
|
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(
|
var cacheStatus = await _cacheService.GetCacheStatusAsync(
|
||||||
suburb,
|
suburb,
|
||||||
state,
|
state,
|
||||||
@@ -288,7 +299,14 @@ public class RadarController : ControllerBase
|
|||||||
|
|
||||||
// Validate time range size to prevent excessive data loading
|
// Validate time range size to prevent excessive data loading
|
||||||
// Default: base limit on cache retention, but allow override via config
|
// 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");
|
var configuredMaxHours = _configuration.GetValue<int?>("TimeSeries:MaxTimeRangeHours");
|
||||||
|
|
||||||
// Use configured value if set, otherwise use cache retention (with minimum of 24 hours)
|
// 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
|
// Get cache status to include in 404 response
|
||||||
var cacheExpirationMinutes = (int)_configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
|
var cacheExpirationMinutesConfig = _configuration.GetValue<double?>("CacheExpirationMinutes");
|
||||||
var cacheManagementCheckIntervalMinutes = _configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
|
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(
|
var cacheStatus = await _cacheService.GetCacheStatusAsync(
|
||||||
suburb,
|
suburb,
|
||||||
state,
|
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; }
|
public int FrameIndex { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public int MinutesAgo { get; set; }
|
public DateTime ObservationTime { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ public class JavaScriptTemplatesConfig
|
|||||||
|
|
||||||
public string CheckActiveFrameSegment { get; set; } = @"() => {
|
public string CheckActiveFrameSegment { get; set; } = @"() => {
|
||||||
const segments = Array.from(document.querySelectorAll('[data-testid=""bom-scrub-segment""]'));
|
const segments = Array.from(document.querySelectorAll('[data-testid=""bom-scrub-segment""]'));
|
||||||
|
if (segments.length === 0) return true;
|
||||||
const activeSegment = segments.find(s => {
|
const activeSegment = segments.find(s => {
|
||||||
const style = window.getComputedStyle(s);
|
const style = window.getComputedStyle(s);
|
||||||
return style.backgroundColor !== 'rgb(148, 148, 148)' && style.backgroundColor !== 'rgb(148, 148, 148)';
|
return style.backgroundColor !== 'rgb(148, 148, 148)' && style.backgroundColor !== 'rgb(148, 148, 148)';
|
||||||
@@ -73,7 +74,7 @@ public class JavaScriptTemplatesConfig
|
|||||||
let minutes = null;
|
let minutes = null;
|
||||||
|
|
||||||
// Parse timestamp format: ""Wednesday 17 Dec, 11:05 pm"" or ""17 Dec, 11:05 pm""
|
// 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) {
|
if (timestampMatch) {
|
||||||
try {
|
try {
|
||||||
const day = parseInt(timestampMatch[1]);
|
const day = parseInt(timestampMatch[1]);
|
||||||
|
|||||||
+2
-10
@@ -2,22 +2,14 @@ namespace BomLocalService.Models;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a single radar frame (historical precipitation data).
|
/// 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.
|
/// Radar frames can be joined across cache folders to create extended historical slideshows.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RadarFrame : CachedFrame
|
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>
|
/// <summary>
|
||||||
/// The absolute UTC observation time for this frame.
|
/// The absolute UTC observation time for this frame.
|
||||||
/// Calculated as: ObservationTime - MinutesAgo.
|
/// Parsed directly from the frame display label during capture.
|
||||||
/// This is set when frames are joined across cache folders in timeseries responses.
|
/// Client calculates "minutes ago" dynamically from this timestamp.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime? AbsoluteObservationTime { get; set; }
|
public DateTime? AbsoluteObservationTime { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ public class RadarResponse
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// List of all captured frames (typically 7 frames: 0-6).
|
/// 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>
|
/// </summary>
|
||||||
public List<RadarFrame> Frames { get; set; } = new();
|
public List<RadarFrame> Frames { get; set; } = new();
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace BomLocalService.Models;
|
|||||||
public class TextPatternsConfig
|
public class TextPatternsConfig
|
||||||
{
|
{
|
||||||
public string ResultsCountPattern { get; set; } = @"(\d+)\s+of\s+(\d+)";
|
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 ObservationTimePattern { get; set; } = @"Observations:\s*(\d+)\s*minutes?\s*ago";
|
||||||
public string ForecastTimePattern { get; set; } = @"Forecast:\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";
|
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.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);
|
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
|
// AddControllersWithViews includes AddControllers, so we use it for both MVC and API controllers
|
||||||
builder.Services.AddControllersWithViews();
|
builder.Services.AddControllersWithViews();
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddOpenApi();
|
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
|
|
||||||
// Configure CORS - MUST be added before other services
|
// Configure CORS - MUST be added before other services
|
||||||
var corsOrigins = builder.Configuration.GetValue<string>("Cors:AllowedOrigins", "*");
|
builder.Services.AddCorsConfiguration(builder.Configuration);
|
||||||
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);
|
|
||||||
|
|
||||||
// Support environment variable override (comma-separated for multiple origins)
|
// Register all services via assembly scanning
|
||||||
var corsOriginsEnv = Environment.GetEnvironmentVariable("CORS__ALLOWEDORIGINS");
|
builder.Services.ScanAndRegisterServices(typeof(Program).Assembly);
|
||||||
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);
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline
|
// Configure the HTTP request pipeline
|
||||||
if (app.Environment.IsDevelopment())
|
|
||||||
{
|
|
||||||
app.MapOpenApi();
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTPS redirection is optional and disabled by default for Docker flexibility
|
// HTTPS redirection is optional and disabled by default for Docker flexibility
|
||||||
// Users can enable it by setting ENABLE_HTTPS_REDIRECTION=true environment variable
|
// Users can enable it by setting EnableHttpsRedirection=true in appsettings.json or ENABLEHTTPSREDIRECTION=true environment variable
|
||||||
// or EnableHttpsRedirection=true in appsettings.json
|
var enableHttpsRedirection = builder.Configuration.GetValue<bool>("EnableHttpsRedirection", false);
|
||||||
var enableHttpsRedirection = builder.Configuration.GetValue<bool>("EnableHttpsRedirection", false) ||
|
|
||||||
Environment.GetEnvironmentVariable("ENABLE_HTTPS_REDIRECTION")?.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
|
|
||||||
if (enableHttpsRedirection)
|
if (enableHttpsRedirection)
|
||||||
{
|
{
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
@@ -151,6 +29,9 @@ if (enableHttpsRedirection)
|
|||||||
// Enable routing (required for CORS to work with controllers)
|
// Enable routing (required for CORS to work with controllers)
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
|
|
||||||
|
// Static files (for wwwroot assets like CSS, JS, images)
|
||||||
|
app.UseStaticFiles();
|
||||||
|
|
||||||
// CORS middleware - MUST be after UseRouting but before MapControllers
|
// CORS middleware - MUST be after UseRouting but before MapControllers
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
|
|
||||||
@@ -167,24 +48,6 @@ app.MapControllers();
|
|||||||
// Map health check endpoint for Docker health monitoring
|
// Map health check endpoint for Docker health monitoring
|
||||||
app.MapHealthChecks("/api/health");
|
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
|
// Cleanup incomplete cache folders from previous crashes/restarts before starting services
|
||||||
var cacheService = app.Services.GetRequiredService<ICacheService>();
|
var cacheService = app.Services.GetRequiredService<ICacheService>();
|
||||||
var deletedCount = cacheService.CleanupIncompleteCacheFolders();
|
var deletedCount = cacheService.CleanupIncompleteCacheFolders();
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img
|
<img width="256" height="auto" alt="icon" src="https://github.com/user-attachments/assets/6f712fbd-4f42-49d4-9ddb-1767bb43551a" />
|
||||||
src="https://github.com/user-attachments/assets/684b73cd-a374-49e6-aaf1-ccc4c5697b47"
|
|
||||||
width="500"
|
|
||||||
alt="f67325fe-33ae-4fd8-9e57-38b46899d09c"
|
|
||||||
/>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# BOM Local Service
|
# 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_ENVIRONMENT` | Runtime environment (Development/Production) | `Production` |
|
||||||
| `ASPNETCORE_URLS` | URLs the service listens on | `http://+:8080` |
|
| `ASPNETCORE_URLS` | URLs the service listens on | `http://+:8080` |
|
||||||
| `ENABLE_HTTPS_REDIRECTION` | Enable HTTPS redirection | `false` |
|
| `ENABLEHTTPSREDIRECTION` | Enable HTTPS redirection | `false` |
|
||||||
|
|
||||||
#### Application Configuration
|
#### 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__CHECKINTERVALMINUTES` | Interval between cache validity checks | `5` | `10` |
|
||||||
| `CACHEMANAGEMENT__INITIALDELAYSECONDS` | Delay before first cache check on startup | `10` | `30` |
|
| `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 (used for both initial and periodic updates) | `1` | `2` |
|
||||||
| `CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS` | Delay between processing different locations | `1` | `2` |
|
|
||||||
|
|
||||||
#### Cache Cleanup
|
#### Cache Cleanup
|
||||||
|
|
||||||
@@ -524,8 +519,7 @@ GET /api/radar/{suburb}/{state}
|
|||||||
{
|
{
|
||||||
"frameIndex": 0,
|
"frameIndex": 0,
|
||||||
"imageUrl": "/api/radar/Brisbane/QLD/frame/0",
|
"imageUrl": "/api/radar/Brisbane/QLD/frame/0",
|
||||||
"minutesAgo": 0,
|
"absoluteObservationTime": "2025-01-15T10:00:00Z"
|
||||||
"observationTime": "2025-01-15T10:00:00Z"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"observationTime": "2025-01-15T10:00:00Z",
|
"observationTime": "2025-01-15T10:00:00Z",
|
||||||
@@ -540,7 +534,7 @@ GET /api/radar/{suburb}/{state}
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response Fields:**
|
**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
|
- `observationTime`: UTC timestamp when the observation was made
|
||||||
- `forecastTime`: UTC timestamp for the forecast
|
- `forecastTime`: UTC timestamp for the forecast
|
||||||
- `weatherStation`: Name of the weather station
|
- `weatherStation`: Name of the weather station
|
||||||
@@ -670,8 +664,7 @@ GET /api/radar/{suburb}/{state}/timeseries?startTime={iso8601}&endTime={iso8601}
|
|||||||
{
|
{
|
||||||
"frameIndex": 0,
|
"frameIndex": 0,
|
||||||
"imageUrl": "/api/radar/Brisbane/QLD/frame/0?cacheFolder=Brisbane_QLD_20250115_100000",
|
"imageUrl": "/api/radar/Brisbane/QLD/frame/0?cacheFolder=Brisbane_QLD_20250115_100000",
|
||||||
"absoluteObservationTime": "2025-01-15T10:00:00Z",
|
"absoluteObservationTime": "2025-01-15T10:00:00Z"
|
||||||
"minutesAgo": 0
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
|
## 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.
|
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
|
// Or loop through all frames for animation
|
||||||
radarData.frames.forEach((frame, index) => {
|
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;
|
_scrapingService = scrapingService;
|
||||||
_debugService = debugService;
|
_debugService = debugService;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 12.5);
|
|
||||||
_cacheManagementCheckIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
|
var cacheExpirationMinutesConfig = configuration.GetValue<double?>("CacheExpirationMinutes");
|
||||||
_timeSeriesWarningFolderCount = configuration.GetValue<int>("TimeSeries:WarningFolderCount", 200);
|
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
|
// Calculate estimated cache update duration
|
||||||
_estimatedUpdateDurationSeconds = CacheHelper.GetEstimatedUpdateDurationSeconds(configuration, CachedDataType.Radar);
|
_estimatedUpdateDurationSeconds = CacheHelper.GetEstimatedUpdateDurationSeconds(configuration, CachedDataType.Radar);
|
||||||
@@ -167,6 +185,9 @@ public class BomRadarService : IBomRadarService, IDisposable
|
|||||||
if (needsUpdate)
|
if (needsUpdate)
|
||||||
{
|
{
|
||||||
// Trigger async update (fire and forget)
|
// 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 () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -177,7 +198,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
|||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error during background cache update for {Suburb}, {State}", suburb, state);
|
_logger.LogError(ex, "Error during background cache update for {Suburb}, {State}", suburb, state);
|
||||||
}
|
}
|
||||||
}, cancellationToken);
|
});
|
||||||
|
|
||||||
status.UpdateTriggered = true;
|
status.UpdateTriggered = true;
|
||||||
status.Message = status.CacheExists
|
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)
|
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>();
|
var uniqueFrames = new List<RadarFrame>();
|
||||||
|
|
||||||
foreach (var frame in radarFrames)
|
foreach (var frame in radarFrames)
|
||||||
{
|
{
|
||||||
frame.ImageUrl = $"/api/radar/{encodedSuburb}/{encodedState}/frame/{frame.FrameIndex}?cacheFolder={Uri.EscapeDataString(folderInfo.FolderName)}";
|
frame.ImageUrl = $"/api/radar/{encodedSuburb}/{encodedState}/frame/{frame.FrameIndex}?cacheFolder={Uri.EscapeDataString(folderInfo.FolderName)}";
|
||||||
|
|
||||||
// Calculate absolute observation time for this frame
|
// Use absolute observation time directly (already set during capture)
|
||||||
// minutesAgo is relative to the cache folder's observation time
|
// Only include frames with unique absolute observation times
|
||||||
// Validate that ObservationTime is reasonable (not default/min value)
|
// Since we process folders newest-first, this ensures we keep frames from the most recent cache folder
|
||||||
if (folderInfo.ObservationTime > DateTime.MinValue.AddYears(1) && frame.MinutesAgo >= 0)
|
if (frame.AbsoluteObservationTime.HasValue &&
|
||||||
|
frame.AbsoluteObservationTime.Value > DateTime.MinValue.AddYears(1) &&
|
||||||
|
!seenAbsoluteTimes.Contains(frame.AbsoluteObservationTime.Value))
|
||||||
{
|
{
|
||||||
frame.AbsoluteObservationTime = folderInfo.ObservationTime.AddMinutes(-frame.MinutesAgo);
|
seenAbsoluteTimes.Add(frame.AbsoluteObservationTime.Value);
|
||||||
|
uniqueFrames.Add(frame);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else if (!frame.AbsoluteObservationTime.HasValue)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Skipping frame {FrameIndex} from folder {FolderName}: Invalid ObservationTime ({ObservationTime}) or MinutesAgo ({MinutesAgo})",
|
_logger.LogWarning("Skipping frame {FrameIndex} from folder {FolderName}: Missing AbsoluteObservationTime",
|
||||||
frame.FrameIndex, folderInfo.FolderName, folderInfo.ObservationTime, frame.MinutesAgo);
|
frame.FrameIndex, folderInfo.FolderName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,15 @@ public class BrowserService : IBrowserService
|
|||||||
public BrowserService(ILogger<BrowserService> logger, IConfiguration configuration, IDebugService debugService)
|
public BrowserService(ILogger<BrowserService> logger, IConfiguration configuration, IDebugService debugService)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_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;
|
_debugService = debugService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
using BomLocalService.Services.Interfaces.Registration;
|
||||||
using BomLocalService.Utilities;
|
using BomLocalService.Utilities;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
namespace BomLocalService.Services;
|
namespace BomLocalService.Services;
|
||||||
|
|
||||||
public class CacheCleanupService : BackgroundService
|
public class CacheCleanupService : BackgroundService, IHostedServiceRegistration
|
||||||
{
|
{
|
||||||
private readonly ILogger<CacheCleanupService> _logger;
|
private readonly ILogger<CacheCleanupService> _logger;
|
||||||
private readonly string _cacheDirectory;
|
private readonly string _cacheDirectory;
|
||||||
@@ -14,8 +15,20 @@ public class CacheCleanupService : BackgroundService
|
|||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
|
_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);
|
_cleanupInterval = TimeSpan.FromHours(cleanupIntervalHours);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
using BomLocalService.Services.Interfaces;
|
using BomLocalService.Services.Interfaces;
|
||||||
|
using BomLocalService.Services.Interfaces.Registration;
|
||||||
using BomLocalService.Utilities;
|
using BomLocalService.Utilities;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
namespace BomLocalService.Services;
|
namespace BomLocalService.Services;
|
||||||
|
|
||||||
public class CacheManagementService : BackgroundService
|
public class CacheManagementService : BackgroundService, IHostedServiceRegistration
|
||||||
{
|
{
|
||||||
private readonly ILogger<CacheManagementService> _logger;
|
private readonly ILogger<CacheManagementService> _logger;
|
||||||
private readonly IBomRadarService _bomRadarService;
|
private readonly IBomRadarService _bomRadarService;
|
||||||
private readonly IBrowserService _browserService;
|
private readonly IBrowserService _browserService;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly TimeSpan _checkInterval;
|
private readonly TimeSpan _checkInterval;
|
||||||
|
private readonly TimeSpan _locationStaggerInterval;
|
||||||
|
private readonly TimeSpan _initialDelayInterval;
|
||||||
private readonly HashSet<string> _activeUpdates = new();
|
private readonly HashSet<string> _activeUpdates = new();
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
|
|
||||||
@@ -24,14 +27,34 @@ public class CacheManagementService : BackgroundService
|
|||||||
_bomRadarService = bomRadarService;
|
_bomRadarService = bomRadarService;
|
||||||
_browserService = browserService;
|
_browserService = browserService;
|
||||||
_configuration = configuration;
|
_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)
|
if (checkIntervalMinutes <= 0 || checkIntervalMinutes > 60)
|
||||||
{
|
{
|
||||||
throw new ArgumentException("CacheManagement:CheckIntervalMinutes must be between 1 and 60", nameof(configuration));
|
throw new ArgumentException("CacheManagement:CheckIntervalMinutes must be between 1 and 60", nameof(configuration));
|
||||||
}
|
}
|
||||||
|
|
||||||
_checkInterval = TimeSpan.FromMinutes(checkIntervalMinutes);
|
_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)
|
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);
|
_logger.LogInformation("Cache management service started. Check interval: {Interval}", _checkInterval);
|
||||||
|
|
||||||
// Wait a bit for the service to fully initialize
|
// Wait a bit for the service to fully initialize
|
||||||
var initialDelaySeconds = _configuration.GetValue<int>("CacheManagement:InitialDelaySeconds", 10);
|
await Task.Delay(_initialDelayInterval, stoppingToken);
|
||||||
await Task.Delay(TimeSpan.FromSeconds(initialDelaySeconds), stoppingToken);
|
|
||||||
|
|
||||||
// Pre-warm the browser before starting cache updates
|
// Pre-warm the browser before starting cache updates
|
||||||
_logger.LogInformation("Pre-warming browser before cache updates");
|
_logger.LogInformation("Pre-warming browser before cache updates");
|
||||||
@@ -58,28 +80,7 @@ public class CacheManagementService : BackgroundService
|
|||||||
|
|
||||||
// Initial cache update on startup
|
// Initial cache update on startup
|
||||||
_logger.LogInformation("Performing initial cache update for {Count} locations", locationsToManage.Count);
|
_logger.LogInformation("Performing initial cache update for {Count} locations", locationsToManage.Count);
|
||||||
int initialUpdatesTriggered = 0;
|
var (initialUpdatesTriggered, initialCachesValid) = await ProcessLocationsAsync(locationsToManage, stoppingToken);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Initial cache update completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid",
|
_logger.LogInformation("Initial cache update completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid",
|
||||||
initialUpdatesTriggered, initialCachesValid);
|
initialUpdatesTriggered, initialCachesValid);
|
||||||
@@ -95,28 +96,7 @@ public class CacheManagementService : BackgroundService
|
|||||||
locationsToManage = GetLocationsToManage();
|
locationsToManage = GetLocationsToManage();
|
||||||
_logger.LogInformation("Starting periodic cache check for {Count} locations", locationsToManage.Count);
|
_logger.LogInformation("Starting periodic cache check for {Count} locations", locationsToManage.Count);
|
||||||
|
|
||||||
int updatesTriggered = 0;
|
var (updatesTriggered, cachesValid) = await ProcessLocationsAsync(locationsToManage, stoppingToken);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Periodic cache check completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid",
|
_logger.LogInformation("Periodic cache check completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid",
|
||||||
updatesTriggered, cachesValid);
|
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)
|
private async Task<(bool updateTriggered, bool isValid)> UpdateCacheIfNeededAsync(string suburb, string state, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var locationKey = LocationHelper.GetLocationKey(suburb, state);
|
var locationKey = LocationHelper.GetLocationKey(suburb, state);
|
||||||
|
|||||||
+17
-12
@@ -27,7 +27,13 @@ public class CacheService : ICacheService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_cacheDirectory = FilePathHelper.GetCacheDirectory(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)
|
if (_cacheExpirationMinutes <= 0)
|
||||||
{
|
{
|
||||||
@@ -148,18 +154,18 @@ public class CacheService : ICacheService
|
|||||||
return null;
|
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 framesMetadata = await LoadFramesMetadataAsync(cacheFolderPath, CachedDataType.Radar, cancellationToken);
|
||||||
var frameMetadata = framesMetadata.FirstOrDefault(f => f.FrameIndex == frameIndex);
|
var frameMetadata = framesMetadata.FirstOrDefault(f => f.FrameIndex == frameIndex);
|
||||||
var minutesAgo = frameMetadata != null
|
var observationTime = frameMetadata?.ObservationTime > DateTime.MinValue.AddYears(1)
|
||||||
? frameMetadata.MinutesAgo
|
? frameMetadata.ObservationTime
|
||||||
: 40 - (frameIndex * 5);
|
: (DateTime?)null;
|
||||||
|
|
||||||
return new RadarFrame
|
return new RadarFrame
|
||||||
{
|
{
|
||||||
FrameIndex = frameIndex,
|
FrameIndex = frameIndex,
|
||||||
ImagePath = framePath,
|
ImagePath = framePath,
|
||||||
MinutesAgo = minutesAgo
|
AbsoluteObservationTime = observationTime
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +200,7 @@ public class CacheService : ICacheService
|
|||||||
var framesMetadata = frames.Select(f => new FrameMetadata
|
var framesMetadata = frames.Select(f => new FrameMetadata
|
||||||
{
|
{
|
||||||
FrameIndex = f.FrameIndex,
|
FrameIndex = f.FrameIndex,
|
||||||
MinutesAgo = f.MinutesAgo
|
ObservationTime = f.AbsoluteObservationTime ?? throw new InvalidOperationException($"Frame {f.FrameIndex} missing AbsoluteObservationTime")
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
var framesPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath, dataType);
|
var framesPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath, dataType);
|
||||||
@@ -418,7 +424,8 @@ public class CacheService : ICacheService
|
|||||||
|
|
||||||
// Load frame metadata from frames.json if available
|
// Load frame metadata from frames.json if available
|
||||||
var framesMetadata = await LoadFramesMetadataAsync(folderPath, dataType, cancellationToken);
|
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
|
// Load frames from data type subfolder
|
||||||
for (int i = 0; i < frameCount; i++)
|
for (int i = 0; i < frameCount; i++)
|
||||||
@@ -428,15 +435,13 @@ public class CacheService : ICacheService
|
|||||||
{
|
{
|
||||||
if (dataType == CachedDataType.Radar)
|
if (dataType == CachedDataType.Radar)
|
||||||
{
|
{
|
||||||
var minutesAgo = metadataDict.ContainsKey(i)
|
var observationTime = metadataDict.ContainsKey(i) ? metadataDict[i] : (DateTime?)null;
|
||||||
? metadataDict[i]
|
|
||||||
: 40 - (i * 5);
|
|
||||||
|
|
||||||
frames.Add(new RadarFrame
|
frames.Add(new RadarFrame
|
||||||
{
|
{
|
||||||
FrameIndex = i,
|
FrameIndex = i,
|
||||||
ImagePath = framePath,
|
ImagePath = framePath,
|
||||||
MinutesAgo = minutesAgo
|
AbsoluteObservationTime = observationTime
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
+10
-13
@@ -14,20 +14,17 @@ namespace BomLocalService.Services;
|
|||||||
public DebugService(ILogger<DebugService> logger, IConfiguration configuration)
|
public DebugService(ILogger<DebugService> logger, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_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
|
// Get debug enabled from configuration (default from appsettings.json, can be overridden via DEBUG__ENABLED environment variable)
|
||||||
var debugEnabledEnv = Environment.GetEnvironmentVariable("DEBUG__ENABLED");
|
// Note: bool defaults to false if not found, which is acceptable for Debug:Enabled
|
||||||
var debugEnabledConfig = configuration.GetValue<bool>("Debug:Enabled", false);
|
_enabled = 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;
|
|
||||||
|
|
||||||
var cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
|
var cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
|
||||||
_debugDirectory = Path.Combine(cacheDirectory, "debug");
|
_debugDirectory = Path.Combine(cacheDirectory, "debug");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using BomLocalService.Models;
|
using BomLocalService.Models;
|
||||||
|
using BomLocalService.Services.Interfaces.Registration;
|
||||||
|
|
||||||
namespace BomLocalService.Services.Interfaces;
|
namespace BomLocalService.Services.Interfaces;
|
||||||
|
|
||||||
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
|
|||||||
/// Main service interface for BOM radar screenshot operations.
|
/// Main service interface for BOM radar screenshot operations.
|
||||||
/// Orchestrates cache management, browser automation, and web scraping to provide radar screenshots for Australian locations.
|
/// Orchestrates cache management, browser automation, and web scraping to provide radar screenshots for Australian locations.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IBomRadarService
|
public interface IBomRadarService : ISingletonService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets cached radar data for a location.
|
/// Gets cached radar data for a location.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using BomLocalService.Services.Interfaces.Registration;
|
||||||
using Microsoft.Playwright;
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
namespace BomLocalService.Services.Interfaces;
|
namespace BomLocalService.Services.Interfaces;
|
||||||
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
|
|||||||
/// Service interface for managing Playwright browser instances and automation.
|
/// Service interface for managing Playwright browser instances and automation.
|
||||||
/// Handles browser lifecycle, context creation, and anti-detection measures for web scraping.
|
/// Handles browser lifecycle, context creation, and anti-detection measures for web scraping.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IBrowserService : IDisposable
|
public interface IBrowserService : ISingletonService, IDisposable
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new browser context with proper configuration for BOM website scraping.
|
/// Creates a new browser context with proper configuration for BOM website scraping.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using BomLocalService.Models;
|
using BomLocalService.Models;
|
||||||
|
using BomLocalService.Services.Interfaces.Registration;
|
||||||
|
|
||||||
namespace BomLocalService.Services.Interfaces;
|
namespace BomLocalService.Services.Interfaces;
|
||||||
|
|
||||||
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
|
|||||||
/// Service interface for managing cached radar screenshot files and metadata.
|
/// Service interface for managing cached radar screenshot files and metadata.
|
||||||
/// Handles file system operations for storing and retrieving cached BOM radar screenshots.
|
/// Handles file system operations for storing and retrieving cached BOM radar screenshots.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ICacheService
|
public interface ICacheService : ISingletonService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the cached screenshot folder path and associated metadata for a location and data type.
|
/// 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);
|
Task SaveMetadataAsync(string cacheFolderPath, LastUpdatedInfo metadata, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="cacheFolderPath">Full path to the cache folder</param>
|
/// <param name="cacheFolderPath">Full path to the cache folder</param>
|
||||||
/// <param name="dataType">The type of cached data</param>
|
/// <param name="dataType">The type of cached data</param>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using BomLocalService.Services.Interfaces.Registration;
|
||||||
using Microsoft.Playwright;
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
namespace BomLocalService.Services.Interfaces;
|
namespace BomLocalService.Services.Interfaces;
|
||||||
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
|
|||||||
/// Service interface for debug file generation during web scraping operations.
|
/// Service interface for debug file generation during web scraping operations.
|
||||||
/// When enabled, saves screenshots, HTML, console logs, and network request information for troubleshooting.
|
/// When enabled, saves screenshots, HTML, console logs, and network request information for troubleshooting.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IDebugService
|
public interface IDebugService : ISingletonService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Indicates whether debug mode is enabled.
|
/// Indicates whether debug mode is enabled.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using BomLocalService.Models;
|
using BomLocalService.Models;
|
||||||
|
using BomLocalService.Services.Interfaces.Registration;
|
||||||
using Microsoft.Playwright;
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
namespace BomLocalService.Services.Interfaces;
|
namespace BomLocalService.Services.Interfaces;
|
||||||
@@ -7,7 +8,7 @@ namespace BomLocalService.Services.Interfaces;
|
|||||||
/// Service interface for scraping radar screenshots from the BOM website.
|
/// Service interface for scraping radar screenshots from the BOM website.
|
||||||
/// Orchestrates the multi-step process of navigating BOM, finding locations, and capturing radar screenshots.
|
/// Orchestrates the multi-step process of navigating BOM, finding locations, and capturing radar screenshots.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IScrapingService
|
public interface IScrapingService : ISingletonService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scrapes a radar screenshot from the BOM website for a given location.
|
/// 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.Models;
|
||||||
|
using BomLocalService.Services.Interfaces.Registration;
|
||||||
using Microsoft.Playwright;
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
namespace BomLocalService.Services.Interfaces;
|
namespace BomLocalService.Services.Interfaces;
|
||||||
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service for finding page elements using configured selectors
|
/// Service for finding page elements using configured selectors
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ISelectorService
|
public interface ISelectorService : ISingletonService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds an element using the configured selectors, trying each in order until one is found
|
/// Finds an element using the configured selectors, trying each in order until one is found
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using BomLocalService.Models;
|
using BomLocalService.Models;
|
||||||
|
using BomLocalService.Services.Interfaces.Registration;
|
||||||
using Microsoft.Playwright;
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
namespace BomLocalService.Services.Interfaces;
|
namespace BomLocalService.Services.Interfaces;
|
||||||
@@ -7,7 +8,7 @@ namespace BomLocalService.Services.Interfaces;
|
|||||||
/// Service interface for parsing time and metadata information from BOM website content.
|
/// 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.
|
/// Extracts observation times, forecast times, weather station names, and distances from BOM weather map pages.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ITimeParsingService
|
public interface ITimeParsingService : ISingletonService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extracts last updated information from a BOM weather map page.
|
/// 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;
|
namespace BomLocalService.Services.Scraping;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Interface for a single scraping step
|
/// Interface for a single scraping step
|
||||||
|
/// Steps are stateless (all request data is in ScrapingContext), so singleton lifetime is appropriate
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IScrapingStep
|
public interface IScrapingStep : ISingletonService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unique name of the step
|
/// Unique name of the step
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
using BomLocalService.Services.Interfaces.Registration;
|
||||||
|
|
||||||
namespace BomLocalService.Services.Scraping;
|
namespace BomLocalService.Services.Scraping;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registry for managing scraping steps
|
/// Registry for managing scraping steps
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IScrapingStepRegistry
|
public interface IScrapingStepRegistry : ISingletonService
|
||||||
{
|
{
|
||||||
void RegisterStep(IScrapingStep step);
|
void RegisterStep(IScrapingStep step);
|
||||||
IScrapingStep? GetStep(string name);
|
IScrapingStep? GetStep(string name);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
using BomLocalService.Services.Interfaces.Registration;
|
||||||
|
|
||||||
namespace BomLocalService.Services.Scraping;
|
namespace BomLocalService.Services.Scraping;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Factory for creating workflows
|
/// Factory for creating workflows
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IWorkflowFactory
|
public interface IWorkflowFactory : ISingletonService
|
||||||
{
|
{
|
||||||
IWorkflow<TResponse> GetWorkflow<TResponse>(string name);
|
IWorkflow<TResponse> GetWorkflow<TResponse>(string name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
|
||||||
namespace BomLocalService.Services.Scraping;
|
namespace BomLocalService.Services.Scraping;
|
||||||
|
|
||||||
public class ScrapingStepRegistry : IScrapingStepRegistry
|
public class ScrapingStepRegistry : IScrapingStepRegistry
|
||||||
@@ -5,9 +7,33 @@ public class ScrapingStepRegistry : IScrapingStepRegistry
|
|||||||
private readonly Dictionary<string, IScrapingStep> _steps = new();
|
private readonly Dictionary<string, IScrapingStep> _steps = new();
|
||||||
private readonly ILogger<ScrapingStepRegistry> _logger;
|
private readonly ILogger<ScrapingStepRegistry> _logger;
|
||||||
|
|
||||||
public ScrapingStepRegistry(ILogger<ScrapingStepRegistry> logger)
|
public ScrapingStepRegistry(ILogger<ScrapingStepRegistry> logger, IServiceProvider serviceProvider)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_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)
|
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.Services.Scraping;
|
||||||
using BomLocalService.Utilities;
|
using BomLocalService.Utilities;
|
||||||
using Microsoft.Playwright;
|
using Microsoft.Playwright;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace BomLocalService.Services.Scraping.Steps.Capture;
|
namespace BomLocalService.Services.Scraping.Steps.Capture;
|
||||||
|
|
||||||
public class CaptureFramesStep : BaseScrapingStep
|
public class CaptureFramesStep : BaseScrapingStep
|
||||||
{
|
{
|
||||||
private readonly ICacheService _cacheService;
|
private readonly ICacheService _cacheService;
|
||||||
|
private readonly IScrubDisplayTimestampParser _scrubDisplayTime;
|
||||||
private readonly int _tileRenderWaitMs;
|
private readonly int _tileRenderWaitMs;
|
||||||
private readonly ScreenshotCropConfig _cropConfig;
|
private readonly ScreenshotCropConfig _cropConfig;
|
||||||
|
|
||||||
@@ -21,19 +21,44 @@ public class CaptureFramesStep : BaseScrapingStep
|
|||||||
ISelectorService selectorService,
|
ISelectorService selectorService,
|
||||||
IDebugService debugService,
|
IDebugService debugService,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ICacheService cacheService)
|
ICacheService cacheService,
|
||||||
|
IScrubDisplayTimestampParser scrubDisplayTime)
|
||||||
: base(logger, selectorService, debugService, configuration)
|
: base(logger, selectorService, debugService, configuration)
|
||||||
{
|
{
|
||||||
_cacheService = cacheService;
|
_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 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
|
_cropConfig = new ScreenshotCropConfig
|
||||||
{
|
{
|
||||||
X = cropSection.GetValue<int>("X", 0),
|
X = cropXConfig.Value,
|
||||||
Y = cropSection.GetValue<int>("Y", 0),
|
Y = cropYConfig.Value,
|
||||||
RightOffset = cropSection.GetValue<int>("RightOffset", 0),
|
RightOffset = cropRightOffsetConfig.Value,
|
||||||
Height = cropSection.GetValue<int?>("Height")
|
Height = cropSection.GetValue<int?>("Height") // Height is optional (nullable)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +80,7 @@ public class CaptureFramesStep : BaseScrapingStep
|
|||||||
|
|
||||||
var frames = new List<RadarFrame>();
|
var frames = new List<RadarFrame>();
|
||||||
var stepForwardButton = SelectorService.GetLocator(context.Page, Selectors.StepForwardButton);
|
var stepForwardButton = SelectorService.GetLocator(context.Page, Selectors.StepForwardButton);
|
||||||
int? previousMinutesAgo = null;
|
DateTime? previousTimestamp = null;
|
||||||
|
|
||||||
for (int frameIndex = 0; frameIndex < frameCount; frameIndex++)
|
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
|
// This is especially important for frame 0 which was just selected in ResetToFirstFrame
|
||||||
await context.Page.WaitForTimeoutAsync(300);
|
await context.Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
// Try extracting with a retry in case the label is still updating
|
// Try extracting timestamp with a retry in case the label is still updating
|
||||||
var minutesAgo = await ExtractMinutesAgoFromDisplayAsync(context.Page);
|
var frameTimestamp = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
|
||||||
if (minutesAgo == null)
|
if (frameTimestamp == null)
|
||||||
{
|
{
|
||||||
// Retry once after a short wait in case label was updating
|
// Retry once after a short wait in case label was updating
|
||||||
await context.Page.WaitForTimeoutAsync(200);
|
await context.Page.WaitForTimeoutAsync(200);
|
||||||
minutesAgo = await ExtractMinutesAgoFromDisplayAsync(context.Page);
|
frameTimestamp = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
var (_, defaultMinutesAgo) = context.FrameInfo[frameIndex];
|
||||||
await WaitForDisplayLabelToChangeAsync(context.Page, previousMinutesAgo.Value);
|
frameTimestamp = context.LastUpdatedInfo.ObservationTime.AddMinutes(-defaultMinutesAgo);
|
||||||
minutesAgo = await ExtractMinutesAgoFromDisplayAsync(context.Page);
|
Logger.LogWarning("Step {Step}: Failed to extract timestamp from display label for frame {FrameIndex}, calculated from observation time: {Timestamp}",
|
||||||
if (minutesAgo == null || minutesAgo == previousMinutesAgo.Value)
|
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];
|
var (_, defaultMinutesAgo) = context.FrameInfo[frameIndex];
|
||||||
minutesAgo = defaultMinutesAgo;
|
frameTimestamp = context.LastUpdatedInfo.ObservationTime.AddMinutes(-defaultMinutesAgo);
|
||||||
Logger.LogWarning("Step {Step}: Display label did not update for frame {FrameIndex}, using calculated default: {MinutesAgo}", Name, frameIndex, minutesAgo);
|
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);
|
var radarFolder = FilePathHelper.GetDataTypeFolderPath(context.CacheFolderPath, CachedDataType.Radar);
|
||||||
if (!Directory.Exists(radarFolder))
|
if (!Directory.Exists(radarFolder))
|
||||||
{
|
{
|
||||||
@@ -111,13 +148,13 @@ public class CaptureFramesStep : BaseScrapingStep
|
|||||||
{
|
{
|
||||||
FrameIndex = frameIndex,
|
FrameIndex = frameIndex,
|
||||||
ImagePath = framePath,
|
ImagePath = framePath,
|
||||||
MinutesAgo = minutesAgo ?? 0
|
AbsoluteObservationTime = frameTimestamp.Value
|
||||||
});
|
});
|
||||||
|
|
||||||
previousMinutesAgo = minutesAgo;
|
previousTimestamp = frameTimestamp;
|
||||||
|
|
||||||
Logger.LogInformation("Step {Step}: Frame {FrameIndex} saved: {Path} ({MinutesAgo} minutes ago)",
|
Logger.LogInformation("Step {Step}: Frame {FrameIndex} saved: {Path} (timestamp: {Timestamp} UTC)",
|
||||||
Name, frameIndex, framePath, minutesAgo ?? 0);
|
Name, frameIndex, framePath, frameTimestamp.Value);
|
||||||
|
|
||||||
_cacheService.RecordUpdateProgressByFolder(context.CacheFolderPath, CacheUpdatePhase.CapturingFrames, frameIndex + 1, frameCount);
|
_cacheService.RecordUpdateProgressByFolder(context.CacheFolderPath, CacheUpdatePhase.CapturingFrames, frameIndex + 1, frameCount);
|
||||||
|
|
||||||
@@ -127,13 +164,14 @@ public class CaptureFramesStep : BaseScrapingStep
|
|||||||
{
|
{
|
||||||
await DismissModalOverlaysAsync(context.Page);
|
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 });
|
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
|
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)
|
private async Task DismissModalOverlaysAsync(IPage page)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -31,16 +31,43 @@ public class PauseRadarStep : BaseScrapingStep
|
|||||||
|
|
||||||
var playPauseButton = SelectorService.GetLocator(context.Page, Selectors.PlayPauseButton);
|
var playPauseButton = SelectorService.GetLocator(context.Page, Selectors.PlayPauseButton);
|
||||||
await playPauseButton.WaitForAsync(new LocatorWaitForOptions { Timeout = 5000 });
|
await playPauseButton.WaitForAsync(new LocatorWaitForOptions { Timeout = 5000 });
|
||||||
|
|
||||||
var buttonLabel = await playPauseButton.Locator(Selectors.PlayPauseLabel.Selectors[0]).TextContentAsync();
|
// New spatial map often uses one step-through control; label may be on the button or in .bom-scrub-action__label
|
||||||
if (buttonLabel?.Trim().Equals(TextPatterns.ExpectedTexts["PauseButtonLabel"], StringComparison.OrdinalIgnoreCase) == true)
|
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);
|
Logger.LogInformation("Step {Step}: Radar is playing, pausing it", Name);
|
||||||
await playPauseButton.ClickAsync();
|
await playPauseButton.ClickAsync();
|
||||||
await context.Page.WaitForTimeoutAsync(300);
|
await context.Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
buttonLabel = await playPauseButton.Locator(Selectors.PlayPauseLabel.Selectors[0]).TextContentAsync();
|
// Clear so post-click read always runs; otherwise stale "Pause" skips the ??= InnerText fallback when the label child is missing
|
||||||
if (buttonLabel?.Trim().Equals(TextPatterns.ExpectedTexts["PlayButtonLabel"], StringComparison.OrdinalIgnoreCase) != true)
|
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);
|
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.Interfaces;
|
||||||
using BomLocalService.Services.Scraping;
|
using BomLocalService.Services.Scraping;
|
||||||
|
using BomLocalService.Utilities;
|
||||||
using Microsoft.Playwright;
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
namespace BomLocalService.Services.Scraping.Steps.Map;
|
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
|
public class ResetToFirstFrameStep : BaseScrapingStep
|
||||||
{
|
{
|
||||||
|
private readonly IScrubDisplayTimestampParser _scrubDisplayTime;
|
||||||
|
|
||||||
public override string Name => "ResetToFirstFrame";
|
public override string Name => "ResetToFirstFrame";
|
||||||
public override string[] Prerequisites => new[] { "PauseRadar" };
|
public override string[] Prerequisites => new[] { "ExtractMetadata" };
|
||||||
|
|
||||||
public ResetToFirstFrameStep(
|
public ResetToFirstFrameStep(
|
||||||
ILogger<ResetToFirstFrameStep> logger,
|
ILogger<ResetToFirstFrameStep> logger,
|
||||||
ISelectorService selectorService,
|
ISelectorService selectorService,
|
||||||
IDebugService debugService,
|
IDebugService debugService,
|
||||||
IConfiguration configuration)
|
IConfiguration configuration,
|
||||||
|
IScrubDisplayTimestampParser scrubDisplayTime)
|
||||||
: base(logger, selectorService, debugService, configuration)
|
: base(logger, selectorService, debugService, configuration)
|
||||||
{
|
{
|
||||||
|
_scrubDisplayTime = scrubDisplayTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override bool CanExecute(ScrapingContext context)
|
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)
|
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Logger.LogInformation("Step {Step}: Resetting to first frame (frame 0)", Name);
|
Logger.LogInformation("Step {Step}: Aligning to oldest frame via step-through + display label (no segment clicks)", Name);
|
||||||
|
|
||||||
try
|
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 t = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
|
||||||
var clicked = await context.Page.EvaluateAsync<bool>(@"() => {
|
if (t.HasValue)
|
||||||
const segment = document.querySelector('[data-testid=""bom-scrub-segment""][data-id=""0""]');
|
samples.Add(t.Value);
|
||||||
if (segment) {
|
|
||||||
segment.click();
|
if (i == frameCount - 1)
|
||||||
return true;
|
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;
|
|
||||||
}");
|
await stepBtn.ClickAsync(new LocatorClickOptions { Force = true });
|
||||||
|
if (cur.HasValue)
|
||||||
if (clicked)
|
{
|
||||||
{
|
await _scrubDisplayTime.WaitForDisplayLabelChangeAsync(
|
||||||
await context.Page.WaitForTimeoutAsync(500);
|
context.Page, SelectorService, cur.Value, context, maxWaitMs: 8000, cancellationToken: cancellationToken);
|
||||||
Logger.LogInformation("Step {Step}: Successfully clicked frame 0 segment via JavaScript", Name);
|
}
|
||||||
}
|
else
|
||||||
else
|
await context.Page.WaitForTimeoutAsync(800);
|
||||||
{
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
await SaveDebugAsync(context, 9, "frame_0_aligned", cancellationToken);
|
||||||
Logger.LogWarning(ex, "Step {Step}: Failed to click first frame segment, continuing anyway", Name);
|
|
||||||
}
|
// Best-effort only: segment-based checks are unreliable on the new spatial map layout
|
||||||
|
|
||||||
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);
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var activeSegment = await context.Page.EvaluateAsync<bool>(JavaScriptTemplates.CheckActiveFrameSegment);
|
var onSegmentZero = await context.Page.EvaluateAsync<bool>(JavaScriptTemplates.CheckActiveFrameSegment);
|
||||||
|
if (onSegmentZero)
|
||||||
if (activeSegment)
|
Logger.LogDebug("Step {Step}: Segment check reports frame 0 (informational)", Name);
|
||||||
{
|
|
||||||
Logger.LogInformation("Step {Step}: Scrubber confirmed at position 0", Name);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Logger.LogDebug("Step {Step}: Could not confirm scrubber position, but continuing", Name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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);
|
await SaveDebugAsync(context, 10, "scrubber_frame0", cancellationToken);
|
||||||
|
|
||||||
// Wait for frame 0 tiles to fully render
|
var tileRenderWaitMsConfig = Configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
|
||||||
var tileRenderWaitMs = Configuration.GetValue<int>("Screenshot:TileRenderWaitMs", 5000);
|
if (!tileRenderWaitMsConfig.HasValue)
|
||||||
await context.Page.WaitForTimeoutAsync(tileRenderWaitMs);
|
{
|
||||||
|
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;
|
context.CurrentState = PageState.Frame0Selected;
|
||||||
return ScrapingStepResult.Successful();
|
return ScrapingStepResult.Successful();
|
||||||
}
|
}
|
||||||
@@ -100,4 +140,3 @@ public class ResetToFirstFrameStep : BaseScrapingStep
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ public class WaitForMapReadyStep : BaseScrapingStep
|
|||||||
IConfiguration configuration)
|
IConfiguration configuration)
|
||||||
: base(logger, selectorService, debugService, 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)
|
public override bool CanExecute(ScrapingContext context)
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using BomLocalService.Models;
|
||||||
using BomLocalService.Services.Interfaces;
|
using BomLocalService.Services.Interfaces;
|
||||||
using BomLocalService.Services.Scraping;
|
using BomLocalService.Services.Scraping;
|
||||||
|
using BomLocalService.Utilities;
|
||||||
|
|
||||||
namespace BomLocalService.Services.Scraping.Steps.Metadata;
|
namespace BomLocalService.Services.Scraping.Steps.Metadata;
|
||||||
|
|
||||||
public class ExtractMetadataStep : BaseScrapingStep
|
public class ExtractMetadataStep : BaseScrapingStep
|
||||||
{
|
{
|
||||||
private readonly ITimeParsingService _timeParsingService;
|
private readonly ITimeParsingService _timeParsingService;
|
||||||
|
|
||||||
public override string Name => "ExtractMetadata";
|
public override string Name => "ExtractMetadata";
|
||||||
public override string[] Prerequisites => new[] { "ResetToFirstFrame" };
|
public override string[] Prerequisites => new[] { "PauseRadar" };
|
||||||
|
|
||||||
public ExtractMetadataStep(
|
public ExtractMetadataStep(
|
||||||
ILogger<ExtractMetadataStep> logger,
|
ILogger<ExtractMetadataStep> logger,
|
||||||
ISelectorService selectorService,
|
ISelectorService selectorService,
|
||||||
@@ -20,41 +23,67 @@ public class ExtractMetadataStep : BaseScrapingStep
|
|||||||
{
|
{
|
||||||
_timeParsingService = timeParsingService;
|
_timeParsingService = timeParsingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override bool CanExecute(ScrapingContext context)
|
public override bool CanExecute(ScrapingContext context)
|
||||||
{
|
{
|
||||||
return context.IsMapReady;
|
return context.IsMapReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
|
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Logger.LogInformation("Step {Step}: Extracting metadata and frame information", Name);
|
Logger.LogInformation("Step {Step}: Extracting metadata and frame information", Name);
|
||||||
|
|
||||||
var lastUpdatedInfo = await _timeParsingService.ExtractLastUpdatedInfoAsync(context.Page);
|
var lastUpdatedInfo = await _timeParsingService.ExtractLastUpdatedInfoAsync(context.Page);
|
||||||
context.LastUpdatedInfo = lastUpdatedInfo;
|
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
|
try
|
||||||
{
|
{
|
||||||
var frameInfo = await context.Page.EvaluateAsync<object[]>(JavaScriptTemplates.ExtractFrameInfo);
|
var element = await context.Page.EvaluateAsync<JsonElement>(JavaScriptTemplates.ExtractFrameInfo);
|
||||||
var result = new List<(int index, int minutesAgo)>();
|
if (element.ValueKind == JsonValueKind.Array && element.GetArrayLength() > 0)
|
||||||
for (int i = 0; i < 7; i++)
|
|
||||||
{
|
{
|
||||||
var minutesAgo = 40 - (i * 5);
|
var byIndex = new Dictionary<int, int>();
|
||||||
result.Add((i, minutesAgo));
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogWarning(ex, "Step {Step}: Failed to extract frame info, using defaults", Name);
|
Logger.LogWarning(ex, "Step {Step}: Failed to extract frame info, using defaults", Name);
|
||||||
context.FrameInfo = Enumerable.Range(0, 7)
|
context.FrameInfo = defaults;
|
||||||
.Select(i => (i, 40 - (i * 5)))
|
|
||||||
.ToList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ScrapingStepResult.Successful();
|
return ScrapingStepResult.Successful();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -65,4 +94,3 @@ public class ExtractMetadataStep : BaseScrapingStep
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ public class NavigateHomepageStep : BaseScrapingStep
|
|||||||
IConfiguration configuration)
|
IConfiguration configuration)
|
||||||
: base(logger, selectorService, debugService, 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)
|
public override bool CanExecute(ScrapingContext context)
|
||||||
|
|||||||
@@ -174,7 +174,12 @@ public class SelectSearchResultStep : BaseScrapingStep
|
|||||||
|
|
||||||
// Wait for forecast page to load
|
// Wait for forecast page to load
|
||||||
await context.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded, new PageWaitForLoadStateOptions { Timeout = 15000 });
|
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 context.Page.WaitForTimeoutAsync(dynamicContentWaitMs);
|
||||||
await SaveDebugAsync(context, 5, "forecast_page_loaded", cancellationToken);
|
await SaveDebugAsync(context, 5, "forecast_page_loaded", cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using BomLocalService.Services.Scraping.Workflows;
|
||||||
|
|
||||||
namespace BomLocalService.Services.Scraping;
|
namespace BomLocalService.Services.Scraping;
|
||||||
|
|
||||||
public class WorkflowFactory : IWorkflowFactory
|
public class WorkflowFactory : IWorkflowFactory
|
||||||
@@ -13,8 +15,10 @@ public class WorkflowFactory : IWorkflowFactory
|
|||||||
{
|
{
|
||||||
return name switch
|
return name switch
|
||||||
{
|
{
|
||||||
"RadarScraping" => (IWorkflow<TResponse>)_serviceProvider.GetRequiredService<Workflows.RadarScrapingWorkflow>(),
|
"RadarScraping" => _serviceProvider.GetRequiredService<IRadarScrapingWorkflow>() as IWorkflow<TResponse>
|
||||||
"TemperatureMap" => (IWorkflow<TResponse>)_serviceProvider.GetRequiredService<Workflows.TemperatureMapWorkflow>(),
|
?? 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}")
|
_ => 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;
|
namespace BomLocalService.Services.Scraping.Workflows;
|
||||||
|
|
||||||
public class RadarScrapingWorkflow : IWorkflow<RadarResponse>
|
public class RadarScrapingWorkflow : IRadarScrapingWorkflow
|
||||||
{
|
{
|
||||||
private readonly ILogger<RadarScrapingWorkflow> _logger;
|
private readonly ILogger<RadarScrapingWorkflow> _logger;
|
||||||
private readonly IScrapingStepRegistry _stepRegistry;
|
private readonly IScrapingStepRegistry _stepRegistry;
|
||||||
@@ -28,8 +28,8 @@ public class RadarScrapingWorkflow : IWorkflow<RadarResponse>
|
|||||||
"ClickRadarLink",
|
"ClickRadarLink",
|
||||||
"WaitForMapReady",
|
"WaitForMapReady",
|
||||||
"PauseRadar",
|
"PauseRadar",
|
||||||
"ResetToFirstFrame",
|
|
||||||
"ExtractMetadata",
|
"ExtractMetadata",
|
||||||
|
"ResetToFirstFrame",
|
||||||
"CalculateMapBounds",
|
"CalculateMapBounds",
|
||||||
"CaptureFrames"
|
"CaptureFrames"
|
||||||
};
|
};
|
||||||
@@ -44,8 +44,20 @@ public class RadarScrapingWorkflow : IWorkflow<RadarResponse>
|
|||||||
_stepRegistry = stepRegistry;
|
_stepRegistry = stepRegistry;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_cacheService = cacheService;
|
_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)
|
public async Task<RadarResponse> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace BomLocalService.Services.Scraping.Workflows;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Future workflow for temperature map scraping (not yet implemented)
|
/// Future workflow for temperature map scraping (not yet implemented)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TemperatureMapWorkflow : IWorkflow<RadarResponse>
|
public class TemperatureMapWorkflow : ITemperatureMapWorkflow
|
||||||
{
|
{
|
||||||
private readonly ILogger<TemperatureMapWorkflow> _logger;
|
private readonly ILogger<TemperatureMapWorkflow> _logger;
|
||||||
|
|
||||||
|
|||||||
@@ -52,9 +52,17 @@ public class SelectorService : ISelectorService
|
|||||||
{
|
{
|
||||||
throw new ArgumentException($"No selectors configured for {config.Name}");
|
throw new ArgumentException($"No selectors configured for {config.Name}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns first selector as locator (for cases where we know it exists)
|
// Match any configured selector (BOM occasionally renames test ids; list is tried in order)
|
||||||
return page.Locator(config.Selectors[0]).First;
|
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)
|
public TimeParsingService(ILogger<TimeParsingService> logger, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_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
|
try
|
||||||
{
|
{
|
||||||
@@ -23,8 +29,8 @@ public class TimeParsingService : ITimeParsingService
|
|||||||
}
|
}
|
||||||
catch (TimeZoneNotFoundException)
|
catch (TimeZoneNotFoundException)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Timezone {Timezone} not found, defaulting to Australia/Brisbane", timezone);
|
_logger.LogError("Timezone {Timezone} not found. Please use a valid IANA timezone identifier.", timezone);
|
||||||
_timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Australia/Brisbane");
|
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
|
try
|
||||||
{
|
{
|
||||||
// Look for the weather metadata section using the correct selector
|
// BOM layouts: legacy map used inner divs; location/spatial pages often use C07_WeatherMetadata with
|
||||||
// HTML structure: <section data-testid="weatherMetadata"> with aria-label="Last updated"
|
// a single section and visible text in the section (no div split). Prefer normalized innerText of the
|
||||||
var metadataSection = page.Locator("section[data-testid='weatherMetadata'], section[aria-label='Last updated']").First;
|
// section so both shapes parse the same.
|
||||||
|
var metadataSection = page.Locator(
|
||||||
// Extract text from each div separately to preserve structure
|
"section[data-testid='weatherMetadata'], " +
|
||||||
// Structure: <div>Observations: X minutes ago, 9:40 pm AEST</div><div>at Gympie...</div><div>Forecast: ...</div>
|
"section[aria-label='Last updated'], " +
|
||||||
|
"section[data-component='C07_WeatherMetadata']").First;
|
||||||
|
|
||||||
var lastUpdatedText = await page.EvaluateAsync<string>(@"() => {
|
var lastUpdatedText = await page.EvaluateAsync<string>(@"() => {
|
||||||
const section = document.querySelector('section[data-testid=""weatherMetadata""]') ||
|
const section =
|
||||||
document.querySelector('section[aria-label=""Last updated""]');
|
document.querySelector('section[data-testid=""weatherMetadata""]') ||
|
||||||
|
document.querySelector('section[aria-label=""Last updated""]') ||
|
||||||
|
document.querySelector('section[data-component=""C07_WeatherMetadata""]');
|
||||||
if (!section) return null;
|
if (!section) return null;
|
||||||
|
const raw = section.innerText || section.textContent || '';
|
||||||
// Get all divs and join with newlines to preserve structure
|
return raw.replace(/\s+/g, ' ').trim();
|
||||||
const divs = section.querySelectorAll('div');
|
|
||||||
return Array.from(divs).map(div => div.textContent.trim()).filter(text => text).join(' ');
|
|
||||||
}");
|
}");
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(lastUpdatedText))
|
if (string.IsNullOrEmpty(lastUpdatedText))
|
||||||
{
|
{
|
||||||
// Fallback: get all text content
|
var fallback = await metadataSection.InnerTextAsync();
|
||||||
lastUpdatedText = await metadataSection.TextContentAsync();
|
lastUpdatedText = string.IsNullOrEmpty(fallback)
|
||||||
|
? null
|
||||||
|
: Regex.Replace(fallback, @"\s+", " ").Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
return ParseLastUpdatedText(lastUpdatedText ?? string.Empty);
|
return ParseLastUpdatedText(lastUpdatedText ?? string.Empty);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to extract last updated information");
|
_logger.LogError(ex, "Failed to extract last updated information");
|
||||||
return new LastUpdatedInfo
|
throw;
|
||||||
{
|
|
||||||
ObservationTime = DateTime.UtcNow,
|
|
||||||
ForecastTime = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses last updated text to extract observation time, forecast time, weather station, and distance
|
/// 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>
|
/// </summary>
|
||||||
public LastUpdatedInfo ParseLastUpdatedText(string text)
|
public LastUpdatedInfo ParseLastUpdatedText(string text)
|
||||||
{
|
{
|
||||||
var info = new LastUpdatedInfo();
|
var info = new LastUpdatedInfo();
|
||||||
_logger.LogInformation("Parsing last updated text: {Text}", text);
|
_logger.LogInformation("Parsing last updated text: {Text}", text);
|
||||||
|
|
||||||
// Parse observation time - use "minutes ago" as primary source for accuracy
|
// Parse observation time from time string (e.g., "9:40 pm AEST")
|
||||||
// "Minutes ago" is reliable and doesn't require date guessing
|
// Ignore relative prefix — BOM uses "a minute ago", "6 minutes ago", "an hour ago", "2 hours ago", etc.
|
||||||
// Time string is used only as fallback if "minutes ago" is unavailable
|
var observationMatch = Regex.Match(text,
|
||||||
var minutesAgoMatch = Regex.Match(text, @"Observations:\s*(\d+)\s*minutes?\s*ago", RegexOptions.IgnoreCase);
|
@"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|,|$)",
|
||||||
if (minutesAgoMatch.Success && int.TryParse(minutesAgoMatch.Groups[1].Value, out var minutesAgo))
|
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
|
var timeStr = observationMatch.Groups[1].Value.Trim();
|
||||||
info.ObservationTime = DateTime.UtcNow.AddMinutes(-minutesAgo);
|
var timezoneStr = observationMatch.Groups[2].Success ? observationMatch.Groups[2].Value.Trim() : null;
|
||||||
_logger.LogInformation("Calculated observation time from 'minutes ago': {MinutesAgo} minutes ago = {Time} UTC",
|
|
||||||
minutesAgo, info.ObservationTime);
|
// 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)
|
||||||
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();
|
timezoneStr = timezoneStr.Substring(0, timezoneStr.Length - 2);
|
||||||
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 (TryParseTimeString(timeStr, timezoneStr, out var observationTime))
|
||||||
if (!string.IsNullOrEmpty(timezoneStr) && timezoneStr.EndsWith("at", StringComparison.OrdinalIgnoreCase) && timezoneStr.Length > 2)
|
{
|
||||||
{
|
info.ObservationTime = observationTime;
|
||||||
timezoneStr = timezoneStr.Substring(0, timezoneStr.Length - 2);
|
_logger.LogInformation("Parsed observation time: {Time} UTC (from '{TimeStr}' {TzStr})",
|
||||||
}
|
observationTime, timeStr, timezoneStr ?? "default timezone");
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("No observation time found in text, using current time");
|
_logger.LogError("Failed to parse observation time from time string '{TimeStr}' with timezone '{TzStr}'", timeStr, timezoneStr ?? "default");
|
||||||
info.ObservationTime = DateTime.UtcNow;
|
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
|
else
|
||||||
{
|
{
|
||||||
// Fallback to time string parsing only if "minutes ago" or "an hour ago" is not available
|
_logger.LogError("No observation time found in text: {Text}", text);
|
||||||
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);
|
throw new InvalidOperationException($"Could not find observation time in text: {text}");
|
||||||
if (forecastMatch.Success && forecastMatch.Groups[1].Success)
|
}
|
||||||
|
|
||||||
|
// 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();
|
info.ForecastTime = forecastTime;
|
||||||
var timezoneStr = forecastMatch.Groups[2].Success ? forecastMatch.Groups[2].Value.Trim() : null;
|
_logger.LogInformation("Parsed forecast time: {Time} UTC (from '{TimeStr}' {TzStr})",
|
||||||
|
forecastTime, timeStr, timezoneStr ?? "default timezone");
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("No forecast time found in text, using current time");
|
_logger.LogError("Failed to parse forecast time from time string '{TimeStr}' with timezone '{TzStr}'", timeStr, timezoneStr ?? "default");
|
||||||
info.ForecastTime = DateTime.UtcNow;
|
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
|
// Extract weather station name
|
||||||
var stationMatch = Regex.Match(text, @"at\s+([^,]+)\s+weather\s+station", RegexOptions.IgnoreCase);
|
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;
|
TimeZoneInfo tz = _timeZoneInfo;
|
||||||
if (!string.IsNullOrEmpty(timezoneStr))
|
if (!string.IsNullOrEmpty(timezoneStr))
|
||||||
{
|
{
|
||||||
// AEST/AEDT are typically the same as Australia/Brisbane or Australia/Sydney
|
// Map timezone abbreviations to actual timezones:
|
||||||
// Both Brisbane and Sydney use AEST/AEDT
|
// - "AEST" = UTC+10 (Australian Eastern Standard Time)
|
||||||
if (timezoneStr.Contains("AEST", StringComparison.OrdinalIgnoreCase) ||
|
// Used by Brisbane year-round, and by Sydney/Melbourne during winter
|
||||||
timezoneStr.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
|
// 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
|
else
|
||||||
{
|
{
|
||||||
// Try to find the timezone, fallback to configured
|
// Try to find the timezone by name, fallback to configured browser timezone
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
tz = TimeZoneInfo.FindSystemTimeZoneById(timezoneStr);
|
tz = TimeZoneInfo.FindSystemTimeZoneById(timezoneStr);
|
||||||
@@ -250,8 +240,7 @@ public class TimeParsingService : ITimeParsingService
|
|||||||
var dateTimeInTz = nowInTz.Date.Add(localTime.TimeOfDay);
|
var dateTimeInTz = nowInTz.Date.Add(localTime.TimeOfDay);
|
||||||
|
|
||||||
// Only adjust to yesterday if the time is clearly in the future
|
// 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 ensures we get the correct date for the observation/forecast time
|
||||||
// This method is now only used as fallback when "minutes ago" is unavailable
|
|
||||||
if (dateTimeInTz > nowInTz)
|
if (dateTimeInTz > nowInTz)
|
||||||
{
|
{
|
||||||
// Time is in the future, so it must be from yesterday
|
// 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)
|
public static int GetFrameCountForDataType(IConfiguration configuration, CachedDataType dataType)
|
||||||
{
|
{
|
||||||
var dataTypeName = dataType.ToString();
|
var dataTypeName = dataType.ToString();
|
||||||
var frameCount = configuration.GetValue<int>($"CachedDataTypes:{dataTypeName}:FrameCount", 7);
|
var frameCountConfig = configuration.GetValue<int?>($"CachedDataTypes:{dataTypeName}:FrameCount");
|
||||||
return 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>
|
/// <summary>
|
||||||
@@ -71,8 +75,20 @@ public static class CacheHelper
|
|||||||
{
|
{
|
||||||
// Calculate based on actual wait times and frame count
|
// Calculate based on actual wait times and frame count
|
||||||
var frameCount = GetFrameCountForDataType(configuration, dataType);
|
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:
|
// Rough calculation:
|
||||||
// - Initial page load and navigation: ~10-15 seconds
|
// - Initial page load and navigation: ~10-15 seconds
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ namespace BomLocalService.Utilities;
|
|||||||
public static class FilePathHelper
|
public static class FilePathHelper
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public static string GetCacheDirectory(IConfiguration configuration)
|
public static string GetCacheDirectory(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
return configuration.GetValue<string>("CacheDirectory")
|
return configuration.GetValue<string>("CacheDirectory")
|
||||||
?? Path.Combine(AppContext.BaseDirectory, "cache");
|
?? throw new InvalidOperationException("CacheDirectory configuration is required. Set it in appsettings.json or via CACHEDIRECTORY environment variable.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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
|
// 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:
|
// Calculate NextUpdateTime based on cache status:
|
||||||
// - If cache is valid: NextUpdateTime = max(CacheExpiresAt, next background service check)
|
// - 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": {
|
"CacheManagement": {
|
||||||
"CheckIntervalMinutes": 5,
|
"CheckIntervalMinutes": 5,
|
||||||
"InitialDelaySeconds": 10,
|
"InitialDelaySeconds": 10,
|
||||||
"UpdateStaggerSeconds": 2,
|
|
||||||
"LocationStaggerSeconds": 1
|
"LocationStaggerSeconds": 1
|
||||||
},
|
},
|
||||||
"CacheCleanup": {
|
"CacheCleanup": {
|
||||||
@@ -62,11 +61,9 @@
|
|||||||
"Name": "Search Button",
|
"Name": "Search Button",
|
||||||
"Selectors": [
|
"Selectors": [
|
||||||
"button[data-testid='searchLabel']",
|
"button[data-testid='searchLabel']",
|
||||||
"button[aria-label='Search for a location']",
|
|
||||||
"button.search-location__trigger-button",
|
"button.search-location__trigger-button",
|
||||||
"button.bom-button:has(span.bom-button__label:has-text('Search for a location'))",
|
"button[aria-label='Search for a location'].search-location__trigger-button",
|
||||||
"button:has-text('Search for a location')",
|
"button.search-location__trigger-button.bom-button:has(span.bom-button__label:has-text('Search for a location'))"
|
||||||
"[data-testid='showSearchModal'] button"
|
|
||||||
],
|
],
|
||||||
"TimeoutMs": 10000,
|
"TimeoutMs": 10000,
|
||||||
"Required": true,
|
"Required": true,
|
||||||
@@ -138,7 +135,10 @@
|
|||||||
},
|
},
|
||||||
"PlayPauseButton": {
|
"PlayPauseButton": {
|
||||||
"Name": "Play Pause Button",
|
"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,
|
"TimeoutMs": 5000,
|
||||||
"Required": true
|
"Required": true
|
||||||
},
|
},
|
||||||
@@ -156,7 +156,11 @@
|
|||||||
},
|
},
|
||||||
"StepForwardButton": {
|
"StepForwardButton": {
|
||||||
"Name": "Step Forward Button",
|
"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,
|
"TimeoutMs": 5000,
|
||||||
"Required": true
|
"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 || ''); }",
|
"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; }",
|
"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; }",
|
"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; }",
|
"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; }",
|
"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; }",
|
"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 })",
|
"GetViewportSize": "() => JSON.stringify({ width: window.innerWidth, height: window.innerHeight })",
|
||||||
@@ -195,7 +199,7 @@
|
|||||||
},
|
},
|
||||||
"TextPatterns": {
|
"TextPatterns": {
|
||||||
"ResultsCountPattern": "(\\d+)\\s+of\\s+(\\d+)",
|
"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",
|
"ObservationTimePattern": "Observations:\\s*(\\d+)\\s*minutes?\\s*ago",
|
||||||
"ForecastTimePattern": "Forecast:\\s*(\\d+)\\s*minutes?\\s+ago",
|
"ForecastTimePattern": "Forecast:\\s*(\\d+)\\s*minutes?\\s+ago",
|
||||||
"ForecastHourAgoPattern": "Forecast:\\s*an\\s+hour\\s+ago",
|
"ForecastHourAgoPattern": "Forecast:\\s*an\\s+hour\\s+ago",
|
||||||
@@ -219,8 +223,8 @@
|
|||||||
"ClickRadarLink": { "Enabled": true },
|
"ClickRadarLink": { "Enabled": true },
|
||||||
"WaitForMapReady": { "Enabled": true },
|
"WaitForMapReady": { "Enabled": true },
|
||||||
"PauseRadar": { "Enabled": true },
|
"PauseRadar": { "Enabled": true },
|
||||||
"ResetToFirstFrame": { "Enabled": true },
|
|
||||||
"ExtractMetadata": { "Enabled": true },
|
"ExtractMetadata": { "Enabled": true },
|
||||||
|
"ResetToFirstFrame": { "Enabled": true },
|
||||||
"CalculateMapBounds": { "Enabled": true },
|
"CalculateMapBounds": { "Enabled": true },
|
||||||
"CaptureFrames": {
|
"CaptureFrames": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ services:
|
|||||||
- CACHEEXPIRATIONMINUTES=${CACHEEXPIRATIONMINUTES:-12.5}
|
- CACHEEXPIRATIONMINUTES=${CACHEEXPIRATIONMINUTES:-12.5}
|
||||||
- CACHEMANAGEMENT__CHECKINTERVALMINUTES=${CACHEMANAGEMENT__CHECKINTERVALMINUTES:-5}
|
- CACHEMANAGEMENT__CHECKINTERVALMINUTES=${CACHEMANAGEMENT__CHECKINTERVALMINUTES:-5}
|
||||||
- CACHEMANAGEMENT__INITIALDELAYSECONDS=${CACHEMANAGEMENT__INITIALDELAYSECONDS:-10}
|
- CACHEMANAGEMENT__INITIALDELAYSECONDS=${CACHEMANAGEMENT__INITIALDELAYSECONDS:-10}
|
||||||
- CACHEMANAGEMENT__UPDATESTAGGERSECONDS=${CACHEMANAGEMENT__UPDATESTAGGERSECONDS:-2}
|
|
||||||
- CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS=${CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS:-1}
|
- CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS=${CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS:-1}
|
||||||
- CACHECLEANUP__INTERVALHOURS=${CACHECLEANUP__INTERVALHOURS:-1}
|
- CACHECLEANUP__INTERVALHOURS=${CACHECLEANUP__INTERVALHOURS:-1}
|
||||||
- SCREENSHOT__DYNAMICCONTENTWAITMS=${SCREENSHOT__DYNAMICCONTENTWAITMS:-2000}
|
- SCREENSHOT__DYNAMICCONTENTWAITMS=${SCREENSHOT__DYNAMICCONTENTWAITMS:-2000}
|
||||||
|
|||||||
+72
-9
@@ -1,29 +1,92 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
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
|
# Function to start Xvfb
|
||||||
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..."
|
echo "Starting Xvfb on display :99..."
|
||||||
Xvfb :99 -screen 0 1920x1080x24 -ac +extension GLX +render -noreset > /dev/null 2>&1 &
|
Xvfb :99 -screen 0 1920x1080x24 -ac +extension GLX +render -noreset > /dev/null 2>&1 &
|
||||||
sleep 1
|
sleep 1.5
|
||||||
if ! pgrep -f "Xvfb :99" > /dev/null; then
|
|
||||||
|
if check_xvfb_working; then
|
||||||
|
echo "Xvfb started successfully"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
echo "ERROR: Xvfb failed to start" >&2
|
echo "ERROR: Xvfb failed to start" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
echo "Xvfb started successfully"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Function to monitor and restart Xvfb if it dies (runs as background process)
|
# Function to monitor and restart Xvfb if it dies (runs as background process)
|
||||||
monitor_xvfb() {
|
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
|
# Monitor while PID 1 (dotnet after exec) is still running
|
||||||
while kill -0 1 2>/dev/null; do
|
while kill -0 1 2>/dev/null; do
|
||||||
sleep 5
|
sleep $backoff_seconds
|
||||||
if ! pgrep -f "Xvfb :99" > /dev/null; then
|
|
||||||
echo "WARNING: Xvfb process not found, restarting..." >&2
|
if ! check_xvfb_working; then
|
||||||
start_xvfb || true
|
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
|
fi
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
@@ -31,7 +94,7 @@ monitor_xvfb() {
|
|||||||
# Cleanup function
|
# Cleanup function
|
||||||
cleanup() {
|
cleanup() {
|
||||||
echo "Shutting down Xvfb..."
|
echo "Shutting down Xvfb..."
|
||||||
pkill -f "Xvfb :99" || true
|
cleanup_xvfb
|
||||||
}
|
}
|
||||||
|
|
||||||
# Set up signal handlers for cleanup
|
# 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