Compare commits

...

1 Commits

Author SHA1 Message Date
taylanbakircioglu dbb9189f16 fix(ui): make Apply Management readable in dark mode (v1.10.2)
Three dark-mode defects reported on the Apply Management page, all the same
class of bug: light-mode colour literals hardcoded where theme tokens belong.

1. The "Pending Changes" box was painted background #fffbe6 with border
   #ffe58f. In dark mode the text on top is light, so the version name,
   timestamp and "View Change" link sat on a cream panel and were unreadable.
   Measured contrast was 1.03:1; it is now 11.50:1 (secondary text 1.03:1 ->
   7.02:1).

2. The added/removed rows in the View Change diff used #f6ffed/#52c41a and
   #fff2f0/#ff4d4f, which stayed near-white inside the otherwise dark diff
   panel. Now 5.49:1 (added) and 4.01:1 (removed), from 2.21:1 and 2.99:1.

3. The "Apply All Configuration Changes" confirm dialog came up white. This one
   is not a colour literal: in Ant Design 5 the STATIC Modal.confirm / message /
   notification APIs render into their own detached root and never see the app's
   ConfigProvider, so they always fall back to the light algorithm. Registering
   ConfigProvider.config({ holderRender }) once at the app root wraps that
   detached root in the same ConfigProvider. Verified against the installed antd
   5.29.3 source rather than assumed: config-provider/index.js sets
   globalHolderRender, and modal/confirm.js wraps the dialog with it. This fixes
   EVERY static dialog in the application — 12 components call Modal.confirm —
   not just this page.

While in the file, six more instances of the same bug were fixed: the error
Alert border, the VIP pending-delete row, two ACME/pending version panels, the
applied-version panel and the agent-error recommendation box.

Light mode is byte-identical. Each token resolves under the default algorithm
to exactly the literal it replaced (colorWarningBg -> #fffbe6, colorSuccessBg ->
#f6ffed, colorErrorBg -> #fff2f0, colorInfoBg, colorErrorBorder, ...), so this
release can only change dark mode. Contrast was measured by resolving the real
design tokens under both algorithms and computing WCAG ratios, not by eye.

Note the diff rows were already low-contrast in LIGHT mode (2.21:1 and 2.99:1)
and remain so; that is the design system's own success/error pair and changing
it would alter the established light-mode appearance, so it is left alone.

holderRender is registered in an effect rather than during render, since
ConfigProvider.config() mutates antd module state; effects still run long
before a user can click anything that opens a static dialog.

Frontend only: no schema, no SCHEMA_VERSION bump, no API change, no environment
variable, zero agent impact. Backend suite unchanged at 1243 passed.
2026-08-08 02:39:23 +03:00
6 changed files with 69 additions and 20 deletions
+1
View File
@@ -2474,6 +2474,7 @@ Developed with ❤️ for the HAProxy community
## Release Notes
- **v1.10.2** (2026-08-08) — **Dark mode fixes on Apply Management**: several panels on the Apply Management page were painted with light-mode colour literals, so in dark mode the **Pending Changes** box rendered as a cream panel with light text on it — measured contrast **1.03:1**, effectively unreadable, now **11.50:1**. The same bug affected the added/removed rows in the *View Change* diff (2.21:1 and 2.99:1, now 5.49:1 and 4.01:1), the ACME and pending-version panels, the VIP pending-delete row, and the agent-error recommendation box; all now derive from theme tokens. Separately, **static confirm dialogs came up white in dark mode**: in Ant Design 5 the static `Modal.confirm` / `message` / `notification` APIs render into their own detached root and never see the app's `ConfigProvider`, so they always used the light algorithm. Registering `ConfigProvider.config({ holderRender })` once at the app root fixes **every** static dialog in the application (12 components use them), not only this page. Light mode is byte-identical — each token resolves under the default algorithm to exactly the literal it replaced. Frontend only: no schema, API, environment or agent change.
- **v1.10.1** (2026-08-08) — **CSR private key encrypted at rest** (Issue #53): the private key of a **pending** CSR is now Fernet-encrypted in the database instead of stored as PEM. It is the one key in the system worth protecting this way — it sits idle for the entire signing window (days to weeks), is never transmitted to an agent, and is destroyed the moment the signed certificate is imported; `ssl_certificates.private_key_content` and the ACME order keys are unchanged, because agents must receive those in plaintext on every poll. The token replaces the PEM in the **same column**, so there is **no schema change and no `SCHEMA_VERSION` bump** (and therefore no re-seed of the built-in roles). CSRs created before this release keep a raw PEM and are still read transparently, so anything already out for signature imports normally with no data migration. The key derives from `SECRET_KEY` via HKDF with its own info string, independent of the VIP/MFA/DNS keys, and an optional `CSR_ENCRYPTION_KEY` enables independent rotation — rotating `SECRET_KEY` without it makes pending CSR keys unrecoverable, which now fails with an explicit "delete and re-create this CSR" error rather than a misleading key-mismatch. `.env.template` now documents all four per-purpose encryption keys. No API, UI or agent change.
- **v1.10.0** (2026-08-07) — **GoDaddy DNS provider for DNS-01** (Issue #35 follow-up): DNS-01 challenges can now be published and cleaned up automatically through **GoDaddy**, alongside the existing Manual and Cloudflare providers, so wildcard and internal-cluster certificates on GoDaddy-hosted zones **renew unattended**. Credentials are a **Production API Key + Secret** pair from `developer.godaddy.com/keys` (a **Personal Access Token** also works — paste it as the Key and leave the Secret blank, which is the forward path as GoDaddy retires `sso-key`); they are **verified against the GoDaddy API before being saved** and **encrypted at rest** (Fernet, the same path as Cloudflare), and are never returned by the API, logged, or written to an order event. GoDaddy's v1 API has **no per-value TXT write** — `PUT` replaces an entire RRset — so add/remove are read-modify-write with sibling values merged back, empty-`data` tombstones filtered out, and `DELETE` used for the last value (`PUT []` is rejected); this is what keeps the **apex + wildcard** case (two TXT values at one `_acme-challenge` name) working, and the record path is hard-gated so it can never collapse onto the zone-wide endpoint that would wipe SPF/DKIM/DMARC. Zone lookup probes the records API rather than the domain listing, so **delegated sub-zones** resolve and small accounts are not falsely rejected. Registry-only addition: one new provider module plus one registry line — no frontend change (the credential form is schema-driven). No schema, API-shape, agent, or rendered-config changes; Manual, Cloudflare and HTTP-01 are unaffected.
- **v1.9.0** (2026-08-04) — **CSR creation** (in-app key + CSR generation and signed-certificate import): a new **CSR tab** on the SSL Certificates page generates a private key and Certificate Signing Request server-side (RSA 2048/4096 or ECDSA P-256/P-384; full subject — O/OU/L/ST/C/email — plus DNS SANs with wildcard support), for certificates signed by an **external or corporate CA**. The operator downloads/copies the CSR PEM, has it signed, then imports the signed certificate (+ optional chain): the backend verifies the certificate against the stored key (hard gate), rejects expired certs, warns on SAN drift, and creates a normal SSL certificate entry (source `CSR`) that flows through the standard **PENDING → Apply Management → agent pull** pipeline. The private key **never leaves the server** — no CSR endpoint returns it, and after import the CSR row's key copy is destroyed (the key then lives only on the certificate, like every other key). Additive schema change: one new table `ssl_csrs` (SCHEMA_VERSION 9 → 10, auto-migrated, no existing table altered); key generation runs off the event loop and is rate-limited per user; existing `ssl.*` permissions govern all new endpoints. No agent or rendered-config changes.
+22
View File
@@ -1,3 +1,25 @@
# Upgrade Notes — v1.10.2 (Dark mode fixes on Apply Management)
**Frontend only. Nothing to do on upgrade.** No schema, no `SCHEMA_VERSION` bump, no API change,
no environment variable, zero agent impact. Light mode is byte-identical: every colour swapped in
this release resolves, under the default algorithm, to exactly the literal it replaced
(`colorWarningBg` → `#fffbe6`, `colorSuccessBg` → `#f6ffed`, `colorErrorBg` → `#fff2f0`, …), so
only dark mode changes.
- **Apply Management panels** were painted with light-mode colour literals, so in dark mode the
"Pending Changes" box rendered as a cream panel with light text on it. Measured contrast was
**1.03:1** — effectively invisible. It is now **11.50:1**. The same class of bug affected the
diff rows in *View Change* (2.21:1 and 2.99:1, now 5.49:1 and 4.01:1), the ACME/pending version
panels, the VIP pending-delete row and the agent-error recommendation box.
- **Static confirm dialogs came up white in dark mode.** In Ant Design 5 the static
`Modal.confirm` / `message` / `notification` APIs render into their own detached root and never
see the app's `ConfigProvider`, so they always used the light algorithm. This release registers
`ConfigProvider.config({ holderRender })` once at the app root, which fixes **every** static
dialog in the app (12 components use them), not just Apply Management.
- **Rollback:** downgrade freely. This release changes rendering only.
---
# Upgrade Notes — v1.10.1 (CSR private key encrypted at rest)
**Backward compatible.** Nothing to do on upgrade, and nothing changes for existing clusters,
+2 -2
View File
@@ -1,5 +1,5 @@
{
"version": "1.10.1",
"releaseName": "CSR private key encrypted at rest",
"version": "1.10.2",
"releaseName": "Dark mode fixes on Apply Management",
"releaseDate": "2026-08-08"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "haproxy-openmanager-frontend",
"version": "1.10.1",
"version": "1.10.2",
"description": "HAProxy Load Balancer Management UI",
"license": "AGPL-3.0-or-later",
"dependencies": {
+23 -5
View File
@@ -484,12 +484,30 @@ function AppContent() {
function ThemedApp() {
const { isDarkMode } = useTheme();
const antdTheme = {
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
};
// Ant Design 5: the STATIC message/notification/Modal.confirm APIs render into their own
// detached root, so they do not see this ConfigProvider and always fall back to the light
// algorithm — a confirm dialog came up white while the app was in dark mode. `holderRender`
// wraps that detached root in the same ConfigProvider, which fixes every static call in the
// app at once (12 components use Modal.confirm) instead of migrating each one to
// App.useApp(). In an effect rather than during render: ConfigProvider.config() mutates
// antd module state, and effects still run long before a user can click anything that opens
// a static modal. Re-registered on theme change so the toggle takes effect immediately.
React.useEffect(() => {
ConfigProvider.config({
holderRender: (children) => (
<ConfigProvider theme={{ algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm }}>
{children}
</ConfigProvider>
),
});
}, [isDarkMode]);
return (
<ConfigProvider
theme={{
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<ConfigProvider theme={antdTheme}>
<AuthProvider>
<ClusterProvider>
<ProgressProvider>
+20 -12
View File
@@ -1092,7 +1092,7 @@ const ApplyManagement = () => {
style={{
marginBottom: 24,
borderRadius: 8,
border: '1px solid #ffccc7',
border: `1px solid ${token.colorErrorBorder}`,
boxShadow: '0 2px 8px rgba(255, 77, 79, 0.15)'
}}
message={
@@ -1348,7 +1348,7 @@ const ApplyManagement = () => {
HA / VIP Changes ({pendingChanges.vips.length})
</Title>
{pendingChanges.vips.map(item => (
<div key={`vip-${item.id}`} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', marginBottom: 6, border: item.pending_delete ? '1px solid #ffccc7' : '1px solid #f0f0f0', borderRadius: 6, background: item.pending_delete ? '#fff1f0' : undefined }}>
<div key={`vip-${item.id}`} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', marginBottom: 6, border: `1px solid ${item.pending_delete ? token.colorErrorBorder : token.colorBorderSecondary}`, borderRadius: 6, background: item.pending_delete ? token.colorErrorBg : undefined }}>
<CloudServerOutlined style={{ color: item.pending_delete ? '#cf1322' : '#13c2c2' }} />
<span style={{ fontWeight: 500 }}>{item.name}</span>
<Tag>{item.virtual_ip}/{item.prefix_length}</Tag>
@@ -1374,8 +1374,8 @@ const ApplyManagement = () => {
const isEnable = /^cluster-\d+-acme-enable-/.test(v.version_name);
return (
<div key={v.id} style={{
padding: 10, border: '1px dashed #1890ff', borderRadius: 6, marginBottom: 8,
display: 'flex', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#f0f8ff'
padding: 10, border: `1px dashed ${token.colorPrimary}`, borderRadius: 6, marginBottom: 8,
display: 'flex', alignItems: 'center', justifyContent: 'space-between', backgroundColor: token.colorInfoBg
}}>
<span style={{ fontFamily: 'monospace' }}>{v.version_name}</span>
<span>
@@ -1419,7 +1419,7 @@ const ApplyManagement = () => {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#f0f8ff'
backgroundColor: token.colorInfoBg
}}>
<span style={{ fontFamily: 'monospace' }}>{v.version_name}</span>
<Tag color="orange">PENDING</Tag>
@@ -1443,7 +1443,7 @@ const ApplyManagement = () => {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#f6ffed'
backgroundColor: token.colorSuccessBg
}}>
<span style={{ fontFamily: 'monospace' }}>{v.version_name}</span>
<Tag color="orange">PENDING</Tag>
@@ -1518,9 +1518,13 @@ const ApplyManagement = () => {
</Descriptions>
</div>
{/* Pending Versions Section */}
{/* Pending Versions Section.
Theme tokens, not the light-mode literals #fffbe6/#ffe58f: in dark mode those
produced a cream panel with light text on it, so the version name, timestamp
and "View Change" link were unreadable. colorWarningBg/Border track the
algorithm, so the "pending" tint survives in both themes. */}
{pendingVersions.length > 0 && (
<div style={{ marginBottom: 24, background: '#fffbe6', borderRadius: 8, border: '1px solid #ffe58f', padding: '16px 16px 8px' }}>
<div style={{ marginBottom: 24, background: token.colorWarningBg, borderRadius: 8, border: `1px solid ${token.colorWarningBorder}`, padding: '16px 16px 8px' }}>
<Title level={5} style={{ marginTop: 0 }}>
<ClockCircleOutlined style={{ marginRight: 8, color: '#faad14' }} />
Pending Changes ({pendingVersions.length})
@@ -1765,8 +1769,8 @@ const ApplyManagement = () => {
<div>
{/* Parsed suggestion */}
{agentSync.parsed_error?.suggestion && (
<div style={{ marginBottom: 12, padding: '8px 12px', background: '#fff7e6', borderRadius: 4, border: '1px solid #ffd591' }}>
<Text strong style={{ color: '#ad4e00' }}>Recommendation: </Text>
<div style={{ marginBottom: 12, padding: '8px 12px', background: token.colorWarningBg, borderRadius: 4, border: `1px solid ${token.colorWarningBorder}` }}>
<Text strong style={{ color: token.colorWarningText }}>Recommendation: </Text>
<Text>{agentSync.parsed_error.suggestion}</Text>
</div>
)}
@@ -1951,13 +1955,17 @@ const ApplyManagement = () => {
{diffData.changes && diffData.changes.length > 0 ? (
diffData.changes.map((change, index) => (
<div key={index} style={{ marginBottom: '4px' }}>
{/* Token-based, not the light literals #f6ffed/#fff2f0: those stayed
near-white in dark mode, so the added/removed rows glared against
the dark diff panel around them. colorSuccessBg/colorErrorBg darken
with the algorithm while keeping the green/red semantics. */}
{change.type === 'added' && (
<div style={{ backgroundColor: '#f6ffed', color: '#52c41a', padding: '2px 8px', borderLeft: '3px solid #52c41a' }}>
<div style={{ backgroundColor: token.colorSuccessBg, color: token.colorSuccessText, padding: '2px 8px', borderLeft: `3px solid ${token.colorSuccess}` }}>
+ {change.line}
</div>
)}
{change.type === 'removed' && (
<div style={{ backgroundColor: '#fff2f0', color: '#ff4d4f', padding: '2px 8px', borderLeft: '3px solid #ff4d4f' }}>
<div style={{ backgroundColor: token.colorErrorBg, color: token.colorErrorText, padding: '2px 8px', borderLeft: `3px solid ${token.colorError}` }}>
- {change.line}
</div>
)}