` display + hint
to change cluster via the header. A `useEffect` keeps
`form.cluster_id` synchronised with `selectedCluster.id` so mid-
wizard header changes propagate; the existing cluster-transition
cleanup effect handles cert-id orphan reconciliation. Resume from
a draft that targets a different cluster now auto-swaps the
header cluster (best-effort `selectCluster()` call) so post-
resume edits stay cluster-consistent.
- **Bulgu #2 — SSL CA bundle dropdown filter.** Backend's
`BackendServers.js` filters the CA-bundle Select with
`?usage_type=server`, so operators only see certs imported with
the right purpose. The wizard pre-fix surfaced EVERY cert in the
cluster regardless of usage, letting an operator submit a payload
that apply-time HAProxy would parse-error on (`unable to load
SSL private key`). Phase D filters explicitly:
* Per-server CA bundle Select → `usage_type === 'server'`.
* SSL & ACME step's "Existing certificate" Select →
`usage_type === 'frontend'`.
The empty-state Alert was also updated to reason about only the
filtered list so a cluster with N server-side certs but zero
frontend certs renders the "no certs imported" hint correctly.
- **Bulgu #3 — Stuck "Validating against HAProxy…".** The root
cause was a self-cancel race in the auto-fire `useEffect`. The
effect deps array included `dryRunResult.status`, and the effect
body called `setDryRunResult({status: 'loading'})` at the top.
The status change re-triggered the effect; React's cleanup of the
previous run fired BEFORE the new body, aborting the in-flight
controller; the new body returned early because `status !==
'idle'`; the aborted fetch's `.catch` block detected
`signal.aborted` and returned without setting state. Status
stayed `'loading'` forever. Audit-fix #1 (round 1) had addressed
the leave-Step-4 cleanup branch but the enter-Step-4 self-abort
was a separate failure mode that only surfaced on a real backend.
Phase D switches the lifecycle to a ref-driven model:
* `dryRunStatusRef` shadows the latest status (synced via a
passive `useEffect`).
* `dryRunInvalidationTick` is the external re-trigger channel;
`onValuesChange` bumps it when the operator edits a Step-4-
visible field (e.g. the Apply Immediately switch).
* The main effect's deps array drops `dryRunResult.status` and
becomes `[step, form, aclBuilderData, dryRunInvalidationTick]`
— none of these change on a self-issued setDryRunResult, so
the self-cancel race is structurally impossible.
* Cleanup nulls the abort ref only if it still points to the
torn-down controller, so a fresh fetch's ref is never
accidentally cleared.
- **Bulgu #4 — Preview missing fields.** The /api/sites/preview
response previously echoed only a sparse subset of fields, so the
SiteDrafts Preview modal could not show whether per-server
timings, backend cookie persistence, frontend maxconn, HSTS, or
ciphersuites would actually land on disk. Phase D enriches both
the backend response (additive — all existing keys preserved)
AND the SiteDrafts UI:
* Backend: emits the full operator-settable surface area on
`would_create` (backend cookie/timeouts/options, per-server
timings + SSL+CA-bundle details, frontend maxconn/timeouts/
compression/ACL counts, HTTPS ciphersuites, etc.).
* Frontend: replaces the four flat Descriptions blocks with a
typed renderer that only surfaces NON-DEFAULT values
(`isMeaningful` predicate) so the modal stays scannable. A
dedicated per-server card surfaces every per-server field
the operator customised. HSTS gets its own section when
enabled.
- **Bulgu #5 — Resume hydration regressions.** Two issues:
1. Existing certificate was wiped on resume. Root cause was
the orphan-detect effect running on the SAME render that
the resume effect committed the new cluster_id. existingCerts
was still `[]` (fetch in flight), so `certIds = new Set()`
and the freshly-resumed `ssl.ssl_certificate_id` looked like
an orphan and got cleared. Phase D fix: short-circuit the
orphan-detect when `existingCertsLoading=true` and add the
loading flag to the effect deps so the check re-runs after
the fetch settles. ALSO: pin `prevClusterRef.current` to
`merged.cluster_id` BEFORE `form.setFieldsValue(merged)` so
the cluster-transition cleanup effect does not misread the
hydration as a user-driven cluster switch.
2. The same stuck "Validating against HAProxy…" — resolved by
the Bulgu #3 self-cancel-race fix above.
- **Bulgu #6 — Create as PENDING button removed.** Pre-fix the
wizard had TWO submit buttons. The "Create as PENDING" button
bypassed the standard manual-flow convention (entity Create →
PENDING version → Apply Management review → operator Apply). The
"Create & Apply" button bypassed Apply Management entirely.
Operators were trained to "always Create & Apply", defeating the
change-review benefit of Apply Management. Phase D consolidates:
* Single button: "Create Site" (or "Create & Apply (ACME)" when
sslMode='acme', because ACME forces the immediate apply for
the HTTP-01 challenge).
* `handleSubmit` derives `effectiveApply` from `sslModeAtSubmit
=== 'acme'` — no button-driven branching.
* Non-ACME flow: `apply_immediately=false` → backend returns
`created_pending` → operator is navigated to /apply-management
where they review the bulk version and click Apply (same
Agent-pull cadence as manual entity creation).
* ACME flow: `apply_immediately=true` (M22 model_validator
enforces this) → standard `created_applied` response.
* The `acmeBlocksDraft` derivation that gated the (now-removed)
PENDING button is retired — handleSubmit's `effectiveApply`
replaces the gate.
##### Phase K Phase D — Backward compatibility / rollback
- **Cluster picker change.** Operators who relied on the wizard-
internal cluster Select must switch via the header instead. No
data-layer change. Drafts saved on a different cluster
auto-swap the header on resume.
- **`/api/sites/preview` response shape.** Additive only — every
pre-existing key keeps the same shape; new keys are
`cluster_id`, `domains`, additive fields on `backend` / `servers`
/ `frontend_http` / `frontend_https`. Legacy frontend callers
ignore unknown fields.
- **`/api/sites` request shape.** Unchanged.
- **No DB migrations** are introduced by Phase K Phase D.
##### Phase K Phase D — Follow-up audit findings (Bulgu #7–#8)
A deeper post-implementation audit surfaced two additional
race conditions that were not visible in the first pass. Both
are now resolved on the same `pilot` branch:
- **Bulgu #7 — Resume cluster swap race on cold mount.** On a
browser refresh of `/sites/new` while a Resume click had
already pre-populated sessionStorage, the wizard mount races
against `ClusterContext`'s `fetchClusters()`. The resume
effect ran with `clustersFromContext=[]`, so
`selectCluster(draftCluster)` was silently skipped. Then
`ClusterContext` finished loading and `selectedCluster`
became the user's `defaultCluster` (NOT the draft's
cluster). The naive header sync then overwrote
`form.cluster_id` with the default cluster, and the
cluster-transition cleanup effect read that overwrite as a
user-driven switch and wiped the draft's cert selections —
the Bulgu #5 second-order failure that survived the
short-circuit fix on a cold mount path.
Fix: header sync effect grew a one-shot post-resume swap
branch keyed on `resumedFromDraft && !resumeClusterSynced`.
When the draft's `cluster_id` is in the freshly-loaded
`clustersFromContext`, the swap pushes the HEADER to the
draft cluster instead of forcing the form to follow the
header. The `resumeClusterSynced` state gates this to
exactly ONE attempt so a later operator-driven header
cluster change is honoured normally. `selectClusterRef`
(a `useRef(selectCluster)` updated by a tiny sync effect)
keeps the dep set small so the header sync effect does not
re-run on every `ClusterProvider` render.
Pin: `tests/test_frontend_auth_bootstrap_phase_j.py::
test_phase_k_phase_d_resume_cluster_swap_race_fix`.
- **Bulgu #8 — Mid-wizard cluster change leaves stale dry-run.**
When an operator on Step 4 changes the header cluster, the
wizard's cluster_id transitions through `form.setFieldsValue`
(the header sync effect's standard force path). Antd's
`setFieldsValue` is a SILENT update that does NOT fire
`onValuesChange`, so the dry-run invalidation tied to
`onValuesChange` never ran. Result: the Step 4 validation
card kept displaying the PREVIOUS cluster's "clean" verdict
even though the wizard payload now targeted a different
cluster.
Fix: the cluster-transition cleanup effect (which already
detected the change to wipe stale cert ids) now also resets
`dryRunResult` to idle and bumps `dryRunInvalidationTick`
whenever `dryRunStatusRef.current !== 'idle'`. The dry-run
effect's dep list picks up the tick bump and re-fires
against the new cluster as soon as the operator reaches
Step 4.
Pin: `tests/test_frontend_auth_bootstrap_phase_j.py::
test_phase_k_phase_d_cluster_change_invalidates_dry_run`.
Both fixes are additive (no API or DB changes) and rollback
without leaving residual state — disabling the new effects
simply restores the previous (racy) behaviour.
##### Phase K Phase D — Operator-feedback round 2 (Bulgu #9–#11)
A second operator-feedback round surfaced one parity gap and two
follow-ups on the wizard's HAProxy validation experience:
- **Bulgu #9 — Wizard PEM upload parity with SSL Management page.**
Pre-fix `services.ssl_service.create_cert_row` (the helper the
wizard calls when `ssl.mode='upload'`) was a thin INSERT that
never parsed the PEM. It stored `primary_domain` / `all_domains`
from the operator-entered FRONTEND domains (not the cert SAN),
left `expiry_date` / `issuer` / `fingerprint` NULL, hard-coded
`status='valid'` and `days_until_expiry=0`, never validated the
private key or chain, never checked name uniqueness (so a
duplicate name would 500 at the DB unique constraint), and could
not reactivate a soft-deleted row of the same name. The
resulting cert showed up on the SSL Management page with empty
expiry/issuer columns and a permanent "valid" status — confusing
UX and clearly inconsistent with the dedicated SSL Management
upload flow (`POST /api/ssl/certificates`).
Fix: `create_cert_row` now mirrors `routers/ssl.py::
create_ssl_certificate`:
- parses the PEM via `utils.ssl_parser.parse_ssl_certificate`
(raises HTTPException 400 on parse failure),
- validates private_key + chain via `validate_private_key`
/ `validate_certificate_chain`,
- computes status / days_until_expiry from the normalised
timezone-naive UTC `expiry_date`,
- enforces name uniqueness within the target cluster (returns
400 instead of a DB-level 500),
- reactivates soft-deleted rows of the same name (preserves
the row id for downstream references).
Pin: `tests/test_ssl_service_extraction.py` — 11 tests cover
the happy path, all 6 negative paths (parse fail, empty content,
bad private key, bad chain, duplicate active name, soft-delete
reactivation), and the "metadata comes from PEM, not payload"
contract.
- **Bulgu #10 — Heuristic validator rejected wizard's own default
timeouts.** The wizard's config synthesis emits `timeout connect
10000ms` / `timeout server 60000ms` / `timeout client 100ms`
(millisecond suffix is canonical HAProxy syntax). The pre-fix
heuristic regex was `^\d+[smhd]?$`, which only allowed the
single-character suffixes `s`/`m`/`h`/`d` — `ms` was rejected
outright even though the same validator's own suggestion text
said "Use format like '5s', '30000ms', '1m'". Operators saw
10+ FALSE-POSITIVE "Invalid timeout value '10000ms'" errors on
the wizard's defaults at Step 4 and could not click Create.
Fix: `utils/haproxy_validator.py::_validate_timeout_directive`
regex relaxed to `^\d+(us|ms|s|m|h|d)?$` — accepting the full
set of HAProxy time-format suffixes (per the HAProxy docs Time
format chapter) while still rejecting malformed values like
`10000xx`, `abc`, `-100ms`, `1.5s`, and bare `ms`.
Pin: `tests/test_haproxy_validator_timeout_units.py` — 17
parametrised cases (11 valid formats, 5 invalid formats, plus
the exact operator-reported failure mode).
- **Bulgu #11 — Operator reported "Previous loses values".**
Architectural review confirmed the wizard's contract is sound:
every step is rendered into a long-lived `` whose only
step-driven prop is the CSS `display` toggle (`block` vs
`none`). React does NOT unmount the children, Antd's Form.Item
registrations stay intact, and the Antd default `preserve=true`
keeps values in form state even for the inner Form.Items that
conditional-render inside `
` (SSL mode
branches, TCP/http frontend mode toggle). All wizard
`setFieldsValue` call-sites are guarded by domain triggers
(cluster change, sslMode change, TCP-mode-clears-https_redirect,
resume hydration) — none fire on a Previous/Next click alone.
No code regression was identified. Most likely operator
perception driver: with Bulgu #10 fixed, the `timeout
connect=10000` / `timeout server=60000` values the operator
saw in the "Advanced backend settings" Collapse after coming
back from Step 4 are simply the wizard's pre-existing defaults
(`backend.timeout_connect=10000`, `backend.timeout_server=
60000`, `backend.timeout_queue=60000`), not regressed values
— these were never operator-entered, just defaults the
operator did not notice in the collapsed Advanced section on
the forward pass.
Defensive measure: a static-source pin test asserts the
architectural contract so a future refactor cannot regress
to per-step conditional rendering or sneak a
`preserve={false}` in:
`tests/test_frontend_auth_bootstrap_phase_j.py::
test_phase_k_phase_d_wizard_preserves_form_state_across_step_navigation`.
If the operator can reproduce specific field-level state loss
on a Previous click after the Bulgu #10 fix, please file the
repro steps so we can target the actual scenario.
##### Rollback considerations (Phase I)
If you must roll back to a pre-rebrand v1.5.x build after operators have already saved drafts on the new build:
- New rows on `wizard_drafts` with `wizard_type='site'` will be invisible to the legacy code path that filters on `wizard_type='proxied_host'` only. Operators will see those new drafts disappear from the listing AND will not be counted against the 50-draft cap. The rows themselves are not deleted — they expire via the standard 30-day TTL prune.
- Pre-rebrand rows with `wizard_type='proxied_host'` continue to work on the legacy build because their value never changed.
- The schema-level `DEFAULT` is not rolled back automatically. Operators rolling back can either (a) leave it at `'site'` (harmless — the legacy build hard-codes `'proxied_host'` in every INSERT, so the default is never consulted) or (b) re-run an `ALTER TABLE wizard_drafts ALTER COLUMN wizard_type SET DEFAULT 'proxied_host'` to restore the original schema.
##### Phase K Phase D — Operator-feedback round 3 (Bulgu #12)
**Operator-reported failure flow** (May 11, 2026):
The wizard's Step 4 dry-run showed 8 WARNINGs but no ERRORs, so Create proceeded; the operator then applied via Apply Management and the real `haproxy -c` parse rejected the config:
```
[ALERT] parsing [/tmp/haproxy-new-config.cfg:79] : error detected while parsing ACL 'acl1' : failed to open pattern file .
[ALERT] parsing [/tmp/haproxy-new-config.cfg:87] : error detected while parsing switching rule : no such ACL : 'acl1'.
[ALERT] Fatal errors found in configuration.
```
The 8 WARNINGs were ALSO operator-confusing false positives:
```
[frontend] Directive 'stick-table' may not be valid in 'frontend' section
[frontend] Directive 'tcp-request' may not be valid in 'frontend' section (×2)
[backend] Directive 'cookie' may not be valid in 'backend' section (×2)
[backend] Missing 'global' section - recommended for production
```
**Two root causes:**
1. **Heuristic validator `valid_directives` was incomplete** — `stick-table`, `tcp-request`, `tcp-response`, `cookie`, `http-after-response`, `errorfile`, `description`, `id`, `filter`, etc. are perfectly valid in their respective sections but the validator's small hand-picked sets did not list them. Every wizard / manual page that emitted them flagged a spurious "may not be valid" WARNING. The wizard's pre-persist apply-time gate uses the same validator; even though it only blocks on ERROR-level findings, the noise polluted the operator-visible response trail and the version-history page.
2. **ACL `-f ` pattern-file references** — the visual ACL builder offered `-f (from file)` as a selectable flag, and neither the manual Frontend API's Pydantic validator (`models/frontend.py::validate_acl_rules`) nor the wizard's Pydantic validator (`models/site_wizard.py::_validate_haproxy_directive_string`) rejected `-f`. HAProxy OpenManager is a fully-managed product: it does NOT provision pattern files onto the HAProxy node's filesystem, so any operator-typed `-f /path/...` ALWAYS resolves to "file not found" at HAProxy reload time. The UI made it trivial to author an unsupported state.
**Three-layer fix:**
**Layer A — Heuristic validator** (`backend/utils/haproxy_validator.py`):
- Expanded `valid_directives['frontend']` to include `stick-table`, `stick`, `tcp-request`, `tcp-response`, `http-after-response`, `errorfile`, `errorloc`, `errorloc302`, `errorloc303`, `http-error`, `description`, `id`, `filter`, `monitor`, `unique-id-format`, `unique-id-header`, `declare`, `http-buffer-request`, plus a long-tail of less-common-but-valid directives.
- Expanded `valid_directives['backend']` to include `cookie`, `appsession`, `tcp-request`, `tcp-response`, `tcp-check`, `retries`, `fullconn`, `dispatch`, `redirect`, `use-server`, `acl`, `capture`, `errorfile`, `description`, `id`, `filter`, `rate-limit`, `email-alert`, `force-persist`, `transparent`, `source`, plus a long-tail.
- Added `partial_fragment: bool = False` parameter to `HAProxyConfigValidator.validate_config()` and the module-level `validate_haproxy_config()`. When True (or auto-detected via the wizard's marker comment), the validator suppresses the "Missing 'global' section" / "Consider adding 'defaults' section" diagnostics — the wizard / cluster synthesis intentionally OMITS those blocks because the agent merges them with its local copy on disk.
- Both the wizard's `/preview` dry-run AND the apply-time pre-persist gate now pass `partial_fragment=True` (`backend/routers/site_wizard.py`).
**Layer B — ACL `-f` rejection in Pydantic** (server-side gate):
- `backend/models/site_wizard.py`: Added `_ACL_FILE_FLAG_PATTERN = re.compile(r"(^|\s)-f(\s|$)")` and rejected the pattern inside `_validate_haproxy_directive_string` with an operator-friendly message explaining why the product cannot support pattern files. This covers `acl_rules`, `use_backend_rules`, and string-shaped `redirect_rules`.
- `backend/models/frontend.py::validate_acl_rules`: Mirrored the same rejection on the manual Frontend API so both create paths return the identical 400/422 envelope.
**Layer C — ACL `-f` removal from the visual builder + UI gates** (client-side authoring guardrail):
- `frontend/src/components/ACLRuleBuilder.js`: Removed `-f` from the selectable `FLAGS` list. Updated `FLAG_HINTS` to drop the `-f` mention. Existing rules that already carry `-f` (loaded from saved drafts pre-fix) keep the tag visible as `-f (deprecated — remove)` so operators can SEE and REMOVE the flag, but cannot re-add it once removed. Added a section-level red `Alert` that counts every rule carrying `-f` and explains the failure mode + remediation. Inline rule-card error decoration (`status='error'` + red border + inline description) surfaces the same message at the per-rule level. Mirrored the regex client-side so raw-mode typed `-f` immediately flags inline.
- `frontend/src/components/SiteWizard.js`: Added a Step 2 → Step 3 hard-gate on the Next button — if ANY rule still carries `-f`, the click surfaces the same operator-friendly error and refuses to advance.
- `frontend/src/components/FrontendManagement.js::handleSubmit`: Mirrored the same gate so the manual Frontend page rejects submit identically.
**Backward compatibility:**
- Existing drafts that contain `-f`-flagged rules still load — the ACLRuleBuilder displays them visibly so operators can remove them. Submit is blocked until they do.
- Existing PERSISTED frontend rows in the DB that already carry `-f` (created before this fix) continue to work at the agent level — the validator changes do NOT retroactively reject them. They can still be EDITED through the UI (which will block save until `-f` is removed) or read via the API for visibility / audit.
- The expanded `valid_directives` sets only ADD entries; nothing previously accepted is now flagged. Pre-existing tests that asserted "Directive X is valid" continue to pass.
**Tests added:**
- `backend/tests/test_haproxy_validator_bulgu12.py` (27 new tests):
- Per-directive false-positive regression pins for both frontend and backend sections.
- `partial_fragment=True` suppression + marker-comment auto-detect.
- Wizard Pydantic `-f` rejection across spacing/position variants.
- Anchor-correctness pin: regex must NOT match `-foo` / `-file` substrings inside other tokens.
- Manual Frontend API parity pin.
- End-to-end pin replaying the user's actual config (minus `-f`) with zero spurious WARNINGs.
- `backend/tests/test_site_wizard_phase2_validator_gate.py`: Widened the pre-window lookback from 400 to 1500 chars to accommodate the partial-fragment forwarding comment block.
**Rollback considerations:**
- Reverting the `valid_directives` expansion brings back operator-visible WARNING noise but does NOT break apply (which only gates on ERROR). Safe to roll back if a regression is discovered.
- Reverting the `-f` Pydantic rejection ALLOWS operators to author the failure mode again, but does not break anything that worked before. Roll back ONLY if a customer has pre-provisioned pattern files and a tightly-controlled need to reference them.
- Reverting the ACLRuleBuilder UI changes is a pure visual revert; the Pydantic gate keeps the safety net.
### Earlier Releases
For earlier release notes (v1.4.0 ACME stability + enterprise audit, v1.3.0, ...) see the [GitHub Releases](https://github.com/taylanbakircioglu/haproxy-openmanager/releases) page.
---
**Made with ❤️ for the HAProxy community**