Files
scadalink-design/src/ScadaLink.Host/Program.cs
Joseph Doherty 6fa4c101ab Fix blazor.web.js 404: move App.razor and Routes.razor to Host project
Root cause: App.razor was in CentralUI (Microsoft.NET.Sdk.Razor RCL) but
MapRazorComponents<App>().AddInteractiveServerRenderMode() serves
_framework/blazor.web.js from the host assembly (Microsoft.NET.Sdk.Web).
Per MS docs, the root component must be in the server (host) project.

- Move App.razor and Routes.razor from CentralUI to Host/Components/
- Add Host/_Imports.razor for Razor component usings
- Make MapCentralUI generic: MapCentralUI<TApp>() accepts root component
- Add AdditionalAssemblies to discover CentralUI's pages/layouts
- Routes.razor references both Host and CentralUI assemblies
- Fix all DI registrations (TemplateEngine services)
- SCADALINK_CONFIG env var for role-specific config loading

Verified: blazor.web.js HTTP 200 (200KB), login page renders with Blazor
interactive server mode, SignalR circuit establishes, LDAP auth works.
2026-03-17 04:01:12 -04:00

207 lines
8.6 KiB
C#

using HealthChecks.UI.Client;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using ScadaLink.CentralUI;
using ScadaLink.ClusterInfrastructure;
using ScadaLink.Communication;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.DataConnectionLayer;
using ScadaLink.DeploymentManager;
using ScadaLink.ExternalSystemGateway;
using ScadaLink.HealthMonitoring;
using ScadaLink.Host;
using ScadaLink.Host.Actors;
using ScadaLink.Host.Health;
using ScadaLink.InboundAPI;
using ScadaLink.NotificationService;
using ScadaLink.Security;
using ScadaLink.SiteEventLogging;
using ScadaLink.SiteRuntime;
using ScadaLink.StoreAndForward;
using ScadaLink.TemplateEngine;
using Serilog;
// SCADALINK_CONFIG determines which role-specific config to load (Central or Site)
// DOTNET_ENVIRONMENT/ASPNETCORE_ENVIRONMENT stay as "Development" for dev tooling (static assets, EF migrations, etc.)
var scadalinkConfig = Environment.GetEnvironmentVariable("SCADALINK_CONFIG")
?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")
?? "Production";
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile($"appsettings.{scadalinkConfig}.json", optional: true)
.AddEnvironmentVariables()
.AddCommandLine(args)
.Build();
// WP-11: Full startup validation — fail fast before any DI or actor system setup
StartupValidator.Validate(configuration);
// Read node options for Serilog enrichment
var nodeRole = configuration["ScadaLink:Node:Role"]!;
var nodeHostname = configuration["ScadaLink:Node:NodeHostname"] ?? "unknown";
var siteId = configuration["ScadaLink:Node:SiteId"] ?? "central";
// WP-14: Serilog structured logging
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.Enrich.WithProperty("SiteId", siteId)
.Enrich.WithProperty("NodeHostname", nodeHostname)
.Enrich.WithProperty("NodeRole", nodeRole)
.WriteTo.Console(outputTemplate:
"[{Timestamp:HH:mm:ss} {Level:u3}] [{NodeRole}/{NodeHostname}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File("logs/scadalink-.log", rollingInterval: Serilog.RollingInterval.Day)
.CreateLogger();
try
{
Log.Information("Starting ScadaLink host as {Role} on {Hostname}", nodeRole, nodeHostname);
if (nodeRole.Equals("Central", StringComparison.OrdinalIgnoreCase))
{
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddConfiguration(configuration);
// WP-14: Serilog
builder.Host.UseSerilog();
// WP-17: Windows Service support (no-op when not running as a Windows Service)
builder.Host.UseWindowsService();
// Shared components
builder.Services.AddClusterInfrastructure();
builder.Services.AddCommunication();
builder.Services.AddHealthMonitoring();
builder.Services.AddCentralHealthAggregation();
builder.Services.AddExternalSystemGateway();
builder.Services.AddNotificationService();
// Central-only components
builder.Services.AddTemplateEngine();
builder.Services.AddDeploymentManager();
builder.Services.AddSecurity();
builder.Services.AddCentralUI();
builder.Services.AddInboundAPI();
var configDbConnectionString = configuration["ScadaLink:Database:ConfigurationDb"]
?? throw new InvalidOperationException("ScadaLink:Database:ConfigurationDb connection string is required for Central role.");
builder.Services.AddConfigurationDatabase(configDbConnectionString);
// WP-12: Health checks for readiness gating
builder.Services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("database")
.AddCheck<AkkaClusterHealthCheck>("akka-cluster");
// WP-13: Akka.NET bootstrap via hosted service
builder.Services.AddSingleton<AkkaHostedService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
// Options binding
BindSharedOptions(builder.Services, builder.Configuration);
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("ScadaLink:Security"));
builder.Services.Configure<InboundApiOptions>(builder.Configuration.GetSection("ScadaLink:InboundApi"));
var app = builder.Build();
// Apply or validate database migrations (skip when running in test harness)
if (!string.Equals(configuration["ScadaLink:Database:SkipMigrations"], "true", StringComparison.OrdinalIgnoreCase))
{
var isDevelopment = app.Environment.IsDevelopment()
|| string.Equals(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), "Development", StringComparison.OrdinalIgnoreCase);
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
await MigrationHelper.ApplyOrValidateMigrationsAsync(dbContext, isDevelopment);
}
}
// Middleware pipeline
app.UseStaticFiles();
app.UseWebSockets();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
// WP-12: Map readiness endpoint — returns 503 until all checks pass, 200 when ready
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapCentralUI<ScadaLink.Host.Components.App>();
app.MapInboundAPI();
await app.RunAsync();
}
else if (nodeRole.Equals("Site", StringComparison.OrdinalIgnoreCase))
{
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder(args);
builder.ConfigureAppConfiguration(config => config.AddConfiguration(configuration));
// WP-14: Serilog
builder.UseSerilog();
// WP-17: Windows Service support (no-op when not running as a Windows Service)
builder.UseWindowsService();
builder.ConfigureServices((context, services) =>
{
// Shared components
services.AddClusterInfrastructure();
services.AddCommunication();
services.AddHealthMonitoring();
services.AddExternalSystemGateway();
services.AddNotificationService();
// Site-only components — AddSiteRuntime registers SiteStorageService with SQLite path
var siteDbPath = context.Configuration["ScadaLink:Database:SiteDbPath"] ?? "site.db";
services.AddSiteRuntime($"Data Source={siteDbPath}");
services.AddDataConnectionLayer();
services.AddStoreAndForward();
services.AddSiteEventLogging();
// WP-13: Akka.NET bootstrap via hosted service
services.AddSingleton<AkkaHostedService>();
services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
// Options binding
BindSharedOptions(services, context.Configuration);
services.Configure<SiteRuntimeOptions>(context.Configuration.GetSection("ScadaLink:SiteRuntime"));
services.Configure<DataConnectionOptions>(context.Configuration.GetSection("ScadaLink:DataConnection"));
services.Configure<StoreAndForwardOptions>(context.Configuration.GetSection("ScadaLink:StoreAndForward"));
services.Configure<SiteEventLogOptions>(context.Configuration.GetSection("ScadaLink:SiteEventLog"));
});
var host = builder.Build();
await host.RunAsync();
}
else
{
throw new InvalidOperationException($"Unknown role: {nodeRole}. Must be 'Central' or 'Site'.");
}
}
catch (Exception ex)
{
Log.Fatal(ex, "ScadaLink host terminated unexpectedly");
throw;
}
finally
{
await Log.CloseAndFlushAsync();
}
static void BindSharedOptions(IServiceCollection services, IConfiguration config)
{
services.Configure<NodeOptions>(config.GetSection("ScadaLink:Node"));
services.Configure<ClusterOptions>(config.GetSection("ScadaLink:Cluster"));
services.Configure<DatabaseOptions>(config.GetSection("ScadaLink:Database"));
services.Configure<CommunicationOptions>(config.GetSection("ScadaLink:Communication"));
services.Configure<HealthMonitoringOptions>(config.GetSection("ScadaLink:HealthMonitoring"));
services.Configure<NotificationOptions>(config.GetSection("ScadaLink:Notification"));
services.Configure<LoggingOptions>(config.GetSection("ScadaLink:Logging"));
}
/// <summary>
/// Exposes the auto-generated Program class for test infrastructure (e.g. WebApplicationFactory).
/// </summary>
public partial class Program { }