`v0.16.14` fixes the Create button (Create user / Create group) disappearing or showing the wrong label after switching Administration tabs. Root cause: a leftover click handler on the admin tabs bar, written before Groups had a Create button at all, still hard-coded "hide the shared Create button unless the tab is Users" and manually poked tab-active/panel-visibility classes directly -- completely independent of and out of sync with the real logic added in v0.16.13's `render()`. Since that same handler also fires when the app restores your last-viewed tab on page load/refresh, it would immediately stomp the button back to the wrong state. Replaced both old handlers with one that simply updates state and calls the real `render()`, so there's a single source of truth for tab switching instead of two handlers disagreeing with each other.
`v0.16.15` cleans up the Groups tab's layout: removed the redundant "Groups / Organize users for Access List permissions." heading, since the tab button and admin panel description already say what the tab is, and it was adding a bare, boxless line of text found nowhere else in Administration once the tab's own Create button moved to the shared header. The Enabled/Disabled stat bar is now the first thing in the panel, structurally matching how the Users tab's own stat bar is positioned. Also added top spacing between the Administration page's subtitle and the row of tab buttons (System, Users, Groups, ...) below it -- that gap had never been set, so the tabs bar sat flush against the subtitle text.
`v0.16.16` ships a batch of fixes found in live use: the Backup type picker (in both the scheduled-backup form and the manual "Create a backup" dialog) no longer shows a long wrapped sentence as the selected value -- it now shows a short "Complete (Recommended)" / "Configuration only" label with the detail moved into the helper text beneath it, and the in-app documentation now explicitly names the "Backup type" field so it's easy to find by search. The Performance page's "Outliers / Slowest requests" section has been removed, along with the per-row error-count badge in the "Throughput by domain" table -- both added noise without being worth the space for most setups. That table's column headers now stay pinned while scrolling instead of scrolling out of view. Rows for domains with no matching Hosted Site, Proxy Host, or Redirect Host are now badged "Not configured" -- that table is built from Caddy's raw access log, so it always included every hostname a request was ever seen for (including scanner/bot traffic hitting made-up subdomains that fall through to the Default Site handler), not just domains you've actually configured; the badge makes that distinction visible instead of leaving it to guesswork. Finally, the Administration Users tab no longer flashes "No users found." for a moment before the user list has actually loaded.
@@ -334,7 +334,6 @@ function renderPerformance() {
$("#performance-summary").innerHTML=`${data.liveRequests} request${data.liveRequests===1?"":"s"} in the last minute across ${label} · <span id="performance-last-checked">Checked ${escapeHtml(formatTime(data.checkedAt))}</span>`;
$("#performance-rows").innerHTML=routes.length?routes.map(route=>`<tr class="${selected&&route.host===selected?"row-highlight":""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}</td><td>${countCell(route.hourRequests,route.hourErrors)}</td><td>${countCell(route.dayRequests,route.dayErrors,route.errorBreakdown,route.host)}</td><td>${formatLatency(route.dayAvgMs)}</td><td>${formatLatency(route.dayP95Ms)}</td><td>${route.dayBytes?escapeHtml(formatBytes(route.dayBytes)):"—"}</td><td>${(route.dayVisitors||0).toLocaleString()}</td><td>${pathsCell(route)}</td></tr>`).join(""):'<tr><td colspan="8" class="quiet-state">No requests have been logged yet.</td></tr>';
// Requests are logged for any Host header Caddy ever saw, including ones with no matching
// Hosted Site / Proxy Host / Redirect Host -- those fall through to the Default Site handler
// instead of a real backend. Badge those rows so they read as log history, not live config.
$("#performance-rows").innerHTML=routes.length?routes.map(route=>{constunconfigured=!configuredDomains.has((route.host||"").toLowerCase());return`<tr class="${selected&&route.host===selected?"row-highlight":""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}${unconfigured?' <span class="chip unconfigured-chip" title="No Hosted Site, Proxy Host, or Redirect Host currently matches this domain -- these requests hit the Default Site handler instead of a real backend.">Not configured</span>':""}</td><td>${countCell(route.hourRequests)}</td><td>${countCell(route.dayRequests)}</td><td>${formatLatency(route.dayAvgMs)}</td><td>${formatLatency(route.dayP95Ms)}</td><td>${route.dayBytes?escapeHtml(formatBytes(route.dayBytes)):"—"}</td><td>${(route.dayVisitors||0).toLocaleString()}</td><td>${pathsCell(route)}</td></tr>`;}).join(""):'<tr><td colspan="8" class="quiet-state">No requests have been logged yet.</td></tr>';
document.querySelectorAll("#user-list .user-card").forEach(card=>{constuser=state.users.find(item=>item.id===card.dataset.userId);constold=card.querySelector('[data-user-action="role"]');if(!user||!old)return;constselect=document.createElement("select");select.className="user-role-select";select.setAttribute("aria-label",`Role for ${user.username}`);select.innerHTML='<option value="administrator">Administrator</option><option value="standard">Standard User</option><option value="viewer">Viewer</option>';select.value=user.role;select.addEventListener("change",async()=>{try{awaitapi(`/api/users/${user.id}`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({role:select.value})});awaitloadFeatureView();toast("User role updated.");}catch(error){select.value=user.role;toast(error.message,"error");}});old.replaceWith(select);});
}
@@ -485,7 +481,7 @@ async function loadFeatureView() {
$("#login-copy").textContent=session.installationSetupPending?"Sign in using the administrator credentials you configured during installation.":"Sign in to manage your sites.";
$("#port-range").textContent=`${state.config.minPort}–${state.config.maxPort}`;$("#port-help").textContent=`Direct LAN access range: ${state.config.minPort}–${state.config.maxPort}`;
@@ -307,7 +307,7 @@ if (backupPasswordInput && !document.querySelector("#backup-password-toggle")) {
}
// Manual "Create backup" button.
document.querySelector("#create-backup")?.addEventListener("click",asyncevent=>{event.preventDefault();event.stopImmediatePropagation();letdialog=document.querySelector("#create-backup-dialog");if(!dialog){dialog=document.createElement("dialog");dialog.id="create-backup-dialog";document.body.append(dialog);}dialog.innerHTML='<form method="dialog" class="dialog-card backup-create-dialog"><div class="dialog-heading"><div><p class="eyebrow">Backup & restore</p><h2>Create a backup</h2></div></div><p class="muted">Choose what to include. Site Gateway saves a copy in <code>/data/backups</code> and downloads a copy to your computer.</p><label>Backup type<select name="type"><option value="complete">Complete (Recommended) — configuration plus Hosted Site files</option><option value="configuration">Configuration only — settings and metadata</option></select></label><small class="backup-dialog-help">Complete backups include uploaded files, icons, certificates, and default-site assets. Configuration-only backups do not include uploaded Hosted Site files.</small><label>Encryption password <span class="optional">Optional</span><input name="password" type="password" placeholder="Optional — enter a password" autocomplete="new-password"></label><small class="backup-dialog-help">If provided, this password is required to restore the downloaded archive.</small><div class="dialog-actions"><button value="cancel" formnovalidate class="button secondary">Cancel</button><button value="confirm" class="button primary">Create backup</button></div></form>';dialog.showModal();constresult=awaitnewPromise(resolve=>dialog.addEventListener("close",()=>resolve(dialog.returnValue),{once:true}));if(result!=="confirm")return;constform=dialog.querySelector("form"),type=form.elements.type.value,password=form.elements.password.value;try{awaitapi("/api/backups",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type,includeLogs:document.querySelector('#backup-settings-form [name="includeLogs"]')?.checked===true,password})});state.backups=awaitapi("/api/backups");renderBackups();toast("Backup created. Use Download in the list below to save it.");}catch(error){toast(error.message);}},true);
document.querySelector("#create-backup")?.addEventListener("click",asyncevent=>{event.preventDefault();event.stopImmediatePropagation();letdialog=document.querySelector("#create-backup-dialog");if(!dialog){dialog=document.createElement("dialog");dialog.id="create-backup-dialog";document.body.append(dialog);}dialog.innerHTML='<form method="dialog" class="dialog-card backup-create-dialog"><div class="dialog-heading"><div><p class="eyebrow">Backup & restore</p><h2>Create a backup</h2></div></div><p class="muted">Choose what to include. Site Gateway saves a copy in <code>/data/backups</code> and downloads a copy to your computer.</p><label>Backup type<select name="type"><option value="complete">Complete (Recommended)</option><option value="configuration">Configuration only</option></select></label><small class="backup-dialog-help">Complete backups include uploaded files, icons, certificates, and default-site assets. Configuration-only backups include settings and metadata, but not uploaded Hosted Site files.</small><label>Encryption password <span class="optional">Optional</span><input name="password" type="password" placeholder="Optional — enter a password" autocomplete="new-password"></label><small class="backup-dialog-help">If provided, this password is required to restore the downloaded archive.</small><div class="dialog-actions"><button value="cancel" formnovalidate class="button secondary">Cancel</button><button value="confirm" class="button primary">Create backup</button></div></form>';dialog.showModal();constresult=awaitnewPromise(resolve=>dialog.addEventListener("close",()=>resolve(dialog.returnValue),{once:true}));if(result!=="confirm")return;constform=dialog.querySelector("form"),type=form.elements.type.value,password=form.elements.password.value;try{awaitapi("/api/backups",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type,includeLogs:document.querySelector('#backup-settings-form [name="includeLogs"]')?.checked===true,password})});state.backups=awaitapi("/api/backups");renderBackups();toast("Backup created. Use Download in the list below to save it.");}catch(error){toast(error.message);}},true);
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.