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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user