From 9f8892d22b63199e6a15930e4704440b4e150444 Mon Sep 17 00:00:00 2001 From: 7heMech <83923848+7heMech@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:04:18 +0000 Subject: [PATCH 1/3] Lay the maintenance bypass lists out in three columns Both bypass lists share one card: the explanation on the left, the list in the middle, and Add my IP and Save stacked on the right. The overview's global list gets Add my IP too, and Save stays off until a list changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- addons/maintenance/app/index.ts | 2 +- addons/maintenance/app/views.client.js | 56 ++++++++++++++------------ addons/maintenance/app/views.css | 21 +++++----- addons/maintenance/app/views.ts | 25 +++++++----- docs/decisions/maintenance.md | 5 ++- tests/test-maintenance.test.ts | 13 ++++++ tools/preview-ui.ts | 2 +- 7 files changed, 74 insertions(+), 50 deletions(-) diff --git a/addons/maintenance/app/index.ts b/addons/maintenance/app/index.ts index f0e38ea..499da26 100644 --- a/addons/maintenance/app/index.ts +++ b/addons/maintenance/app/index.ts @@ -75,7 +75,7 @@ export async function handle( } try { const globalStatus = await maintenanceService.globalStatus(); - if (!selected) return html(layout("Maintenance Mode", fleetView(await maintenanceService.listSites(), globalStatus), updateNotice), csrf); + if (!selected) return html(layout("Maintenance Mode", fleetView(await maintenanceService.listSites(), globalStatus, clientIp(req)), updateNotice), csrf); const domain = validateDomain(selected); if (!domain) return html(layout("Invalid site", '
That is not a valid hostname.
', updateNotice), csrf, 400); const [page, template] = await Promise.all([maintenanceService.site(domain), maintenanceService.template(domain)]); diff --git a/addons/maintenance/app/views.client.js b/addons/maintenance/app/views.client.js index 250f5a3..44ec0fc 100644 --- a/addons/maintenance/app/views.client.js +++ b/addons/maintenance/app/views.client.js @@ -355,43 +355,49 @@ async function resetTemplate(domain, confirmed) { } } -async function saveBypasses(domain) { - const field = CLP_ROOT.getElementById('bypass-ips'); - const ips = String(field && field.value || '').split(/[\n,]+/).map(function (ip) { return ip.trim(); }).filter(Boolean); - clearNotice(); - busy(true); - try { - const reply = await call(siteEndpoint(domain, '/bypasses'), { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ips: ips }) - }); - field.value = reply.data.bypasses.join('\n'); - notify('IP bypasses saved.', 'ok'); - } catch (error) { notify('Could not save IP bypasses: ' + error.message, 'error'); } - finally { busy(false); } +function bypassList(value) { + return String(value || '').split(/[\n,]+/).map(function (ip) { return ip.trim(); }).filter(Boolean); +} + +// Save stays off until the list differs from what the server last returned. +function syncBypassSave(field) { + const save = CLP_ROOT.querySelector('[data-bypass-save="' + field.id + '"]'); + if (save) save.disabled = bypassList(field.value).join('\n') === bypassList(field.defaultValue).join('\n'); } -async function saveGlobalBypasses() { - const field = CLP_ROOT.getElementById('global-bypass-ips'); +async function saveBypassList(id, endpoint, saved, failed) { + const field = CLP_ROOT.getElementById(id); if (!field) return; - const ips = field.value.split(/[\n,]+/).map(function (ip) { return ip.trim(); }).filter(Boolean); clearNotice(); busy(true); try { - const reply = await call('/api/global-bypasses', { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ips: ips }) + const reply = await call(endpoint, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ips: bypassList(field.value) }) }); - field.value = reply.data.bypasses.join('\n'); - notify('Global IP bypasses saved.', 'ok'); - } catch (error) { notify('Could not save global IP bypasses: ' + error.message, 'error'); } - finally { busy(false); } + field.value = field.defaultValue = reply.data.bypasses.join('\n'); + notify(saved, 'ok'); + } catch (error) { notify(failed + error.message, 'error'); } + finally { + busy(false); + syncBypassSave(field); + } +} + +function saveBypasses(domain) { + return saveBypassList('bypass-ips', siteEndpoint(domain, '/bypasses'), 'IP bypasses saved.', 'Could not save IP bypasses: '); +} + +function saveGlobalBypasses() { + return saveBypassList('global-bypass-ips', '/api/global-bypasses', 'Global IP bypasses saved.', 'Could not save global IP bypasses: '); } -function addCurrentIp(ip) { - const field = CLP_ROOT.getElementById('bypass-ips'); +function addCurrentIp(id, ip) { + const field = CLP_ROOT.getElementById(id); if (!field || !ip) return; - const values = field.value.split(/[\n,]+/).map(function (value) { return value.trim(); }).filter(Boolean); + const values = bypassList(field.value); if (values.indexOf(ip) === -1) values.push(ip); field.value = values.join('\n'); + syncBypassSave(field); } function initMaintenance() { diff --git a/addons/maintenance/app/views.css b/addons/maintenance/app/views.css index e76a831..b8c5e6f 100644 --- a/addons/maintenance/app/views.css +++ b/addons/maintenance/app/views.css @@ -70,17 +70,18 @@ html.dark #template-ace .ace_constant, html.dark #template-ace .ace_numeric, html.dark #template-ace .ace_entity { color:#d9a9ff; } html.dark #template-ace .ace_comment { color:#93a1ad; } -.bypass-grid { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:16px; align-items:end; } -.bypass-field { min-width:0; margin:0; } -.bypass-field span, .bypass-field textarea { display:block; } -.bypass-field span { margin-bottom:7px; } -.bypass-field textarea { min-height:110px; resize:vertical; font-family:var(--mono); } -.bypass-actions { justify-content:flex-end; } +.bypass-card { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto; grid-template-areas:"intro list actions"; gap:16px 24px; align-items:start; } +.bypass-intro { grid-area:intro; } +.bypass-intro h2 { margin:0 0 8px; } +.bypass-intro .hint { margin:0 0 6px; } +.bypass-list { grid-area:list; display:block; width:100%; min-width:0; resize:vertical; font-family:var(--mono); } +.bypass-actions { grid-area:actions; display:flex; flex-direction:column; gap:10px; } +@media (max-width:1000px) { + .bypass-card { grid-template-columns:minmax(0,1fr) auto; grid-template-areas:"intro intro" "list actions"; } +} .global-card { display:flex; justify-content:space-between; align-items:flex-start; gap:24px; } .global-card h2 { margin:0 0 8px; } .global-card p { margin:0; } -.global-bypass { border-top:1px solid var(--border); margin-top:20px; padding-top:20px; } -.global-bypass .bypass-field textarea { width:100%; } @media (max-width:760px) { .site-heading { display: grid; @@ -126,7 +127,7 @@ html.dark #template-ace .ace_comment { color:#93a1ad; } .fleet-table.maintenance-fleet-table td.bypass-cell { display: none; } } @media (max-width:700px) { - .bypass-grid { grid-template-columns:1fr; } - .bypass-actions { justify-content:flex-start; } + .bypass-card { grid-template-columns:1fr; grid-template-areas:"intro" "list" "actions"; } + .bypass-actions { flex-direction:row; flex-wrap:wrap; } .global-card { flex-direction:column; } } diff --git a/addons/maintenance/app/views.ts b/addons/maintenance/app/views.ts index 30073b6..4a5c4cd 100644 --- a/addons/maintenance/app/views.ts +++ b/addons/maintenance/app/views.ts @@ -72,7 +72,7 @@ function statusBadge(site: MaintenanceSiteView, globalEnabled = false): string { * bulk edit of the switches below it. It is not one: it decides what visitors * get while every site keeps the setting it has saved. */ -function globalCard(globalEnabled: boolean, disabled: boolean, bypasses: string[]): string { +function globalCard(globalEnabled: boolean, disabled: boolean, bypasses: string[], currentIp: string): string { return `

Global maintenance

@@ -83,13 +83,19 @@ function globalCard(globalEnabled: boolean, disabled: boolean, bypasses: string[
-
-
-

One IPv4 or IPv6 visitor address per line. These skip maintenance on every site, including sites with their own setting on. For Cloudflare sites, enter the visitor IP.

-
`; +
+ ${bypassCard("global-bypass-ips", "Global IP bypasses", "These addresses skip maintenance on every site, including sites with their own setting on.", bypasses, currentIp, "saveGlobalBypasses()")}`; } -export function fleetView(sites: MaintenanceSiteView[], globalState: boolean | GlobalMaintenanceStatus = false): string { +function bypassCard(id: string, title: string, scope: string, bypasses: string[], currentIp: string, save: string): string { + return `
+

${title}

${scope}

One IPv4 or IPv6 visitor address per line. For Cloudflare sites, enter the visitor IP.

+ +
${currentIp ? `` : ""}
+
`; +} + +export function fleetView(sites: MaintenanceSiteView[], globalState: boolean | GlobalMaintenanceStatus = false, currentIp = ""): string { const globalEnabled = typeof globalState === "boolean" ? globalState : globalState.global; const globalBypasses = typeof globalState === "boolean" ? [] : globalState.bypasses; const available = sites.filter((site) => !site.error); @@ -111,7 +117,7 @@ export function fleetView(sites: MaintenanceSiteView[], globalState: boolean | G
In maintenance
${inMaintenanceCount}
Live
${liveCount}
- ${globalCard(globalEnabled, sites.length === 0, globalBypasses)} + ${globalCard(globalEnabled, sites.length === 0, globalBypasses, currentIp)}

Sites

${sites.length ? `${rows}
SiteTypeEffective statusPageBypassesSite setting
` : '
No CloudPanel sites were found.
'}
`; @@ -129,10 +135,7 @@ export function siteView(

Maintenance response

Visitors receive HTTP 503 with a five-minute Retry-After header. ACME certificate challenges and bypassed IPs remain live.

-

IP bypasses

One IPv4 or IPv6 visitor address per line. For Cloudflare sites, enter the visitor IP. Global bypasses from the overview also apply here.

-
-
${currentIp ? `` : ""}
-
+ ${bypassCard("bypass-ips", "IP bypasses", "These addresses skip maintenance on this site, as do the global bypasses on the overview.", site.bypasses, currentIp, `saveBypasses('${escJs(site.domain)}')`)}

Maintenance page

Custom HTML and CSS are stored for this site. Active scripts and form controls are removed.

diff --git a/docs/decisions/maintenance.md b/docs/decisions/maintenance.md index fa3b08a..8652d85 100644 --- a/docs/decisions/maintenance.md +++ b/docs/decisions/maintenance.md @@ -17,10 +17,11 @@ how many have maintenance saved off. While it is on, every site serves 503 and a site whose own setting is off, or could not be read, shows a "Maintenance (Global)" badge and is counted as in maintenance; turning it off returns each site to its saved setting. Turning it on or off purges Varnish -cache across the fleet. The card also saves up to 64 global IP bypasses. Each +cache across the fleet. A card below it saves up to 64 global IP bypasses. Each one applies to every site, including sites with their own maintenance setting on, and remains saved when the global override is off. Site bypasses continue -to apply to their own site. +to apply to their own site. Both bypass lists keep Save off until the list +differs from the saved one. A domain query opens the focused editor for that site. That page is drawn in the shell's site mode, so CloudPanel's site information and site tabs stay above it diff --git a/tests/test-maintenance.test.ts b/tests/test-maintenance.test.ts index dcd6422..cb09fa9 100644 --- a/tests/test-maintenance.test.ts +++ b/tests/test-maintenance.test.ts @@ -319,6 +319,19 @@ test("fleet overview renders the global override and badges according to global ])).not.toContain('id="global-toggle" disabled'); }); +test("both bypass lists offer the operator's IP and hold Save until the list changes", () => { + const fleet = fleetView([], { global: false, bypasses: ["203.0.113.8"] }, "198.51.100.4"); + expect(fleet).toContain(`onclick="addCurrentIp('global-bypass-ips', '198.51.100.4')"`); + expect(fleet).toContain('data-bypass-save="global-bypass-ips" disabled'); + const site = siteView( + { domain: "example.com", type: "php", user: "one", enabled: false, customTemplate: false, bypasses: [] }, + { domain: "example.com", custom: false, html: "" }, + "198.51.100.4", + ); + expect(site).toContain(`onclick="addCurrentIp('bypass-ips', '198.51.100.4')"`); + expect(site).toContain('data-bypass-save="bypass-ips" disabled'); +}); + test("siteView explains that the global override outranks this site's saved setting", () => { const site = { domain: "one.example.com", type: "php", user: "one", enabled: false, customTemplate: false, bypasses: [] }; const template = { domain: "one.example.com", custom: false, html: "

Maintenance

" }; diff --git a/tools/preview-ui.ts b/tools/preview-ui.ts index 5a73181..a12b624 100644 --- a/tools/preview-ui.ts +++ b/tools/preview-ui.ts @@ -688,7 +688,7 @@ const server = Bun.serve({ site ? `Maintenance — ${site.domain}` : "Maintenance Mode", site ? maintenanceSiteView(site, { domain: site.domain, custom: site.customTemplate, html: DEFAULT_MAINTENANCE_TEMPLATE }, "203.0.113.8", globalEnabled) - : maintenanceFleetView(empty ? [] : maintenanceSites, { global: globalEnabled, bypasses: empty ? [] : ["203.0.113.8"] }), + : maintenanceFleetView(empty ? [] : maintenanceSites, { global: globalEnabled, bypasses: empty ? [] : ["203.0.113.8"] }, "203.0.113.8"), notice, site ? { From e223a3e7714fc5e82827e610321fb42c798d3029 Mon Sep 17 00:00:00 2001 From: 7heMech <83923848+7heMech@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:07:02 +0000 Subject: [PATCH 2/3] Put the bypass buttons under the list instead of a third column Co-Authored-By: Claude Opus 5.5 (1M context) --- addons/maintenance/app/views.css | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/addons/maintenance/app/views.css b/addons/maintenance/app/views.css index b8c5e6f..320bf88 100644 --- a/addons/maintenance/app/views.css +++ b/addons/maintenance/app/views.css @@ -70,15 +70,12 @@ html.dark #template-ace .ace_constant, html.dark #template-ace .ace_numeric, html.dark #template-ace .ace_entity { color:#d9a9ff; } html.dark #template-ace .ace_comment { color:#93a1ad; } -.bypass-card { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto; grid-template-areas:"intro list actions"; gap:16px 24px; align-items:start; } +.bypass-card { display:grid; grid-template-columns:minmax(0,2fr) minmax(0,3fr); grid-template-areas:"intro list" "intro actions"; gap:12px 32px; align-items:start; } .bypass-intro { grid-area:intro; } .bypass-intro h2 { margin:0 0 8px; } .bypass-intro .hint { margin:0 0 6px; } .bypass-list { grid-area:list; display:block; width:100%; min-width:0; resize:vertical; font-family:var(--mono); } -.bypass-actions { grid-area:actions; display:flex; flex-direction:column; gap:10px; } -@media (max-width:1000px) { - .bypass-card { grid-template-columns:minmax(0,1fr) auto; grid-template-areas:"intro intro" "list actions"; } -} +.bypass-actions { grid-area:actions; display:flex; flex-wrap:wrap; justify-content:flex-end; gap:10px; } .global-card { display:flex; justify-content:space-between; align-items:flex-start; gap:24px; } .global-card h2 { margin:0 0 8px; } .global-card p { margin:0; } @@ -128,6 +125,6 @@ html.dark #template-ace .ace_comment { color:#93a1ad; } } @media (max-width:700px) { .bypass-card { grid-template-columns:1fr; grid-template-areas:"intro" "list" "actions"; } - .bypass-actions { flex-direction:row; flex-wrap:wrap; } + .bypass-actions { justify-content:flex-start; } .global-card { flex-direction:column; } } From fbfee693440a28cf02a01fed4b7b82913dc895fd Mon Sep 17 00:00:00 2001 From: 7heMech <83923848+7heMech@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:34:36 +0000 Subject: [PATCH 3/3] Stack the bypass cards in one column with a shorter description Co-Authored-By: Claude Opus 5.5 (1M context) --- addons/maintenance/app/views.css | 13 +++++-------- addons/maintenance/app/views.ts | 11 +++++------ 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/addons/maintenance/app/views.css b/addons/maintenance/app/views.css index 320bf88..bd820fe 100644 --- a/addons/maintenance/app/views.css +++ b/addons/maintenance/app/views.css @@ -70,12 +70,11 @@ html.dark #template-ace .ace_constant, html.dark #template-ace .ace_numeric, html.dark #template-ace .ace_entity { color:#d9a9ff; } html.dark #template-ace .ace_comment { color:#93a1ad; } -.bypass-card { display:grid; grid-template-columns:minmax(0,2fr) minmax(0,3fr); grid-template-areas:"intro list" "intro actions"; gap:12px 32px; align-items:start; } -.bypass-intro { grid-area:intro; } -.bypass-intro h2 { margin:0 0 8px; } -.bypass-intro .hint { margin:0 0 6px; } -.bypass-list { grid-area:list; display:block; width:100%; min-width:0; resize:vertical; font-family:var(--mono); } -.bypass-actions { grid-area:actions; display:flex; flex-wrap:wrap; justify-content:flex-end; gap:10px; } +.bypass-card h2 { margin:0 0 8px; } +.bypass-card .hint { margin:0; } +.bypass-card .form-actions { flex-wrap:wrap; margin-top:16px; } +.bypass-card .btn { white-space:nowrap; } +.bypass-list { display:block; width:100%; margin-top:16px; resize:vertical; font-family:var(--mono); } .global-card { display:flex; justify-content:space-between; align-items:flex-start; gap:24px; } .global-card h2 { margin:0 0 8px; } .global-card p { margin:0; } @@ -124,7 +123,5 @@ html.dark #template-ace .ace_comment { color:#93a1ad; } .fleet-table.maintenance-fleet-table td.bypass-cell { display: none; } } @media (max-width:700px) { - .bypass-card { grid-template-columns:1fr; grid-template-areas:"intro" "list" "actions"; } - .bypass-actions { justify-content:flex-start; } .global-card { flex-direction:column; } } diff --git a/addons/maintenance/app/views.ts b/addons/maintenance/app/views.ts index 4a5c4cd..8809de0 100644 --- a/addons/maintenance/app/views.ts +++ b/addons/maintenance/app/views.ts @@ -84,14 +84,13 @@ function globalCard(globalEnabled: boolean, disabled: boolean, bypasses: string[
- ${bypassCard("global-bypass-ips", "Global IP bypasses", "These addresses skip maintenance on every site, including sites with their own setting on.", bypasses, currentIp, "saveGlobalBypasses()")}`; + ${bypassCard("global-bypass-ips", "Global IP bypasses", "These skip maintenance on every site.", bypasses, currentIp, "saveGlobalBypasses()")}`; } function bypassCard(id: string, title: string, scope: string, bypasses: string[], currentIp: string, save: string): string { - return `
-

${title}

${scope}

One IPv4 or IPv6 visitor address per line. For Cloudflare sites, enter the visitor IP.

- -
${currentIp ? `` : ""}
+ return `

${title}

${scope} One visitor IP per line.

+ +
${currentIp ? `` : ""}
`; } @@ -135,7 +134,7 @@ export function siteView(

Maintenance response

Visitors receive HTTP 503 with a five-minute Retry-After header. ACME certificate challenges and bypassed IPs remain live.

- ${bypassCard("bypass-ips", "IP bypasses", "These addresses skip maintenance on this site, as do the global bypasses on the overview.", site.bypasses, currentIp, `saveBypasses('${escJs(site.domain)}')`)} + ${bypassCard("bypass-ips", "IP bypasses", "These skip maintenance on this site, as do the global bypasses.", site.bypasses, currentIp, `saveBypasses('${escJs(site.domain)}')`)}

Maintenance page

Custom HTML and CSS are stored for this site. Active scripts and form controls are removed.