Completes local-dev-master-slave-setup: dual-instance frontend tooling, module-capability gating, and master/slave protocol self-healing fixes

Frontend (Unit 2 completion): dual dev-server tooling (pnpm dev:slave,
pnpm dev:all), per-instance browser tab titles, and a backend
capability check (SystemController + useSystemCapabilities +
ModuleGuard) so a Master-only page is hidden on a slave instance
instead of assuming every backend has every module.

Master/slave protocol fixes surfaced by actually running master and
slave side by side locally:
- Deactivating a CMS instance (Inactive) now releases the slave's
  master gate instead of leaving it stuck on its last pushed status.
- The periodic integrity check now also re-pushes status to every
  reachable slave (previously URL-verification only) and runs once
  immediately on startup.
- Added the originally-specified (but never implemented) slave-pull
  path: a slave now periodically polls its own status from the master
  (GET /api/v1/SlaveStatus) and fails open to Available if the master
  is unreachable for too long, complementing the existing push.
- The slave's own Settings page can no longer "successfully" change
  local availability while the master controls it; it's now locked
  with an explanatory banner and the backend rejects the write with
  409 instead of silently no-op'ing it.
- CMS instance status badges now match the dashboard's color/icon
  styling instead of a plain grey badge.

Also corrected the master-cms-module design docs to match this
as-built behavior, and flagged (without a full rewrite) a larger,
pre-existing divergence between its inception-stage application
design and what construction actually built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 19:53:52 +02:00
co-authored by Claude Sonnet 5
parent 274946dbff
commit 0447993181
81 changed files with 2191 additions and 86 deletions
@@ -5,6 +5,8 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Modules;
using SlpModularCms.Modules.Availability.BackgroundServices;
using SlpModularCms.Modules.Availability.Config;
using SlpModularCms.Modules.Availability.Data;
using SlpModularCms.Modules.Availability.Middleware;
using SlpModularCms.Modules.Availability.Repositories;
@@ -34,6 +36,14 @@ public class AvailabilityModule : IModule
services.AddScoped<IMasterRegistrationRepository, MasterRegistrationRepository>();
services.AddScoped<MasterAvailabilityServiceDependencies>();
services.AddScoped<IMasterAvailabilityService, MasterAvailabilityService>();
services.AddOptions<MasterPollingOptions>().BindConfiguration("MasterPolling");
services.AddHttpClient<IMasterStatusPollClient, MasterStatusPollClient>((sp, client) =>
{
var pollingOptions = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<MasterPollingOptions>>();
client.Timeout = TimeSpan.FromSeconds(pollingOptions.Value.HttpTimeoutSeconds);
});
services.AddHostedService<MasterStatusPollingBackgroundService>();
}
public void UseModule(IApplicationBuilder app)
@@ -0,0 +1,66 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SlpModularCms.Modules.Availability.Config;
using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability.BackgroundServices;
/// <summary>
/// Periodically pulls this slave's status from its registered master, so the slave's
/// in-memory master-gate stays correct even without an explicit push from the master
/// (e.g. after a slave restart, or if a push was missed). Fails open (Available) if the
/// master has been unreachable for too long.
/// </summary>
public class MasterStatusPollingBackgroundService(
IServiceScopeFactory scopeFactory,
IOptions<MasterPollingOptions> options,
ILogger<MasterStatusPollingBackgroundService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Poll once immediately on startup, so a freshly (re)started slave re-syncs right away
// instead of defaulting to Available until the first interval elapses.
await ExecuteTickAsync(stoppingToken);
var interval = TimeSpan.FromSeconds(options.Value.PollIntervalSeconds);
using var timer = new PeriodicTimer(interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await ExecuteTickAsync(stoppingToken);
}
}
internal async Task ExecuteTickAsync(CancellationToken stoppingToken)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var masterAvailabilityService = scope.ServiceProvider.GetRequiredService<IMasterAvailabilityService>();
var pollClient = scope.ServiceProvider.GetRequiredService<IMasterStatusPollClient>();
var target = await masterAvailabilityService.GetPollTargetAsync();
if (target is null)
{
// No master registered yet; nothing to poll.
return;
}
var polled = await pollClient.GetStatusAsync(target.MasterUrl, target.PlainApiKey);
if (polled is not null)
{
await masterAvailabilityService.ApplyPolledStatusAsync(polled.IsAvailable, polled.DisableMessage);
return;
}
var failOpenAfter = TimeSpan.FromMinutes(options.Value.FailOpenAfterMinutes);
await masterAvailabilityService.RecordPollFailureAsync(failOpenAfter);
}
catch (Exception ex)
{
logger.LogError(ex, "Unhandled error during master status poll tick.");
}
}
}
@@ -0,0 +1,18 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Modules.Availability.Config;
[ExcludeFromCodeCoverage]
public class MasterPollingOptions
{
/// <summary>How often the slave pulls its status from the master.</summary>
public int PollIntervalSeconds { get; set; } = 30;
/// <summary>
/// How long the master may stay unreachable before the slave gives up waiting and
/// fails open (becomes Available again) instead of staying stuck on a stale status.
/// </summary>
public int FailOpenAfterMinutes { get; set; } = 5;
public int HttpTimeoutSeconds { get; set; } = 5;
}
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using SlpModularCms.Core.Availability;
using SlpModularCms.Modules.Availability.Services;
@@ -26,6 +27,7 @@ public class AvailabilityController : ControllerBase
Status = details.Status.ToString(),
CheckedAt = DateTimeOffset.UtcNow,
Message = details.Message ?? string.Empty,
details.IsMasterControlled,
});
}
@@ -33,17 +35,28 @@ public class AvailabilityController : ControllerBase
[Authorize(Policy = "OwnerOnly")]
public async Task<IActionResult> UpdateStatus([FromBody] UpdateStatusRequest request)
{
if (_availabilityService is PersistentAvailabilityService persistentService)
if (_availabilityService is not PersistentAvailabilityService persistentService)
{
await persistentService.UpdateStatusAsync(
request.NewStatus,
request.Reason,
User.Identity?.Name);
return Ok();
return BadRequest("Status update niet ondersteund door huidige service.");
}
return BadRequest("Status update niet ondersteund door huidige service.");
try
{
await persistentService.UpdateStatusAsync(
request.NewStatus,
request.Reason,
User.Identity?.Name);
return Ok();
}
catch (MasterControlledAvailabilityException ex)
{
return Conflict(new ProblemDetails
{
Status = StatusCodes.Status409Conflict,
Title = ex.Message,
});
}
}
}
@@ -9,4 +9,7 @@ public class MasterRegistration
public string ApiKey { get; set; } = string.Empty;
public DateTimeOffset RegisteredAt { get; set; }
public DateTimeOffset? LastContactedAt { get; set; }
/// <summary>Timestamp of the last time this slave successfully polled the master for its status.</summary>
public DateTimeOffset? LastPolledAt { get; set; }
}
@@ -23,12 +23,15 @@ public class AvailabilityMiddleware
// Auth and Setup must stay open so admins can log in and the frontend
// can determine whether the system is initialized.
// Master endpoints bypass so master can always push status or re-register.
// SlaveStatus bypasses so a slave can always pull the master's status, even if the
// master instance is (for whatever reason) reporting itself as locally unavailable.
private static readonly string[] _bypassPrefixes =
[
"/api/v1/Availability/status",
"/api/v1/Auth/",
"/api/v1/Setup/status",
"/api/v1/master/",
"/api/v1/SlaveStatus",
];
public async Task InvokeAsync(
@@ -0,0 +1,60 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using SlpModularCms.Modules.Availability.Data;
#nullable disable
namespace SlpModularCms.Modules.Availability.Migrations
{
[DbContext(typeof(AvailabilityDbContext))]
[Migration("20260704142458_AddLastPolledAtToMasterRegistration")]
partial class AddLastPolledAtToMasterRegistration
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("SlpModularCms.Modules.Availability.Data.Entities.MasterRegistration", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ApiKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTimeOffset?>("LastContactedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("LastPolledAt")
.HasColumnType("datetimeoffset");
b.Property<string>("MasterUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTimeOffset>("RegisteredAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.ToTable("AvailabilityMasterRegistrations", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SlpModularCms.Modules.Availability.Migrations
{
/// <inheritdoc />
public partial class AddLastPolledAtToMasterRegistration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "LastPolledAt",
table: "AvailabilityMasterRegistrations",
type: "datetimeoffset",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LastPolledAt",
table: "AvailabilityMasterRegistrations");
}
}
}
@@ -36,6 +36,9 @@ namespace SlpModularCms.Modules.Availability.Migrations
b.Property<DateTimeOffset?>("LastContactedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("LastPolledAt")
.HasColumnType("datetimeoffset");
b.Property<string>("MasterUrl")
.IsRequired()
.HasMaxLength(500)
@@ -6,4 +6,20 @@ public interface IMasterAvailabilityService
Task<bool> PushStatusAsync(string apiKey, bool isAvailable, string? disableMessage);
Task<string?> GetRegisteredUrlAsync(string apiKey);
MasterGateStatus GetMasterStatus();
/// <summary>Returns the master URL and plain API key to poll, or null if no master is registered.</summary>
Task<MasterPollTarget?> GetPollTargetAsync();
/// <summary>Applies a successfully polled status from the master and records the poll timestamp.</summary>
Task ApplyPolledStatusAsync(bool isAvailable, string? disableMessage);
/// <summary>
/// Called after a failed poll attempt. If the master has been unreachable for longer than
/// <paramref name="failOpenAfter"/>, forces the gate open (Available) so a dead/unreachable
/// master never permanently blocks the slave.
/// </summary>
Task RecordPollFailureAsync(TimeSpan failOpenAfter);
}
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public record MasterPollTarget(string MasterUrl, string PlainApiKey);
@@ -0,0 +1,9 @@
namespace SlpModularCms.Modules.Availability.Services;
public interface IMasterStatusPollClient
{
Task<PolledMasterStatus?> GetStatusAsync(string masterUrl, string plainApiKey);
}
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public record PolledMasterStatus(bool IsAvailable, string? DisableMessage);
@@ -72,6 +72,51 @@ public class MasterAvailabilityService : IMasterAvailabilityService
return true;
}
public async Task<MasterPollTarget?> GetPollTargetAsync()
{
var existing = await _deps.Repository.GetAsync();
if (existing is null) return null;
var plainKey = _deps.KeyProtector.Unprotect(existing.ApiKey);
if (plainKey is null) return null;
return new MasterPollTarget(existing.MasterUrl, plainKey);
}
public async Task ApplyPolledStatusAsync(bool isAvailable, string? disableMessage)
{
_masterIsAvailable = isAvailable;
_masterDisableMessage = disableMessage;
var existing = await _deps.Repository.GetAsync();
if (existing is null) return;
existing.LastPolledAt = DateTimeOffset.UtcNow;
existing.LastContactedAt = DateTimeOffset.UtcNow;
_deps.Repository.Update(existing);
await _deps.Repository.SaveChangesAsync();
}
public async Task RecordPollFailureAsync(TimeSpan failOpenAfter)
{
var existing = await _deps.Repository.GetAsync();
if (existing is null) return;
var unreachableSince = existing.LastPolledAt ?? existing.RegisteredAt;
if (DateTimeOffset.UtcNow - unreachableSince < failOpenAfter)
return;
if (_masterIsAvailable)
return;
_deps.Logger.LogWarning(
"Master unreachable since {UnreachableSince}; failing open (Available) after {FailOpenAfter}.",
unreachableSince, failOpenAfter);
_masterIsAvailable = true;
_masterDisableMessage = null;
}
public async Task<string?> GetRegisteredUrlAsync(string apiKey)
{
var existing = await _deps.Repository.GetAsync();
@@ -0,0 +1,31 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace SlpModularCms.Modules.Availability.Services;
public class MasterStatusPollClient(HttpClient httpClient) : IMasterStatusPollClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<PolledMasterStatus?> GetStatusAsync(string masterUrl, string plainApiKey)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Get, $"{masterUrl.TrimEnd('/')}/api/v1/SlaveStatus");
request.Headers.Add("X-Master-Api-Key", plainApiKey);
var response = await httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
return null;
var result = await response.Content.ReadFromJsonAsync<SlaveStatusResponse>(JsonOptions);
return result is null ? null : new PolledMasterStatus(result.IsAvailable, result.DisableMessage);
}
catch
{
return null;
}
}
private record SlaveStatusResponse(bool IsAvailable, string? DisableMessage);
}
@@ -13,15 +13,20 @@ public class PersistentAvailabilityService : IAvailabilityService
{
private readonly ApplicationDbContext _context;
private readonly AvailabilityOptions _options;
private readonly IMasterAvailabilityService _masterAvailabilityService;
// Circuit Breaker state
private static DateTimeOffset _lastErrorTime = DateTimeOffset.MinValue;
private static AvailabilityStatus _cachedStatus = AvailabilityStatus.Available;
public PersistentAvailabilityService(ApplicationDbContext context, IOptions<AvailabilityOptions> options)
public PersistentAvailabilityService(
ApplicationDbContext context,
IOptions<AvailabilityOptions> options,
IMasterAvailabilityService masterAvailabilityService)
{
_context = context;
_options = options.Value;
_masterAvailabilityService = masterAvailabilityService;
}
public async Task<AvailabilityStatus> IsAvailableAsync()
@@ -56,6 +61,12 @@ public class PersistentAvailabilityService : IAvailabilityService
public async Task<AvailabilityStatusDetails> GetStatusDetailsAsync()
{
var masterStatus = _masterAvailabilityService.GetMasterStatus();
if (!masterStatus.IsAvailable)
{
return new AvailabilityStatusDetails(AvailabilityStatus.NotAvailable, masterStatus.DisableMessage, IsMasterControlled: true);
}
try
{
var state = await _context.AvailabilityStates.FirstOrDefaultAsync();
@@ -77,6 +88,12 @@ public class PersistentAvailabilityService : IAvailabilityService
/// </summary>
public async Task UpdateStatusAsync(AvailabilityStatus newStatus, string? reason, string? updatedBy)
{
var masterStatus = _masterAvailabilityService.GetMasterStatus();
if (!masterStatus.IsAvailable)
{
throw new MasterControlledAvailabilityException(masterStatus.DisableMessage);
}
var state = await _context.AvailabilityStates.FirstOrDefaultAsync();
if (state == null)