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
@@ -14,6 +14,10 @@ public class IntegrityCheckBackgroundService(
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Run once immediately on startup so a freshly (re)started slave has its
// master-gate status re-synced right away, instead of waiting up to a full interval.
await ExecuteTickAsync(stoppingToken);
var interval = TimeSpan.FromMinutes(options.Value.IntegrityCheckIntervalMinutes);
using var timer = new PeriodicTimer(interval);
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SlpModularCms.Modules.Master.Services;
namespace SlpModularCms.Modules.Master.Controllers;
/// <summary>
/// Lets a registered slave pull its own configured status from the master, using the
/// same shared API key used for master-to-slave calls. This is the counterpart of the
/// master-initiated push in CmsInstanceService.UpdateStatusAsync/VerifyIntegrityAsync,
/// letting a slave self-heal its master-gate state (e.g. after a restart) instead of
/// relying solely on the master successfully reaching it.
/// </summary>
[ApiController]
[Route("SlaveStatus")]
[AllowAnonymous]
public class SlaveStatusController(ICmsInstanceService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get()
{
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(apiKey)) return Unauthorized();
var result = await service.GetStatusForApiKeyAsync(apiKey);
if (result is null) return Unauthorized();
return Ok(new { result.IsAvailable, result.DisableMessage });
}
}
@@ -70,10 +70,28 @@ public class CmsInstanceService(MasterServiceDependencies deps) : ICmsInstanceSe
deps.Repository.Update(instance);
await deps.Repository.SaveChangesAsync();
if (request.Status == CmsInstanceStatus.Inactive)
return new UpdateStatusResult(Success: true, SlaveContactSuccess: true);
var plainKey = deps.ApiKeyProtector.Unprotect(instance.ApiKey);
if (request.Status == CmsInstanceStatus.Inactive)
{
// The master no longer manages this slave, so release the master gate
// instead of leaving it stuck on whatever status was last pushed.
var released = await deps.SlaveClient.PushStatusAsync(instance.Url, plainKey, isAvailable: true, disableMessage: null);
if (released)
{
instance.LastStatusPushedAt = DateTimeOffset.UtcNow;
deps.Repository.Update(instance);
await deps.Repository.SaveChangesAsync();
}
else
{
deps.Logger.LogError("Failed to release master gate on deactivated slave {SlaveUrl}", instance.Url);
}
return new UpdateStatusResult(Success: true, SlaveContactSuccess: released);
}
var pushed = await deps.SlaveClient.PushStatusAsync(
instance.Url,
plainKey,
@@ -94,6 +112,35 @@ public class CmsInstanceService(MasterServiceDependencies deps) : ICmsInstanceSe
return new UpdateStatusResult(Success: true, SlaveContactSuccess: pushed);
}
public async Task<SlaveStatusPollResponse?> GetStatusForApiKeyAsync(string plainApiKey)
{
var instances = await deps.Repository.GetActiveAsync();
foreach (var instance in instances)
{
string? candidateKey;
try
{
candidateKey = deps.ApiKeyProtector.Unprotect(instance.ApiKey);
}
catch
{
continue;
}
if (candidateKey != plainApiKey)
continue;
instance.LastContactedAt = DateTimeOffset.UtcNow;
deps.Repository.Update(instance);
await deps.Repository.SaveChangesAsync();
return new SlaveStatusPollResponse(instance.Status == CmsInstanceStatus.Available, instance.DisableMessage);
}
return null;
}
public async Task VerifyIntegrityAsync()
{
var masterUrl = ResolveMasterUrl();
@@ -140,6 +187,20 @@ public class CmsInstanceService(MasterServiceDependencies deps) : ICmsInstanceSe
instance.LastIntegrityCheckFailedAt = null;
}
// The slave keeps its master-gate status in memory only, so a slave restart
// silently resets it to "available" until the next explicit status change.
// Re-push the master's persisted status every check to keep it in sync.
var pushed = await deps.SlaveClient.PushStatusAsync(
instance.Url,
plainKey,
isAvailable: instance.Status == CmsInstanceStatus.Available,
disableMessage: instance.DisableMessage);
if (pushed)
{
instance.LastStatusPushedAt = DateTimeOffset.UtcNow;
}
deps.Repository.Update(instance);
await deps.Repository.SaveChangesAsync();
}
@@ -8,4 +8,12 @@ public interface ICmsInstanceService
Task<CmsInstanceDto> AddAsync(CreateCmsInstanceRequest request);
Task<UpdateStatusResult> UpdateStatusAsync(Guid id, UpdateStatusRequest request);
Task VerifyIntegrityAsync();
/// <summary>
/// Looks up the registered CMS instance by its plain (unprotected) API key, for a slave
/// pulling its own status. Returns null when no instance's key matches (unauthorized).
/// </summary>
Task<SlaveStatusPollResponse?> GetStatusForApiKeyAsync(string plainApiKey);
}
public record SlaveStatusPollResponse(bool IsAvailable, string? DisableMessage);