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>
9.9 KiB
Business Logic Model — Unit 1: master-backend
Flow 1 — AddAsync (Add Slave CMS)
Trigger: POST /api/v1/CmsInstances (Owner only)
sequenceDiagram
box rgba(99,179,237,0.3) API Layer
participant Ctrl as CmsInstanceController
end
box rgba(154,230,180,0.3) Service Layer
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Infrastructure
participant DP as IDataProtector
participant Ctx as IHttpContextAccessor
participant Opts as MasterModuleOptions
end
box rgba(200,200,200,0.3) Persistence
participant DB as MasterDbContext
end
Ctrl->>Svc: AddAsync(request)
Note over Svc: Validate Name, Url, ApiKey not empty
Note over Svc: Validate Url starts with http or https
Svc->>DP: Protect(request.ApiKey)
DP-->>Svc: encryptedApiKey
Svc->>Repo: AddAsync(new CmsInstance)
Note over Svc,Repo: Status=Available, LastContactedAt=null
Svc->>Repo: SaveChangesAsync()
Repo->>DB: INSERT CmsInstances
Note over Svc: Determine masterUrl
Svc->>Ctx: try get base URL from HttpContext
alt HttpContext available
Ctx-->>Svc: masterUrl from request
else HttpContext unavailable
Svc->>Opts: read MasterUrl
Opts-->>Svc: configured masterUrl
end
Svc->>DP: Unprotect(encryptedApiKey)
DP-->>Svc: plainApiKey
Svc->>Client: RegisterMasterAsync(slaveUrl, plainApiKey, masterUrl)
alt Registration success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else Registration failed
Client-->>Svc: false
Note over Svc: LastContactedAt stays null (owner can see)
end
Svc-->>Ctrl: CmsInstanceDto
Ctrl-->>Ctrl: return 201 Created
Text alternative: Controller calls service; service validates, encrypts ApiKey, persists entity, determines master URL from HttpContext or config, attempts slave registration, updates LastContactedAt on success; always returns DTO regardless of registration outcome.
Flow 2 — UpdateStatusAsync (Set Slave Status)
Trigger: PUT /api/v1/CmsInstances/{id}/status (Owner only)
sequenceDiagram
box rgba(99,179,237,0.3) API Layer
participant Ctrl as CmsInstanceController
end
box rgba(154,230,180,0.3) Service Layer
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Infrastructure
participant DP as IDataProtector
end
box rgba(200,200,200,0.3) Persistence
participant DB as MasterDbContext
end
Ctrl->>Svc: UpdateStatusAsync(id, status, disableMessage)
Svc->>Repo: GetByIdAsync(id)
Repo->>DB: SELECT CmsInstances WHERE Id
DB-->>Repo: CmsInstance or null
Repo-->>Svc: entity or null
alt Entity not found
Svc-->>Ctrl: throw NotFoundException
end
Note over Svc: Validate DisableMessage required if NotAvailable
alt Validation fails
Svc-->>Ctrl: throw ValidationException
end
alt newStatus = Inactive
Svc->>Repo: UpdateAsync (Status=Inactive, DisableMessage=null)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Svc->>DP: Unprotect(entity.ApiKey)
DP-->>Svc: plainApiKey
Svc->>Client: PushStatusAsync(slaveUrl, plainApiKey, isAvailable=true, disableMessage=null)
Note over Svc: Releases the master gate — the master no longer manages this slave, so it must not stay stuck on its last pushed status
alt Release success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastStatusPushedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=true)
else Release failed
Client-->>Svc: false
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=false)
end
else newStatus = Available or NotAvailable
Svc->>Repo: UpdateAsync (Status, DisableMessage)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Svc->>DP: Unprotect(entity.ApiKey)
DP-->>Svc: plainApiKey
Svc->>Client: PushStatusAsync(slaveUrl, plainApiKey, status, disableMessage)
alt Push success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastStatusPushedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=true)
else Push failed
Client-->>Svc: false
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=false)
end
end
Ctrl-->>Ctrl: return 200 OK with UpdateStatusResult
Text alternative: Controller calls service with id and new status; service loads entity, validates, updates DB, then decrypts ApiKey and pushes status to slave — for Inactive this push is always isAvailable=true, disableMessage=null (releasing the gate); for Available/NotAvailable it pushes the new status as-is. Returns SlaveContactSuccess=false if the push fails, but the DB write is always the authority.
Updated 2026-07-04: the
Inactivebranch previously did not push anything to the slave at all (see history below) — this left the slave stuck on whatever status it had last received, indefinitely. Fixed by always releasing the gate on deactivation.
Flow 3 — VerifyIntegrityAsync (Background Integrity Check)
Trigger: IntegrityCheckBackgroundService — one tick immediately on host startup, then every IntegrityCheckIntervalMinutes (startup tick added 2026-07-04)
sequenceDiagram
box rgba(200,200,200,0.3) Background
participant Timer as PeriodicTimer
participant BgSvc as IntegrityCheckBackgroundService
end
box rgba(154,230,180,0.3) Service Layer
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Infrastructure
participant DP as IDataProtector
participant Opts as MasterModuleOptions
end
box rgba(200,200,200,0.3) Persistence
participant DB as MasterDbContext
end
Timer->>BgSvc: Tick
BgSvc->>Svc: VerifyIntegrityAsync()
Svc->>Repo: GetActiveAsync()
Repo->>DB: SELECT WHERE Status != Inactive
DB-->>Repo: list of CmsInstance
Repo-->>Svc: instances
loop for each instance
Svc->>DP: Unprotect(instance.ApiKey)
DP-->>Svc: plainApiKey
Svc->>Opts: read MasterUrl
Opts-->>Svc: masterUrl
Svc->>Client: GetRegisteredMasterUrlAsync(slaveUrl, plainApiKey)
alt Slave unreachable
Client-->>Svc: throws or returns null
Svc->>Repo: UpdateAsync (LastIntegrityCheckFailedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Note over Svc: Unreachable for URL check — skip the status re-push for this instance this cycle
else Slave reachable
Client-->>Svc: registeredMasterUrl
alt URLs match
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow, LastIntegrityCheckFailedAt = null)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else URL mismatch
Svc->>Client: RegisterMasterAsync(slaveUrl, plainApiKey, masterUrl)
alt Re-registration success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow, LastIntegrityCheckFailedAt = null)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else Re-registration failed
Client-->>Svc: false
Svc->>Repo: UpdateAsync (LastIntegrityCheckFailedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
end
end
Note over Svc: Status re-push (added 2026-07-04) — runs whenever the slave was reachable, independent of the URL-match outcome
Svc->>Client: PushStatusAsync(slaveUrl, plainApiKey, isAvailable=(Status==Available), disableMessage)
alt Push success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastStatusPushedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
else Push failed
Client-->>Svc: false
Note over Svc: Logged; no flag change — next cycle (or the immediate startup tick) will retry
end
end
end
Svc-->>BgSvc: done
Text alternative: Background timer triggers integrity service; for each non-Inactive slave: decrypts key, retrieves registered master URL, clears failure flag on match, re-registers on mismatch, sets LastIntegrityCheckFailedAt when slave is unreachable or re-registration fails. (Added 2026-07-04) For every slave that was reachable, the service additionally re-pushes the master's currently persisted Status/DisableMessage to that slave — this is what lets a slave that reset its in-memory gate (e.g. after a restart) catch up without waiting for the next explicit admin status change. Combined with the new immediate startup tick on IntegrityCheckBackgroundService, this reconciliation now also runs right after the master process (re)starts.
Updated 2026-07-04: previously this flow only verified/re-registered the master URL and never re-pushed status (see history below) — a restarted slave (whose in-memory master-gate defaults to
Available) would show the wrong status until the master's next explicit UI-driven change.