Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Admin/Services/AdminSettingsBootstrapService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ public async Task StartAsync(CancellationToken cancellationToken)
using var scope = _serviceScopeFactory.CreateScope();
var adminSettingsService = scope.ServiceProvider.GetRequiredService<IAdminSettingsService>();
var settings = await adminSettingsService.GetAsync(cancellationToken);
_pollingRuntimeState.SetMaintenanceModeEnabled(settings.IsMaintenanceMode);
_pollingRuntimeState.SetPollingEnabled(settings.IsPollingEnabled);
var startupMessage =
$"Admin settings initialized.\nmaintenance: db={settings.IsMaintenanceMode}, memory=n/a\npolling: db={settings.IsPollingEnabled}, memory={_pollingRuntimeState.IsPollingEnabled}";
$"Admin settings initialized.\nmaintenance: db={settings.IsMaintenanceMode}, memory={_pollingRuntimeState.IsMaintenanceModeEnabled}\npolling: db={settings.IsPollingEnabled}, memory={_pollingRuntimeState.IsPollingEnabled}";

WriteHighlightedMessage(startupMessage);

Expand Down
16 changes: 11 additions & 5 deletions Admin/Services/AdminSettingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ public class AdminSettingsService : IAdminSettingsService
private const int SettingsRowId = 1;

private readonly AppDbContext _appDbContext;
private readonly TimeProvider _timeProvider;
private readonly IPollingRuntimeState _pollingRuntimeState;

public AdminSettingsService(AppDbContext appDbContext, IPollingRuntimeState pollingRuntimeState)
public AdminSettingsService(
AppDbContext appDbContext,
TimeProvider timeProvider,
IPollingRuntimeState pollingRuntimeState)
{
_appDbContext = appDbContext;
_timeProvider = timeProvider;
_pollingRuntimeState = pollingRuntimeState;
}

Expand All @@ -24,8 +29,8 @@ public async Task<AdminSettings> GetAsync(CancellationToken cancellationToken =

public async Task<bool> IsMaintenanceModeEnabledAsync(CancellationToken cancellationToken = default)
{
var settings = await GetOrCreateAsync(cancellationToken);
return settings.IsMaintenanceMode;
await Task.CompletedTask;
return _pollingRuntimeState.IsMaintenanceModeEnabled;
}

public async Task<bool> IsPollingEnabledAsync(CancellationToken cancellationToken = default)
Expand All @@ -39,6 +44,7 @@ public async Task SetMaintenanceModeAsync(bool isEnabled, CancellationToken canc
var settings = await GetOrCreateAsync(cancellationToken);
settings.IsMaintenanceMode = isEnabled;
await _appDbContext.SaveChangesAsync(cancellationToken);
_pollingRuntimeState.SetMaintenanceModeEnabled(isEnabled);
}

public async Task SetPollingEnabledAsync(bool isEnabled, CancellationToken cancellationToken = default)
Expand All @@ -57,7 +63,7 @@ public async Task UpdateGtfsUploadStatusAsync(
CancellationToken cancellationToken = default)
{
var settings = await GetOrCreateAsync(cancellationToken);
settings.LastGtfsUploadAtUtc = DateTime.UtcNow;
settings.LastGtfsUploadAtUtc = _timeProvider.GetUtcNow().UtcDateTime;
settings.LastGtfsUploadFileName = fileName;
settings.LastGtfsImportStatus = status;
settings.LastGtfsImportError = error;
Expand All @@ -73,7 +79,7 @@ public async Task RecordGtfsUploadResultAsync(
CancellationToken cancellationToken = default)
{
var settings = await GetOrCreateAsync(cancellationToken);
settings.LastGtfsUploadAtUtc = DateTime.UtcNow;
settings.LastGtfsUploadAtUtc = _timeProvider.GetUtcNow().UtcDateTime;
settings.LastGtfsUploadFileName = fileName;
settings.LastGtfsImportStatus = isSuccessful ? "completed" : "failed";
settings.LastGtfsImportError = error;
Expand Down
4 changes: 4 additions & 0 deletions Admin/Services/IPollingRuntimeState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ public interface IPollingRuntimeState
{
bool IsPollingEnabled { get; }

bool IsMaintenanceModeEnabled { get; }

void SetPollingEnabled(bool isEnabled);

void SetMaintenanceModeEnabled(bool isEnabled);

Task WaitUntilPollingEnabledAsync(CancellationToken cancellationToken);

Task WaitForPollingStateChangeOrTimeoutAsync(TimeSpan timeout, CancellationToken cancellationToken);
Expand Down
7 changes: 7 additions & 0 deletions Admin/Services/PollingRuntimeState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ public class PollingRuntimeState : IPollingRuntimeState

public bool IsPollingEnabled { get; private set; }

public bool IsMaintenanceModeEnabled { get; private set; }

public void SetMaintenanceModeEnabled(bool isEnabled)
{
IsMaintenanceModeEnabled = isEnabled;
}

public void SetPollingEnabled(bool isEnabled)
{
lock (_sync)
Expand Down
9 changes: 8 additions & 1 deletion Models/Entities/GtfsImportRun.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
namespace TransitAnalyticsAPI.Models.Entities;

public enum GtfsImportStatus
{
Running,
Completed,
Failed
}

public class GtfsImportRun
{
public long Id { get; set; }
Expand All @@ -10,7 +17,7 @@ public class GtfsImportRun

public DateTime? CompletedAtUtc { get; set; }

public string Status { get; set; } = string.Empty;
public GtfsImportStatus Status { get; set; } = GtfsImportStatus.Running;

public string? Notes { get; set; }

Expand Down
6 changes: 6 additions & 0 deletions Persistence/AppDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<GtfsImportRun>()
.Property(importRun => importRun.Status)
.HasConversion(
status => status.ToString().ToLowerInvariant(),
value => Enum.Parse<GtfsImportStatus>(value, true));

modelBuilder.Entity<VehiclePosition>()
.HasIndex(vehiclePosition => new { vehiclePosition.VehicleId, vehiclePosition.RecordedAtUtc });

Expand Down
67 changes: 3 additions & 64 deletions Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using TransitAnalyticsAPI.Background;
using TransitAnalyticsAPI.Clients.AucklandTransport;
using TransitAnalyticsAPI.Configuration;
using TransitAnalyticsAPI.Websockets;
using TransitAnalyticsAPI.Middleware;
using TransitAnalyticsAPI.Persistence;
using TransitAnalyticsAPI.Services;
Expand All @@ -33,6 +34,7 @@
SingleReader = true,
SingleWriter = false
}));
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<IPollingRuntimeState, PollingRuntimeState>();
builder.Services.AddSingleton<IGtfsUploadQueue, GtfsUploadQueue>();
builder.Services.AddScoped<IAdminSettingsService, AdminSettingsService>();
Expand Down Expand Up @@ -133,72 +135,9 @@
app.UseAuthorization();
app.UseHttpsRedirection();

app.Map("/ws/vehicles", async context =>
{
var webSocketOptions = context.RequestServices
.GetRequiredService<Microsoft.Extensions.Options.IOptions<VehicleWebSocketOptions>>()
.Value;
var requestLogger = context.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("VehicleWebSocketEndpoint");

var adminSettingsService = context.RequestServices.GetRequiredService<IAdminSettingsService>();
if (await adminSettingsService.IsMaintenanceModeEnabledAsync(context.RequestAborted))
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(new
{
error = "service_unavailable",
message = "The service is in maintenance mode."
});
return;
}

if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}

if (!IsAllowedWebSocketOrigin(context, webSocketOptions))
{
requestLogger.LogWarning(
"Rejected websocket request from unexpected origin {Origin}.",
context.Request.Headers.Origin.ToString());
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new
{
error = "forbidden",
message = "The request origin is not allowed."
});
return;
}

var socket = await context.WebSockets.AcceptWebSocketAsync();
var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
using var scope = app.Services.CreateScope();
var webSocketService = scope.ServiceProvider.GetRequiredService<IVehicleWebSocketService>();

await webSocketService.HandleConnectionAsync(socket, ipAddress, context.RequestAborted);
});
app.MapVehicleWebSocket();

app.MapRazorPages();
app.MapControllers();

app.Run();

static bool IsAllowedWebSocketOrigin(HttpContext context, VehicleWebSocketOptions options)
{
if (options.AllowedOrigins.Length == 0)
{
return true;
}

if (!context.Request.Headers.TryGetValue("Origin", out var originValues))
{
return false;
}

var origin = originValues.ToString();
return options.AllowedOrigins.Contains(origin, StringComparer.OrdinalIgnoreCase);
}
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ The app:
- Entity Framework Core
- Hosted background services

## Project Structure

- `Controllers/` — HTTP API endpoints
- `Websockets/` — WebSocket transport endpoints
- `Services/` — application and business logic
- `Persistence/` — EF Core DbContext and database access
- `Models/` — entities and DTOs
- `Configuration/` — strongly-typed option classes
- `Middleware/` — request pipeline middleware
- `Background/` — hosted background services
- `Clients/` — external API integrations
- `Admin/` — admin security and services
- `Areas/Admin/Pages/` — Razor Pages for the admin UI

## Features

### Public/backend API
Expand Down
3 changes: 2 additions & 1 deletion Services/ActiveImportRunResolver.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using TransitAnalyticsAPI.Models.Entities;
using TransitAnalyticsAPI.Persistence;

namespace TransitAnalyticsAPI.Services;
Expand All @@ -16,7 +17,7 @@ public ActiveImportRunResolver(AppDbContext appDbContext)
{
return await _appDbContext.GtfsImportRuns
.AsNoTracking()
.Where(importRun => importRun.IsActive && importRun.Status == "completed")
.Where(importRun => importRun.IsActive && importRun.Status == GtfsImportStatus.Completed)
.OrderByDescending(importRun => importRun.CompletedAtUtc)
.Select(importRun => (long?)importRun.Id)
.FirstOrDefaultAsync(cancellationToken);
Expand Down
16 changes: 9 additions & 7 deletions Services/GtfsImportService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ public class GtfsImportService : IGtfsImportService
private const int BatchSize = 5000;

private readonly AppDbContext _appDbContext;
private readonly TimeProvider _timeProvider;

public GtfsImportService(AppDbContext appDbContext)
public GtfsImportService(AppDbContext appDbContext, TimeProvider timeProvider)
{
_appDbContext = appDbContext;
_timeProvider = timeProvider;
}

public async Task<GtfsImportResult> ImportRoutesAndTripsAsync(
Expand All @@ -33,8 +35,8 @@ public async Task<GtfsImportResult> ImportRoutesAndTripsAsync(
var importRun = new GtfsImportRun
{
SourceVersion = sourceVersion,
StartedAtUtc = DateTime.UtcNow,
Status = "running",
StartedAtUtc = _timeProvider.GetUtcNow().UtcDateTime,
Status = GtfsImportStatus.Running,
IsActive = false
};

Expand Down Expand Up @@ -68,8 +70,8 @@ public async Task<GtfsImportResult> ImportRoutesAndTripsAsync(
.SingleAsync(run => run.Id == importRun.Id, cancellationToken);

importRunToFinalize.IsActive = true;
importRunToFinalize.Status = "completed";
importRunToFinalize.CompletedAtUtc = DateTime.UtcNow;
importRunToFinalize.Status = GtfsImportStatus.Completed;
importRunToFinalize.CompletedAtUtc = _timeProvider.GetUtcNow().UtcDateTime;

await _appDbContext.SaveChangesAsync(cancellationToken);
await DeleteInactiveImportRunsAsync(importRun.Id, cancellationToken);
Expand All @@ -90,9 +92,9 @@ public async Task<GtfsImportResult> ImportRoutesAndTripsAsync(
var importRunToFail = await _appDbContext.GtfsImportRuns
.SingleAsync(run => run.Id == importRun.Id, cancellationToken);

importRunToFail.Status = "failed";
importRunToFail.Status = GtfsImportStatus.Failed;
importRunToFail.Notes = exception.Message;
importRunToFail.CompletedAtUtc = DateTime.UtcNow;
importRunToFail.CompletedAtUtc = _timeProvider.GetUtcNow().UtcDateTime;
await _appDbContext.SaveChangesAsync(cancellationToken);
throw;
}
Expand Down
6 changes: 4 additions & 2 deletions Services/SystemLogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,20 @@ public class SystemLogService<T> : ISystemLogService<T>
private static readonly string Source = typeof(T).Name;

private readonly AppDbContext _appDbContext;
private readonly TimeProvider _timeProvider;

public SystemLogService(AppDbContext appDbContext)
public SystemLogService(AppDbContext appDbContext, TimeProvider timeProvider)
{
_appDbContext = appDbContext;
_timeProvider = timeProvider;
}

public async Task LogAsync(SystemLogType type, string description, string? details = null,
CancellationToken cancellationToken = default)
{
_appDbContext.SystemLogs.Add(new SystemLog
{
CreatedAtUtc = DateTime.UtcNow,
CreatedAtUtc = _timeProvider.GetUtcNow().UtcDateTime,
Type = type,
Source = Source,
Description = description,
Expand Down
5 changes: 4 additions & 1 deletion Services/VehicleLatestQueryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,24 @@ public class VehicleLatestQueryService : IVehicleLatestQueryService
{
private readonly AppDbContext _appDbContext;
private readonly IVehicleMetadataLookupService _vehicleMetadataLookupService;
private readonly TimeProvider _timeProvider;
private readonly TimeSpan _latestPositionMaxAge;

public VehicleLatestQueryService(
AppDbContext appDbContext,
IVehicleMetadataLookupService vehicleMetadataLookupService,
TimeProvider timeProvider,
IOptions<VehicleOptions> vehicleOptions)
{
_appDbContext = appDbContext;
_vehicleMetadataLookupService = vehicleMetadataLookupService;
_timeProvider = timeProvider;
_latestPositionMaxAge = TimeSpan.FromMinutes(Math.Max(1, vehicleOptions.Value.LatestPositionMaxAgeMinutes));
}

public async Task<List<VehicleLatestDto>> GetLatestAsync(CancellationToken cancellationToken = default)
{
var cutoffUtc = DateTime.UtcNow - _latestPositionMaxAge;
var cutoffUtc = _timeProvider.GetUtcNow().UtcDateTime - _latestPositionMaxAge;

var latestPositions = await _appDbContext.VehiclePositions
.AsNoTracking()
Expand Down
9 changes: 8 additions & 1 deletion Services/VehiclePositionMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@ namespace TransitAnalyticsAPI.Services;

public class VehiclePositionMapper : IVehiclePositionMapper
{
private readonly TimeProvider _timeProvider;

public VehiclePositionMapper(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}

public List<VehiclePosition> Map(IEnumerable<AucklandTransportFeedEntity> entities)
{
var ingestedAtUtc = DateTime.UtcNow;
var ingestedAtUtc = _timeProvider.GetUtcNow().UtcDateTime;

return entities
.Where(entity =>
Expand Down
5 changes: 4 additions & 1 deletion Services/VehicleRetentionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,25 @@ namespace TransitAnalyticsAPI.Services;
public class VehicleRetentionService : IVehicleRetentionService
{
private readonly AppDbContext _appDbContext;
private readonly TimeProvider _timeProvider;
private readonly TimeSpan _historyRetention;
private readonly ISystemLogService<VehicleRetentionService> _systemLog;

public VehicleRetentionService(
AppDbContext appDbContext,
TimeProvider timeProvider,
IOptions<VehicleOptions> vehicleOptions,
ISystemLogService<VehicleRetentionService> systemLogService)
{
_appDbContext = appDbContext;
_timeProvider = timeProvider;
_historyRetention = TimeSpan.FromDays(Math.Max(1, vehicleOptions.Value.HistoryRetentionDays));
_systemLog = systemLogService;
}

public async Task<int> DeleteExpiredAsync(CancellationToken cancellationToken = default)
{
var cutoffUtc = DateTime.UtcNow - _historyRetention;
var cutoffUtc = _timeProvider.GetUtcNow().UtcDateTime - _historyRetention;

var dbSize = await _appDbContext.Database
.SqlQueryRaw<string>("SELECT pg_size_pretty(pg_database_size(current_database())) AS \"Value\"")
Expand Down
Loading
Loading