Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion addons/maintenance/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", '<div class="alert">That is not a valid hostname.</div>', updateNotice), csrf, 400);
const [page, template] = await Promise.all([maintenanceService.site(domain), maintenanceService.template(domain)]);
Expand Down
56 changes: 31 additions & 25 deletions addons/maintenance/app/views.client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
15 changes: 5 additions & 10 deletions addons/maintenance/app/views.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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; }
}
24 changes: 13 additions & 11 deletions addons/maintenance/app/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<div class="card" data-global-maintenance="${globalEnabled}"><div class="global-card">
<div>
<h2>Global maintenance</h2>
Expand All @@ -83,13 +83,18 @@ function globalCard(globalEnabled: boolean, disabled: boolean, bypasses: string[
<span class="switch switch-danger"><input type="checkbox" id="global-toggle" ${globalEnabled ? "checked" : ""} ${disabled ? "disabled" : ""} onchange="toggleGlobalMaintenance(this.checked)"><span></span></span>
</label>
</div>
<div class="global-bypass"><label class="bypass-field" for="global-bypass-ips"><span>Global IP bypasses</span><textarea id="global-bypass-ips" spellcheck="false">${esc(bypasses.join("\n"))}</textarea></label>
<div class="actions bypass-actions"><button class="btn btn-primary" type="button" onclick="saveGlobalBypasses()">Save bypasses</button></div>
<p class="hint">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.</p>
</div></div>`;
</div></div>
${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 `<div class="card bypass-card"><h2>${title}</h2><p class="hint">${scope} One visitor IP per line.</p>
<textarea id="${id}" class="bypass-list" aria-label="${title}" spellcheck="false" rows="${Math.min(Math.max(bypasses.length + 1, 3), 8)}" oninput="syncBypassSave(this)">${esc(bypasses.join("\n"))}</textarea>
<div class="form-actions">${currentIp ? `<button class="btn" type="button" onclick="addCurrentIp('${id}', '${escJs(currentIp)}')">Add my IP (${esc(currentIp)})</button>` : ""}<button class="btn btn-primary" type="button" data-bypass-save="${id}" disabled onclick="${save}">Save bypasses</button></div>
</div>`;
}

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);
Expand All @@ -111,7 +116,7 @@ export function fleetView(sites: MaintenanceSiteView[], globalState: boolean | G
<div class="stat"><div class="label">In maintenance</div><div class="value">${inMaintenanceCount}</div></div>
<div class="stat"><div class="label">Live</div><div class="value">${liveCount}</div></div>
</div>
${globalCard(globalEnabled, sites.length === 0, globalBypasses)}
${globalCard(globalEnabled, sites.length === 0, globalBypasses, currentIp)}
<div class="card card-table"><div class="card-header"><h2>Sites</h2></div>
${sites.length ? `<table class="fleet-table maintenance-fleet-table"><thead><tr><th scope="col">Site</th><th scope="col">Type</th><th scope="col">Effective status</th><th scope="col">Page</th><th scope="col">Bypasses</th><th scope="col" class="action-cell">Site setting</th></tr></thead><tbody data-global-maintenance="${globalEnabled}">${rows}</tbody></table>` : '<div class="empty">No CloudPanel sites were found.</div>'}
</div>`;
Expand All @@ -129,10 +134,7 @@ export function siteView(
<div class="card"><div class="switch-row"><div><h2>Maintenance response</h2><p class="hint">Visitors receive HTTP 503 with a five-minute Retry-After header. ACME certificate challenges and bypassed IPs remain live.</p></div>
<label class="switch switch-danger"><input type="checkbox" data-toggle-domain="${esc(site.domain)}" data-available="true" aria-label="Maintenance mode for ${esc(site.domain)}" ${site.enabled ? "checked" : ""} onchange="toggleMaintenance('${escJs(site.domain)}', this.checked)"><span></span></label>
</div></div>
<div class="card"><div class="card-header"><div><h2>IP bypasses</h2><p class="hint">One IPv4 or IPv6 visitor address per line. For Cloudflare sites, enter the visitor IP. Global bypasses from the overview also apply here.</p></div></div>
<div class="bypass-grid"><label class="bypass-field" for="bypass-ips"><span>Allowed IP addresses</span><textarea id="bypass-ips" spellcheck="false">${esc(site.bypasses.join("\n"))}</textarea></label>
<div class="actions bypass-actions">${currentIp ? `<button class="btn" type="button" onclick="addCurrentIp('${escJs(currentIp)}')">Add my IP (${esc(currentIp)})</button>` : ""}<button class="btn btn-primary" type="button" onclick="saveBypasses('${escJs(site.domain)}')">Save bypasses</button></div></div>
</div>
${bypassCard("bypass-ips", "IP bypasses", "These skip maintenance on this site, as do the global bypasses.", site.bypasses, currentIp, `saveBypasses('${escJs(site.domain)}')`)}
<div class="card"><div class="card-header"><div><h2>Maintenance page</h2><p class="hint">Custom HTML and CSS are stored for this site. Active scripts and form controls are removed.</p></div></div>
<div class="toolbar editor-toolbar">
<div class="editor-tabs" role="tablist"><button class="btn" type="button" data-editor-tab="editor" aria-selected="true" onclick="showEditorTab('editor')">HTML / CSS</button><button class="btn" type="button" data-editor-tab="preview" aria-selected="false" onclick="showEditorTab('preview')">Preview</button></div>
Expand Down
5 changes: 3 additions & 2 deletions docs/decisions/maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions tests/test-maintenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<h1>Maintenance</h1>" };
Expand Down
2 changes: 1 addition & 1 deletion tools/preview-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
? {
Expand Down