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:
@@ -182,15 +182,19 @@ De `ModuleOrchestrator` zal de module nu automatisch ontdekken en laden bij het
|
|||||||
De `SlpModularCms.Modules.Master` module laat een Owner op één "Master"-CMS de beschikbaarheid van andere ("slave") CMS-instanties centraal beheren. Elke slave die de `SlpModularCms.Modules.Availability`-module draait, respecteert een master-gecontroleerde aan/uit-status naast zijn eigen lokale beschikbaarheidsschakelaar.
|
De `SlpModularCms.Modules.Master` module laat een Owner op één "Master"-CMS de beschikbaarheid van andere ("slave") CMS-instanties centraal beheren. Elke slave die de `SlpModularCms.Modules.Availability`-module draait, respecteert een master-gecontroleerde aan/uit-status naast zijn eigen lokale beschikbaarheidsschakelaar.
|
||||||
|
|
||||||
### Architectuur
|
### Architectuur
|
||||||
- **Master** (`SlpModularCms.Modules.Master`): eigen `MasterDbContext` met de `CmsInstance`-entiteit (URL, versleutelde API key, status). Bevat `CmsInstanceController` (`/CmsInstances`, Owner-only), `SlaveApiClient` (uitgaande HTTP-calls naar slaves) en `IntegrityCheckBackgroundService` (periodieke reconciliatie, standaard elk uur).
|
- **Master** (`SlpModularCms.Modules.Master`): eigen `MasterDbContext` met de `CmsInstance`-entiteit (URL, versleutelde API key, status). Bevat `CmsInstanceController` (`/CmsInstances`, Owner-only), `SlaveApiClient` (uitgaande HTTP-calls naar slaves), `SlaveStatusController` (`GET /api/v1/SlaveStatus`, laat een slave zijn eigen status ophalen) en `IntegrityCheckBackgroundService` (periodieke reconciliatie + status-herpush, standaard elk uur).
|
||||||
- **Slave-extensie** (`SlpModularCms.Modules.Availability`): eigen `MasterRegistration`-entiteit, `MasterController` (interne endpoints onder `/api/v1/master/*`, buiten de beschikbaarheids-gate om) en een uitgebreide `AvailabilityMiddleware` die zowel de lokale als de master-gate evalueert.
|
- **Slave-extensie** (`SlpModularCms.Modules.Availability`): eigen `MasterRegistration`-entiteit (incl. `LastPolledAt`), `MasterController` (interne endpoints onder `/api/v1/master/*`, buiten de beschikbaarheids-gate om), `MasterStatusPollingBackgroundService` (periodiek pullen van de eigen status bij de Master) en een uitgebreide `AvailabilityMiddleware` die zowel de lokale als de master-gate evalueert.
|
||||||
|
|
||||||
### Registratie- en statusflow
|
### Registratie- en statusflow
|
||||||
1. Owner voegt op de Master `/cms`-pagina een slave toe met diens URL.
|
1. Owner voegt op de Master `/cms`-pagina een slave toe met diens URL.
|
||||||
2. De Master genereert een API key, versleutelt deze (Data Protection) en slaat hem op bij de `CmsInstance`.
|
2. De Master genereert een API key, versleutelt deze (Data Protection) en slaat hem op bij de `CmsInstance`.
|
||||||
3. De Master pusht de registratie naar de slave: `POST /api/v1/master/register` met header `X-Master-Api-Key`.
|
3. De Master pusht de registratie naar de slave: `POST /api/v1/master/register` met header `X-Master-Api-Key`.
|
||||||
4. Zet de Owner de status van een slave om (Available / NotAvailable / Inactive), dan pusht de Master dit synchroon door naar de slave.
|
4. Zet de Owner de status van een slave om (Available / NotAvailable / Inactive), dan pusht de Master dit synchroon door naar de slave. Bij **Inactive** stuurt de Master expliciet `Available` (de gate wordt vrijgegeven — de Master beheert de slave niet meer).
|
||||||
5. **Fail-open**: lukt de push niet, dan wordt de statuswijziging op de Master **niet** teruggedraaid — `IntegrityCheckBackgroundService` haalt de reconciliatie in tijdens de volgende cyclus (`MasterModuleOptions.IntegrityCheckIntervalMinutes`). Een slave die herstart voordat de Master opnieuw pusht, staat standaard weer open (`_masterIsAvailable = true` bij opstarten) — er is bewust geen TTL op de laatst bekende status.
|
|
||||||
|
**Twee onafhankelijke synchronisatiepaden** (push én pull), zodat lokale manipulatie of een gemiste update op de slave zichzelf herstelt:
|
||||||
|
- **Push** (Master → Slave, direct): elke statuswijziging via de UI, plus elke `IntegrityCheckBackgroundService`-cyclus (herpusht de laatst opgeslagen status naar elke actieve slave — vangt slaves op die net herstart zijn).
|
||||||
|
- **Pull** (Slave → Master, periodiek): `MasterStatusPollingBackgroundService` op de slave haalt zelf zijn status op bij `GET /api/v1/SlaveStatus` (`MasterPolling:PollIntervalSeconds`, standaard 30s). Dit is de guard tegen lokale manipulatie van de slave-status en tegen gemiste pushes.
|
||||||
|
- **Fail-open**: is de Master langer dan `MasterPolling:FailOpenAfterMinutes` (standaard 5 min) onbereikbaar via de pull, dan valt de slave automatisch terug naar `Available` — een dode of onbereikbare Master mag een slave nooit permanent blokkeren.
|
||||||
|
|
||||||
### Configuratie
|
### Configuratie
|
||||||
Nieuwe sectie `MasterModule` in `appsettings.json` (zie ook `appsettings.Development.json`):
|
Nieuwe sectie `MasterModule` in `appsettings.json` (zie ook `appsettings.Development.json`):
|
||||||
@@ -203,6 +207,78 @@ Nieuwe sectie `MasterModule` in `appsettings.json` (zie ook `appsettings.Develop
|
|||||||
```
|
```
|
||||||
- `MasterUrl` is de publieke URL van déze master-instantie, gebruikt door `IntegrityCheckBackgroundService` (buiten een HTTP-requestcontext heeft de background service geen `HttpContext` om dit uit af te leiden).
|
- `MasterUrl` is de publieke URL van déze master-instantie, gebruikt door `IntegrityCheckBackgroundService` (buiten een HTTP-requestcontext heeft de background service geen `HttpContext` om dit uit af te leiden).
|
||||||
|
|
||||||
|
Nieuwe sectie `MasterPolling` (op elke instantie die `Modules.Availability` laadt — dus ook de slave):
|
||||||
|
```json
|
||||||
|
"MasterPolling": {
|
||||||
|
"PollIntervalSeconds": 30,
|
||||||
|
"FailOpenAfterMinutes": 5,
|
||||||
|
"HttpTimeoutSeconds": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Heeft geen effect zolang er geen `MasterRegistration` bestaat (bijv. op de Master zelf, of op een slave die nog niet gekoppeld is).
|
||||||
|
|
||||||
|
### Vergrendeld Instellingen-scherm op een master-gecontroleerde slave
|
||||||
|
Zolang de master-gate een slave op `NotAvailable` heeft gezet (via push of pull), toont de slave's eigen `/settings`-pagina (`SettingsPage.tsx`) dit als een vergrendelde toestand in plaats van een normaal te wijzigen instelling:
|
||||||
|
- Een banner legt uit dat de Master CMS deze status beheert.
|
||||||
|
- De modusknoppen, het redenveld en de opslaanknop zijn disabled.
|
||||||
|
- Een lokale poging om de status alsnog te wijzigen (bijv. via een directe API-call) wordt door de backend geweigerd met `409 Conflict` (`MasterControlledAvailabilityException` in `PersistentAvailabilityService.UpdateStatusAsync`) — de master-gate kan dus niet per ongeluk of expres lokaal worden omzeild.
|
||||||
|
- `GET /api/v1/Availability/status` geeft dit door via het veld `isMasterControlled`.
|
||||||
|
|
||||||
|
## Lokaal Master + Slave Draaien (Dev)
|
||||||
|
|
||||||
|
Om de master↔slave-connectie (zie "Master CMS Module" hierboven) lokaal te kunnen testen, kun je twee backend-instanties tegelijk draaien: een volledige "master" (met de `SlpModularCms.Modules.Master`-module) en een "slave"-instantie zonder die module. Dit is puur een lokale ontwikkel-/testopstelling — er is geen nieuwe functionaliteit aan de master/slave-protocol zelf toegevoegd.
|
||||||
|
|
||||||
|
### 1. Backends starten
|
||||||
|
|
||||||
|
**Master** (bestaande `SlpModularCms.Api`, ongewijzigd):
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/SlpModularCms.Api --launch-profile https
|
||||||
|
```
|
||||||
|
Bereikbaar op `https://localhost:7221` (Scalar op `/scalar`).
|
||||||
|
|
||||||
|
**Slave** (nieuwe `SlpModularCms.Api.Slave`, zonder de Master-module):
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/SlpModularCms.Api.Slave --launch-profile https
|
||||||
|
```
|
||||||
|
Bereikbaar op `https://localhost:7222` (Scalar op `/scalar`). Vereist een eigen `src/SlpModularCms.Api.Slave/appsettings.local.json` — kopieer `appsettings.local.json.example` naar `appsettings.local.json` en vul een **eigen** lokale database in (bijv. `Database=SlpModularCmsSlave`), zodat master- en slave-data gescheiden blijven.
|
||||||
|
|
||||||
|
De `Modules.Availability`-migraties worden automatisch toegepast bij het opstarten (zie "Per-module migraties" hierboven). De `SlpModularCms.Core`-migraties (Identity) worden **nooit** automatisch toegepast — dit moet je, net als bij de master, één keer handmatig doen voor de nieuwe slave-database:
|
||||||
|
```powershell
|
||||||
|
dotnet ef database update --project src\SlpModularCms.Core --startup-project src\SlpModularCms.Api.Slave --context ApplicationDbContext
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Frontend starten
|
||||||
|
|
||||||
|
**Tegen de master** (standaard):
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
Draait op `http://localhost:5173`, gebruikt `.env.local` (`VITE_API_BASE_URL=https://localhost:7221`).
|
||||||
|
|
||||||
|
**Tegen de slave** (optioneel, alleen nodig als je de slave ook via de admin-UI wilt bekijken):
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
pnpm dev:slave
|
||||||
|
```
|
||||||
|
Draait op `http://localhost:5174`, gebruikt `.env.slave.local` (`VITE_API_BASE_URL=https://localhost:7222`) — kopieer eerst `.env.example` naar `.env.slave.local` met die waarde.
|
||||||
|
|
||||||
|
**Beide tegelijk** (zoals een Compound-configuratie in Rider):
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
pnpm dev:all
|
||||||
|
```
|
||||||
|
Start `pnpm dev` en `pnpm dev:slave` parallel in één terminal, met gekleurde `master`/`slave`-prefixes per regel zodat de output van elkaar te onderscheiden blijft. Stoppen met `Ctrl+C` sluit beide dev-servers af.
|
||||||
|
|
||||||
|
### 3. Slave koppelen aan de master
|
||||||
|
|
||||||
|
Met beide backends (en de master-frontend) draaiend:
|
||||||
|
1. Log in op de master-frontend (`http://localhost:5173`) en ga naar de `/cms`-pagina.
|
||||||
|
2. Gebruik de bestaande **"Add CMS Instance"**-dialoog om de lokale slave toe te voegen met URL `https://localhost:7222`.
|
||||||
|
3. De master genereert en pusht een API key naar de slave (`POST /api/v1/master/register`); de instantie zou daarna als **verbonden/gezond** moeten worden getoond.
|
||||||
|
|
||||||
|
Zie `aidlc-docs/features/local-dev-master-slave-setup/inception/requirements/requirements.md` voor de volledige requirements en rationale achter deze opstelling.
|
||||||
|
|
||||||
## Productie Setup
|
## Productie Setup
|
||||||
|
|
||||||
### 1. Build & Publish
|
### 1. Build & Publish
|
||||||
|
|||||||
@@ -6,4 +6,4 @@
|
|||||||
| CMS Frontend (cms-frontend) | ✅ Complete | unknown | Frontend, Identity (Auth), Availability | 2026-06-16 |
|
| CMS Frontend (cms-frontend) | ✅ Complete | unknown | Frontend, Identity (Auth), Availability | 2026-06-16 |
|
||||||
| Master CMS Module (master-cms-module) | ✅ Complete | unknown | Modules, Availability | 2026-06-26 |
|
| Master CMS Module (master-cms-module) | ✅ Complete | unknown | Modules, Availability | 2026-06-26 |
|
||||||
| Tech Debt Backlog (tech-debt-backlog) | 🔵 Inception | unknown | Modules.Master, Frontend | 2026-07-01 |
|
| Tech Debt Backlog (tech-debt-backlog) | 🔵 Inception | unknown | Modules.Master, Frontend | 2026-07-01 |
|
||||||
| Local Dev Master/Slave Setup (local-dev-master-slave-setup) | 🟢 Construction | unknown | Modules.Master, Api, Frontend | 2026-07-02 |
|
| Local Dev Master/Slave Setup (local-dev-master-slave-setup) | ✅ Complete | unknown | Modules.Master, Api, Frontend | 2026-07-02 |
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- **Feature Slug**: local-dev-master-slave-setup
|
- **Feature Slug**: local-dev-master-slave-setup
|
||||||
- **Project Type**: Brownfield
|
- **Project Type**: Brownfield
|
||||||
- **Start Date**: 2026-07-02T00:00:00Z
|
- **Start Date**: 2026-07-02T00:00:00Z
|
||||||
- **Current Stage**: CONSTRUCTION - Unit 2 (Frontend dual-instance tooling & runbook) - Code Generation
|
- **Current Stage**: OPERATIONS - Placeholder (feature complete)
|
||||||
- **Branch**: unknown
|
- **Branch**: unknown
|
||||||
|
|
||||||
## Workspace State
|
## Workspace State
|
||||||
@@ -42,5 +42,5 @@
|
|||||||
|
|
||||||
### 🟢 CONSTRUCTION PHASE
|
### 🟢 CONSTRUCTION PHASE
|
||||||
- [x] Unit 1 (Backend dual-instance hosting) — Code Generation complete (`construction/unit-1-backend-dual-instance-hosting/code/summary.md`); build + full test suite verified green; manual module-discovery check confirms slave excludes Master
|
- [x] Unit 1 (Backend dual-instance hosting) — Code Generation complete (`construction/unit-1-backend-dual-instance-hosting/code/summary.md`); build + full test suite verified green; manual module-discovery check confirms slave excludes Master
|
||||||
- [ ] Unit 2 (Frontend dual-instance tooling & runbook) — In Progress
|
- [x] Unit 2 (Frontend dual-instance tooling & runbook) — Code Generation complete (`construction/unit-2-frontend-dual-instance-tooling/code/summary.md`)
|
||||||
- [ ] Build and Test — Not started
|
- [x] Build and Test — Complete (`construction/build-and-test/build-and-test-summary.md`); build succeeds, 193/193 automated tests pass, module isolation verified manually, end-to-end connection test documented as a manual follow-up (no local SQL Server available in this session)
|
||||||
|
|||||||
@@ -269,3 +269,215 @@
|
|||||||
**Context**: Code Generation Part 1 (Planning) for Unit 2 — awaiting user approval before generation.
|
**Context**: Code Generation Part 1 (Planning) for Unit 2 — awaiting user approval before generation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Code Generation Plan — Unit 2 — Approved
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "Kan je de voorgaande changes committen voor me" then "Approve Plan"
|
||||||
|
|
||||||
|
**AI Response**: "Committed Unit 1's changes as 274946d prior to approval. Plan approved. Proceeding to Code Generation Part 2 (Generation) for Unit 2."
|
||||||
|
|
||||||
|
**Context**: Code Generation, Unit 2, Part 1 complete. Starting Part 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Generation — Unit 2 — Complete
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: N/A (generation step)
|
||||||
|
|
||||||
|
**AI Response**: "Executed all 4 plan steps: created frontend/.env.slave.local (verified gitignored via *.local pattern), updated .env.example with slave-mode documentation, added dev:slave npm script (vite --mode slave --port 5174), added 'Lokaal Master + Slave Draaien (Dev)' runbook section to root README.md covering starting both backends, both frontends, and using the existing Add CMS Instance dialog to connect them. Wrote construction/unit-2-frontend-dual-instance-tooling/code/summary.md."
|
||||||
|
|
||||||
|
**Context**: Code Generation Part 2 complete for Unit 2 — last unit. Presenting completion message, awaiting approval before Build and Test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Generation — Unit 2 — Approved
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "Continue to Next Stage naar Build and Test"
|
||||||
|
|
||||||
|
**AI Response**: "Unit 2 approved. Both units complete. Proceeding to Build and Test."
|
||||||
|
|
||||||
|
**Context**: All units complete. Starting Build and Test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build and Test Stage
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**Build Status**: Success
|
||||||
|
|
||||||
|
**Test Status**: Pass (193/193 automated; 1 manual integration scenario documented as developer follow-up — no local SQL Server available in this sandboxed session)
|
||||||
|
|
||||||
|
**Files Generated**:
|
||||||
|
- build-instructions.md
|
||||||
|
- unit-test-instructions.md
|
||||||
|
- integration-test-instructions.md
|
||||||
|
- build-and-test-summary.md
|
||||||
|
|
||||||
|
**AI Response**: "Ran dotnet build (success) and dotnet test (193/193 pass across 4 projects). Verified module isolation scenario manually (master loads 3 modules, slave loads 2, no Master). Documented the end-to-end Add CMS Instance connection scenario as a manual follow-up since no container runtime is available in this environment to provision a local SQL Server. Updated active-features.md to ✅ Complete (Operations is a placeholder for this project)."
|
||||||
|
|
||||||
|
**Context**: Build and Test complete. Presenting completion message, awaiting final approval.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build and Test — Change Requested (Recognizable Tab Titles)
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "Geef de frontend van de slave een herkenbare html title zodta de tabbladen snel herkenbaar maken welk systeem ik bekijk"
|
||||||
|
|
||||||
|
**AI Response**: "Added VITE_APP_TITLE env var (optional, defaults to 'SlpModularCms'), wired into frontend/src/lib/config.ts (appTitle field) and set via document.title in main.tsx. Set VITE_APP_TITLE=SlpModularCms (Slave) in frontend/.env.slave.local and VITE_APP_TITLE=SlpModularCms (Master) in the developer's existing frontend/.env.local for symmetry. Updated frontend/.env.example and vite-env.d.ts. Verified: pnpm build succeeds (tsc + vite build), pnpm test 208/209 pass — the 1 failure (AddCmsInstanceDialog.test.tsx timeout) reproduced as a flaky test unrelated to this change (passed 5/5 in isolation). Updated Unit 2's code summary."
|
||||||
|
|
||||||
|
**Context**: Small addition to Unit 2 scope during Build and Test review. Re-presenting Build and Test completion message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build and Test — Fix Requested (Slave Connection String)
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "Ik denk dat de connectionstring niet goed is voor de Slave CMS. Maak van de appsettings.local.json.example een implementatie van de appsettings.local.json met dezelfde credentials, maar zijn eigen database"
|
||||||
|
|
||||||
|
**AI Response**: "Created the real (gitignored) src/SlpModularCms.Api.Slave/appsettings.local.json, mirroring the master's credentials (Server=127.0.0.1,1433;User ID=sa;...) with Database=SlpModularCmsSlave instead of SlpModularCms. Verified via git check-ignore it's untracked. Ran the slave with --no-build: it successfully connected to the local SQL Server, created the SlpModularCmsSlave database, and applied EF Core migrations — confirming the connection string now works (this environment does have a reachable local SQL Server at 127.0.0.1:1433, contrary to the earlier Build and Test assumption). Attempted to also run master simultaneously to redo the full end-to-end connectivity scenario, but hit 'address already in use' on both :7221 and :7222 from leftover dotnet run child processes surviving earlier timeout-wrapped manual test invocations in this session — a session artifact, not a code defect. Did not force-kill processes broadly per sandbox safety controls; flagged for the user to clear stray processes before their own manual verification. Updated Unit 1's code summary with these findings."
|
||||||
|
|
||||||
|
**Context**: Connection string issue resolved and verified via actual DB/migration success. Full simultaneous master+slave connectivity test still pending due to a leftover-process port conflict in this session, not a code issue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Port Conflict — Resolved
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "ik heb mijn processen afgesloten. jij mag jouwe afsluiten"
|
||||||
|
|
||||||
|
**AI Response**: "Checked via Get-CimInstance for any SlpModularCms-named processes (none found) and via Get-NetTCPConnection for ports 7221/7222/5284/5285 (none listening) — no leftover processes remain on either side; ports are free."
|
||||||
|
|
||||||
|
**Context**: Environment clean. Ready for the user to perform their own manual end-to-end master+slave connectivity verification whenever convenient.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Status Check — Gap Found and Fixed
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "Zijn de nodige migraties gemaakt en zijn beide databases up-to-date?"
|
||||||
|
|
||||||
|
**AI Response**: "Checked all DbContext/startup-project combinations via 'dotnet ef migrations list' (read-only). Master DB (SlpModularCms) fully up to date across ApplicationDbContext (Core/Identity), AvailabilityDbContext, and MasterDbContext. Slave DB (SlpModularCmsSlave) had AvailabilityDbContext applied (auto-migrated on startup) but ApplicationDbContext (Core/Identity) had 2 pending migrations — Core's Identity migrations are never auto-applied (only Availability and Master modules call Database.Migrate() in UseModule), and nobody had run the manual dotnet ef database update for the new slave DB yet. Fixed by running 'dotnet ef database update --project src/SlpModularCms.Core --startup-project src/SlpModularCms.Api.Slave --context ApplicationDbContext' — both migrations now applied, verified via a follow-up migrations list showing no pending entries. Added this exact command to the README runbook so it isn't missed again in the future. Updated Unit 1's code summary with the full before/after migration status table."
|
||||||
|
|
||||||
|
**Context**: Both databases now confirmed fully up to date across all applicable DbContexts. Runbook documentation updated to prevent recurrence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix — CMS Page Wrongly Visible on Slave
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "De front-end van de Slave toont nog steeds de CMS-pagina. Deze is dus alleen voor de Master. De CMS-en zonder de Master-module mogen die pagina niet zien"
|
||||||
|
|
||||||
|
**AI Response**: "Dispatched an Explore subagent to map how /cms visibility currently works — found it was gated purely by Owner role (router.tsx cmsRoute, Sidebar.tsx NAV_ITEMS), with zero awareness of whether the connected backend actually has Modules.Master loaded (never an issue before this feature, since every instance always had it). Implemented a backend capability check: ModuleOrchestrator.ModuleNames property, new SystemController exposing GET /api/v1/System/capabilities, orchestrator registered as a DI singleton in both Program.cs files. Frontend: useSystemCapabilities hook, new ModuleGuard component (mirrors RoleGuard), applied to both the /cms route and the Sidebar nav item requiring the 'Master' module. Added MSW mock handler (defaults to including Master, so existing tests are unaffected) and i18n strings. Verified: dotnet build/test all pass (193/193); pnpm build succeeds; pnpm test 210/210 in isolation (one pre-existing flaky test in the full run, unrelated). Live-verified against actually running master and slave instances — curled the new endpoint directly: master returns ['Availability','Identity','Master'], slave returns ['Availability','Identity'] only. Cleaned up the two verification processes by their specific PIDs afterward. Documented in a new master-only-cms-page-fix.md under Unit 2's code folder."
|
||||||
|
|
||||||
|
**Context**: Genuine functional gap surfaced by this feature (frontend had no way to know backend module composition) now fixed and verified end-to-end, not just conceptually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix — CMS Instance Status Badge Styling & Master-Gate Status Not Reflected on Slave
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "1: De badge die getoond wordt op de CMS-pagina toont beschikbaar in grijs in plaats van in groen en zonder icoontje. Laat die badge hetzelfde werken als op de availability op de dashboard. Inactive mag wel grijs zijn. 2: Als ik de status van een slave verander gebeurd er niks en er wordt ook geen call gedaan naar de slave om de status te veranderen. Was dit niet wat er was afgesproken dat er zou gebeuren als de master de status veranderd? Als ik naar de slave ga blijft die Beschikbaar ongeacht of de master hem heeft ingesteld op Niet beschikbaar. Ook na refresh en re-login"
|
||||||
|
|
||||||
|
**AI Response**: "Fix 1 (badge styling): frontend/src/components/cms/CmsInstanceList.tsx used a plain shadcn Badge (variant-based, no color/icon for Available) instead of the colored-pill + icon pattern used by AvailabilityStatusBadge on the dashboard. Replaced it with the same visual pattern: green+CheckCircle for Available, red+XCircle for NotAvailable, grey+MinusCircle for Inactive (as requested, Inactive stays grey). Fix 2 (master status not reaching slave dashboard): confirmed CmsInstanceService.UpdateStatusAsync (Modules.Master) does correctly call SlaveClient.PushStatusAsync, which does reach MasterController/MasterAvailabilityService.PushStatusAsync (Modules.Availability) on the slave and updates its in-memory MasterGateStatus. However, that master-gate status was only ever consulted by AvailabilityMiddleware to gate/503 *other* API calls — the dashboard's own availability badge queries GET /api/v1/Availability/status, which is deliberately bypassed by the middleware (so admins can still log in and see the app) and is backed by PersistentAvailabilityService, which never looked at the master gate at all. So the push from master WAS working, but its effect was invisible on the slave's own dashboard. Fixed by injecting IMasterAvailabilityService into PersistentAvailabilityService and having GetStatusDetailsAsync() return NotAvailable + the master's disableMessage whenever the master gate reports unavailable, falling back to the locally persisted status otherwise. Added a regression test (GetStatusDetailsAsync_ShouldReturnNotAvailable_WhenMasterGateDisabled) and updated two existing test call sites for the new constructor parameter. Verified: dotnet test on Modules.Availability.Tests — 61/61 pass; pnpm build succeeds; pnpm test — 210/210 pass (no flaky failures this run)."
|
||||||
|
|
||||||
|
**Context**: Fix 2 was a real functional gap in the master/slave feature's core promise (this feature exists specifically to let the master's status changes be observable on the slave) — not a new capability, but completing what 'master pushes status to slave' was always supposed to mean end-to-end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature — Combined `pnpm dev:all` Script for Frontend
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "Kan je voor de front-end een script toevoegen waarmee ik in 1x master en slave kan opstarten. Beetje zoals ik in Rider de Compound configuratie heb"
|
||||||
|
|
||||||
|
**AI Response**: "Added 'dev:all' script to frontend/package.json using the new 'concurrently' devDependency: runs 'pnpm dev' (master, :5173) and 'pnpm dev:slave' (slave, :5174) in parallel in one terminal, each line prefixed and colored (blue/magenta) so output stays distinguishable — analogous to a Rider Compound run configuration. Ctrl+C stops both. Verified by actually running 'pnpm dev:all' for ~10s: both Vite dev servers started correctly on their expected ports with correctly labeled/colored output. Updated README with the new command under 'Frontend starten'."
|
||||||
|
|
||||||
|
**Context**: Pure dev-ergonomics addition, no behavior change to the app itself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Report — Slave Shows Available After Restart Despite Master Having Disabled It
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "Als ik de master en slave opnieuw opstart dan zegt de slave dat hij beschikbaar is terwijl de master aangeeft dat die onbeschikbaar zou moeten zijn. Hoe komt dat?"
|
||||||
|
|
||||||
|
**AI Response**: "Root cause: the slave's master-gate status (MasterAvailabilityService._masterIsAvailable / _masterDisableMessage) is held in static in-memory fields only — never persisted — defaulting to available=true on every process start. The master's CmsInstance.Status IS correctly persisted in its own DB, but nothing re-pushes that persisted status to the slave on startup; a push only happens when an admin explicitly changes status via the UI (CmsInstanceService.UpdateStatusAsync). The existing periodic IntegrityCheckBackgroundService (default: every 60 minutes) only re-verifies/re-registers the slave's registered master URL in VerifyIntegrityAsync — it never re-pushed status, so even the periodic reconciliation wouldn't have caught this within a reasonable time. Fixed both halves: (1) CmsInstanceService.VerifyIntegrityAsync now also re-pushes the master's persisted status (SlaveClient.PushStatusAsync) to every active slave on each integrity check, so a slave that resets to 'available' on restart gets corrected; (2) IntegrityCheckBackgroundService.ExecuteAsync now runs one check immediately on startup instead of waiting for the first PeriodicTimer tick, so this resync happens right after the master (re)starts rather than up to 60 minutes later. Added a regression test (VerifyIntegrityAsync_RePushesPersistedStatus_ToResyncSlaveAfterRestart). Verified: dotnet test on Modules.Master.Tests — 43/43 pass (was 42)."
|
||||||
|
|
||||||
|
**Context**: This is the same underlying gap as the earlier CMS-badge/master-gate fix (in-memory state on the slave not being kept in sync with the master's persisted intent), surfacing again specifically around process restarts rather than live status pushes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Question — Refresh Tokens Overwritten Between Master and Slave Frontends
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "Ik kan niet op beide systemen ingelogd zijn. De refresh tokens wordne overschreven. Is dat iets vanwege het lokaal draaien? [...] ik gok dat dat aan de hand van domein is wat lokaal allebei localhost is, klopt dat?"
|
||||||
|
|
||||||
|
**AI Response**: "Confirmed the user's hypothesis. AuthController.GetCookieOptions() sets the refreshToken cookie without an explicit Domain, so it's host-scoped to whichever host issued it — but per RFC 6265, cookies are never port-scoped, only domain+path. Master (localhost:7221) and slave (localhost:7222) both set 'refreshToken' for domain 'localhost', path '/api/v1/auth' — identical cookie key, so the last login always overwrites the other. In production this isn't an issue since each CMS instance has its own real domain (properly separated by the browser's cookie jar); this is purely a local-dev artifact of both instances happening to share the 'localhost' hostname. Offered two workarounds (hosts-file entries like master.local/slave.local for a production-like separation, or simply using separate browser profiles/incognito for master vs slave) without recommending any code change, since this isn't a defect."
|
||||||
|
|
||||||
|
**Context**: User confirmed the incognito-workaround is sufficient; no code change made. Not a bug — purely local-dev cookie-domain behavior, explicitly ruled out as something to fix in the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix — Slave Stuck on Last Pushed Status After Master Sets It to Inactive
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "Wat ik wel zie is dat de slave op 'Niet Beschikbaar' blijft staan als ik hem in de Master van 'Niet beschikbaar' naar 'Inactief zet'. Als de Master de Slave niet meer beheert moet de Slave gewoon weer Beschikbaar worden. Zat er niet al een systeem in dat Slave bij Master kijkt wat de status is? Daar zouden we het bij kunnen zetten. Ik weet ook niet of dat een periodieke check is?"
|
||||||
|
|
||||||
|
**AI Response**: "Confirmed there is no slave-polls-master mechanism at all — the master/slave protocol is entirely push-based from the master side (explicit status pushes on UpdateStatusAsync, plus the periodic IntegrityCheckBackgroundService discussed in the prior fix). Root cause of this specific report: CmsInstanceService.UpdateStatusAsync had an early-return for CmsInstanceStatus.Inactive that skipped SlaveClient.PushStatusAsync entirely ('master no longer manages this slave' was implemented as 'do nothing'), so the slave kept whatever gate state (e.g. NotAvailable) it was last pushed, forever — and since VerifyIntegrityAsync's periodic resync only iterates GetActiveAsync() (which excludes Inactive instances), it would never self-correct either. Fixed by having the Inactive branch explicitly push isAvailable=true, disableMessage=null to the slave — releasing the master gate — before returning, mirroring the same release-on-deactivation logic. Updated the existing UpdateStatusAsync_DoesNotPushToSlave_WhenStatusIsInactive test (renamed/inverted to UpdateStatusAsync_ReleasesMasterGate_WhenStatusIsInactive) and added UpdateStatusAsync_ReturnsSlaveContactFalse_WhenReleasingMasterGateFails. Verified: dotnet test on Modules.Master.Tests — 44/44 pass (was 43)."
|
||||||
|
|
||||||
|
**Context**: Third fix in this feature's 'slave in-memory master-gate state must stay in sync with the master's actual intent' theme — this one specifically for the deactivation/detach path, which had simply never pushed anything to the slave at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature — Slave Pull-Based Status Polling with Fail-Open
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "volgens mij was het idee dat de slave ook af en toe bij de master vroef wat de status is zodat lokaal niet de boel zomaar kan worden aangepast en dat de slave af en toe een refreh doet van de status. ook weer om dit soort issues tegen te gaan. Als de master te lang onbeschikbaar is of gewoon niet reageert moet de slave beschikbaar worden zodat het een fail-open is"
|
||||||
|
|
||||||
|
**AI Response**: "Dispatched an Explore subagent to check whether this was ever actually specified. Confirmed: the original master-cms-module inception requirements (FR-MASTER-06 'Slave Pull Model: Periodic Master Check', FR-MASTER-07 'Slave Fallback Behavior', NFR-MASTER-01 'Fail-Open Safety') explicitly called for a slave-initiated periodic pull with fail-open, but the later construction-phase design silently switched to a push-only model (master calls slave), keeping only the in-memory fail-open *default* (true on process start) rather than an active reconciling pull. Implemented the originally-specified pull side to complement (not replace) the existing push: (1) new master-side GET /api/v1/SlaveStatus endpoint (SlaveStatusController + ICmsInstanceService.GetStatusForApiKeyAsync), authenticated by matching the caller's plain API key against each active CmsInstance's decrypted key — added to AvailabilityMiddleware's bypass list so it's always reachable regardless of the master's own local status; (2) slave-side MasterStatusPollingBackgroundService (Modules.Availability), polling on a configurable interval (MasterPollingOptions, default 30s / 15s in dev) via new IMasterStatusPollClient, applying successful results through new IMasterAvailabilityService methods (GetPollTargetAsync, ApplyPolledStatusAsync); (3) fail-open: RecordPollFailureAsync forces the gate back to Available if the master has been unreachable for longer than FailOpenAfterMinutes (default 5min / 2min in dev), measured from MasterRegistration.LastPolledAt (new persisted field, new EF migration AddLastPolledAtToMasterRegistration, applied to both master and slave DBs). The existing push mechanism is untouched and still fires instantly on explicit status changes; polling is the self-healing safety net for everything push can miss (restarts, dropped pushes, local tampering). Verified: dotnet test across the whole solution — 216/216 pass (75 Availability + 50 Master + 54 Core + 37 Identity). Live smoke-tested by starting both master and slave: confirmed the new background service starts without crashing the host, correctly detects a connection failure (mismatched port in this quick ad-hoc run, not a code issue) and handles it gracefully through the fail-open path rather than an unhandled exception."
|
||||||
|
|
||||||
|
**Context**: This closes a real design regression: the pull+fail-open behavior was part of the original spec but got dropped during construction without anyone flagging it, and its absence was the root cause behind several of today's earlier reports (slave stuck on stale status after restart, after deactivation). Push and pull now both exist and complement each other.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix — Slave Settings Silently Accepted (and 'Succeeded') Local Availability Changes While Master-Controlled
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "Als de Slave op 'Niet beschikbaar' is gezet door de master en je probeert in de instellingen van de slave het systeem op beschikbaar te zetten dan krijg je wel een succesmelding. dat moet een adere mrlding worden, maar eigenlijk moet je helemaal de beschikbaarheid niet moeten kunnen veranderen in de instellingen en je moet daar dan een melding zien waarom"
|
||||||
|
|
||||||
|
**AI Response**: "Root cause: PersistentAvailabilityService.UpdateStatusAsync always wrote the requested status to the local DB unconditionally, while GetStatusDetailsAsync (fixed earlier today) displays the master-gate status whenever the master has disabled the instance — so a local admin's 'Available' write was accepted and even reported as a DB update, but instantly invisible/overridden on the very same GET that renders the Settings page, giving a false success toast for a change that had no observable effect. Fixed by rejecting the change outright instead of silently no-op'ing it: (1) new MasterControlledAvailabilityException (Core.Availability), thrown by PersistentAvailabilityService.UpdateStatusAsync when IMasterAvailabilityService.GetMasterStatus().IsAvailable is false, before touching the DB; (2) AvailabilityController.UpdateStatus catches it and returns 409 Conflict with a ProblemDetails body; (3) AvailabilityStatusDetails gained an IsMasterControlled flag (true whenever GetStatusDetailsAsync short-circuits on the master gate), now included in GET /api/v1/Availability/status; (4) frontend SettingsPage.tsx reads availability.isMasterControlled and disables the mode buttons, the reason textarea, and the save button, showing a locked-state banner explaining that the Master CMS controls this status; a 409 from the mutation (e.g. a race where master disables it mid-edit) shows a specific 'Master CMS controls this status' toast instead of the generic error. Verified: dotnet test — 219/219 pass across the solution (78 Availability, up from 75); pnpm build succeeds; pnpm test — 213/213 pass (up from 210, 3 new SettingsPage tests covering the locked banner, the unlocked default state, and the 409 toast)."
|
||||||
|
|
||||||
|
**Context**: Same root theme as the day's earlier fixes (slave-side local state must never silently diverge from the master's actual intent) — this time the gap was a misleading UI: an operation appeared to succeed while being fully overridden, which is worse than an operation that visibly fails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Verification — README Addition + Full AI-DLC Documentation Cross-Check
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "ja zet het erbij en check alles ook tegenover de aidlc documentatie" (following "Volgens mij werkt alles nu zoals verwacht. Doe nog 1 check of functionaliteit overeenkomt met de documentatie en of de tests slagen" and a prior note that the master-controlled-lock UI wasn't mentioned in the README)
|
||||||
|
|
||||||
|
**AI Response**: "Added a README section documenting the master-controlled Settings lock (banner, disabled controls, 409 on a bypass attempt, IsMasterControlled field). Then cross-checked the actual master/slave protocol documentation in aidlc-docs/features/master-cms-module/ (the feature that originally built the protocol this session's fixes touched) against the real as-built code — full details logged in that feature's own audit.md, since the corrections landed in its docs, not this feature's. Summary: found and fixed several actively-contradicted statements about Inactive-transition push behavior, VerifyIntegrityAsync's scope, and the master-gate cache's 'no expiry' claim; added new rule-set/flow sections for the slave-pull and master-controlled-lock mechanisms; flagged (via banners, not full rewrite) a larger pre-existing divergence between inception-stage application-design docs and what construction actually built, unrelated to today's changes. Also caught and fixed a real (if minor) config-consistency gap of my own: the new MasterPolling section had only been added to appsettings.Development.json, not the base appsettings.json for either Api or Api.Slave — added there too for both. Re-ran the full solution build + test suite after all changes: 219/219 backend, 213/213 frontend, all green."
|
||||||
|
|
||||||
|
**Context**: Closing verification pass for today's whole run of fixes (badge styling, master-controlled availability, slave restart resync, Inactive gate release, slave-pull/fail-open, master-controlled settings lock) — confirms the implementation, its tests, and its documentation are now mutually consistent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# Build and Test Summary — Local Dev Master/Slave Setup
|
||||||
|
|
||||||
|
## Build Status
|
||||||
|
- **Build Tool**: .NET 10 SDK (`dotnet build`)
|
||||||
|
- **Build Status**: ✅ Success
|
||||||
|
- **Build Artifacts**: `src/SlpModularCms.Api.Slave/bin/` (new), plus unchanged outputs for all existing projects
|
||||||
|
- **Build Time**: ~5 seconds (incremental)
|
||||||
|
|
||||||
|
## Test Execution Summary
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- **Total Tests**: 193 (54 + 60 + 37 + 42 across `Core.Tests`, `Modules.Availability.Tests`, `Modules.Identity.Tests`, `Modules.Master.Tests`)
|
||||||
|
- **Passed**: 193
|
||||||
|
- **Failed**: 0
|
||||||
|
- **Coverage**: Unchanged from before this feature (no new business logic; relocated code retains its existing tests)
|
||||||
|
- **Status**: ✅ Pass
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- **Test Scenarios**: 2 (module isolation; end-to-end master/slave connection via existing Add CMS Instance flow)
|
||||||
|
- **Passed**: 1 (module isolation — verified via manual `dotnet run` of both hosts, confirmed via log output: master loads 3 modules, slave loads exactly 2, Master excluded)
|
||||||
|
- **Failed**: 0
|
||||||
|
- **Not Run**: 1 (end-to-end connection scenario — requires a local SQL Server instance; no container runtime available in this sandboxed session. Documented as a manual step for the developer in `integration-test-instructions.md` and the root `README.md` runbook.)
|
||||||
|
- **Status**: ⚠️ Partial — the scenario this feature actually changes (module isolation) is fully verified; the scenario exercising the pre-existing, unmodified master/slave protocol requires manual follow-up.
|
||||||
|
|
||||||
|
### Performance Tests
|
||||||
|
- **Status**: N/A — this feature is local developer tooling with no performance requirements (NFR-3, local-only scope).
|
||||||
|
|
||||||
|
### Additional Tests
|
||||||
|
- **Contract Tests**: N/A — no API contracts changed
|
||||||
|
- **Security Tests**: N/A — no new attack surface; `appsettings.local.json` secrets-hygiene pattern (NFR-4) followed and verified via `git check-ignore`
|
||||||
|
- **E2E Tests**: See Integration Tests Scenario 2 above
|
||||||
|
|
||||||
|
## Overall Status
|
||||||
|
- **Build**: ✅ Success
|
||||||
|
- **All Automated Tests**: ✅ Pass (193/193)
|
||||||
|
- **Manual Follow-up Required**: Yes — developer should run the end-to-end connection test (root `README.md`, "Lokaal Master + Slave Draaien (Dev)") at least once with a real local SQL Server to confirm the full workflow before relying on it.
|
||||||
|
- **Ready for Operations**: Yes (Operations is a placeholder for this project; no deployment/monitoring work applicable to local dev tooling)
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
- Developer runs the manual end-to-end verification (Scenario 2) locally to close out the one remaining unverified item.
|
||||||
|
- Feature is otherwise complete: `SlpModularCms.Api.Slave` exists and correctly excludes the Master module, `SlpModularCms.Core.Hosting` avoids code duplication between the two hosts, frontend tooling and documentation are in place.
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# Build Instructions — Local Dev Master/Slave Setup
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- **Build Tool**: .NET 10 SDK
|
||||||
|
- **Dependencies**: NuGet packages restore automatically on build (no new external dependencies introduced by this feature)
|
||||||
|
- **Environment Variables**: None required to build (runtime config is via `appsettings.local.json`, see below)
|
||||||
|
- **System Requirements**: Same as the rest of the repository — no new system requirements
|
||||||
|
|
||||||
|
## Build Steps
|
||||||
|
|
||||||
|
### 1. Restore & Build
|
||||||
|
```powershell
|
||||||
|
dotnet build SlpModularCms.sln
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Verify Build Success
|
||||||
|
- **Expected Output**: `Build succeeded.` with 0 errors (NuGet advisory warnings for `Microsoft.OpenApi` and a few `NU1510`/prune warnings are pre-existing and unrelated to this feature).
|
||||||
|
- **Build Artifacts**: `src/SlpModularCms.Api/bin/`, `src/SlpModularCms.Api.Slave/bin/` (new), plus all existing project outputs.
|
||||||
|
- **Common Warnings**: NU1903 (Microsoft.OpenApi advisory) and NU1510 (package pruning) appear across multiple projects — pre-existing, not introduced by this feature.
|
||||||
|
|
||||||
|
### Actual Result (this session)
|
||||||
|
Ran `dotnet build SlpModularCms.sln` — **Build succeeded**, 0 errors.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Build Fails with `CS0246` in `SlpModularCms.Core/Hosting/*.cs`
|
||||||
|
- **Cause**: `SlpModularCms.Core` is a plain `Microsoft.NET.Sdk` project (not `Sdk.Web`), so ASP.NET Core implicit usings (`Microsoft.Extensions.DependencyInjection`, `Microsoft.Extensions.Configuration`, `Microsoft.AspNetCore.Builder`, `Microsoft.AspNetCore.Http`) aren't automatically available like they are in `SlpModularCms.Api`.
|
||||||
|
- **Solution**: Already fixed during Code Generation — explicit `using` statements were added to `ServiceCollectionExtensions.cs`. If this recurs after further edits, add the missing explicit `using`.
|
||||||
|
|
||||||
|
### `SlpModularCms.Api.Slave` fails to start with a SQL connection error
|
||||||
|
- **Cause**: Missing `src/SlpModularCms.Api.Slave/appsettings.local.json` (gitignored, must be created locally per developer).
|
||||||
|
- **Solution**: Copy `appsettings.local.json.example` to `appsettings.local.json` and fill in your local SQL Server credentials, using a **different** `Database=` name than the master instance (e.g. `SlpModularCmsSlave`).
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
# Integration Test Instructions — Local Dev Master/Slave Setup
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Verify that the two backend instances (Unit 1) and frontend tooling (Unit 2) work together correctly: the slave instance genuinely excludes the Master module, and the existing master↔slave connection mechanism (unchanged by this feature) can be exercised end-to-end using the two local instances.
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: Module isolation — Slave excludes Master, Master keeps all modules
|
||||||
|
|
||||||
|
**Description**: Confirm `SlpModularCms.Api.Slave`'s `ModuleOrchestrator` never discovers `Modules.Master`, while `SlpModularCms.Api` is unaffected.
|
||||||
|
|
||||||
|
**Setup**: None beyond a successful build (no database required — module discovery happens before any DB access).
|
||||||
|
|
||||||
|
**Test Steps** (already executed in this session):
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/SlpModularCms.Api --launch-profile https --no-build
|
||||||
|
dotnet run --project src/SlpModularCms.Api.Slave --launch-profile https --no-build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Results**:
|
||||||
|
- Master log output: `Module ontdekt: Availability`, `Module ontdekt: Identity`, `Module ontdekt: Master`, `3 modules succesvol geladen.`
|
||||||
|
- Slave log output: `Module ontdekt: Availability`, `Module ontdekt: Identity`, `2 modules succesvol geladen.` — **no** `Master` line.
|
||||||
|
|
||||||
|
**Actual Result**: ✅ **Passed** — confirmed exactly as expected in this session's console output (see Unit 1 code generation summary).
|
||||||
|
|
||||||
|
**Cleanup**: Stop both processes (Ctrl+C / process termination).
|
||||||
|
|
||||||
|
### Scenario 2: End-to-end master/slave connection via the existing "Add CMS Instance" flow
|
||||||
|
|
||||||
|
**Description**: With both instances running against separate local databases, use the master frontend's existing "Add CMS Instance" dialog to register the local slave and confirm the connection is established (per FR-4 and the runbook in root `README.md`).
|
||||||
|
|
||||||
|
**Setup**:
|
||||||
|
1. A local SQL Server instance reachable from both backends (e.g. via the `podman run ... mcr.microsoft.com/mssql/server` command in root `README.md`).
|
||||||
|
2. `src/SlpModularCms.Api/appsettings.local.json` (master) and `src/SlpModularCms.Api.Slave/appsettings.local.json` (slave, from `appsettings.local.json.example`) pointing at **different** database names on that SQL Server.
|
||||||
|
3. Master and slave backends running (`dotnet run --project src/SlpModularCms.Api --launch-profile https` and `dotnet run --project src/SlpModularCms.Api.Slave --launch-profile https`).
|
||||||
|
4. Master frontend running (`pnpm dev` in `frontend/`), logged in as an Owner.
|
||||||
|
|
||||||
|
**Test Steps**:
|
||||||
|
1. Navigate to the `/cms` page on the master frontend.
|
||||||
|
2. Use "Add CMS Instance" with URL `https://localhost:7222` (the local slave).
|
||||||
|
3. Observe the instance's status in the UI.
|
||||||
|
|
||||||
|
**Expected Results**: The instance appears in the list and its status reflects a successful connection (per the existing, unchanged `CmsInstanceService`/`SlaveApiClient` ↔ `MasterController` protocol documented in root `README.md`'s "Master CMS Module" section).
|
||||||
|
|
||||||
|
**Actual Result**: ⚠️ **Not run in this session** — this sandboxed environment has no accessible container runtime (`docker`/`podman` both unavailable), so no local SQL Server instance could be provisioned to run either backend past module discovery. **This step requires manual execution by the developer** following the runbook in root `README.md` ("Lokaal Master + Slave Draaien (Dev)"). Scenario 1 (module isolation, which does not require a database) was fully verified automatically and is the aspect this feature actually changes — Scenario 2 exercises the pre-existing, unmodified master/slave protocol and primarily validates that the new run configuration (ports, separate databases, CORS) doesn't get in the way of it.
|
||||||
|
|
||||||
|
**Cleanup**: Remove the CMS instance registration if desired; stop both backends and frontends; stop the SQL Server container if it was started solely for this test.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- No automated integration test suite was added for Scenario 2 — it's an inherently manual, cross-process, cross-database verification of local developer tooling, not a candidate for CI automation (per NFR-3, this feature is explicitly local-only in scope).
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
# Unit Test Execution — Local Dev Master/Slave Setup
|
||||||
|
|
||||||
|
## Run Unit Tests
|
||||||
|
|
||||||
|
### 1. Execute All Unit Tests
|
||||||
|
```powershell
|
||||||
|
dotnet test SlpModularCms.sln
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Review Test Results
|
||||||
|
- **Expected**: All existing test suites pass unchanged — this feature adds no new business logic, so no new unit tests were written (per Unit of Work Q3 = A).
|
||||||
|
- **Test Coverage**: Unchanged from before this feature (the relocated `ModuleOrchestrator`/`ApiPrefixConvention` classes retain their existing test coverage, now in `SlpModularCms.Core.Tests/Hosting/` instead of `SlpModularCms.Modules.Identity.Tests/Infrastructure/`).
|
||||||
|
- **Test Report Location**: Console output from `dotnet test`; no separate report file generated by default.
|
||||||
|
|
||||||
|
### Actual Result (this session)
|
||||||
|
Ran `dotnet test SlpModularCms.sln`:
|
||||||
|
|
||||||
|
| Test Project | Passed | Failed | Skipped |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SlpModularCms.Core.Tests` (incl. relocated `Hosting` tests) | 54 | 0 | 0 |
|
||||||
|
| `SlpModularCms.Modules.Availability.Tests` | 60 | 0 | 0 |
|
||||||
|
| `SlpModularCms.Modules.Identity.Tests` | 37 | 0 | 0 |
|
||||||
|
| `SlpModularCms.Modules.Master.Tests` | 42 | 0 | 0 |
|
||||||
|
| **Total** | **193** | **0** | **0** |
|
||||||
|
|
||||||
|
No regressions from relocating `ModuleOrchestrator`, `ServiceCollectionExtensions`, and `ApiPrefixConvention` into `SlpModularCms.Core`, or from moving their tests into `Core.Tests`.
|
||||||
|
|
||||||
|
### 3. Fix Failing Tests
|
||||||
|
Not applicable this run — all tests passed on first execution after Code Generation.
|
||||||
+4
-4
@@ -10,14 +10,14 @@
|
|||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
- [ ] **Step 1 — Frontend env files for slave mode**
|
- [x] **Step 1 — Frontend env files for slave mode**
|
||||||
- Create `frontend/.env.slave.local` (gitignored via existing `frontend/.gitignore` `*.local` pattern — verified via `git check-ignore`): `VITE_API_BASE_URL=https://localhost:7222`
|
- Create `frontend/.env.slave.local` (gitignored via existing `frontend/.gitignore` `*.local` pattern — verified via `git check-ignore`): `VITE_API_BASE_URL=https://localhost:7222`
|
||||||
- Modify `frontend/.env.example`: add a second documented block showing the slave-mode value, alongside the existing master-mode `VITE_API_BASE_URL` example
|
- Modify `frontend/.env.example`: add a second documented block showing the slave-mode value, alongside the existing master-mode `VITE_API_BASE_URL` example
|
||||||
|
|
||||||
- [ ] **Step 2 — `dev:slave` npm script**
|
- [x] **Step 2 — `dev:slave` npm script**
|
||||||
- Modify `frontend/package.json`: add `"dev:slave": "vite --mode slave --port 5174"` to the `scripts` section. Vite's mode-based env loading will load `.env.slave.local` when run with `--mode slave` (Vite loads `.env.[mode].local` in addition to `.env.local`; since both files would apply, and `.env.local` takes precedence per Vite's env-file priority for the same key when both exist for a mode, name the slave file `.env.slave.local` specifically — this file only loads when `--mode slave` is passed, so there is no conflict with the default `.env.local` used by `pnpm dev`)
|
- Modify `frontend/package.json`: add `"dev:slave": "vite --mode slave --port 5174"` to the `scripts` section. Vite's mode-based env loading will load `.env.slave.local` when run with `--mode slave` (Vite loads `.env.[mode].local` in addition to `.env.local`; since both files would apply, and `.env.local` takes precedence per Vite's env-file priority for the same key when both exist for a mode, name the slave file `.env.slave.local` specifically — this file only loads when `--mode slave` is passed, so there is no conflict with the default `.env.local` used by `pnpm dev`)
|
||||||
|
|
||||||
- [ ] **Step 3 — Runbook documentation**
|
- [x] **Step 3 — Runbook documentation**
|
||||||
- Modify root `README.md`: add new section **"Lokaal Master + Slave Draaien (Dev)"** immediately after the existing "Master CMS Module" section, covering:
|
- Modify root `README.md`: add new section **"Lokaal Master + Slave Draaien (Dev)"** immediately after the existing "Master CMS Module" section, covering:
|
||||||
1. Starting the master backend (`dotnet run --project src/SlpModularCms.Api --launch-profile https`)
|
1. Starting the master backend (`dotnet run --project src/SlpModularCms.Api --launch-profile https`)
|
||||||
2. Starting the slave backend (`dotnet run --project src/SlpModularCms.Api.Slave --launch-profile https`), noting it needs its own `appsettings.local.json` (from `appsettings.local.json.example`) with a separate local database
|
2. Starting the slave backend (`dotnet run --project src/SlpModularCms.Api.Slave --launch-profile https`), noting it needs its own `appsettings.local.json` (from `appsettings.local.json.example`) with a separate local database
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
4. Using the existing "Add CMS Instance" dialog on the master frontend to register the slave (URL `https://localhost:7222`) and confirm it shows as connected/healthy
|
4. Using the existing "Add CMS Instance" dialog on the master frontend to register the slave (URL `https://localhost:7222`) and confirm it shows as connected/healthy
|
||||||
5. Cross-reference to this feature's requirements doc for anyone wanting the full rationale
|
5. Cross-reference to this feature's requirements doc for anyone wanting the full rationale
|
||||||
|
|
||||||
- [ ] **Step 4 — Documentation summary**
|
- [x] **Step 4 — Documentation summary**
|
||||||
- Create `aidlc-docs/features/local-dev-master-slave-setup/construction/unit-2-frontend-dual-instance-tooling/code/summary.md` documenting what was created/modified
|
- Create `aidlc-docs/features/local-dev-master-slave-setup/construction/unit-2-frontend-dual-instance-tooling/code/summary.md` documenting what was created/modified
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|||||||
+24
@@ -43,3 +43,27 @@
|
|||||||
- **Master** (`SlpModularCms.Api`): discovers and loads all 3 modules — Availability, Identity, Master.
|
- **Master** (`SlpModularCms.Api`): discovers and loads all 3 modules — Availability, Identity, Master.
|
||||||
- **Slave** (`SlpModularCms.Api.Slave`): discovers and loads exactly 2 modules — Availability, Identity. **Master is correctly excluded.**
|
- **Slave** (`SlpModularCms.Api.Slave`): discovers and loads exactly 2 modules — Availability, Identity. **Master is correctly excluded.**
|
||||||
- Full end-to-end run (requiring a real local SQL Server/localdb instance and manual "Add CMS Instance" registration) is deferred to Build and Test / Unit 2, per the plan.
|
- Full end-to-end run (requiring a real local SQL Server/localdb instance and manual "Add CMS Instance" registration) is deferred to Build and Test / Unit 2, per the plan.
|
||||||
|
|
||||||
|
## Follow-up: Real `appsettings.local.json` for the Slave (user request)
|
||||||
|
|
||||||
|
The user reported the slave's connection string looked wrong and asked for a real `src/SlpModularCms.Api.Slave/appsettings.local.json` (gitignored, mirroring `src/SlpModularCms.Api/appsettings.local.json`'s credentials but with its own database). Created with `Database=SlpModularCmsSlave` (vs. master's `SlpModularCms`), same `Server=127.0.0.1,1433;User ID=sa;Password=...` credentials and same `JwtSettings.Secret`. Verified via `git check-ignore` that it's not tracked.
|
||||||
|
|
||||||
|
**Verified working**: running `dotnet run --launch-profile https --no-build` in `SlpModularCms.Api.Slave` with this file present successfully connected to the local SQL Server, created the `SlpModularCmsSlave` database, and applied EF Core migrations (`CREATE DATABASE`, `__EFMigrationsHistory` setup, migration application) — confirming the connection string is correct. This environment does have a reachable SQL Server at `127.0.0.1:1433`, unlike assumed earlier in Build and Test.
|
||||||
|
|
||||||
|
**Known issue hit during this verification, not related to the fix**: a subsequent attempt to run both master and slave simultaneously hit `Failed to bind to address ... address already in use` on both `:7221` and `:7222` — leftover `dotnet run` child processes from earlier manual verification steps in this session likely survived their parent `timeout` calls and are still holding those ports. This is a session/environment artifact, not a defect in the generated code. Resolved: user closed their own processes and confirmed via `Get-CimInstance`/`Get-NetTCPConnection` that no `SlpModularCms` processes or listeners remained on ports 7221/7222/5284/5285.
|
||||||
|
|
||||||
|
## Follow-up: Slave Database Migration Gap (discovered while checking migration status)
|
||||||
|
|
||||||
|
User asked whether the necessary migrations exist and both databases are up to date. Checked via `dotnet ef migrations list` for every `DbContext`/startup-project combination:
|
||||||
|
|
||||||
|
| Database | Context | Status before fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `SlpModularCms` (master) | `ApplicationDbContext` (Core/Identity) | ✅ Applied (2/2) |
|
||||||
|
| `SlpModularCms` (master) | `AvailabilityDbContext` | ✅ Applied (1/1) |
|
||||||
|
| `SlpModularCms` (master) | `MasterDbContext` | ✅ Applied (1/1) |
|
||||||
|
| `SlpModularCmsSlave` (slave) | `AvailabilityDbContext` | ✅ Applied (1/1) — auto-migrated via `Database.Migrate()` in `AvailabilityModule.UseModule` |
|
||||||
|
| `SlpModularCmsSlave` (slave) | `ApplicationDbContext` (Core/Identity) | ❌ **2 migrations pending** — `SlpModularCms.Core`'s `ApplicationDbContext` is never auto-migrated (only `Modules.Availability` and `Modules.Master` call `Database.Migrate()` in their `UseModule`); it always requires the manual `dotnet ef database update` step documented in root `README.md`, and nobody had run it yet for the new slave database.
|
||||||
|
|
||||||
|
**Fixed**: ran `dotnet ef database update --project src/SlpModularCms.Core --startup-project src/SlpModularCms.Api.Slave --context ApplicationDbContext`. Re-checked with `migrations list` — both `20260612191736_InitialCreate` and `20260619130625_AddsDisplayName` now show as applied (no `(Pending)` marker). Slave database is now fully up to date.
|
||||||
|
|
||||||
|
**Documentation fix**: added a note + the exact command to the "Lokaal Master + Slave Draaien (Dev)" runbook in root `README.md`, right after the slave-startup instructions, so this doesn't get missed again by whoever (re)creates the slave database.
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# Fix — CMS Page Was Visible on Slave (No Backend Capability Check)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The `/cms` page (managing registered CMS instances — an Owner-only feature backed by `SlpModularCms.Modules.Master`) was gated purely by role (`Owner`), with no awareness of whether the *connected backend* actually has the Master module loaded. Before this feature, this was never an issue — every deployed instance always had `Modules.Master` loaded. Now that a Master-less slave instance exists, an Owner using the frontend against the slave could still see the nav link and open `/cms`, where its data calls (`GET /api/v1/CmsInstances`) would 404 against a backend that has no such controller.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `SlpModularCms.Core.Hosting.ModuleOrchestrator` — added `ModuleNames` (public `IReadOnlyList<string>`), listing the names of modules actually discovered on this instance.
|
||||||
|
- New `SlpModularCms.Core.Hosting.SystemController` — `GET /api/v1/System/capabilities` returns `{ "modules": [...] }` for whichever instance is asked.
|
||||||
|
- `SlpModularCms.Api/Program.cs` and `SlpModularCms.Api.Slave/Program.cs` — registered the `ModuleOrchestrator` instance itself as a DI singleton (`builder.Services.AddSingleton(orchestrator)`) so the new controller can inject it.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `src/api/types.ts` — added `SystemCapabilities { modules: string[] }`.
|
||||||
|
- `src/api/useSystemCapabilities.ts` — new React Query hook (`staleTime: Infinity` — a backend's module set never changes mid-session), calling `/api/v1/System/capabilities`.
|
||||||
|
- `src/components/auth/ModuleGuard.tsx` — new guard component (mirrors `RoleGuard`), hides its children and shows a "not available on this instance" message when the required module isn't in the backend's capability list.
|
||||||
|
- `src/router.tsx` — `/cms` route now wraps its page in `<ModuleGuard requiredModule="Master">` (inside the existing `<RoleGuard allowedRoles={['Owner']}>`).
|
||||||
|
- `src/components/layout/Sidebar.tsx` — the CMS nav item now also requires `capabilities.modules.includes('Master')` before rendering.
|
||||||
|
- `src/i18n/locales/{nl,en}/translation.json` — added `errors.featureUnavailableTitle` / `errors.featureUnavailable`.
|
||||||
|
- `src/mocks/system/handlers.ts` — new MSW handler for `*/System/capabilities`, defaulting to `['Availability', 'Identity', 'Master']` so existing CMS-related tests keep passing unchanged; registered in `src/mocks/index.ts`.
|
||||||
|
- `src/components/layout/Sidebar.test.tsx` — updated the "Owner sees ... CMS" assertion to `findByTestId` (now async, since nav-cms visibility depends on the capabilities fetch), and added a new regression test: "Owner does not see CMS when the backend has no Master module (slave instance)".
|
||||||
|
|
||||||
|
## Verification Performed
|
||||||
|
|
||||||
|
- `dotnet build` / `dotnet test` — succeed; all 193 backend tests still pass (module relocation from the earlier fix is untouched; this only adds new code).
|
||||||
|
- `pnpm build` — succeeds, no type errors.
|
||||||
|
- `pnpm test` — 210/210 pass in isolation (209/210 in the full parallel run; the 1 failure is the same pre-existing flaky `AddCmsInstanceDialog.test.tsx` timeout seen earlier in this feature's Build and Test, unrelated to this change — confirmed passing 5/5 when re-run alone).
|
||||||
|
- **Live verification against real running instances**: started both `SlpModularCms.Api` (master) and `SlpModularCms.Api.Slave` against the local SQL Server and curled the new endpoint directly:
|
||||||
|
- Master: `curl https://localhost:7221/api/v1/System/capabilities` → `{"modules":["Availability","Identity","Master"]}`
|
||||||
|
- Slave: `curl https://localhost:7222/api/v1/System/capabilities` → `{"modules":["Availability","Identity"]}`
|
||||||
|
- Confirms the capability check reflects each instance's actual loaded modules, not just a hardcoded assumption.
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# Code Generation Summary — Unit 2: Frontend Dual-Instance Tooling & Runbook
|
||||||
|
|
||||||
|
## Created
|
||||||
|
|
||||||
|
- `frontend/.env.slave.local` — `VITE_API_BASE_URL=https://localhost:7222`, `VITE_APP_TITLE=SlpModularCms (Slave)` (gitignored via existing `frontend/.gitignore` `*.local` pattern, verified via `git check-ignore`)
|
||||||
|
- `aidlc-docs/features/local-dev-master-slave-setup/construction/unit-2-frontend-dual-instance-tooling/code/summary.md` (this file)
|
||||||
|
|
||||||
|
## Modified
|
||||||
|
|
||||||
|
- `frontend/.env.example` — added a documented block explaining how to create `.env.slave.local` and use `pnpm dev:slave`, and how `VITE_APP_TITLE` distinguishes tabs
|
||||||
|
- `frontend/.env.local` (developer's existing local file, gitignored) — added `VITE_APP_TITLE=SlpModularCms (Master)` for symmetry with the slave
|
||||||
|
- `frontend/package.json` — added `"dev:slave": "vite --mode slave --port 5174"` script
|
||||||
|
- `frontend/src/vite-env.d.ts` — added optional `VITE_APP_TITLE` to the typed env interface
|
||||||
|
- `frontend/src/lib/config.ts` — added `appTitle` to `AppConfig`, sourced from `VITE_APP_TITLE` with a `'SlpModularCms'` fallback when unset
|
||||||
|
- `frontend/src/main.tsx` — sets `document.title` from `getAppConfig().appTitle` at startup, so each instance's browser tab is recognizable
|
||||||
|
- `README.md` (root) — added new section **"Lokaal Master + Slave Draaien (Dev)"** after "Master CMS Module", covering: starting both backends, starting the frontend against either instance, and using the existing "Add CMS Instance" dialog to connect them
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- No new tests — this unit is env/config/documentation only, no testable logic.
|
||||||
|
- `pnpm dev:slave` uses Vite's `--mode slave` flag, which loads `.env.slave.local` in addition to the default `.env`/`.env.local` files; `--port 5174` overrides `vite.config.ts`'s default port (5173) for this invocation only.
|
||||||
|
- **Added after initial Build and Test review** (user request): distinguishable browser tab titles per instance via `VITE_APP_TITLE`, defaulting to `"SlpModularCms"` so existing setups without the var keep today's title unchanged.
|
||||||
|
- Full end-to-end verification (starting both backends with real local databases, running both frontends, and confirming the "Add CMS Instance" flow actually connects them) is performed in Build and Test, since it requires a running local SQL Server instance that isn't available in this automated environment.
|
||||||
|
|
||||||
|
## Verification Performed (Tab Title Change)
|
||||||
|
|
||||||
|
- `pnpm build` (tsc -b + vite build) — succeeds, no type errors.
|
||||||
|
- `pnpm test` — 208/209 pass; the 1 failure (`AddCmsInstanceDialog.test.tsx`, a findByTestId timeout) reproduced as flaky under this session's load and passed 5/5 when re-run in isolation — unrelated to the title change (no title-related assertions, and `main.tsx` is not exercised by component tests).
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
# Audit Log — Master CMS Module
|
# Audit Log — Master CMS Module
|
||||||
|
|
||||||
|
## Documentation Audit & Correction — Post-`local-dev-master-slave-setup` Follow-Up Fixes
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "ja zet het erbij en check alles ook tegenover de aidlc documentatie" (in response to a request to also add README docs for a new master-controlled availability lock, and to cross-check everything against the AI-DLC documentation)
|
||||||
|
|
||||||
|
**AI Response**: "Dispatched an Explore subagent to compare the functional-design docs here against the actual as-built code, given a string of same-day fixes (in the local-dev-master-slave-setup feature's session) that changed real master/slave protocol behavior: Inactive now releases the gate instead of doing nothing, VerifyIntegrityAsync now re-pushes status (not just URL-verification) and IntegrityCheckBackgroundService runs once on startup, a new slave-pull mechanism (SlaveStatusController + MasterStatusPollingBackgroundService + fail-open via MasterRegistration.LastPolledAt) was added, and PersistentAvailabilityService now merges/enforces the master gate (IsMasterControlled, MasterControlledAvailabilityException, 409 Conflict) instead of ignoring it. Found and corrected multiple actively-contradicted statements: construction/master-backend/functional-design/{business-rules,business-logic-model,domain-entities}.md (Inactive-transition push behavior, SlaveContactSuccess semantics, VerifyIntegrityAsync flow, added new Slave Pull and Master-Controlled-Lock rule sections); construction/slave-availability-extension/functional-design/{business-rules,business-logic-model,domain-entities}.md (master-gate cache no longer 'no expiry', added LastPolledAt field, added Rule Sets 5-6 and Flows 5-6 for the pull/fail-open and lock mechanisms); inception/application-design/application-design.md (Inactive/fail-open constraint rows). Also fixed one long-standing (pre-2026-07-04, unrelated to today) inaccuracy found in passing: slave-availability-extension/domain-entities.md claimed MasterRegistration.ApiKey is stored plain-text, but the code (MasterAvailabilityService using IMasterApiKeyProtector) actually encrypts it. Separately flagged (added superseded-warning banners rather than rewriting) a much larger, pre-existing divergence in inception/application-design/{services,components,component-methods}.md: these inception-stage docs describe an entirely different pull-based design (static cache + MasterModuleOptions.CacheMinutes + /api/internal/master/* routes) that construction never actually built — this predates today's session and is a separate, bigger gap than what today's fixes caused; not fully rewritten, just clearly marked as historical/superseded pending a dedicated pass. Also added a code-summary.md addendum for master-backend listing the new SlaveStatusController/methods. Re-ran the full backend suite after all doc edits (docs-only + two appsettings.json additions) to confirm nothing was inadvertently broken: 219/219 pass."
|
||||||
|
|
||||||
|
**Context**: This is a documentation-only audit and correction pass — no application code was changed (only aidlc-docs/*.md files and two appsettings.json files that added a previously-Development-only MasterPolling config section to the base/production configs for consistency, functionally a no-op since the C# options class already had matching defaults).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Build and Test Stage — Approved
|
## Build and Test Stage — Approved
|
||||||
|
|
||||||
**Timestamp**: 2026-07-01T00:10:00Z
|
**Timestamp**: 2026-07-01T00:10:00Z
|
||||||
|
|||||||
+11
@@ -61,3 +61,14 @@ This generates the `Migrations/` folder contents. The migration is applied autom
|
|||||||
- `Microsoft.Extensions.Http.Resilience` version `9.6.0` — verify/update during `dotnet restore` if a newer version is available for .NET 10
|
- `Microsoft.Extensions.Http.Resilience` version `9.6.0` — verify/update during `dotnet restore` if a newer version is available for .NET 10
|
||||||
- `IntegrityCheckIntervalMinutes = 0` in tests forces immediate PeriodicTimer ticks (valid for test scenarios only)
|
- `IntegrityCheckIntervalMinutes = 0` in tests forces immediate PeriodicTimer ticks (valid for test scenarios only)
|
||||||
- Slave-side endpoints (`/api/v1/master/register`, `/api/v1/master/status`, `/api/v1/master/registered-url`) are implemented in Unit 2 (slave-availability-extension)
|
- Slave-side endpoints (`/api/v1/master/register`, `/api/v1/master/status`, `/api/v1/master/registered-url`) are implemented in Unit 2 (slave-availability-extension)
|
||||||
|
|
||||||
|
## Addendum — 2026-07-04 (added outside this unit's original scope, in `local-dev-master-slave-setup` follow-up fixes)
|
||||||
|
|
||||||
|
This unit predates the following; see `slave-availability-extension/functional-design/business-rules.md` Rule Set 5 and `application-design/application-design.md` for the full picture:
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `Controllers/SlaveStatusController.cs` | New. `[AllowAnonymous]` `GET /api/v1/SlaveStatus`; authenticates via `X-Master-Api-Key` header matched against each active `CmsInstance`'s decrypted key; lets a slave pull its own status instead of relying solely on the master's push |
|
||||||
|
| `Services/ICmsInstanceService.cs` / `CmsInstanceService.cs` | `GetStatusForApiKeyAsync(plainApiKey)` added; `UpdateStatusAsync`'s `Inactive` branch now pushes `Available`/null to release the gate (previously a no-op); `VerifyIntegrityAsync` now also re-pushes persisted status to every reachable active slave each cycle |
|
||||||
|
| `BackgroundServices/IntegrityCheckBackgroundService.cs` | Now runs one tick immediately on startup, in addition to the periodic timer |
|
||||||
|
| `Models/SlaveStatusPollResponse` (in `ICmsInstanceService.cs`) | New record: `IsAvailable`, `DisableMessage` |
|
||||||
|
|||||||
+30
-3
@@ -97,7 +97,19 @@ sequenceDiagram
|
|||||||
Svc->>Repo: UpdateAsync (Status=Inactive, DisableMessage=null)
|
Svc->>Repo: UpdateAsync (Status=Inactive, DisableMessage=null)
|
||||||
Svc->>Repo: SaveChangesAsync()
|
Svc->>Repo: SaveChangesAsync()
|
||||||
Repo->>DB: UPDATE CmsInstances
|
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)
|
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
|
else newStatus = Available or NotAvailable
|
||||||
Svc->>Repo: UpdateAsync (Status, DisableMessage)
|
Svc->>Repo: UpdateAsync (Status, DisableMessage)
|
||||||
Svc->>Repo: SaveChangesAsync()
|
Svc->>Repo: SaveChangesAsync()
|
||||||
@@ -119,13 +131,15 @@ sequenceDiagram
|
|||||||
Ctrl-->>Ctrl: return 200 OK with UpdateStatusResult
|
Ctrl-->>Ctrl: return 200 OK with UpdateStatusResult
|
||||||
```
|
```
|
||||||
|
|
||||||
Text alternative: Controller calls service with id and new status; service loads entity, validates, updates DB, then for non-Inactive transitions decrypts ApiKey and pushes status to slave; returns SlaveContactSuccess=false if push fails but DB is always the authority.
|
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 `Inactive` branch 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)
|
## Flow 3 — VerifyIntegrityAsync (Background Integrity Check)
|
||||||
|
|
||||||
**Trigger**: `IntegrityCheckBackgroundService` periodic timer (every `IntegrityCheckIntervalMinutes`)
|
**Trigger**: `IntegrityCheckBackgroundService` — one tick immediately on host startup, then every `IntegrityCheckIntervalMinutes` **(startup tick added 2026-07-04)**
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@@ -163,6 +177,7 @@ sequenceDiagram
|
|||||||
Svc->>Repo: UpdateAsync (LastIntegrityCheckFailedAt = UtcNow)
|
Svc->>Repo: UpdateAsync (LastIntegrityCheckFailedAt = UtcNow)
|
||||||
Svc->>Repo: SaveChangesAsync()
|
Svc->>Repo: SaveChangesAsync()
|
||||||
Repo->>DB: UPDATE CmsInstances
|
Repo->>DB: UPDATE CmsInstances
|
||||||
|
Note over Svc: Unreachable for URL check — skip the status re-push for this instance this cycle
|
||||||
else Slave reachable
|
else Slave reachable
|
||||||
Client-->>Svc: registeredMasterUrl
|
Client-->>Svc: registeredMasterUrl
|
||||||
alt URLs match
|
alt URLs match
|
||||||
@@ -183,9 +198,21 @@ sequenceDiagram
|
|||||||
Repo->>DB: UPDATE CmsInstances
|
Repo->>DB: UPDATE CmsInstances
|
||||||
end
|
end
|
||||||
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
|
||||||
end
|
end
|
||||||
Svc-->>BgSvc: done
|
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.
|
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.
|
||||||
|
|||||||
+45
-14
@@ -10,7 +10,11 @@ graph TD
|
|||||||
CheckMsg{"newStatus = NotAvailable\nAND disableMessage\nis null or empty?"}
|
CheckMsg{"newStatus = NotAvailable\nAND disableMessage\nis null or empty?"}
|
||||||
ValidationErr["Throw ValidationException\nDisableMessage required"]
|
ValidationErr["Throw ValidationException\nDisableMessage required"]
|
||||||
CheckInactive{"newStatus\n= Inactive?"}
|
CheckInactive{"newStatus\n= Inactive?"}
|
||||||
SetInactive["Status = Inactive\nDisableMessage = null\nNo HTTP push\nSlaveContactSuccess = true"]
|
SetInactive["Status = Inactive\nDisableMessage = null\nPersist to MasterDbContext"]
|
||||||
|
ReleaseGate["Decrypt ApiKey\nPushStatusAsync(isAvailable=true,\ndisableMessage=null)\n— release the master gate"]
|
||||||
|
ReleaseOk{"Release push\nsucceeded?"}
|
||||||
|
ReleaseDone["LastStatusPushedAt = UtcNow\nSave"]
|
||||||
|
ReturnRelease["Return UpdateStatusResult\nSuccess=true\nSlaveContactSuccess=(release result)"]
|
||||||
PersistStatus["Persist Status + DisableMessage\nto MasterDbContext"]
|
PersistStatus["Persist Status + DisableMessage\nto MasterDbContext"]
|
||||||
DecryptKey["Decrypt ApiKey\nvia IDataProtector"]
|
DecryptKey["Decrypt ApiKey\nvia IDataProtector"]
|
||||||
PushSlave["PushStatusAsync\nto slave endpoint"]
|
PushSlave["PushStatusAsync\nto slave endpoint"]
|
||||||
@@ -25,7 +29,8 @@ graph TD
|
|||||||
CheckExists -->|"yes"| CheckMsg
|
CheckExists -->|"yes"| CheckMsg
|
||||||
CheckMsg -->|"yes — invalid"| ValidationErr
|
CheckMsg -->|"yes — invalid"| ValidationErr
|
||||||
CheckMsg -->|"no — valid"| CheckInactive
|
CheckMsg -->|"no — valid"| CheckInactive
|
||||||
CheckInactive -->|"yes"| SetInactive --> Done
|
CheckInactive -->|"yes"| SetInactive --> ReleaseGate --> ReleaseOk
|
||||||
|
ReleaseOk -->|"yes/no"| ReleaseDone --> ReturnRelease --> Done
|
||||||
CheckInactive -->|"no"| PersistStatus --> DecryptKey --> PushSlave --> PushOk
|
CheckInactive -->|"no"| PersistStatus --> DecryptKey --> PushSlave --> PushOk
|
||||||
PushOk -->|"yes"| UpdatePushed --> ReturnOk --> Done
|
PushOk -->|"yes"| UpdatePushed --> ReturnOk --> Done
|
||||||
PushOk -->|"no"| ReturnWarn --> Done
|
PushOk -->|"no"| ReturnWarn --> Done
|
||||||
@@ -34,13 +39,15 @@ graph TD
|
|||||||
classDef action fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
|
classDef action fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
|
||||||
classDef terminal fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
|
classDef terminal fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
|
||||||
classDef error fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
|
classDef error fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
|
||||||
class CheckExists,CheckMsg,CheckInactive,PushOk decision
|
class CheckExists,CheckMsg,CheckInactive,PushOk,ReleaseOk decision
|
||||||
class PersistStatus,DecryptKey,PushSlave,UpdatePushed,SetInactive action
|
class PersistStatus,DecryptKey,PushSlave,UpdatePushed,SetInactive,ReleaseGate,ReleaseDone action
|
||||||
class Start,Done terminal
|
class Start,Done terminal
|
||||||
class NotFound,ValidationErr error
|
class NotFound,ValidationErr error
|
||||||
```
|
```
|
||||||
|
|
||||||
Text alternative: Load entity (404 if missing) → validate DisableMessage required for NotAvailable → for Inactive skip push → for others persist, decrypt key, push to slave, set SlaveContactSuccess based on push result.
|
Text alternative: Load entity (404 if missing) → validate DisableMessage required for NotAvailable → for Inactive, persist the Inactive status **and then explicitly push `Available` (disableMessage=null) to the slave** to release the master gate (the master no longer manages this slave, so it must not leave the slave stuck on a stale status) → for other statuses persist, decrypt key, push to slave, set SlaveContactSuccess based on push result.
|
||||||
|
|
||||||
|
> **Updated 2026-07-04**: originally `Inactive` skipped the HTTP push entirely (see history below); this was found to leave slaves permanently stuck on their last pushed status (e.g. `NotAvailable`) after being detached from the master, and was fixed to always release the gate on deactivation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -58,26 +65,34 @@ graph TD
|
|||||||
RegOk{"Re-registration\nsucceeded?"}
|
RegOk{"Re-registration\nsucceeded?"}
|
||||||
ClearAfterReg["LastIntegrityCheckFailedAt = null\nLastContactedAt = UtcNow\nSave"]
|
ClearAfterReg["LastIntegrityCheckFailedAt = null\nLastContactedAt = UtcNow\nSave"]
|
||||||
SetFailedReg["LastIntegrityCheckFailedAt = UtcNow\nSave"]
|
SetFailedReg["LastIntegrityCheckFailedAt = UtcNow\nSave"]
|
||||||
|
RePushStatus["PushStatusAsync\n(re-push persisted Status/DisableMessage\nto this active slave)"]
|
||||||
|
RePushOk{"Push\nsucceeded?"}
|
||||||
|
UpdatePushedAt["LastStatusPushedAt = UtcNow\nSave"]
|
||||||
Next(["Next instance"])
|
Next(["Next instance"])
|
||||||
|
|
||||||
Start --> GetUrl --> Reachable
|
Start --> GetUrl --> Reachable
|
||||||
Reachable -->|"no"| SetFailed --> Next
|
Reachable -->|"no"| SetFailed --> Next
|
||||||
Reachable -->|"yes"| UrlMatch
|
Reachable -->|"yes"| UrlMatch
|
||||||
UrlMatch -->|"match"| ClearOk --> Next
|
UrlMatch -->|"match"| ClearOk --> RePushStatus
|
||||||
UrlMatch -->|"mismatch"| ReRegister --> RegOk
|
UrlMatch -->|"mismatch"| ReRegister --> RegOk
|
||||||
RegOk -->|"yes"| ClearAfterReg --> Next
|
RegOk -->|"yes"| ClearAfterReg --> RePushStatus
|
||||||
RegOk -->|"no"| SetFailedReg --> Next
|
RegOk -->|"no"| SetFailedReg --> Next
|
||||||
|
RePushStatus --> RePushOk
|
||||||
|
RePushOk -->|"yes"| UpdatePushedAt --> Next
|
||||||
|
RePushOk -->|"no"| Next
|
||||||
|
|
||||||
classDef decision fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
|
classDef decision fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
|
||||||
classDef action fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
|
classDef action fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
|
||||||
classDef terminal fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
|
classDef terminal fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
|
||||||
classDef error fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
|
classDef error fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
|
||||||
class Reachable,UrlMatch,RegOk decision
|
class Reachable,UrlMatch,RegOk,RePushOk decision
|
||||||
class GetUrl,SetFailed,ClearOk,ReRegister,ClearAfterReg,SetFailedReg action
|
class GetUrl,SetFailed,ClearOk,ReRegister,ClearAfterReg,SetFailedReg,RePushStatus,UpdatePushedAt action
|
||||||
class Start,Next terminal
|
class Start,Next terminal
|
||||||
```
|
```
|
||||||
|
|
||||||
Text alternative: For each active slave — attempt to get its registered master URL; if unreachable set failure flag; if reachable and URL matches clear flag; if mismatch re-register; clear flag on success, set flag on failure.
|
Text alternative: For each active slave — attempt to get its registered master URL; if unreachable set failure flag and move on; if reachable and URL matches clear the flag, if mismatch re-register (clearing the flag on success, setting it on failure); then — regardless of the URL check's outcome, as long as the slave was reachable — **re-push the master's currently persisted Status/DisableMessage to that slave** (this is the reconciliation path for a slave that reset its in-memory gate, e.g. after a restart, or missed an earlier push). `IntegrityCheckBackgroundService` also now runs one tick immediately on host startup, in addition to its periodic interval, so this resync happens right after the master (re)starts rather than waiting a full cycle.
|
||||||
|
|
||||||
|
> **Updated 2026-07-04**: originally this check only verified/re-registered the master URL and never re-pushed status (see history below); this left restarted slaves stuck on a stale in-memory status (reset to `Available` by default) until the next explicit status change — fixed by adding the re-push step and the immediate startup run.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,10 +116,12 @@ Text alternative: For each active slave — attempt to get its registered master
|
|||||||
|------|----|---------------|-----------|-------|
|
|------|----|---------------|-----------|-------|
|
||||||
| Any | `Available` | Clear to null | Yes | Slave re-enabled |
|
| Any | `Available` | Clear to null | Yes | Slave re-enabled |
|
||||||
| Any | `NotAvailable` | Required, non-empty | Yes | Slave disabled with message |
|
| Any | `NotAvailable` | Required, non-empty | Yes | Slave disabled with message |
|
||||||
| Any | `Inactive` | Clear to null | **No** | Master stops all contact |
|
| Any | `Inactive` | Clear to null | **Yes** — pushes `Available`/null | Master stops managing the slave, but must first release the gate so the slave doesn't stay stuck on its last pushed status |
|
||||||
| `Inactive` | `Available` | Clear to null | Yes | Reactivation |
|
| `Inactive` | `Available` | Clear to null | Yes | Reactivation |
|
||||||
| `Inactive` | `NotAvailable` | Required, non-empty | Yes | Reactivation with disable |
|
| `Inactive` | `NotAvailable` | Required, non-empty | Yes | Reactivation with disable |
|
||||||
|
|
||||||
|
> **Updated 2026-07-04**: the `Inactive` row previously said "No" HTTP push (see history below) — corrected after the no-push behavior was found to leave slaves permanently stuck on their last status.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ApiKey Encryption Rules
|
## ApiKey Encryption Rules
|
||||||
@@ -132,9 +149,9 @@ Text alternative: For each active slave — attempt to get its registered master
|
|||||||
|
|
||||||
| Rule | Description |
|
| Rule | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| BR-CONTACT-01 | Instances with `Status = Inactive` are excluded from `GetActiveAsync` and never contacted via HTTP |
|
| BR-CONTACT-01 | Instances with `Status = Inactive` are excluded from `GetActiveAsync` and are not contacted by the periodic integrity check / re-push cycle |
|
||||||
| BR-CONTACT-02 | Status push is skipped when transitioning any status → `Inactive` |
|
| BR-CONTACT-02 | **(Updated 2026-07-04)** The transition to `Inactive` itself always performs exactly one status push — `Available`, `disableMessage=null` — to release the master gate on the slave before the instance drops out of `GetActiveAsync` for good. Previously this push was skipped entirely; that left the slave stuck on its last pushed status indefinitely. |
|
||||||
| BR-CONTACT-03 | Integrity check runs only against instances where `Status != Inactive` |
|
| BR-CONTACT-03 | Integrity check (and its new status re-push, see BR-02) runs only against instances where `Status != Inactive` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -146,3 +163,17 @@ Text alternative: For each active slave — attempt to get its registered master
|
|||||||
| BR-BG-02 | Each tick creates and disposes its own `IServiceScope` |
|
| BR-BG-02 | Each tick creates and disposes its own `IServiceScope` |
|
||||||
| BR-BG-03 | Exceptions within a single slave's integrity check are caught, logged, and do not abort processing for remaining slaves |
|
| BR-BG-03 | Exceptions within a single slave's integrity check are caught, logged, and do not abort processing for remaining slaves |
|
||||||
| BR-BG-04 | If `MasterModuleOptions.MasterUrl` is null or empty, the background service logs a warning and skips the entire integrity check for that cycle |
|
| BR-BG-04 | If `MasterModuleOptions.MasterUrl` is null or empty, the background service logs a warning and skips the entire integrity check for that cycle |
|
||||||
|
| BR-BG-05 | **(Added 2026-07-04)** `IntegrityCheckBackgroundService` runs one tick immediately on host startup (in addition to its periodic `PeriodicTimer` cycle), so a freshly (re)started master resyncs slave statuses right away instead of waiting up to `IntegrityCheckIntervalMinutes` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slave Pull (Status Poll) Rules — Added 2026-07-04
|
||||||
|
|
||||||
|
This closes a design gap: the original inception requirements (`inception/requirements/requirements.md`, FR-MASTER-06/07, NFR-MASTER-01) specified a slave-initiated periodic pull with fail-open, but construction implemented push-only. The pull side was added alongside the existing push mechanism (not instead of it) after a slave was observed staying on a stale status through a restart and a deactivation.
|
||||||
|
|
||||||
|
| Rule | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| BR-PULL-01 | `SlaveStatusController` exposes `GET /api/v1/SlaveStatus`, authenticated via the `X-Master-Api-Key` header — no `[Authorize]`/JWT, since the caller is a slave process, not a logged-in user |
|
||||||
|
| BR-PULL-02 | `CmsInstanceService.GetStatusForApiKeyAsync` identifies the calling slave by decrypting each active `CmsInstance.ApiKey` and comparing it to the caller's plain key (no separate slave-identity field exists; the shared key is the only credential) — returns `null` (→ 401) when no match is found |
|
||||||
|
| BR-PULL-03 | A successful poll updates `CmsInstance.LastContactedAt`, mirroring the existing convention used by push-based master↔slave calls |
|
||||||
|
| BR-PULL-04 | `/api/v1/SlaveStatus` is added to `AvailabilityMiddleware`'s bypass list on the master's own instance, so it stays reachable regardless of the master's own local availability status |
|
||||||
|
|||||||
+19
-2
@@ -71,7 +71,9 @@ public enum CmsInstanceStatus
|
|||||||
|-------|---------|----------------------|
|
|-------|---------|----------------------|
|
||||||
| `Available` | Slave is enabled; normal operation | Yes (status push + integrity checks) |
|
| `Available` | Slave is enabled; normal operation | Yes (status push + integrity checks) |
|
||||||
| `NotAvailable` | Slave is disabled; `DisableMessage` served to end-users | Yes (status push + integrity checks) |
|
| `NotAvailable` | Slave is disabled; `DisableMessage` served to end-users | Yes (status push + integrity checks) |
|
||||||
| `Inactive` | Soft-removed; greyed out in UI | **No** — all HTTP contact is halted |
|
| `Inactive` | Soft-removed; greyed out in UI | On the transition **into** `Inactive`: one final push (`Available`, no message) to release the gate. Afterwards: **No** further contact — excluded from `GetActiveAsync`, so no more pushes/integrity checks/re-pushes |
|
||||||
|
|
||||||
|
> **Updated 2026-07-04**: previously `Inactive` meant no HTTP contact at all, including on the transition itself — this left slaves stuck on their last pushed status after being detached. See BR-CONTACT-02 in `business-rules.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -129,4 +131,19 @@ public enum CmsInstanceStatus
|
|||||||
| Property | Type | Notes |
|
| Property | Type | Notes |
|
||||||
|----------|------|-------|
|
|----------|------|-------|
|
||||||
| `Success` | `bool` | Always `true` when status persisted to DB (DB is the authority) |
|
| `Success` | `bool` | Always `true` when status persisted to DB (DB is the authority) |
|
||||||
| `SlaveContactSuccess` | `bool` | `true` if HTTP push to slave succeeded; `false` if push failed (slave unreachable); not applicable for `Inactive` transitions (returns `true`) |
|
| `SlaveContactSuccess` | `bool` | `true` if the HTTP push to the slave succeeded; `false` if it failed (slave unreachable). Applies to `Inactive` transitions too — reflects whether the gate-release push succeeded, not a hardcoded `true` |
|
||||||
|
|
||||||
|
> **Updated 2026-07-04**: `SlaveContactSuccess` for `Inactive` used to always be hardcoded `true` (no push happened, so nothing could fail) — now reflects the real result of the release push.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SlaveStatusPollResponse (API Response) — Added 2026-07-04
|
||||||
|
|
||||||
|
Response shape for the new slave-pull endpoint (`GET /api/v1/SlaveStatus`, `SlaveStatusController`), used by `MasterStatusPollingBackgroundService` on the slave side (see `slave-availability-extension/functional-design/domain-entities.md`). This is the counterpart of the push-based flow above — added to close a gap versus the original inception requirements (FR-MASTER-06/07), which specified a slave-initiated pull in addition to the push that construction actually implemented.
|
||||||
|
|
||||||
|
| Property | Type | Notes |
|
||||||
|
|----------|------|-------|
|
||||||
|
| `IsAvailable` | `bool` | `true` when `CmsInstance.Status == Available` |
|
||||||
|
| `DisableMessage` | `string?` | `CmsInstance.DisableMessage` |
|
||||||
|
|
||||||
|
Identified by matching the caller's plain API key (header `X-Master-Api-Key`) against each active `CmsInstance`'s decrypted key — there is no separate slave-identity field, the shared key doubles as the credential. No `[Authorize]`/JWT on this endpoint.
|
||||||
|
|||||||
+105
@@ -150,3 +150,108 @@ sequenceDiagram
|
|||||||
```
|
```
|
||||||
|
|
||||||
Text alternative: Middleware first checks bypass paths, then admin JWT. If neither applies: checks static master cache; if master unavailable return 503. If master available: checks local availability service; if locally unavailable return 503. Otherwise pass through.
|
Text alternative: Middleware first checks bypass paths, then admin JWT. If neither applies: checks static master cache; if master unavailable return 503. If master available: checks local availability service; if locally unavailable return 503. Otherwise pass through.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 5: Slave Poll (Slave → Master) — Added 2026-07-04
|
||||||
|
|
||||||
|
**Trigger**: `MasterStatusPollingBackgroundService` — one tick immediately on slave startup, then every `MasterPolling:PollIntervalSeconds` (default 30s).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
box rgba(76,175,80,0.15) Slave CMS
|
||||||
|
participant Timer as PeriodicTimer
|
||||||
|
participant BgSvc as MasterStatusPollingBackgroundService
|
||||||
|
participant Svc as MasterAvailabilityService
|
||||||
|
participant Client as MasterStatusPollClient
|
||||||
|
participant DB as AvailabilityDbContext
|
||||||
|
participant Cache as StaticCache
|
||||||
|
end
|
||||||
|
box rgba(33,150,243,0.15) Master CMS
|
||||||
|
participant MC as SlaveStatusController
|
||||||
|
end
|
||||||
|
|
||||||
|
Timer->>BgSvc: Tick
|
||||||
|
BgSvc->>Svc: GetPollTargetAsync()
|
||||||
|
Svc->>DB: GetRegistrationAsync()
|
||||||
|
alt No registration
|
||||||
|
DB-->>Svc: null
|
||||||
|
Svc-->>BgSvc: null
|
||||||
|
BgSvc-->>Timer: no-op, wait for next tick
|
||||||
|
else Registration exists
|
||||||
|
DB-->>Svc: MasterUrl, encrypted ApiKey
|
||||||
|
Svc-->>BgSvc: MasterUrl, plain ApiKey
|
||||||
|
BgSvc->>Client: GetStatusAsync(masterUrl, plainApiKey)
|
||||||
|
Client->>MC: GET /api/v1/SlaveStatus with X-Master-Api-Key header
|
||||||
|
alt Poll succeeds
|
||||||
|
MC-->>Client: 200 OK { IsAvailable, DisableMessage }
|
||||||
|
Client-->>BgSvc: PolledMasterStatus
|
||||||
|
BgSvc->>Svc: ApplyPolledStatusAsync(isAvailable, disableMessage)
|
||||||
|
Svc->>Cache: set _masterIsAvailable / _masterDisableMessage
|
||||||
|
Svc->>DB: Update LastPolledAt=now, LastContactedAt=now
|
||||||
|
else Poll fails (network error, timeout, 401, etc.)
|
||||||
|
Client-->>BgSvc: null
|
||||||
|
BgSvc->>Svc: RecordPollFailureAsync(failOpenAfter)
|
||||||
|
Svc->>DB: read LastPolledAt (or RegisteredAt if never polled)
|
||||||
|
alt Unreachable longer than failOpenAfter
|
||||||
|
Svc->>Cache: force _masterIsAvailable=true, _masterDisableMessage=null
|
||||||
|
Note over Svc: Fail-open — a dead/unreachable master must never permanently block this slave
|
||||||
|
else Still within grace period
|
||||||
|
Note over Svc: No change — leave the existing cached gate as-is
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: Background timer triggers the poller; if no master is registered, it's a no-op. Otherwise it calls `GET /api/v1/SlaveStatus` on the registered master. On success, the response overwrites the in-memory gate and updates `LastPolledAt`/`LastContactedAt`. On failure, the gate is left alone unless the master has been unreachable (via poll) for longer than `MasterPolling:FailOpenAfterMinutes`, in which case the gate is forced open (`Available`, no message).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 6: Local Availability Status Read/Write with Master-Gate Override — Added 2026-07-04
|
||||||
|
|
||||||
|
Applies to the slave's own `/settings` admin UI/API — `GET /api/v1/Availability/status` and `POST /api/v1/Availability/admin/status` — layered on top of `PersistentAvailabilityService`, which previously only ever reflected the locally-persisted `GlobalAvailabilityState` regardless of the master gate.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
box rgba(33,150,243,0.15) Frontend (SettingsPage)
|
||||||
|
participant FE as Owner Browser
|
||||||
|
end
|
||||||
|
box rgba(76,175,80,0.15) Slave CMS
|
||||||
|
participant Ctrl as AvailabilityController
|
||||||
|
participant Svc as PersistentAvailabilityService
|
||||||
|
participant MasterSvc as MasterAvailabilityService
|
||||||
|
participant DB as ApplicationDbContext
|
||||||
|
end
|
||||||
|
|
||||||
|
FE->>Ctrl: GET /api/v1/Availability/status
|
||||||
|
Ctrl->>Svc: GetStatusDetailsAsync()
|
||||||
|
Svc->>MasterSvc: GetMasterStatus()
|
||||||
|
alt Master gate closed (IsAvailable = false)
|
||||||
|
MasterSvc-->>Svc: NotAvailable, masterMessage
|
||||||
|
Svc-->>Ctrl: Status=NotAvailable, Message=masterMessage, IsMasterControlled=true
|
||||||
|
else Master gate open
|
||||||
|
MasterSvc-->>Svc: Available
|
||||||
|
Svc->>DB: read GlobalAvailabilityState
|
||||||
|
DB-->>Svc: local Status/Message
|
||||||
|
Svc-->>Ctrl: Status, Message, IsMasterControlled=false
|
||||||
|
end
|
||||||
|
Ctrl-->>FE: 200 OK
|
||||||
|
|
||||||
|
FE->>Ctrl: POST /api/v1/Availability/admin/status (attempted local change)
|
||||||
|
Ctrl->>Svc: UpdateStatusAsync(newStatus, reason, updatedBy)
|
||||||
|
Svc->>MasterSvc: GetMasterStatus()
|
||||||
|
alt Master gate closed
|
||||||
|
MasterSvc-->>Svc: NotAvailable
|
||||||
|
Svc-->>Ctrl: throw MasterControlledAvailabilityException
|
||||||
|
Ctrl-->>FE: 409 Conflict (ProblemDetails)
|
||||||
|
else Master gate open
|
||||||
|
MasterSvc-->>Svc: Available
|
||||||
|
Svc->>DB: persist newStatus/reason
|
||||||
|
Svc-->>Ctrl: success
|
||||||
|
Ctrl-->>FE: 200 OK
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: Reading status now checks the master gate first — if closed, the response reflects the master's forced status and message, with `IsMasterControlled=true`, regardless of what's persisted locally. Writing a status change now performs the same master-gate check first: if closed, the write is rejected outright with `409 Conflict` instead of silently succeeding with no visible effect, since the master gate would have overridden it on the very next read anyway.
|
||||||
|
|
||||||
|
> Previously (before 2026-07-04): `GetStatusDetailsAsync` ignored the master gate entirely and always returned the locally-persisted status; `UpdateStatusAsync` always wrote the requested change regardless of the master gate, giving a misleading "success" for a change that was immediately invisible.
|
||||||
|
|||||||
+31
-1
@@ -69,6 +69,7 @@ Text alternative: Bypass path check first. Admin JWT next (bypasses both gates).
|
|||||||
| `/api/v1/Auth/` | Already bypassed — login must always work |
|
| `/api/v1/Auth/` | Already bypassed — login must always work |
|
||||||
| `/api/v1/Setup/status` | Already bypassed — frontend init check |
|
| `/api/v1/Setup/status` | Already bypassed — frontend init check |
|
||||||
| `/api/v1/master/` | **NEW** — master management endpoints must bypass gate so master can always push status or re-register |
|
| `/api/v1/master/` | **NEW** — master management endpoints must bypass gate so master can always push status or re-register |
|
||||||
|
| `/api/v1/SlaveStatus` | **NEW, added 2026-07-04** — this is actually the *master's* incoming endpoint for slave pulls, but it's added to this same bypass list on any instance that also loads `Modules.Availability` (i.e. the master itself), so the master's own local-gate status never blocks a slave from reading it |
|
||||||
|
|
||||||
**Cache behavior rules**:
|
**Cache behavior rules**:
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ Text alternative: Bypass path check first. Admin JWT next (bypasses both gates).
|
|||||||
|---|------|
|
|---|------|
|
||||||
| BR-SLAVE-08 | `_masterIsAvailable` defaults to `true` (fail-open) on process startup |
|
| BR-SLAVE-08 | `_masterIsAvailable` defaults to `true` (fail-open) on process startup |
|
||||||
| BR-SLAVE-09 | `_masterDisableMessage` defaults to `null` on process startup |
|
| BR-SLAVE-09 | `_masterDisableMessage` defaults to `null` on process startup |
|
||||||
| BR-SLAVE-10 | Cache has no expiry (Q4=A); only updated on `POST /status` with valid API key |
|
| BR-SLAVE-10 | **(Updated 2026-07-04)** Cache is updated on `POST /status` (push, valid API key) **and** periodically overwritten by `MasterStatusPollingBackgroundService` pulling `GET /api/v1/SlaveStatus` from the master (see Rule Set 5). It is no longer purely push-driven or expiry-free: a poll failure that persists past `MasterPolling:FailOpenAfterMinutes` (default 5 min, measured from `MasterRegistration.LastPolledAt`) forcibly resets the cache to `Available`/`null` regardless of the last pushed value. |
|
||||||
| BR-SLAVE-11 | 503 response from master gate includes `_masterDisableMessage` in `ProblemDetails.Detail` |
|
| BR-SLAVE-11 | 503 response from master gate includes `_masterDisableMessage` in `ProblemDetails.Detail` |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -89,6 +90,7 @@ Text alternative: Bypass path check first. Admin JWT next (bypasses both gates).
|
|||||||
| BR-SLAVE-13 | On `RegisterAsync`: if row with that Id exists → update. If not → insert. Never delete. |
|
| BR-SLAVE-13 | On `RegisterAsync`: if row with that Id exists → update. If not → insert. Never delete. |
|
||||||
| BR-SLAVE-14 | `RegisteredAt` is set once at creation and never updated |
|
| BR-SLAVE-14 | `RegisteredAt` is set once at creation and never updated |
|
||||||
| BR-SLAVE-15 | `LastContactedAt` is updated on every successful master call (register, status push, get-url) |
|
| BR-SLAVE-15 | `LastContactedAt` is updated on every successful master call (register, status push, get-url) |
|
||||||
|
| BR-SLAVE-16 | **(Added 2026-07-04)** `LastPolledAt` is updated whenever this slave successfully polls the master via `MasterStatusPollingBackgroundService` (distinct from `LastContactedAt`, which tracks master-initiated contact) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,3 +103,31 @@ Text alternative: Bypass path check first. Admin JWT next (bypasses both gates).
|
|||||||
| Get registered URL | `GET` | `/api/v1/master/registered-url` | None (API key in header) |
|
| Get registered URL | `GET` | `/api/v1/master/registered-url` | None (API key in header) |
|
||||||
|
|
||||||
All three endpoints are unauthenticated from ASP.NET Core's perspective — they use the custom `X-Master-Api-Key` header validation implemented in `MasterAvailabilityService`. They are also in the middleware bypass list so the gate cannot block master management calls.
|
All three endpoints are unauthenticated from ASP.NET Core's perspective — they use the custom `X-Master-Api-Key` header validation implemented in `MasterAvailabilityService`. They are also in the middleware bypass list so the gate cannot block master management calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rule Set 5: Slave Pull + Fail-Open — Added 2026-07-04
|
||||||
|
|
||||||
|
Closes a gap versus the original inception requirements (`inception/requirements/requirements.md`, FR-MASTER-06 "Slave Pull Model", FR-MASTER-07 "Slave Fallback Behavior", NFR-MASTER-01 "Fail-Open Safety"): construction had implemented push-only, with fail-open surviving only as an in-memory startup default (BR-SLAVE-08/09) rather than an actively-reconciling pull. Added after a slave was observed remaining on a stale status through a restart and again after being deactivated on the master.
|
||||||
|
|
||||||
|
| # | Rule |
|
||||||
|
|---|------|
|
||||||
|
| BR-PULL-01 | `MasterStatusPollingBackgroundService` runs one tick immediately on slave startup, then every `MasterPolling:PollIntervalSeconds` (default `30`) |
|
||||||
|
| BR-PULL-02 | If no `MasterRegistration` exists yet, the poll tick is a no-op (nothing to poll) |
|
||||||
|
| BR-PULL-03 | On a successful poll (`GET /api/v1/SlaveStatus` on the registered master, header `X-Master-Api-Key`), the response overwrites the in-memory gate (`_masterIsAvailable`/`_masterDisableMessage`) and updates `MasterRegistration.LastPolledAt` + `LastContactedAt` |
|
||||||
|
| BR-PULL-04 | On a failed poll (network error, timeout, or non-success HTTP status), the gate is **not** changed immediately — instead `RecordPollFailureAsync` checks how long it's been since `LastPolledAt` (or `RegisteredAt` if never polled) |
|
||||||
|
| BR-PULL-05 | **Fail-open**: if that elapsed time exceeds `MasterPolling:FailOpenAfterMinutes` (default `5`), the gate is forced to `Available`/`null` — a master that is dead or unreachable must never permanently block a slave |
|
||||||
|
| BR-PULL-06 | Push (BR-SLAVE-01 through 11) and pull (this rule set) are independent and complementary: push gives instant reactivity to an explicit admin status change; pull is the self-healing safety net for everything push can miss (slave restarts, dropped pushes, local tampering with the in-memory gate) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rule Set 6: Master-Controlled Availability Lock — Added 2026-07-04
|
||||||
|
|
||||||
|
Applies to the slave's own local availability admin UI/API (`PersistentAvailabilityService` / `AvailabilityController` — the endpoints an Owner uses on `/settings` to set *this instance's own* Maintenance/NotAvailable status), not the master↔slave protocol endpoints above. Added because an Owner on a master-disabled slave could previously "successfully" set local status to `Available` with no visible effect, since the master gate silently overrode the display.
|
||||||
|
|
||||||
|
| # | Rule |
|
||||||
|
|---|------|
|
||||||
|
| BR-LOCK-01 | `GET /api/v1/Availability/status` returns the master-gate status (not the locally-persisted one) whenever the master gate is closed (`IsAvailable = false`), and includes a new `IsMasterControlled: true` flag in that case |
|
||||||
|
| BR-LOCK-02 | `POST /api/v1/Availability/admin/status` (`PersistentAvailabilityService.UpdateStatusAsync`) throws `MasterControlledAvailabilityException` and makes **no** DB write when the master gate is closed, instead of silently persisting a change that would have no visible effect |
|
||||||
|
| BR-LOCK-03 | The controller translates that exception into `409 Conflict` (`ProblemDetails`) |
|
||||||
|
| BR-LOCK-04 | The frontend Settings page reads `isMasterControlled` and disables the mode selector, the reason field, and the save button, showing a banner explaining that the Master CMS controls this status |
|
||||||
|
|||||||
+6
-5
@@ -40,9 +40,10 @@ Singleton row — at most one record exists per slave instance. Upserted on each
|
|||||||
|-------|------|-------------|-------|
|
|-------|------|-------------|-------|
|
||||||
| `Id` | `Guid` | PK | Fixed value (e.g. `Guid.Empty`) enforces singleton |
|
| `Id` | `Guid` | PK | Fixed value (e.g. `Guid.Empty`) enforces singleton |
|
||||||
| `MasterUrl` | `string` | Required, max 500 | URL of the master CMS that registered this slave |
|
| `MasterUrl` | `string` | Required, max 500 | URL of the master CMS that registered this slave |
|
||||||
| `ApiKey` | `string` | Required, max 1000 | Plain-text API key sent in first registration; used for subsequent validation |
|
| `ApiKey` | `string` | Required, max 1000 | API key sent in first registration, encrypted via `IMasterApiKeyProtector` (ASP.NET Core Data Protection) before storage, decrypted for each subsequent validation — **corrected 2026-07-04**, this was previously (incorrectly) documented as stored plain-text |
|
||||||
| `RegisteredAt` | `DateTimeOffset` | Required | Timestamp of first registration |
|
| `RegisteredAt` | `DateTimeOffset` | Required | Timestamp of first registration |
|
||||||
| `LastContactedAt` | `DateTimeOffset?` | Optional | Updated on every successful master call (register, status push, get-url) |
|
| `LastContactedAt` | `DateTimeOffset?` | Optional | Updated on every successful master-initiated call (register, status push, get-url) |
|
||||||
|
| `LastPolledAt` | `DateTimeOffset?` | Optional | **Added 2026-07-04**. Updated on every successful *slave-initiated* poll (`MasterStatusPollingBackgroundService`) — the counterpart to `LastContactedAt`, tracking the opposite direction of contact. Also the basis for the fail-open timeout (see below). |
|
||||||
|
|
||||||
> **Singleton enforcement**: The `Id` is a fixed known value (`Guid.Parse("00000000-0000-0000-0000-000000000001")`). On first `POST /api/v1/master/register` the row is created; on re-registration the same row is updated in-place. This avoids a composite unique constraint and makes EF upsert trivial.
|
> **Singleton enforcement**: The `Id` is a fixed known value (`Guid.Parse("00000000-0000-0000-0000-000000000001")`). On first `POST /api/v1/master/register` the row is created; on re-registration the same row is updated in-place. This avoids a composite unique constraint and makes EF upsert trivial.
|
||||||
|
|
||||||
@@ -54,10 +55,10 @@ Not a DB entity — lives in memory on the slave process.
|
|||||||
|
|
||||||
| Field | Type | Default | Notes |
|
| Field | Type | Default | Notes |
|
||||||
|-------|------|---------|-------|
|
|-------|------|---------|-------|
|
||||||
| `_masterIsAvailable` | `bool` | `true` | Set by status push; `true` = pass gate |
|
| `_masterIsAvailable` | `bool` | `true` | Set by status push **or** successful poll; forced back to `true` on prolonged poll failure (fail-open) |
|
||||||
| `_masterDisableMessage` | `string?` | `null` | Message forwarded from master to 503 response |
|
| `_masterDisableMessage` | `string?` | `null` | Message forwarded from master to 503 response; cleared to `null` on fail-open |
|
||||||
|
|
||||||
**No expiry** (Q4=A): cache is valid indefinitely until the master pushes again. If the master goes offline permanently, the last known status is used. Default = `true` (Available) = fail-open.
|
> **Updated 2026-07-04**: originally documented as having "no expiry" (Q4=A), valid indefinitely until the next push. This is no longer accurate now that `MasterStatusPollingBackgroundService` actively polls the master (see Rule Set 5 / Flow 5 in the sibling docs) and forces the cache back to `Available`/`null` if the master has been unreachable via poll for longer than `MasterPolling:FailOpenAfterMinutes` (default 5 min, measured from `MasterRegistration.LastPolledAt`). Push-driven updates (this section's original description) are unchanged and still apply; the poll is an additional, independent path that can also write to this same cache.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -145,11 +145,11 @@ Text alternative: Master CMS has new module with controller, service, repository
|
|||||||
| Constraint | Source | Impact |
|
| Constraint | Source | Impact |
|
||||||
|-----------|--------|--------|
|
|-----------|--------|--------|
|
||||||
| `ApiKey` never returned in API responses | NFR-MASTER-03 | `CmsInstanceDto` excludes `ApiKey`; only accepted in `CreateCmsInstanceRequest` |
|
| `ApiKey` never returned in API responses | NFR-MASTER-03 | `CmsInstanceDto` excludes `ApiKey`; only accepted in `CreateCmsInstanceRequest` |
|
||||||
| Fail-open on Master unreachable | NFR-MASTER-01 | `MasterAvailabilityService` returns last cached status (default Available) on HTTP failure |
|
| Fail-open on Master unreachable | NFR-MASTER-01 | **(Updated 2026-07-04)** Originally only an in-memory startup default (`_masterIsAvailable = true`); now actively enforced by `MasterStatusPollingBackgroundService.RecordPollFailureAsync`, which forces the gate open if the master has been unreachable via poll for longer than `MasterPolling:FailOpenAfterMinutes` — this is the slave-pull half of FR-MASTER-06/07 that was originally specified but not implemented until 2026-07-04 (see `slave-availability-extension/functional-design/business-rules.md` Rule Set 5) |
|
||||||
| Per-module DbContext + migrations | NFR-MASTER-06 | New `MasterDbContext` in `Modules.Master`; new `AvailabilityDbContext` in `Modules.Availability` |
|
| Per-module DbContext + migrations | NFR-MASTER-06 | New `MasterDbContext` in `Modules.Master`; new `AvailabilityDbContext` in `Modules.Availability` |
|
||||||
| Master exemption from own gate | FR-MASTER-09 | Handled naturally: no `MasterRegistration` record exists on Master instance → gate skipped |
|
| Master exemption from own gate | FR-MASTER-09 | Handled naturally: no `MasterRegistration` record exists on Master instance → gate skipped |
|
||||||
| Disable message required for NotAvailable | FR-MASTER-14 | Validated in `CmsInstanceService.UpdateStatusAsync` before persistence |
|
| Disable message required for NotAvailable | FR-MASTER-14 | Validated in `CmsInstanceService.UpdateStatusAsync` before persistence |
|
||||||
| Inactive slaves: no HTTP contact | FR-MASTER-13 | `GetActiveAsync()` filters out Inactive before integrity checks and status pushes |
|
| Inactive slaves: no further HTTP contact | FR-MASTER-13 | **(Updated 2026-07-04)** `GetActiveAsync()` filters out Inactive instances from ongoing integrity checks and status re-pushes, as before — but the transition *into* `Inactive` itself now always performs one final push (`Available`, no message) to release the master gate before the instance drops out; previously this transition performed no push at all, leaving the slave stuck on its last status |
|
||||||
| Owner role only | FR-MASTER-10 | `[Authorize(Policy = "OwnerOnly")]` on all `CmsInstanceController` actions |
|
| Owner role only | FR-MASTER-10 | `[Authorize(Policy = "OwnerOnly")]` on all `CmsInstanceController` actions |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+2
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
> Method signatures at the interface level. Detailed business rules and implementation logic are deferred to Functional Design (CONSTRUCTION phase).
|
> Method signatures at the interface level. Detailed business rules and implementation logic are deferred to Functional Design (CONSTRUCTION phase).
|
||||||
|
|
||||||
|
> ⚠️ **Partially superseded, found stale 2026-07-04**: the `IMasterAvailabilityService` methods and `/api/internal/master/*` routes below describe an inception-stage pull design that construction did not build as-is (actual routes are `/api/v1/master/*`; the method set differs — see `RegisterAsync`/`PushStatusAsync`/`GetRegisteredUrlAsync`/`GetMasterStatus` in `construction/slave-availability-extension/`, plus 2026-07-04 additions `GetPollTargetAsync`/`ApplyPolledStatusAsync`/`RecordPollFailureAsync`). Treat the functional-design docs under `construction/` as current truth for exact signatures.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Unit 1 — master-backend
|
## Unit 1 — master-backend
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Components — Master CMS Module
|
# Components — Master CMS Module
|
||||||
|
|
||||||
|
> ⚠️ **Partially superseded, found stale 2026-07-04**: the slave-side `IMasterAvailabilityService` description below (static `_cachedStatus`/`_lastFetchedAt`, `CacheMinutes`-based staleness, `/api/internal/master/*` routes) describes an inception-stage design that construction did not build as-is — the actual implementation is push-based (`/api/v1/master/*`, `_masterIsAvailable`/`_masterDisableMessage`), plus a 2026-07-04 addition of a differently-shaped slave-pull (`MasterStatusPollingBackgroundService` + `GET /api/v1/SlaveStatus`, time-based fail-open). The Unit 1 (`master-backend`) component list above the slave-side section is accurate. See `construction/master-backend/functional-design/*.md` and `construction/slave-availability-extension/functional-design/*.md` for the as-built design. This gap predates 2026-07-04 and was found (not caused) during today's documentation audit.
|
||||||
|
|
||||||
## Unit 1 — master-backend (`SlpModularCms.Modules.Master`)
|
## Unit 1 — master-backend (`SlpModularCms.Modules.Master`)
|
||||||
|
|
||||||
### MasterModule
|
### MasterModule
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Services — Master CMS Module
|
# Services — Master CMS Module
|
||||||
|
|
||||||
|
> ⚠️ **Superseded, found stale 2026-07-04**: this document is the *inception-stage* design and describes an earlier pull-based `IMasterAvailabilityService` (static cache + `MasterModuleOptions.CacheMinutes` + `/api/internal/master/*` routes) that was **not what got built**. Construction pivoted to a push-based protocol instead (`/api/v1/master/*` routes, `_masterIsAvailable`/`_masterDisableMessage` fields, no `CacheMinutes`). A slave-pull mechanism was eventually added too, but on 2026-07-04 and with a different shape (`MasterStatusPollingBackgroundService` polling `GET /api/v1/SlaveStatus` on an interval, with a time-based fail-open) than what's described below. **Treat this file as historical intent, not current truth** — the accurate, as-built design lives in `construction/master-backend/functional-design/*.md` and `construction/slave-availability-extension/functional-design/*.md`. This divergence predates 2026-07-04 and was found (not caused) during today's documentation audit; `CacheMinutes`/`ApiKey` dead-config fallout is already tracked as TD-001 in `tech-debt-backlog`.
|
||||||
|
|
||||||
## Service Definitions
|
## Service Definitions
|
||||||
|
|
||||||
### Master-Side Services
|
### Master-Side Services
|
||||||
|
|||||||
@@ -11,3 +11,15 @@
|
|||||||
**Context**: Feature aangemaakt tijdens master-cms-module sessie; geen actieve executie gestart, puur registratie voor later
|
**Context**: Feature aangemaakt tijdens master-cms-module sessie; geen actieve executie gestart, puur registratie voor later
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## TD-004 Added — Master-Controlled Availability Copy Review
|
||||||
|
|
||||||
|
**Timestamp**: 2026-07-04T00:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "De tekst mag nog wel wat anders worden, maar ik weet nu even niet wat dus ik kan dat nu ook zelf niet veranderen. Kan je hier een tech debt item voor maken zodat ik dit later op kan pakken?"
|
||||||
|
|
||||||
|
**AI Response**: "TD-004 toegevoegd aan backlog.md: de teksten voor de master-controlled availability-lock op SettingsPage (banner-titel, banner-body, en de 409-conflict-toast — drie i18n-sleutels in nl/en translation.json) zijn functioneel correct maar de exacte formulering moet nog herzien worden. Puur copy, geen logica-wijziging verwacht; genoteerd dat de bijbehorende SettingsPage.test.tsx-assertie op tekst matcht en dus mee moet worden aangepast bij het oppakken."
|
||||||
|
|
||||||
|
**Context**: Item ontstond direct na de master-controlled-availability fix in local-dev-master-slave-setup (zelfde sessie, 2026-07-04); user wilde de tekst niet blokkerend laten zijn voor de rest van het werk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -43,6 +43,20 @@ Items discovered incidentally while working on other features. Not yet prioritiz
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## TD-004: Review copy for the master-controlled availability lock on `SettingsPage`
|
||||||
|
|
||||||
|
**Found during**: `local-dev-master-slave-setup` — Build & Test follow-up fix (2026-07-04), user flagged the wording right after it was added but didn't have a replacement in mind yet.
|
||||||
|
|
||||||
|
**Location**:
|
||||||
|
- `frontend/src/pages/SettingsPage.tsx` (locked-state banner + disabled controls)
|
||||||
|
- `frontend/src/i18n/locales/nl/translation.json` and `en/translation.json`, keys `settings.availability.masterControlledTitle`, `settings.availability.masterControlled`, `settings.availability.masterControlledSaveError`
|
||||||
|
|
||||||
|
**Issue**: When a slave's availability is controlled by its Master CMS, the Settings page now shows a banner ("Beheerd door Master-CMS" / "Deze instantie is door de Master-CMS uitgeschakeld...") and disables the mode buttons, reason field, and save button, plus a specific toast on a 409 conflict ("Kan niet worden gewijzigd: de Master-CMS beheert deze status."). The behavior (locking + explaining why) is correct and intentional; only the exact phrasing needs a revisit — user wants different wording but hadn't decided on it at the time.
|
||||||
|
|
||||||
|
**Done looks like**: Update the three translation keys above (NL and EN) to the reviewed copy. No code/logic changes expected — this is copy-only. Re-run `pnpm test` for `SettingsPage.test.tsx` afterward (its assertions match on text via regex, e.g. `/master cms controls this status/i`, so wording changes will need matching test updates too).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- None of these block functionality — `npm run build` and all test suites pass regardless.
|
- None of these block functionality — `npm run build` and all test suites pass regardless.
|
||||||
|
|||||||
@@ -4,3 +4,16 @@
|
|||||||
# Set CookieSameSite=None in appsettings.Development.json so the cookie
|
# Set CookieSameSite=None in appsettings.Development.json so the cookie
|
||||||
# is sent cross-origin when the frontend and backend run on different ports.
|
# is sent cross-origin when the frontend and backend run on different ports.
|
||||||
VITE_API_BASE_URL=https://localhost:7221
|
VITE_API_BASE_URL=https://localhost:7221
|
||||||
|
|
||||||
|
# Browser tab title. Defaults to "SlpModularCms" if unset — override so the
|
||||||
|
# tab is instantly recognizable when running multiple instances side by side
|
||||||
|
# (e.g. local master vs. slave, see below).
|
||||||
|
VITE_APP_TITLE=SlpModularCms
|
||||||
|
|
||||||
|
# --- Local master/slave dev setup ---
|
||||||
|
# To point the frontend at the slave instance (SlpModularCms.Api.Slave,
|
||||||
|
# see root README.md "Lokaal Master + Slave Draaien (Dev)") instead of the
|
||||||
|
# master, copy this file to `.env.slave.local` with:
|
||||||
|
# VITE_API_BASE_URL=https://localhost:7222
|
||||||
|
# VITE_APP_TITLE=SlpModularCms (Slave)
|
||||||
|
# and run `pnpm dev:slave` (uses --mode slave, port 5174) instead of `pnpm dev`.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"dev:slave": "vite --mode slave --port 5174",
|
||||||
|
"dev:all": "concurrently -n master,slave -c blue,magenta \"pnpm dev\" \"pnpm dev:slave\"",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^4.1.9",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
|||||||
Generated
+67
@@ -99,6 +99,9 @@ importers:
|
|||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^4.1.9
|
specifier: ^4.1.9
|
||||||
version: 4.1.9(vitest@4.1.9)
|
version: 4.1.9(vitest@4.1.9)
|
||||||
|
concurrently:
|
||||||
|
specifier: ^9.1.2
|
||||||
|
version: 9.2.3
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^10.3.0
|
specifier: ^10.3.0
|
||||||
version: 10.5.0(jiti@2.7.0)
|
version: 10.5.0(jiti@2.7.0)
|
||||||
@@ -1252,6 +1255,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
chalk@4.1.2:
|
||||||
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
@@ -1274,6 +1281,11 @@ packages:
|
|||||||
color-name@1.1.4:
|
color-name@1.1.4:
|
||||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||||
|
|
||||||
|
concurrently@9.2.3:
|
||||||
|
resolution: {integrity: sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
@@ -1917,6 +1929,9 @@ packages:
|
|||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
rxjs@7.8.2:
|
||||||
|
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
|
||||||
|
|
||||||
saxes@6.0.0:
|
saxes@6.0.0:
|
||||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||||
engines: {node: '>=v12.22.7'}
|
engines: {node: '>=v12.22.7'}
|
||||||
@@ -1954,6 +1969,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
shell-quote@1.8.4:
|
||||||
|
resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
siginfo@2.0.0:
|
siginfo@2.0.0:
|
||||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
@@ -2000,6 +2019,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
supports-color@8.1.1:
|
||||||
|
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
symbol-tree@3.2.4:
|
symbol-tree@3.2.4:
|
||||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||||
|
|
||||||
@@ -2047,6 +2070,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
tree-kill@1.2.2:
|
||||||
|
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
ts-api-utils@2.5.0:
|
ts-api-utils@2.5.0:
|
||||||
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
||||||
engines: {node: '>=18.12'}
|
engines: {node: '>=18.12'}
|
||||||
@@ -2260,6 +2287,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
yargs@17.7.2:
|
||||||
|
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
yargs@17.7.3:
|
yargs@17.7.3:
|
||||||
resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
|
resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -3359,6 +3390,11 @@ snapshots:
|
|||||||
|
|
||||||
chai@6.2.2: {}
|
chai@6.2.2: {}
|
||||||
|
|
||||||
|
chalk@4.1.2:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 4.3.0
|
||||||
|
supports-color: 7.2.0
|
||||||
|
|
||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
@@ -3379,6 +3415,15 @@ snapshots:
|
|||||||
|
|
||||||
color-name@1.1.4: {}
|
color-name@1.1.4: {}
|
||||||
|
|
||||||
|
concurrently@9.2.3:
|
||||||
|
dependencies:
|
||||||
|
chalk: 4.1.2
|
||||||
|
rxjs: 7.8.2
|
||||||
|
shell-quote: 1.8.4
|
||||||
|
supports-color: 8.1.1
|
||||||
|
tree-kill: 1.2.2
|
||||||
|
yargs: 17.7.2
|
||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
cookie-es@3.1.1: {}
|
cookie-es@3.1.1: {}
|
||||||
@@ -3965,6 +4010,10 @@ snapshots:
|
|||||||
'@rolldown/binding-win32-arm64-msvc': 1.0.3
|
'@rolldown/binding-win32-arm64-msvc': 1.0.3
|
||||||
'@rolldown/binding-win32-x64-msvc': 1.0.3
|
'@rolldown/binding-win32-x64-msvc': 1.0.3
|
||||||
|
|
||||||
|
rxjs@7.8.2:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
saxes@6.0.0:
|
saxes@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
xmlchars: 2.2.0
|
xmlchars: 2.2.0
|
||||||
@@ -3989,6 +4038,8 @@ snapshots:
|
|||||||
|
|
||||||
shebang-regex@3.0.0: {}
|
shebang-regex@3.0.0: {}
|
||||||
|
|
||||||
|
shell-quote@1.8.4: {}
|
||||||
|
|
||||||
siginfo@2.0.0: {}
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
signal-exit@4.1.0: {}
|
signal-exit@4.1.0: {}
|
||||||
@@ -4026,6 +4077,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 4.0.0
|
has-flag: 4.0.0
|
||||||
|
|
||||||
|
supports-color@8.1.1:
|
||||||
|
dependencies:
|
||||||
|
has-flag: 4.0.0
|
||||||
|
|
||||||
symbol-tree@3.2.4: {}
|
symbol-tree@3.2.4: {}
|
||||||
|
|
||||||
tagged-tag@1.0.0: {}
|
tagged-tag@1.0.0: {}
|
||||||
@@ -4061,6 +4116,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
|
tree-kill@1.2.2: {}
|
||||||
|
|
||||||
ts-api-utils@2.5.0(typescript@6.0.3):
|
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
@@ -4209,6 +4266,16 @@ snapshots:
|
|||||||
|
|
||||||
yargs-parser@21.1.1: {}
|
yargs-parser@21.1.1: {}
|
||||||
|
|
||||||
|
yargs@17.7.2:
|
||||||
|
dependencies:
|
||||||
|
cliui: 8.0.1
|
||||||
|
escalade: 3.2.0
|
||||||
|
get-caller-file: 2.0.5
|
||||||
|
require-directory: 2.1.1
|
||||||
|
string-width: 4.2.3
|
||||||
|
y18n: 5.0.8
|
||||||
|
yargs-parser: 21.1.1
|
||||||
|
|
||||||
yargs@17.7.3:
|
yargs@17.7.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
cliui: 8.0.1
|
cliui: 8.0.1
|
||||||
|
|||||||
@@ -115,4 +115,13 @@ export interface AvailabilityResponse {
|
|||||||
status: AvailabilityStatus;
|
status: AvailabilityStatus;
|
||||||
checkedAt: string; // ISO 8601
|
checkedAt: string; // ISO 8601
|
||||||
message: string;
|
message: string;
|
||||||
|
/** True when a Master CMS has taken control of this instance's gate — the local status cannot be changed here. */
|
||||||
|
isMasterControlled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which optional modules (e.g. "Master") this backend instance has loaded —
|
||||||
|
// lets the frontend tell a master-only feature apart from a slave instance
|
||||||
|
// without that module (local master/slave dev setup).
|
||||||
|
export interface SystemCapabilities {
|
||||||
|
modules: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { SystemCapabilities } from './types';
|
||||||
|
|
||||||
|
// Backend may return PascalCase (Modules) if no camelCase policy is set.
|
||||||
|
type RawSystemCapabilities = { modules?: string[]; Modules?: string[] };
|
||||||
|
|
||||||
|
export function useSystemCapabilities() {
|
||||||
|
return useQuery<SystemCapabilities, Error>({
|
||||||
|
queryKey: ['system', 'capabilities'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const raw = await api.get<RawSystemCapabilities>('/api/v1/System/capabilities');
|
||||||
|
return { modules: raw.modules ?? raw.Modules ?? [] };
|
||||||
|
},
|
||||||
|
// Which modules a backend has loaded is fixed for the lifetime of that
|
||||||
|
// backend process — no need to ever refetch within a session.
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSystemCapabilities } from '@/api/useSystemCapabilities';
|
||||||
|
|
||||||
|
interface ModuleGuardProps {
|
||||||
|
requiredModule: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides a feature that only exists on backend instances with a given module loaded
|
||||||
|
* (e.g. the CMS-instance management page requires Modules.Master — a local slave
|
||||||
|
* instance without it should not expose this page even to an Owner).
|
||||||
|
*/
|
||||||
|
export function ModuleGuard({ requiredModule, children }: ModuleGuardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: capabilities, isPending } = useSystemCapabilities();
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
|
||||||
|
<span className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capabilities?.modules.includes(requiredModule)) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="feature-unavailable-message"
|
||||||
|
className="flex flex-col items-center justify-center gap-4 py-16 text-center"
|
||||||
|
>
|
||||||
|
<h1 className="text-2xl font-semibold">{t('errors.featureUnavailableTitle')}</h1>
|
||||||
|
<p className="max-w-sm text-sm text-muted-foreground">{t('errors.featureUnavailable')}</p>
|
||||||
|
<Link to="/dashboard" className="text-sm text-primary underline-offset-4 hover:underline">
|
||||||
|
{t('nav.dashboard')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { MoreHorizontal } from 'lucide-react';
|
import { MoreHorizontal, CheckCircle, XCircle, MinusCircle } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -18,11 +17,23 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { CmsInstance, CmsInstanceStatus } from '@/api/types';
|
import type { CmsInstance, CmsInstanceStatus } from '@/api/types';
|
||||||
|
|
||||||
function statusBadgeVariant(status: CmsInstanceStatus) {
|
const STATUS_BADGE_CONFIG: Record<
|
||||||
if (status === 'Available') return 'secondary';
|
CmsInstanceStatus,
|
||||||
if (status === 'NotAvailable') return 'destructive';
|
{ colorClass: string; Icon: React.ComponentType<{ className?: string }> }
|
||||||
return 'outline';
|
> = {
|
||||||
}
|
Available: {
|
||||||
|
colorClass: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||||
|
Icon: CheckCircle,
|
||||||
|
},
|
||||||
|
NotAvailable: {
|
||||||
|
colorClass: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||||
|
Icon: XCircle,
|
||||||
|
},
|
||||||
|
Inactive: {
|
||||||
|
colorClass: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
|
||||||
|
Icon: MinusCircle,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function formatDate(iso: string | null): string {
|
function formatDate(iso: string | null): string {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
@@ -50,7 +61,9 @@ export function CmsInstanceList({ instances, onSetStatus }: CmsInstanceListProps
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{instances.map((instance) => (
|
{instances.map((instance) => {
|
||||||
|
const { colorClass, Icon } = STATUS_BADGE_CONFIG[instance.status];
|
||||||
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={instance.id}
|
key={instance.id}
|
||||||
data-testid="cms-instance-row"
|
data-testid="cms-instance-row"
|
||||||
@@ -59,12 +72,13 @@ export function CmsInstanceList({ instances, onSetStatus }: CmsInstanceListProps
|
|||||||
<TableCell>{instance.name}</TableCell>
|
<TableCell>{instance.name}</TableCell>
|
||||||
<TableCell>{instance.url}</TableCell>
|
<TableCell>{instance.url}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<div
|
||||||
variant={statusBadgeVariant(instance.status)}
|
|
||||||
data-testid="cms-instance-status-badge"
|
data-testid="cms-instance-status-badge"
|
||||||
|
className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-sm font-medium ${colorClass}`}
|
||||||
>
|
>
|
||||||
{t(`cms.status.${instance.status}`)}
|
<Icon className="size-4" />
|
||||||
</Badge>
|
<span>{t(`cms.status.${instance.status}`)}</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{formatDate(instance.lastContactedAt)}</TableCell>
|
<TableCell>{formatDate(instance.lastContactedAt)}</TableCell>
|
||||||
<TableCell>{instance.disableMessage ?? '—'}</TableCell>
|
<TableCell>{instance.disableMessage ?? '—'}</TableCell>
|
||||||
@@ -90,7 +104,8 @@ export function CmsInstanceList({ instances, onSetStatus }: CmsInstanceListProps
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ describe('Sidebar role filtering (BR-U3-01 – BR-U3-06)', () => {
|
|||||||
expect(await screen.findByTestId('nav-dashboard')).toBeInTheDocument();
|
expect(await screen.findByTestId('nav-dashboard')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('nav-users')).toBeInTheDocument();
|
expect(screen.getByTestId('nav-users')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('nav-settings')).toBeInTheDocument();
|
expect(screen.getByTestId('nav-settings')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('nav-cms')).toBeInTheDocument();
|
// CMS nav item also waits on the async system-capabilities check (Master module presence).
|
||||||
|
expect(await screen.findByTestId('nav-cms')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('nav-profile')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('nav-profile')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,6 +42,23 @@ describe('Sidebar role filtering (BR-U3-01 – BR-U3-06)', () => {
|
|||||||
expect(screen.queryByTestId('nav-profile')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('nav-profile')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Owner does not see CMS when the backend has no Master module (slave instance)', async () => {
|
||||||
|
mockAuthenticatedAs('Owner');
|
||||||
|
server.use(
|
||||||
|
http.get('*/System/capabilities', () =>
|
||||||
|
HttpResponse.json({ modules: ['Availability', 'Identity'] }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderApp('/dashboard');
|
||||||
|
expect(await screen.findByTestId('nav-dashboard')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('nav-users')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('nav-settings')).toBeInTheDocument();
|
||||||
|
// Let the async system-capabilities query settle before asserting its absence,
|
||||||
|
// otherwise this would also pass trivially during the loading state.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
expect(screen.queryByTestId('nav-cms')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('User sees Dashboard only', async () => {
|
it('User sees Dashboard only', async () => {
|
||||||
mockAuthenticatedAs('User');
|
mockAuthenticatedAs('User');
|
||||||
renderApp('/dashboard');
|
renderApp('/dashboard');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { LucideIcon } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuth } from '@/contexts/auth-context';
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
import { useAvailabilityStatus } from '@/api/useAvailability';
|
import { useAvailabilityStatus } from '@/api/useAvailability';
|
||||||
|
import { useSystemCapabilities } from '@/api/useSystemCapabilities';
|
||||||
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
|
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
|
||||||
import { ThemeToggle } from './ThemeToggle';
|
import { ThemeToggle } from './ThemeToggle';
|
||||||
import { UserMenu } from './UserMenu';
|
import { UserMenu } from './UserMenu';
|
||||||
@@ -17,6 +18,7 @@ interface NavItem {
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
testId: string;
|
testId: string;
|
||||||
roles?: Role[];
|
roles?: Role[];
|
||||||
|
requiredModule?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED_WHEN_UNAVAILABLE = ['/dashboard', '/settings'];
|
const ALLOWED_WHEN_UNAVAILABLE = ['/dashboard', '/settings'];
|
||||||
@@ -24,7 +26,7 @@ const ALLOWED_WHEN_UNAVAILABLE = ['/dashboard', '/settings'];
|
|||||||
const NAV_ITEMS: NavItem[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
|
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
|
||||||
{ to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users', roles: ['Owner', 'Administrator'] },
|
{ to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users', roles: ['Owner', 'Administrator'] },
|
||||||
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms', roles: ['Owner'] },
|
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms', roles: ['Owner'], requiredModule: 'Master' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SETTINGS_ITEM: NavItem = {
|
const SETTINGS_ITEM: NavItem = {
|
||||||
@@ -39,11 +41,14 @@ export function Sidebar({ onClose }: SidebarProps = {}) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { data: availability } = useAvailabilityStatus();
|
const { data: availability } = useAvailabilityStatus();
|
||||||
|
const { data: capabilities } = useSystemCapabilities();
|
||||||
|
|
||||||
const role = user?.role as Role | undefined;
|
const role = user?.role as Role | undefined;
|
||||||
const systemUnavailable = availability?.status === 'NotAvailable';
|
const systemUnavailable = availability?.status === 'NotAvailable';
|
||||||
const visibleItems = NAV_ITEMS.filter(
|
const visibleItems = NAV_ITEMS.filter(
|
||||||
(item) => !item.roles || (role && item.roles.includes(role))
|
(item) =>
|
||||||
|
(!item.roles || (role && item.roles.includes(role))) &&
|
||||||
|
(!item.requiredModule || capabilities?.modules.includes(item.requiredModule))
|
||||||
);
|
);
|
||||||
const showSettings = !SETTINGS_ITEM.roles || (role && SETTINGS_ITEM.roles.includes(role));
|
const showSettings = !SETTINGS_ITEM.roles || (role && SETTINGS_ITEM.roles.includes(role));
|
||||||
|
|
||||||
|
|||||||
@@ -177,7 +177,10 @@
|
|||||||
"Available": "Available",
|
"Available": "Available",
|
||||||
"Maintenance": "Maintenance",
|
"Maintenance": "Maintenance",
|
||||||
"NotAvailable": "Unavailable"
|
"NotAvailable": "Unavailable"
|
||||||
}
|
},
|
||||||
|
"masterControlledTitle": "Controlled by Master CMS",
|
||||||
|
"masterControlled": "This instance has been disabled by the Master CMS. Availability cannot be changed here until the Master releases it.",
|
||||||
|
"masterControlledSaveError": "Cannot be changed: the Master CMS controls this status."
|
||||||
},
|
},
|
||||||
"modules": { "title": "Module Management", "comingSoon": "Coming soon" },
|
"modules": { "title": "Module Management", "comingSoon": "Coming soon" },
|
||||||
"systemConfig": { "title": "System Configuration", "comingSoon": "Coming soon" },
|
"systemConfig": { "title": "System Configuration", "comingSoon": "Coming soon" },
|
||||||
@@ -243,6 +246,8 @@
|
|||||||
"generic": "Something went wrong. Please try again.",
|
"generic": "Something went wrong. Please try again.",
|
||||||
"accessDenied": "You do not have permission to access this page.",
|
"accessDenied": "You do not have permission to access this page.",
|
||||||
"setupRequired": "System setup required. Please initialize the system first.",
|
"setupRequired": "System setup required. Please initialize the system first.",
|
||||||
"invalidInvitationToken": "Invalid or expired invitation token."
|
"invalidInvitationToken": "Invalid or expired invitation token.",
|
||||||
|
"featureUnavailableTitle": "Not available on this instance",
|
||||||
|
"featureUnavailable": "This feature is only available on a Master CMS instance."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,7 +177,10 @@
|
|||||||
"Available": "Beschikbaar",
|
"Available": "Beschikbaar",
|
||||||
"Maintenance": "Onderhoud",
|
"Maintenance": "Onderhoud",
|
||||||
"NotAvailable": "Niet beschikbaar"
|
"NotAvailable": "Niet beschikbaar"
|
||||||
}
|
},
|
||||||
|
"masterControlledTitle": "Beheerd door Master-CMS",
|
||||||
|
"masterControlled": "Deze instantie is door de Master-CMS uitgeschakeld. De beschikbaarheid kan hier niet worden gewijzigd totdat de Master de status weer vrijgeeft.",
|
||||||
|
"masterControlledSaveError": "Kan niet worden gewijzigd: de Master-CMS beheert deze status."
|
||||||
},
|
},
|
||||||
"modules": { "title": "Modulebeheer", "comingSoon": "Binnenkort beschikbaar" },
|
"modules": { "title": "Modulebeheer", "comingSoon": "Binnenkort beschikbaar" },
|
||||||
"systemConfig": { "title": "Systeemconfiguratie", "comingSoon": "Binnenkort beschikbaar" },
|
"systemConfig": { "title": "Systeemconfiguratie", "comingSoon": "Binnenkort beschikbaar" },
|
||||||
@@ -243,6 +246,8 @@
|
|||||||
"generic": "Er is iets misgegaan. Probeer het opnieuw.",
|
"generic": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||||
"accessDenied": "Je hebt geen toestemming om deze pagina te bekijken.",
|
"accessDenied": "Je hebt geen toestemming om deze pagina te bekijken.",
|
||||||
"setupRequired": "Systeeminstallatie vereist. Initialiseer eerst het systeem.",
|
"setupRequired": "Systeeminstallatie vereist. Initialiseer eerst het systeem.",
|
||||||
"invalidInvitationToken": "Ongeldig of verlopen uitnodigingstoken."
|
"invalidInvitationToken": "Ongeldig of verlopen uitnodigingstoken.",
|
||||||
|
"featureUnavailableTitle": "Niet beschikbaar op deze instantie",
|
||||||
|
"featureUnavailable": "Deze functie is alleen beschikbaar op een Master-CMS-instantie."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
|||||||
*/
|
*/
|
||||||
const configSchema = z.object({
|
const configSchema = z.object({
|
||||||
apiBaseUrl: z.string().url(),
|
apiBaseUrl: z.string().url(),
|
||||||
|
appTitle: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppConfig = z.infer<typeof configSchema>;
|
export type AppConfig = z.infer<typeof configSchema>;
|
||||||
@@ -20,6 +21,7 @@ export function getAppConfig(): AppConfig {
|
|||||||
|
|
||||||
const raw: AppConfig = {
|
const raw: AppConfig = {
|
||||||
apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
|
apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
|
||||||
|
appTitle: import.meta.env.VITE_APP_TITLE ?? 'SlpModularCms',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { AuthProvider } from '@/contexts/AuthProvider';
|
|||||||
import { useAuth } from '@/contexts/auth-context';
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { router } from '@/router';
|
import { router } from '@/router';
|
||||||
|
import { getAppConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
document.title = getAppConfig().appTitle;
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const availabilityHandlers = [
|
|||||||
status: 'Available',
|
status: 'Available',
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
message: '',
|
message: '',
|
||||||
|
isMasterControlled: false,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { setupHandlers } from './setup/handlers';
|
|||||||
import { invitationHandlers } from './invitation/handlers';
|
import { invitationHandlers } from './invitation/handlers';
|
||||||
import { availabilityHandlers } from './availability/handlers';
|
import { availabilityHandlers } from './availability/handlers';
|
||||||
import { cmsHandlers } from './cms/handlers';
|
import { cmsHandlers } from './cms/handlers';
|
||||||
|
import { systemHandlers } from './system/handlers';
|
||||||
|
|
||||||
/** All default MSW handlers, composed from feature folders (Q3-B). */
|
/** All default MSW handlers, composed from feature folders (Q3-B). */
|
||||||
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers, ...availabilityHandlers, ...cmsHandlers];
|
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers, ...availabilityHandlers, ...cmsHandlers, ...systemHandlers];
|
||||||
|
|
||||||
export { authHandlers } from './auth/handlers';
|
export { authHandlers } from './auth/handlers';
|
||||||
export { userHandlers } from './users/handlers';
|
export { userHandlers } from './users/handlers';
|
||||||
@@ -14,4 +15,5 @@ export { setupHandlers, setupUninitializedHandlers, setupConflictHandlers, setup
|
|||||||
export { invitationHandlers } from './invitation/handlers';
|
export { invitationHandlers } from './invitation/handlers';
|
||||||
export { availabilityHandlers } from './availability/handlers';
|
export { availabilityHandlers } from './availability/handlers';
|
||||||
export { cmsHandlers, resetMockCmsInstances, getMockCmsInstances } from './cms/handlers';
|
export { cmsHandlers, resetMockCmsInstances, getMockCmsInstances } from './cms/handlers';
|
||||||
|
export { systemHandlers } from './system/handlers';
|
||||||
export * from './auth/fixtures';
|
export * from './auth/fixtures';
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
|
||||||
|
export const systemHandlers = [
|
||||||
|
http.get('*/System/capabilities', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
modules: ['Availability', 'Identity', 'Master'],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
@@ -72,6 +72,52 @@ describe('SettingsPage', () => {
|
|||||||
expect(await screen.findByText(/something went wrong/i, {}, { timeout: 5000 })).toBeInTheDocument();
|
expect(await screen.findByText(/something went wrong/i, {}, { timeout: 5000 })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disables the availability controls and shows a banner when master-controlled', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_BASE}/api/v1/Availability/status`, () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
status: 'NotAvailable',
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
message: 'Uitgeschakeld door master',
|
||||||
|
isMasterControlled: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
mockAuthenticated();
|
||||||
|
renderApp('/settings');
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('availability-master-controlled-banner', {}, { timeout: 5000 })).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('mode-option-Available')).toBeDisabled();
|
||||||
|
expect(screen.getByTestId('mode-option-Maintenance')).toBeDisabled();
|
||||||
|
expect(screen.getByTestId('mode-option-NotAvailable')).toBeDisabled();
|
||||||
|
expect(screen.getByTestId('availability-reason-input')).toBeDisabled();
|
||||||
|
expect(screen.getByTestId('availability-save-button')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show the master-controlled banner when not master-controlled', async () => {
|
||||||
|
mockAuthenticated();
|
||||||
|
renderApp('/settings');
|
||||||
|
|
||||||
|
await screen.findByTestId('availability-save-button', {}, { timeout: 5000 });
|
||||||
|
expect(screen.queryByTestId('availability-master-controlled-banner')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('availability-save-button')).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a master-controlled error toast on a 409 conflict from the server', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_BASE}/api/v1/Availability/admin/status`, () =>
|
||||||
|
HttpResponse.json({ title: 'Conflict' }, { status: 409 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
mockAuthenticated();
|
||||||
|
renderApp('/settings');
|
||||||
|
|
||||||
|
await screen.findByTestId('availability-save-button', {}, { timeout: 5000 });
|
||||||
|
await userEvent.click(screen.getByTestId('availability-save-button'));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/master cms controls this status/i, {}, { timeout: 5000 })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders all placeholder sections', async () => {
|
it('renders all placeholder sections', async () => {
|
||||||
mockAuthenticated();
|
mockAuthenticated();
|
||||||
renderApp('/settings');
|
renderApp('/settings');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AvailabilityStatusBadge } from '@/components/shared/AvailabilityStatusB
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { ProblemDetailsError } from '@/lib/api-client';
|
||||||
import type { AvailabilityStatus } from '@/api/types';
|
import type { AvailabilityStatus } from '@/api/types';
|
||||||
|
|
||||||
function PlaceholderCard({ titleKey, comingSoonKey }: { titleKey: string; comingSoonKey: string }) {
|
function PlaceholderCard({ titleKey, comingSoonKey }: { titleKey: string; comingSoonKey: string }) {
|
||||||
@@ -41,13 +42,19 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [availability]);
|
}, [availability]);
|
||||||
|
|
||||||
|
const isMasterControlled = availability?.isMasterControlled ?? false;
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
await updateAvailability.mutateAsync({ newStatus: selectedMode, reason });
|
await updateAvailability.mutateAsync({ newStatus: selectedMode, reason });
|
||||||
toast.success(t('settings.availability.saveSuccess'));
|
toast.success(t('settings.availability.saveSuccess'));
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (err instanceof ProblemDetailsError && err.status === 409) {
|
||||||
|
toast.error(t('settings.availability.masterControlledSaveError'));
|
||||||
|
} else {
|
||||||
toast.error(t('errors.generic'));
|
toast.error(t('errors.generic'));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const modes: AvailabilityStatus[] = ['Available', 'Maintenance', 'NotAvailable'];
|
const modes: AvailabilityStatus[] = ['Available', 'Maintenance', 'NotAvailable'];
|
||||||
@@ -73,6 +80,19 @@ export function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{isMasterControlled && (
|
||||||
|
<div
|
||||||
|
data-testid="availability-master-controlled-banner"
|
||||||
|
className="flex items-start gap-2 rounded-md border border-amber-400/50 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
<Lock className="size-4 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t('settings.availability.masterControlledTitle')}</p>
|
||||||
|
<p>{t('settings.availability.masterControlled')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('settings.availability.mode')}</Label>
|
<Label>{t('settings.availability.mode')}</Label>
|
||||||
<div className="flex gap-2 flex-wrap" data-testid="availability-mode-selector">
|
<div className="flex gap-2 flex-wrap" data-testid="availability-mode-selector">
|
||||||
@@ -81,8 +101,9 @@ export function SettingsPage() {
|
|||||||
key={mode}
|
key={mode}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedMode(mode)}
|
onClick={() => setSelectedMode(mode)}
|
||||||
|
disabled={isMasterControlled}
|
||||||
data-testid={`mode-option-${mode}`}
|
data-testid={`mode-option-${mode}`}
|
||||||
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-colors ${
|
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||||
selectedMode === mode
|
selectedMode === mode
|
||||||
? 'border-primary bg-primary text-primary-foreground'
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
: 'border-input bg-background hover:bg-accent'
|
: 'border-input bg-background hover:bg-accent'
|
||||||
@@ -103,15 +124,16 @@ export function SettingsPage() {
|
|||||||
rows={2}
|
rows={2}
|
||||||
value={reason}
|
value={reason}
|
||||||
onChange={(e) => setReason(e.target.value)}
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
disabled={isMasterControlled}
|
||||||
data-testid="availability-reason-input"
|
data-testid="availability-reason-input"
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
placeholder={t('settings.availability.reason')}
|
placeholder={t('settings.availability.reason')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={updateAvailability.isPending}
|
disabled={updateAvailability.isPending || isMasterControlled}
|
||||||
data-testid="availability-save-button"
|
data-testid="availability-save-button"
|
||||||
>
|
>
|
||||||
{updateAvailability.isPending
|
{updateAvailability.isPending
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { AppLayout } from '@/components/layout/AppLayout';
|
|||||||
import { LoginPage } from '@/pages/LoginPage';
|
import { LoginPage } from '@/pages/LoginPage';
|
||||||
import { NotFoundPage } from '@/pages/NotFoundPage';
|
import { NotFoundPage } from '@/pages/NotFoundPage';
|
||||||
import { RoleGuard } from '@/components/auth/RoleGuard';
|
import { RoleGuard } from '@/components/auth/RoleGuard';
|
||||||
|
import { ModuleGuard } from '@/components/auth/ModuleGuard';
|
||||||
|
|
||||||
export interface RouterContext {
|
export interface RouterContext {
|
||||||
auth: AuthContextValue;
|
auth: AuthContextValue;
|
||||||
@@ -189,7 +190,9 @@ const cmsRoute = createRoute({
|
|||||||
path: '/cms',
|
path: '/cms',
|
||||||
component: () => (
|
component: () => (
|
||||||
<RoleGuard allowedRoles={['Owner']}>
|
<RoleGuard allowedRoles={['Owner']}>
|
||||||
|
<ModuleGuard requiredModule="Master">
|
||||||
{lazyPage(() => import('@/pages/CmsPage'), 'CmsPage')()}
|
{lazyPage(() => import('@/pages/CmsPage'), 'CmsPage')()}
|
||||||
|
</ModuleGuard>
|
||||||
</RoleGuard>
|
</RoleGuard>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+2
@@ -5,6 +5,8 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_API_BASE_URL: string;
|
readonly VITE_API_BASE_URL: string;
|
||||||
/** Set to 'true' to run the MSW mock backend in the browser during dev. */
|
/** Set to 'true' to run the MSW mock backend in the browser during dev. */
|
||||||
readonly VITE_ENABLE_MSW?: string;
|
readonly VITE_ENABLE_MSW?: string;
|
||||||
|
/** Browser tab title; lets local master/slave dev instances be told apart. Defaults to "SlpModularCms". */
|
||||||
|
readonly VITE_APP_TITLE?: string;
|
||||||
// Add future typed env flags here.
|
// Add future typed env flags here.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ builder.Services.AddCmsRateLimiting(builder.Configuration);
|
|||||||
|
|
||||||
// 3. Add Module Services
|
// 3. Add Module Services
|
||||||
orchestrator.RegisterModuleServices(builder.Services);
|
orchestrator.RegisterModuleServices(builder.Services);
|
||||||
|
builder.Services.AddSingleton(orchestrator);
|
||||||
|
|
||||||
// 4. Global Controller Configuration with Conventions
|
// 4. Global Controller Configuration with Conventions
|
||||||
builder.Services.AddControllers(options =>
|
builder.Services.AddControllers(options =>
|
||||||
|
|||||||
@@ -20,6 +20,11 @@
|
|||||||
"CircuitBreakerSeconds": 30,
|
"CircuitBreakerSeconds": 30,
|
||||||
"StatusCacheSeconds": 1
|
"StatusCacheSeconds": 1
|
||||||
},
|
},
|
||||||
|
"MasterPolling": {
|
||||||
|
"PollIntervalSeconds": 15,
|
||||||
|
"FailOpenAfterMinutes": 2,
|
||||||
|
"HttpTimeoutSeconds": 5
|
||||||
|
},
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"AllowedOrigins": [
|
"AllowedOrigins": [
|
||||||
"http://localhost:5174",
|
"http://localhost:5174",
|
||||||
|
|||||||
@@ -20,6 +20,11 @@
|
|||||||
"CircuitBreakerSeconds": 30,
|
"CircuitBreakerSeconds": 30,
|
||||||
"StatusCacheSeconds": 1
|
"StatusCacheSeconds": 1
|
||||||
},
|
},
|
||||||
|
"MasterPolling": {
|
||||||
|
"PollIntervalSeconds": 30,
|
||||||
|
"FailOpenAfterMinutes": 5,
|
||||||
|
"HttpTimeoutSeconds": 5
|
||||||
|
},
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"AllowedOrigins": []
|
"AllowedOrigins": []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ builder.Services.AddCmsRateLimiting(builder.Configuration);
|
|||||||
|
|
||||||
// 3. Add Module Services
|
// 3. Add Module Services
|
||||||
orchestrator.RegisterModuleServices(builder.Services);
|
orchestrator.RegisterModuleServices(builder.Services);
|
||||||
|
builder.Services.AddSingleton(orchestrator);
|
||||||
|
|
||||||
// 4. Global Controller Configuration with Conventions
|
// 4. Global Controller Configuration with Conventions
|
||||||
builder.Services.AddControllers(options =>
|
builder.Services.AddControllers(options =>
|
||||||
|
|||||||
@@ -20,6 +20,11 @@
|
|||||||
"CircuitBreakerSeconds": 30,
|
"CircuitBreakerSeconds": 30,
|
||||||
"StatusCacheSeconds": 1
|
"StatusCacheSeconds": 1
|
||||||
},
|
},
|
||||||
|
"MasterPolling": {
|
||||||
|
"PollIntervalSeconds": 15,
|
||||||
|
"FailOpenAfterMinutes": 2,
|
||||||
|
"HttpTimeoutSeconds": 5
|
||||||
|
},
|
||||||
"MasterModule": {
|
"MasterModule": {
|
||||||
"IntegrityCheckIntervalMinutes": 60,
|
"IntegrityCheckIntervalMinutes": 60,
|
||||||
"HttpTimeoutSeconds": 10,
|
"HttpTimeoutSeconds": 10,
|
||||||
|
|||||||
@@ -25,6 +25,11 @@
|
|||||||
"HttpTimeoutSeconds": 10,
|
"HttpTimeoutSeconds": 10,
|
||||||
"MasterUrl": "<public-url-of-this-master-instance>"
|
"MasterUrl": "<public-url-of-this-master-instance>"
|
||||||
},
|
},
|
||||||
|
"MasterPolling": {
|
||||||
|
"PollIntervalSeconds": 30,
|
||||||
|
"FailOpenAfterMinutes": 5,
|
||||||
|
"HttpTimeoutSeconds": 5
|
||||||
|
},
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"AllowedOrigins": []
|
"AllowedOrigins": []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ namespace SlpModularCms.Core.Availability;
|
|||||||
/// Status en optionele admin-melding van de systeembeschikbaarheid.
|
/// Status en optionele admin-melding van de systeembeschikbaarheid.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ExcludeFromCodeCoverage]
|
[ExcludeFromCodeCoverage]
|
||||||
public record AvailabilityStatusDetails(AvailabilityStatus Status, string? Message);
|
public record AvailabilityStatusDetails(AvailabilityStatus Status, string? Message, bool IsMasterControlled = false);
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace SlpModularCms.Core.Availability;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when an attempt is made to change the local availability status while a
|
||||||
|
/// master CMS has taken control of this instance's gate (e.g. set it to NotAvailable).
|
||||||
|
/// The local status change would have no visible effect since the master gate
|
||||||
|
/// overrides it, so it's rejected outright instead of silently doing nothing.
|
||||||
|
/// </summary>
|
||||||
|
public class MasterControlledAvailabilityException(string? masterDisableMessage)
|
||||||
|
: InvalidOperationException("De beschikbaarheid van dit systeem wordt beheerd door de Master-CMS en kan hier niet worden gewijzigd.")
|
||||||
|
{
|
||||||
|
public string? MasterDisableMessage { get; } = masterDisableMessage;
|
||||||
|
}
|
||||||
@@ -19,6 +19,12 @@ public class ModuleOrchestrator
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Names of the modules discovered on this instance (e.g. so the frontend can tell
|
||||||
|
/// a master-only feature apart from a slave instance without that module loaded).
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> ModuleNames => _modules.Select(m => m.Name).ToArray();
|
||||||
|
|
||||||
public void DiscoverModules()
|
public void DiscoverModules()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Start module discovery...");
|
_logger.LogInformation("Start module discovery...");
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Core.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exposes which optional modules are loaded on this instance, so clients (e.g. the
|
||||||
|
/// frontend) can tell a master-only feature apart from an instance without that module
|
||||||
|
/// (see the local master/slave dev setup) without guessing from a 404.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("[controller]")]
|
||||||
|
public class SystemController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ModuleOrchestrator _orchestrator;
|
||||||
|
|
||||||
|
public SystemController(ModuleOrchestrator orchestrator)
|
||||||
|
{
|
||||||
|
_orchestrator = orchestrator;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("capabilities")]
|
||||||
|
public IActionResult GetCapabilities()
|
||||||
|
{
|
||||||
|
return Ok(new { Modules = _orchestrator.ModuleNames });
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using SlpModularCms.Modules.Availability.BackgroundServices;
|
||||||
|
using SlpModularCms.Modules.Availability.Config;
|
||||||
|
using SlpModularCms.Modules.Availability.Services;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Availability.Tests.BackgroundServices;
|
||||||
|
|
||||||
|
public class MasterStatusPollingBackgroundServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteTickAsync_DoesNothing_WhenNoMasterRegistered()
|
||||||
|
{
|
||||||
|
var masterAvailabilityService = Substitute.For<IMasterAvailabilityService>();
|
||||||
|
masterAvailabilityService.GetPollTargetAsync().Returns((MasterPollTarget?)null);
|
||||||
|
var pollClient = Substitute.For<IMasterStatusPollClient>();
|
||||||
|
var sut = CreateSut(masterAvailabilityService, pollClient);
|
||||||
|
|
||||||
|
await sut.ExecuteTickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await pollClient.DidNotReceive().GetStatusAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||||
|
await masterAvailabilityService.DidNotReceive().ApplyPolledStatusAsync(Arg.Any<bool>(), Arg.Any<string?>());
|
||||||
|
await masterAvailabilityService.DidNotReceive().RecordPollFailureAsync(Arg.Any<TimeSpan>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteTickAsync_AppliesPolledStatus_WhenPollSucceeds()
|
||||||
|
{
|
||||||
|
var masterAvailabilityService = Substitute.For<IMasterAvailabilityService>();
|
||||||
|
masterAvailabilityService.GetPollTargetAsync().Returns(new MasterPollTarget("https://master.test", "key"));
|
||||||
|
var pollClient = Substitute.For<IMasterStatusPollClient>();
|
||||||
|
pollClient.GetStatusAsync("https://master.test", "key").Returns(new PolledMasterStatus(false, "Onderhoud"));
|
||||||
|
var sut = CreateSut(masterAvailabilityService, pollClient);
|
||||||
|
|
||||||
|
await sut.ExecuteTickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await masterAvailabilityService.Received(1).ApplyPolledStatusAsync(false, "Onderhoud");
|
||||||
|
await masterAvailabilityService.DidNotReceive().RecordPollFailureAsync(Arg.Any<TimeSpan>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteTickAsync_RecordsPollFailure_WhenPollFails()
|
||||||
|
{
|
||||||
|
var masterAvailabilityService = Substitute.For<IMasterAvailabilityService>();
|
||||||
|
masterAvailabilityService.GetPollTargetAsync().Returns(new MasterPollTarget("https://master.test", "key"));
|
||||||
|
var pollClient = Substitute.For<IMasterStatusPollClient>();
|
||||||
|
pollClient.GetStatusAsync("https://master.test", "key").Returns((PolledMasterStatus?)null);
|
||||||
|
var sut = CreateSut(masterAvailabilityService, pollClient, failOpenAfterMinutes: 5);
|
||||||
|
|
||||||
|
await sut.ExecuteTickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await masterAvailabilityService.DidNotReceive().ApplyPolledStatusAsync(Arg.Any<bool>(), Arg.Any<string?>());
|
||||||
|
await masterAvailabilityService.Received(1).RecordPollFailureAsync(TimeSpan.FromMinutes(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteTickAsync_DoesNotThrow_WhenPollTargetLookupThrows()
|
||||||
|
{
|
||||||
|
var masterAvailabilityService = Substitute.For<IMasterAvailabilityService>();
|
||||||
|
masterAvailabilityService.GetPollTargetAsync().Throws(new InvalidOperationException("boom"));
|
||||||
|
var pollClient = Substitute.For<IMasterStatusPollClient>();
|
||||||
|
var sut = CreateSut(masterAvailabilityService, pollClient);
|
||||||
|
|
||||||
|
var act = async () => await sut.ExecuteTickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().NotThrowAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MasterStatusPollingBackgroundService CreateSut(
|
||||||
|
IMasterAvailabilityService masterAvailabilityService,
|
||||||
|
IMasterStatusPollClient pollClient,
|
||||||
|
int failOpenAfterMinutes = 5)
|
||||||
|
{
|
||||||
|
var provider = Substitute.For<IServiceProvider>();
|
||||||
|
provider.GetService(typeof(IMasterAvailabilityService)).Returns(masterAvailabilityService);
|
||||||
|
provider.GetService(typeof(IMasterStatusPollClient)).Returns(pollClient);
|
||||||
|
|
||||||
|
var scope = Substitute.For<IServiceScope>();
|
||||||
|
scope.ServiceProvider.Returns(provider);
|
||||||
|
|
||||||
|
var factory = Substitute.For<IServiceScopeFactory>();
|
||||||
|
factory.CreateAsyncScope().Returns(new AsyncServiceScope(scope));
|
||||||
|
|
||||||
|
var options = Options.Create(new MasterPollingOptions { FailOpenAfterMinutes = failOpenAfterMinutes });
|
||||||
|
|
||||||
|
return new MasterStatusPollingBackgroundService(
|
||||||
|
factory,
|
||||||
|
options,
|
||||||
|
Substitute.For<ILogger<MasterStatusPollingBackgroundService>>());
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
-1
@@ -81,7 +81,10 @@ public class AvailabilityControllerTests
|
|||||||
var availabilityOptions = Substitute.For<IOptions<AvailabilityOptions>>();
|
var availabilityOptions = Substitute.For<IOptions<AvailabilityOptions>>();
|
||||||
availabilityOptions.Value.Returns(new AvailabilityOptions { CircuitBreakerSeconds = 30 });
|
availabilityOptions.Value.Returns(new AvailabilityOptions { CircuitBreakerSeconds = 30 });
|
||||||
|
|
||||||
var persistentService = new PersistentAvailabilityService(context, availabilityOptions);
|
var masterAvailabilityService = Substitute.For<IMasterAvailabilityService>();
|
||||||
|
masterAvailabilityService.GetMasterStatus().Returns(new MasterGateStatus(true, null));
|
||||||
|
|
||||||
|
var persistentService = new PersistentAvailabilityService(context, availabilityOptions, masterAvailabilityService);
|
||||||
|
|
||||||
var controller = new AvailabilityController(persistentService);
|
var controller = new AvailabilityController(persistentService);
|
||||||
var httpContext = new DefaultHttpContext();
|
var httpContext = new DefaultHttpContext();
|
||||||
@@ -94,4 +97,50 @@ public class AvailabilityControllerTests
|
|||||||
|
|
||||||
result.Should().BeOfType<OkResult>();
|
result.Should().BeOfType<OkResult>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateStatus_ReturnsConflict_WhenMasterControlsTheGate()
|
||||||
|
{
|
||||||
|
var dbOptions = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
using var context = new ApplicationDbContext(dbOptions);
|
||||||
|
|
||||||
|
var availabilityOptions = Substitute.For<IOptions<AvailabilityOptions>>();
|
||||||
|
availabilityOptions.Value.Returns(new AvailabilityOptions { CircuitBreakerSeconds = 30 });
|
||||||
|
|
||||||
|
var masterAvailabilityService = Substitute.For<IMasterAvailabilityService>();
|
||||||
|
masterAvailabilityService.GetMasterStatus().Returns(new MasterGateStatus(false, "Uitgeschakeld door master"));
|
||||||
|
|
||||||
|
var persistentService = new PersistentAvailabilityService(context, availabilityOptions, masterAvailabilityService);
|
||||||
|
|
||||||
|
var controller = new AvailabilityController(persistentService);
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
httpContext.User = new ClaimsPrincipal(
|
||||||
|
new ClaimsIdentity([new Claim(ClaimTypes.Name, "admin@test.com")], "TestAuth"));
|
||||||
|
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
|
||||||
|
|
||||||
|
var result = await controller.UpdateStatus(
|
||||||
|
new UpdateStatusRequest(AvailabilityStatus.Available, null));
|
||||||
|
|
||||||
|
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
|
||||||
|
conflict.StatusCode.Should().Be(409);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatus_IncludesIsMasterControlled_WhenTrue()
|
||||||
|
{
|
||||||
|
_availabilityService.GetStatusDetailsAsync()
|
||||||
|
.Returns(new AvailabilityStatusDetails(AvailabilityStatus.NotAvailable, "Master zegt nee", IsMasterControlled: true));
|
||||||
|
|
||||||
|
var result = await CreateController().GetStatus();
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
ok.Value.Should().BeEquivalentTo(new
|
||||||
|
{
|
||||||
|
Status = "NotAvailable",
|
||||||
|
Message = "Master zegt nee",
|
||||||
|
IsMasterControlled = true,
|
||||||
|
}, options => options.ExcludingMissingMembers());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ public class PersistentAvailabilityServiceTests
|
|||||||
var availabilityOptions = Substitute.For<IOptions<AvailabilityOptions>>();
|
var availabilityOptions = Substitute.For<IOptions<AvailabilityOptions>>();
|
||||||
availabilityOptions.Value.Returns(new AvailabilityOptions { CircuitBreakerSeconds = 30 });
|
availabilityOptions.Value.Returns(new AvailabilityOptions { CircuitBreakerSeconds = 30 });
|
||||||
|
|
||||||
_service = new PersistentAvailabilityService(_context, availabilityOptions);
|
var masterAvailabilityService = Substitute.For<IMasterAvailabilityService>();
|
||||||
|
masterAvailabilityService.GetMasterStatus().Returns(new MasterGateStatus(true, null));
|
||||||
|
|
||||||
|
_service = new PersistentAvailabilityService(_context, availabilityOptions, masterAvailabilityService);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -99,6 +102,24 @@ public class PersistentAvailabilityServiceTests
|
|||||||
details.Status.Should().Be(AvailabilityStatus.Available); // Fallback
|
details.Status.Should().Be(AvailabilityStatus.Available); // Fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatusDetailsAsync_ShouldReturnNotAvailable_WhenMasterGateDisabled()
|
||||||
|
{
|
||||||
|
var availabilityOptions = Substitute.For<IOptions<AvailabilityOptions>>();
|
||||||
|
availabilityOptions.Value.Returns(new AvailabilityOptions { CircuitBreakerSeconds = 30 });
|
||||||
|
|
||||||
|
var masterAvailabilityService = Substitute.For<IMasterAvailabilityService>();
|
||||||
|
masterAvailabilityService.GetMasterStatus().Returns(new MasterGateStatus(false, "Master heeft deze slave uitgeschakeld"));
|
||||||
|
|
||||||
|
var service = new PersistentAvailabilityService(_context, availabilityOptions, masterAvailabilityService);
|
||||||
|
|
||||||
|
var details = await service.GetStatusDetailsAsync();
|
||||||
|
|
||||||
|
details.Status.Should().Be(AvailabilityStatus.NotAvailable);
|
||||||
|
details.Message.Should().Be("Master heeft deze slave uitgeschakeld");
|
||||||
|
details.IsMasterControlled.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateStatusAsync_UpdatesExistingRecord_WhenCalledTwice()
|
public async Task UpdateStatusAsync_UpdatesExistingRecord_WhenCalledTwice()
|
||||||
{
|
{
|
||||||
@@ -112,4 +133,21 @@ public class PersistentAvailabilityServiceTests
|
|||||||
dbState.Status.Should().Be(AvailabilityStatus.Available);
|
dbState.Status.Should().Be(AvailabilityStatus.Available);
|
||||||
dbState.UpdatedBy.Should().Be("Admin2");
|
dbState.UpdatedBy.Should().Be("Admin2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateStatusAsync_Throws_WhenMasterControlsTheGate()
|
||||||
|
{
|
||||||
|
var availabilityOptions = Substitute.For<IOptions<AvailabilityOptions>>();
|
||||||
|
availabilityOptions.Value.Returns(new AvailabilityOptions { CircuitBreakerSeconds = 30 });
|
||||||
|
|
||||||
|
var masterAvailabilityService = Substitute.For<IMasterAvailabilityService>();
|
||||||
|
masterAvailabilityService.GetMasterStatus().Returns(new MasterGateStatus(false, "Uitgeschakeld door master"));
|
||||||
|
|
||||||
|
var service = new PersistentAvailabilityService(_context, availabilityOptions, masterAvailabilityService);
|
||||||
|
|
||||||
|
var act = async () => await service.UpdateStatusAsync(AvailabilityStatus.Available, null, "Admin");
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<MasterControlledAvailabilityException>();
|
||||||
|
(await _context.AvailabilityStates.FirstOrDefaultAsync()).Should().BeNull();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+111
@@ -237,4 +237,115 @@ public class MasterAvailabilityServiceTests : IDisposable
|
|||||||
status.IsAvailable.Should().BeTrue();
|
status.IsAvailable.Should().BeTrue();
|
||||||
status.DisableMessage.Should().BeNull();
|
status.DisableMessage.Should().BeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- GetPollTargetAsync ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPollTargetAsync_ReturnsMasterUrlAndPlainKey_WhenRegistered()
|
||||||
|
{
|
||||||
|
var existing = new MasterRegistration
|
||||||
|
{
|
||||||
|
Id = MasterRegistration.SingletonId,
|
||||||
|
MasterUrl = "https://master.example.com",
|
||||||
|
ApiKey = "enc:key",
|
||||||
|
RegisteredAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
_repo.GetAsync().Returns(existing);
|
||||||
|
_protector.Unprotect("enc:key").Returns("key");
|
||||||
|
|
||||||
|
var target = await _svc.GetPollTargetAsync();
|
||||||
|
|
||||||
|
target.Should().NotBeNull();
|
||||||
|
target!.MasterUrl.Should().Be("https://master.example.com");
|
||||||
|
target.PlainApiKey.Should().Be("key");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPollTargetAsync_ReturnsNull_WhenNoRegistration()
|
||||||
|
{
|
||||||
|
_repo.GetAsync().Returns((MasterRegistration?)null);
|
||||||
|
|
||||||
|
var target = await _svc.GetPollTargetAsync();
|
||||||
|
|
||||||
|
target.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ApplyPolledStatusAsync ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyPolledStatusAsync_UpdatesGateAndPersistsPollTimestamp()
|
||||||
|
{
|
||||||
|
var existing = new MasterRegistration
|
||||||
|
{
|
||||||
|
Id = MasterRegistration.SingletonId,
|
||||||
|
MasterUrl = "https://master.example.com",
|
||||||
|
ApiKey = "enc:key",
|
||||||
|
RegisteredAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
_repo.GetAsync().Returns(existing);
|
||||||
|
|
||||||
|
await _svc.ApplyPolledStatusAsync(false, "Onderhoud");
|
||||||
|
|
||||||
|
var status = _svc.GetMasterStatus();
|
||||||
|
status.IsAvailable.Should().BeFalse();
|
||||||
|
status.DisableMessage.Should().Be("Onderhoud");
|
||||||
|
existing.LastPolledAt.Should().NotBeNull();
|
||||||
|
_repo.Received(1).Update(existing);
|
||||||
|
await _repo.Received(1).SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RecordPollFailureAsync ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordPollFailureAsync_DoesNotFailOpen_WhenWithinGracePeriod()
|
||||||
|
{
|
||||||
|
var existing = new MasterRegistration
|
||||||
|
{
|
||||||
|
Id = MasterRegistration.SingletonId,
|
||||||
|
MasterUrl = "https://master.example.com",
|
||||||
|
ApiKey = "enc:key",
|
||||||
|
RegisteredAt = DateTimeOffset.UtcNow,
|
||||||
|
LastPolledAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
_repo.GetAsync().Returns(existing);
|
||||||
|
_protector.Unprotect("enc:key").Returns("key");
|
||||||
|
await _svc.PushStatusAsync("key", false, "Down");
|
||||||
|
|
||||||
|
await _svc.RecordPollFailureAsync(TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
|
var status = _svc.GetMasterStatus();
|
||||||
|
status.IsAvailable.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordPollFailureAsync_FailsOpen_WhenMasterUnreachableTooLong()
|
||||||
|
{
|
||||||
|
var existing = new MasterRegistration
|
||||||
|
{
|
||||||
|
Id = MasterRegistration.SingletonId,
|
||||||
|
MasterUrl = "https://master.example.com",
|
||||||
|
ApiKey = "enc:key",
|
||||||
|
RegisteredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||||
|
LastPolledAt = DateTimeOffset.UtcNow.AddMinutes(-10)
|
||||||
|
};
|
||||||
|
_repo.GetAsync().Returns(existing);
|
||||||
|
_protector.Unprotect("enc:key").Returns("key");
|
||||||
|
await _svc.PushStatusAsync("key", false, "Down");
|
||||||
|
|
||||||
|
await _svc.RecordPollFailureAsync(TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
|
var status = _svc.GetMasterStatus();
|
||||||
|
status.IsAvailable.Should().BeTrue();
|
||||||
|
status.DisableMessage.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordPollFailureAsync_DoesNothing_WhenNoRegistration()
|
||||||
|
{
|
||||||
|
_repo.GetAsync().Returns((MasterRegistration?)null);
|
||||||
|
|
||||||
|
var act = async () => await _svc.RecordPollFailureAsync(TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
|
await act.Should().NotThrowAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using SlpModularCms.Modules.Availability.Services;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Availability.Tests.Services;
|
||||||
|
|
||||||
|
public class MasterStatusPollClientTests
|
||||||
|
{
|
||||||
|
private const string MasterUrl = "https://master.test";
|
||||||
|
private const string ApiKey = "plain-key";
|
||||||
|
|
||||||
|
private static MasterStatusPollClient CreateSut(HttpMessageHandler handler)
|
||||||
|
=> new(new HttpClient(handler));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatusAsync_ReturnsStatus_WhenResponseIsSuccess()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(new { IsAvailable = false, DisableMessage = "Onderhoud" });
|
||||||
|
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, json);
|
||||||
|
var sut = CreateSut(handler);
|
||||||
|
|
||||||
|
var result = await sut.GetStatusAsync(MasterUrl, ApiKey);
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.IsAvailable.Should().BeFalse();
|
||||||
|
result.DisableMessage.Should().Be("Onderhoud");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatusAsync_ReturnsNull_WhenResponseIsFailure()
|
||||||
|
{
|
||||||
|
var handler = new FakeHttpMessageHandler(HttpStatusCode.Unauthorized);
|
||||||
|
var sut = CreateSut(handler);
|
||||||
|
|
||||||
|
var result = await sut.GetStatusAsync(MasterUrl, ApiKey);
|
||||||
|
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatusAsync_ReturnsNull_WhenExceptionIsThrown()
|
||||||
|
{
|
||||||
|
var handler = new ThrowingHttpMessageHandler();
|
||||||
|
var sut = CreateSut(handler);
|
||||||
|
|
||||||
|
var result = await sut.GetStatusAsync(MasterUrl, ApiKey);
|
||||||
|
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatusAsync_SetsApiKeyHeader()
|
||||||
|
{
|
||||||
|
string? capturedKey = null;
|
||||||
|
var handler = new CapturingHttpMessageHandler(req =>
|
||||||
|
{
|
||||||
|
req.Headers.TryGetValues("X-Master-Api-Key", out var values);
|
||||||
|
capturedKey = values?.FirstOrDefault();
|
||||||
|
});
|
||||||
|
var sut = CreateSut(handler);
|
||||||
|
|
||||||
|
await sut.GetStatusAsync(MasterUrl, ApiKey);
|
||||||
|
|
||||||
|
capturedKey.Should().Be(ApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeHttpMessageHandler(HttpStatusCode statusCode, string? content = null) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response = new HttpResponseMessage(statusCode);
|
||||||
|
if (content is not null)
|
||||||
|
response.Content = new StringContent(content, Encoding.UTF8, "application/json");
|
||||||
|
return Task.FromResult(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ThrowingHttpMessageHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
=> throw new HttpRequestException("Connection refused");
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CapturingHttpMessageHandler(Action<HttpRequestMessage> capture) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
capture(request);
|
||||||
|
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
response.Content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(new { IsAvailable = true, DisableMessage = (string?)null }),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json");
|
||||||
|
return Task.FromResult(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using SlpModularCms.Core.Availability;
|
using SlpModularCms.Core.Availability;
|
||||||
using SlpModularCms.Core.Modules;
|
using SlpModularCms.Core.Modules;
|
||||||
|
using SlpModularCms.Modules.Availability.BackgroundServices;
|
||||||
|
using SlpModularCms.Modules.Availability.Config;
|
||||||
using SlpModularCms.Modules.Availability.Data;
|
using SlpModularCms.Modules.Availability.Data;
|
||||||
using SlpModularCms.Modules.Availability.Middleware;
|
using SlpModularCms.Modules.Availability.Middleware;
|
||||||
using SlpModularCms.Modules.Availability.Repositories;
|
using SlpModularCms.Modules.Availability.Repositories;
|
||||||
@@ -34,6 +36,14 @@ public class AvailabilityModule : IModule
|
|||||||
services.AddScoped<IMasterRegistrationRepository, MasterRegistrationRepository>();
|
services.AddScoped<IMasterRegistrationRepository, MasterRegistrationRepository>();
|
||||||
services.AddScoped<MasterAvailabilityServiceDependencies>();
|
services.AddScoped<MasterAvailabilityServiceDependencies>();
|
||||||
services.AddScoped<IMasterAvailabilityService, MasterAvailabilityService>();
|
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)
|
public void UseModule(IApplicationBuilder app)
|
||||||
|
|||||||
+66
@@ -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.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using SlpModularCms.Core.Availability;
|
using SlpModularCms.Core.Availability;
|
||||||
using SlpModularCms.Modules.Availability.Services;
|
using SlpModularCms.Modules.Availability.Services;
|
||||||
@@ -26,6 +27,7 @@ public class AvailabilityController : ControllerBase
|
|||||||
Status = details.Status.ToString(),
|
Status = details.Status.ToString(),
|
||||||
CheckedAt = DateTimeOffset.UtcNow,
|
CheckedAt = DateTimeOffset.UtcNow,
|
||||||
Message = details.Message ?? string.Empty,
|
Message = details.Message ?? string.Empty,
|
||||||
|
details.IsMasterControlled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +35,12 @@ public class AvailabilityController : ControllerBase
|
|||||||
[Authorize(Policy = "OwnerOnly")]
|
[Authorize(Policy = "OwnerOnly")]
|
||||||
public async Task<IActionResult> UpdateStatus([FromBody] UpdateStatusRequest request)
|
public async Task<IActionResult> UpdateStatus([FromBody] UpdateStatusRequest request)
|
||||||
{
|
{
|
||||||
if (_availabilityService is PersistentAvailabilityService persistentService)
|
if (_availabilityService is not PersistentAvailabilityService persistentService)
|
||||||
|
{
|
||||||
|
return BadRequest("Status update niet ondersteund door huidige service.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
await persistentService.UpdateStatusAsync(
|
await persistentService.UpdateStatusAsync(
|
||||||
request.NewStatus,
|
request.NewStatus,
|
||||||
@@ -42,8 +49,14 @@ public class AvailabilityController : ControllerBase
|
|||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
catch (MasterControlledAvailabilityException ex)
|
||||||
return BadRequest("Status update niet ondersteund door huidige service.");
|
{
|
||||||
|
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 string ApiKey { get; set; } = string.Empty;
|
||||||
public DateTimeOffset RegisteredAt { get; set; }
|
public DateTimeOffset RegisteredAt { get; set; }
|
||||||
public DateTimeOffset? LastContactedAt { 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
|
// Auth and Setup must stay open so admins can log in and the frontend
|
||||||
// can determine whether the system is initialized.
|
// can determine whether the system is initialized.
|
||||||
// Master endpoints bypass so master can always push status or re-register.
|
// 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 =
|
private static readonly string[] _bypassPrefixes =
|
||||||
[
|
[
|
||||||
"/api/v1/Availability/status",
|
"/api/v1/Availability/status",
|
||||||
"/api/v1/Auth/",
|
"/api/v1/Auth/",
|
||||||
"/api/v1/Setup/status",
|
"/api/v1/Setup/status",
|
||||||
"/api/v1/master/",
|
"/api/v1/master/",
|
||||||
|
"/api/v1/SlaveStatus",
|
||||||
];
|
];
|
||||||
|
|
||||||
public async Task InvokeAsync(
|
public async Task InvokeAsync(
|
||||||
|
|||||||
+60
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -36,6 +36,9 @@ namespace SlpModularCms.Modules.Availability.Migrations
|
|||||||
b.Property<DateTimeOffset?>("LastContactedAt")
|
b.Property<DateTimeOffset?>("LastContactedAt")
|
||||||
.HasColumnType("datetimeoffset");
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastPolledAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
b.Property<string>("MasterUrl")
|
b.Property<string>("MasterUrl")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
|
|||||||
@@ -6,4 +6,20 @@ public interface IMasterAvailabilityService
|
|||||||
Task<bool> PushStatusAsync(string apiKey, bool isAvailable, string? disableMessage);
|
Task<bool> PushStatusAsync(string apiKey, bool isAvailable, string? disableMessage);
|
||||||
Task<string?> GetRegisteredUrlAsync(string apiKey);
|
Task<string?> GetRegisteredUrlAsync(string apiKey);
|
||||||
MasterGateStatus GetMasterStatus();
|
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;
|
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)
|
public async Task<string?> GetRegisteredUrlAsync(string apiKey)
|
||||||
{
|
{
|
||||||
var existing = await _deps.Repository.GetAsync();
|
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 ApplicationDbContext _context;
|
||||||
private readonly AvailabilityOptions _options;
|
private readonly AvailabilityOptions _options;
|
||||||
|
private readonly IMasterAvailabilityService _masterAvailabilityService;
|
||||||
|
|
||||||
// Circuit Breaker state
|
// Circuit Breaker state
|
||||||
private static DateTimeOffset _lastErrorTime = DateTimeOffset.MinValue;
|
private static DateTimeOffset _lastErrorTime = DateTimeOffset.MinValue;
|
||||||
private static AvailabilityStatus _cachedStatus = AvailabilityStatus.Available;
|
private static AvailabilityStatus _cachedStatus = AvailabilityStatus.Available;
|
||||||
|
|
||||||
public PersistentAvailabilityService(ApplicationDbContext context, IOptions<AvailabilityOptions> options)
|
public PersistentAvailabilityService(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
IOptions<AvailabilityOptions> options,
|
||||||
|
IMasterAvailabilityService masterAvailabilityService)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
|
_masterAvailabilityService = masterAvailabilityService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AvailabilityStatus> IsAvailableAsync()
|
public async Task<AvailabilityStatus> IsAvailableAsync()
|
||||||
@@ -56,6 +61,12 @@ public class PersistentAvailabilityService : IAvailabilityService
|
|||||||
|
|
||||||
public async Task<AvailabilityStatusDetails> GetStatusDetailsAsync()
|
public async Task<AvailabilityStatusDetails> GetStatusDetailsAsync()
|
||||||
{
|
{
|
||||||
|
var masterStatus = _masterAvailabilityService.GetMasterStatus();
|
||||||
|
if (!masterStatus.IsAvailable)
|
||||||
|
{
|
||||||
|
return new AvailabilityStatusDetails(AvailabilityStatus.NotAvailable, masterStatus.DisableMessage, IsMasterControlled: true);
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var state = await _context.AvailabilityStates.FirstOrDefaultAsync();
|
var state = await _context.AvailabilityStates.FirstOrDefaultAsync();
|
||||||
@@ -77,6 +88,12 @@ public class PersistentAvailabilityService : IAvailabilityService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task UpdateStatusAsync(AvailabilityStatus newStatus, string? reason, string? updatedBy)
|
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();
|
var state = await _context.AvailabilityStates.FirstOrDefaultAsync();
|
||||||
|
|
||||||
if (state == null)
|
if (state == null)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using NSubstitute;
|
||||||
|
using SlpModularCms.Modules.Master.Controllers;
|
||||||
|
using SlpModularCms.Modules.Master.Services;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Master.Tests.Controllers;
|
||||||
|
|
||||||
|
public class SlaveStatusControllerTests
|
||||||
|
{
|
||||||
|
private readonly ICmsInstanceService _service = Substitute.For<ICmsInstanceService>();
|
||||||
|
private readonly SlaveStatusController _controller;
|
||||||
|
|
||||||
|
public SlaveStatusControllerTests()
|
||||||
|
{
|
||||||
|
_controller = new SlaveStatusController(_service);
|
||||||
|
_controller.ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetApiKeyHeader(string? value)
|
||||||
|
{
|
||||||
|
if (value != null)
|
||||||
|
_controller.HttpContext.Request.Headers["X-Master-Api-Key"] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_Returns401_WhenHeaderMissing()
|
||||||
|
{
|
||||||
|
var result = await _controller.Get();
|
||||||
|
|
||||||
|
result.Should().BeOfType<UnauthorizedResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_Returns401_WhenKeyDoesNotMatchAnyInstance()
|
||||||
|
{
|
||||||
|
SetApiKeyHeader("wrong-key");
|
||||||
|
_service.GetStatusForApiKeyAsync("wrong-key").Returns((SlaveStatusPollResponse?)null);
|
||||||
|
|
||||||
|
var result = await _controller.Get();
|
||||||
|
|
||||||
|
result.Should().BeOfType<UnauthorizedResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_Returns200_WithStatus_WhenKeyMatches()
|
||||||
|
{
|
||||||
|
SetApiKeyHeader("key123");
|
||||||
|
_service.GetStatusForApiKeyAsync("key123").Returns(new SlaveStatusPollResponse(false, "Onderhoud"));
|
||||||
|
|
||||||
|
var result = await _controller.Get();
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
ok.StatusCode.Should().Be(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,16 +140,32 @@ public class CmsInstanceServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateStatusAsync_DoesNotPushToSlave_WhenStatusIsInactive()
|
public async Task UpdateStatusAsync_ReleasesMasterGate_WhenStatusIsInactive()
|
||||||
{
|
{
|
||||||
var instance = ActiveInstance();
|
var instance = ActiveInstance();
|
||||||
_repo.GetByIdAsync(instance.Id).Returns(instance);
|
_repo.GetByIdAsync(instance.Id).Returns(instance);
|
||||||
|
_protector.Unprotect("encrypted-key").Returns("plain");
|
||||||
|
_slaveClient.PushStatusAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
||||||
|
|
||||||
var result = await CreateSut().UpdateStatusAsync(instance.Id, new UpdateStatusRequest(CmsInstanceStatus.Inactive, null));
|
var result = await CreateSut().UpdateStatusAsync(instance.Id, new UpdateStatusRequest(CmsInstanceStatus.Inactive, null));
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
result.Success.Should().BeTrue();
|
||||||
result.SlaveContactSuccess.Should().BeTrue();
|
result.SlaveContactSuccess.Should().BeTrue();
|
||||||
await _slaveClient.DidNotReceive().PushStatusAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>());
|
await _slaveClient.Received(1).PushStatusAsync("https://slave.test", "plain", true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateStatusAsync_ReturnsSlaveContactFalse_WhenReleasingMasterGateFails()
|
||||||
|
{
|
||||||
|
var instance = ActiveInstance();
|
||||||
|
_repo.GetByIdAsync(instance.Id).Returns(instance);
|
||||||
|
_protector.Unprotect("encrypted-key").Returns("plain");
|
||||||
|
_slaveClient.PushStatusAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(false);
|
||||||
|
|
||||||
|
var result = await CreateSut().UpdateStatusAsync(instance.Id, new UpdateStatusRequest(CmsInstanceStatus.Inactive, null));
|
||||||
|
|
||||||
|
result.Success.Should().BeTrue();
|
||||||
|
result.SlaveContactSuccess.Should().BeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -180,6 +196,47 @@ public class CmsInstanceServiceTests
|
|||||||
result.SlaveContactSuccess.Should().BeFalse();
|
result.SlaveContactSuccess.Should().BeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- GetStatusForApiKeyAsync ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatusForApiKeyAsync_ReturnsStatus_WhenKeyMatchesAnInstance()
|
||||||
|
{
|
||||||
|
var instance = ActiveInstance();
|
||||||
|
instance.Status = CmsInstanceStatus.NotAvailable;
|
||||||
|
instance.DisableMessage = "Onderhoud";
|
||||||
|
_repo.GetActiveAsync().Returns([instance]);
|
||||||
|
_protector.Unprotect("encrypted-key").Returns("plain");
|
||||||
|
|
||||||
|
var result = await CreateSut().GetStatusForApiKeyAsync("plain");
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.IsAvailable.Should().BeFalse();
|
||||||
|
result.DisableMessage.Should().Be("Onderhoud");
|
||||||
|
_repo.Received(1).Update(Arg.Is<CmsInstance>(i => i.LastContactedAt.HasValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatusForApiKeyAsync_ReturnsNull_WhenNoInstanceMatchesKey()
|
||||||
|
{
|
||||||
|
var instance = ActiveInstance();
|
||||||
|
_repo.GetActiveAsync().Returns([instance]);
|
||||||
|
_protector.Unprotect("encrypted-key").Returns("some-other-key");
|
||||||
|
|
||||||
|
var result = await CreateSut().GetStatusForApiKeyAsync("plain");
|
||||||
|
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatusForApiKeyAsync_ReturnsNull_WhenNoActiveInstances()
|
||||||
|
{
|
||||||
|
_repo.GetActiveAsync().Returns(Array.Empty<CmsInstance>());
|
||||||
|
|
||||||
|
var result = await CreateSut().GetStatusForApiKeyAsync("plain");
|
||||||
|
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
// --- VerifyIntegrityAsync ---
|
// --- VerifyIntegrityAsync ---
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -239,6 +296,24 @@ public class CmsInstanceServiceTests
|
|||||||
_repo.Received().Update(Arg.Is<CmsInstance>(i => i.LastIntegrityCheckFailedAt == null));
|
_repo.Received().Update(Arg.Is<CmsInstance>(i => i.LastIntegrityCheckFailedAt == null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifyIntegrityAsync_RePushesPersistedStatus_ToResyncSlaveAfterRestart()
|
||||||
|
{
|
||||||
|
var instance = ActiveInstance();
|
||||||
|
instance.Status = CmsInstanceStatus.NotAvailable;
|
||||||
|
instance.DisableMessage = "Onderhoud";
|
||||||
|
_repo.GetActiveAsync().Returns([instance]);
|
||||||
|
_protector.Unprotect("encrypted-key").Returns("plain");
|
||||||
|
_slaveClient.GetRegisteredMasterUrlAsync(Arg.Any<string>(), Arg.Any<string>()).Returns("https://master.test");
|
||||||
|
_slaveClient.PushStatusAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
||||||
|
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
|
||||||
|
|
||||||
|
await CreateSut().VerifyIntegrityAsync();
|
||||||
|
|
||||||
|
await _slaveClient.Received(1).PushStatusAsync("https://slave.test", "plain", false, "Onderhoud");
|
||||||
|
_repo.Received().Update(Arg.Is<CmsInstance>(i => i.LastStatusPushedAt.HasValue));
|
||||||
|
}
|
||||||
|
|
||||||
private static CmsInstance ActiveInstance() => new()
|
private static CmsInstance ActiveInstance() => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ public class IntegrityCheckBackgroundService(
|
|||||||
{
|
{
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
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);
|
var interval = TimeSpan.FromMinutes(options.Value.IntegrityCheckIntervalMinutes);
|
||||||
using var timer = new PeriodicTimer(interval);
|
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);
|
deps.Repository.Update(instance);
|
||||||
await deps.Repository.SaveChangesAsync();
|
await deps.Repository.SaveChangesAsync();
|
||||||
|
|
||||||
if (request.Status == CmsInstanceStatus.Inactive)
|
|
||||||
return new UpdateStatusResult(Success: true, SlaveContactSuccess: true);
|
|
||||||
|
|
||||||
var plainKey = deps.ApiKeyProtector.Unprotect(instance.ApiKey);
|
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(
|
var pushed = await deps.SlaveClient.PushStatusAsync(
|
||||||
instance.Url,
|
instance.Url,
|
||||||
plainKey,
|
plainKey,
|
||||||
@@ -94,6 +112,35 @@ public class CmsInstanceService(MasterServiceDependencies deps) : ICmsInstanceSe
|
|||||||
return new UpdateStatusResult(Success: true, SlaveContactSuccess: pushed);
|
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()
|
public async Task VerifyIntegrityAsync()
|
||||||
{
|
{
|
||||||
var masterUrl = ResolveMasterUrl();
|
var masterUrl = ResolveMasterUrl();
|
||||||
@@ -140,6 +187,20 @@ public class CmsInstanceService(MasterServiceDependencies deps) : ICmsInstanceSe
|
|||||||
instance.LastIntegrityCheckFailedAt = null;
|
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);
|
deps.Repository.Update(instance);
|
||||||
await deps.Repository.SaveChangesAsync();
|
await deps.Repository.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,12 @@ public interface ICmsInstanceService
|
|||||||
Task<CmsInstanceDto> AddAsync(CreateCmsInstanceRequest request);
|
Task<CmsInstanceDto> AddAsync(CreateCmsInstanceRequest request);
|
||||||
Task<UpdateStatusResult> UpdateStatusAsync(Guid id, UpdateStatusRequest request);
|
Task<UpdateStatusResult> UpdateStatusAsync(Guid id, UpdateStatusRequest request);
|
||||||
Task VerifyIntegrityAsync();
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user