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..bd820fe 100644 --- a/addons/maintenance/app/views.css +++ b/addons/maintenance/app/views.css @@ -70,17 +70,14 @@ 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 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; } -.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 +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-grid { grid-template-columns:1fr; } - .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 30073b6..8809de0 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,18 @@ 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 skip maintenance on every site.", 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 visitor IP per line.

+ +
${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 +116,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 +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.

-

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 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.

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 ? {