Compare commits

..

7 Commits

8 changed files with 88 additions and 39 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
<img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2">
<img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
<img alt="Version" src="https://img.shields.io/badge/version-0.16.5-62E6A7">
<img alt="Version" src="https://img.shields.io/badge/version-0.16.13-62E6A7">
</p>
<p>
<a href="#why-site-gateway">Why Site Gateway</a> ·
+14
View File
@@ -138,3 +138,17 @@ Roughly in priority order:
`v0.16.4` rewords the Reload & Restart section's "Docker socket not detected" message so it no longer reads as a duplicate of the Docker container-selection message elsewhere on the System tab. Both checks are independent (one gates the proxy-target container picker, the other gates whether Site Gateway can confirm this container will actually come back up before offering a restart), but they previously used the exact same sentence, which looked like a copy-paste mistake. No behavior change — the Restart button is still disabled under the same conditions as before.
`v0.16.5` gives the Docker container-selection status its own `.health-tile` card (matching `BACKUP_PASSWORD` directly above it) instead of a plain paragraph — the two Environment/Integrations rows now look consistent whether Docker's socket is mounted or not. Also adds breathing room between the Reload & Restart buttons and the status line beneath them, which was sitting flush against the button row.
`v0.16.6` fixes the real cause of the System tab's Scheduled jobs section always showing "No scheduled jobs reported.": the dashboard API nests job data under `dashboard.system.jobs`, but the System tab was reading `dashboard.jobs` — one level too shallow, so it was always undefined regardless of what the server returned. Also fixes the Docker socket status tile rendering wider than the `BACKUP_PASSWORD` tile above it — it wasn't wrapped in the same `.health-grid` container, so it spanned the full panel width instead of matching the two-column tile layout used everywhere else on the System tab.
`v0.16.7` fixes the real cause of the System tab visibly blinking in and out on every page reload: unlike every other Administration tab (Users, Groups, Gateway defaults, Audit log, Backup & restore, Logs & Retention, Danger Zone — all static HTML present from the first paint), System and API Access were built entirely by JavaScript after the initial data fetch completed, so there was a real window on every reload where every other tab was already visible and these two genuinely were not there yet. Confirmed via a screenshot taken mid-reload showing exactly that. Fix: gave System and API Access the same static tab-button-and-panel shell every other tab already has, so they're present immediately; their content still fills in a moment later via JavaScript, same as every other tab already does. Also reworked the Scheduled Jobs section to match the Storage section's card styling (health-tile grid instead of a plain list) and added last-run timestamps where the server tracks them (scheduled backups, log pruning, public IP checks, configuration drift checks).
`v0.16.8` reworks the System tab's Integrations section: the `BACKUP_PASSWORD` and Docker socket status tiles now share a single two-column grid and sit side by side, instead of each occupying its own separate grid and leaving an empty column next to it. Dropped the redundant "Docker container selection" heading text since the tile's own "Docker socket" label already says the same thing. Moved the Sync section's "Resync now" button out of the panel heading row and into its own row below the status text, matching the layout every other actioned section (like Reload & Restart) already uses, instead of crowding the button into the title row. Also added real last-run tracking for the two jobs that previously always showed "No run recorded yet": Upstream checks and Access-log import now record a timestamp every time their interval actually runs.
`v0.16.10` fixes a real storage leak: every log prune (scheduled or manual) takes a `pre-prune-<timestamp>.sqlite` safety snapshot into the backups folder, but these are not `.sgbackup` files — they never appeared in the Backup & Restore list and couldn't be deleted from there, so they accumulated indefinitely and silently inflated the System tab's Storage breakdown even after deleting every visible backup. Added automatic cleanup that keeps only the 3 most recent snapshots after each prune, plus a one-time cleanup on startup so existing accumulated snapshots are cleared out immediately after upgrading rather than waiting for the next prune to run.
`v0.16.11` merges the System tab's separate "Sync" and "Reload & restart" sections into one "Sync & control" panel, since the two were both short, related, single-button gateway-control sections that were falling out of line with the rest of the tab's panel widths on their own. Also adds a spacing rule so the merged panel's status line and description text don't sit flush against each other.
`v0.16.12` fixes the Backup & Restore tab's "Backup history" section getting stuck on "Loading backup history…" indefinitely. Root cause: `renderBackupHistory()` is only invoked from the periodic `refresh()` cycle, and it bailed out before fetching whenever the Backups tab wasn't the currently active admin tab at that moment — but switching admin tabs only toggles CSS visibility, it never re-triggers a fetch. So if a refresh cycle landed while you were on a different tab, the placeholder text was left in place with no later refresh ever replacing it. Same bug class as v0.16.3's System-tab fix; resolved the same way, by always populating the section's content regardless of the panel's current visibility.
`v0.16.13` fixes three Administration/Logs layout inconsistencies found in live use. First, the Groups tab was missing the stat-count bar ("N Administrators · N Standard Users · ...") that every other listing tab (Users) shows, making it look unfinished by comparison — added a matching Enabled/Disabled group count bar. Second, "Create group" lived in its own row inside the Groups panel instead of the shared top-right header button used by "Create user," "New hosted site," and every other creation action — moved it into that same header slot so it behaves and aligns like all the others. Third, the Logs page's "Refresh logs" button (and Performance's and Certificates') sat directly against the first box below it with no gap, because those three pages are the only ones with no status-summary bar to provide the usual spacing under the page header — added a matching top margin so they're consistent with every other page.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "site-gateway",
"version": "0.16.5",
"version": "0.16.13",
"private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module",
+5 -3
View File
@@ -504,8 +504,9 @@ function render() {
$("#certificates-view").classList.toggle("hidden", state.view !== "certificates"); $("#logs-view").classList.toggle("hidden", state.view !== "logs"); $("#performance-view").classList.toggle("hidden", state.view !== "performance"); $("#users-view").classList.toggle("hidden", state.view !== "administration"); $("#account-view").classList.toggle("hidden", state.view !== "account");
if (state.view === "administration") { const adminTab = state.adminTab || "users"; document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item.dataset.adminTab === adminTab)); document.querySelectorAll("[data-admin-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.adminPanel !== adminTab)); }
$("#streaming-view").classList.toggle("hidden", state.view !== "streaming"); $("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation");
const adminUsersActive = state.view === "administration" && document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab === "users";
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || ["streaming","redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates"); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
const activeAdminTab = state.view === "administration" ? document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab : null;
const adminUsersActive = activeAdminTab === "users", adminGroupsActive = activeAdminTab === "groups";
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || adminGroupsActive || ["streaming","redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates"); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
if (overview) {
$("#page-title").textContent = "Dashboard";
$("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
@@ -515,7 +516,7 @@ function render() {
if (!management) {
const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], performance:["Performance","Live and historical request throughput across your gateway."], administration:["Administration","Users, gateway defaults, backups, and updates."], streaming:["Streaming hosts","Forward raw TCP/UDP traffic on a specific port straight to another host and port."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."], account:["My Account","Manage your profile, password, and two-factor authentication."] };
const heading = headings[state.view] || ["Site Gateway",""]; $("#page-title").textContent = heading[0]; $("#page-subtitle").textContent = heading[1];
$("#open-create").textContent = state.view === "administration" ? " Create user" : state.view === "streaming" ? " New streaming host" : state.view === "redirects" ? " New redirect host" : state.view === "access" ? " New Access List" : $("#open-create").textContent;
$("#open-create").textContent = state.view === "administration" ? (adminGroupsActive ? " Create group" : " Create user") : state.view === "streaming" ? " New streaming host" : state.view === "redirects" ? " New redirect host" : state.view === "access" ? " New Access List" : $("#open-create").textContent;
if (state.view === "streaming") $("#stream-empty").classList.toggle("hidden", !state.loaded || state.streams.length > 0);
if (state.view === "streaming") { const items = state.streams; const running = items.filter(item => item.status === "running").length, disabled = items.filter(item => item.status === "disabled").length, errors = items.filter(item => item.status === "error").length; $("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = errors; $("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = errors ? "Needs attention" : "No issues"; $("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = `status-dot ${errors ? "error" : "inactive"}`; $(".port-note").classList.add("hidden"); }
if (state.view === "redirects") $("#redirect-empty .create-trigger").textContent = "Create a redirect host";
@@ -659,6 +660,7 @@ $("#event-category").addEventListener("change", renderLogs);
// --- "Create" dialog: opens the right create form/dialog for the current view --------------
function openCreate() {
if (state.view === "administration" && state.adminTab === "groups") { openNewGroupEditor(); return; }
if (state.view === "administration") { $("#user-form").reset(); $("#user-error").textContent = ""; return $("#user-dialog").showModal(); }
if (state.view === "streaming") { $("#stream-form").reset(); delete $("#stream-form").dataset.editing; $("#stream-title").textContent = "Create a streaming host"; $("#stream-form .button.primary").textContent = "Create streaming host"; $("#stream-error").textContent = ""; return $("#stream-dialog").showModal(); }
if (state.view === "redirects") { $("#redirect-form").reset(); delete $("#redirect-form").dataset.editing; $("#redirect-error").textContent = ""; return $("#redirect-dialog").showModal(); }
+29 -19
View File
@@ -258,12 +258,12 @@ document.addEventListener("change", async event => { const checkbox = event.targ
document.querySelector("#access-list")?.addEventListener("change", async event => { const checkbox = event.target.closest("[data-assignment-kind]"); if (!checkbox) return; event.stopImmediatePropagation(); const accessListId = document.querySelector("#access-form")?.dataset.editing; const kind = checkbox.dataset.assignmentKind; if (!accessListId) { checkbox.checked = !checkbox.checked; toast("Open an Access List before assigning hosts."); return; } try { await api("/api/access-lists/" + accessListId + "/assignments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kind, hostId: checkbox.dataset.assignmentId, assigned: checkbox.checked }) }); await refresh(); renderAssignmentEditor(accessListId); ensureAssignmentSearch(); toast(checkbox.checked ? "Host added to Access List." : "Host removed from Access List."); } catch (error) { checkbox.checked = !checkbox.checked; toast(error.message); } }, true);
// --- Groups admin panel (dynamically inserted "Groups" tab) ----------------------
function renderGroups() { const tabs = document.querySelector(".admin-tabs"); const usersPanel = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !usersPanel) return; let tab = tabs.querySelector('[data-admin-tab="groups"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "groups"; tab.textContent = "Groups"; tabs.insertBefore(tab, tabs.children[1]); } let panel = document.querySelector('[data-admin-panel="groups"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "groups"; panel.className = "settings-panel hidden"; usersPanel.parentElement.insertBefore(panel, usersPanel.nextElementSibling); } panel.innerHTML = '<div class="panel-heading"><div><h2>Groups</h2><p class="muted">Organize users for Access List permissions.</p></div><button id="create-group" class="button primary">Create group</button></div>' + (state.groups.length ? '<div class="user-grid">' + state.groups.map(group => '<article class="site-card group-card"><div class="card-top"><div class="site-icon">GR</div><div class="menu-wrap"><button class="icon-button menu-button" aria-label="Group options" aria-expanded="false">•••</button><div class="menu"><button data-group-action="edit" data-group-id="' + group.id + '">Edit group</button><button data-group-action="icon" data-group-id="' + group.id + '">Change icon</button><button data-group-action="delete" data-group-id="' + group.id + '" class="danger-text">Delete group</button></div></div></div><h2>' + extendedEscape(group.name) + '</h2><p class="address">' + (group.members?.length || 0) + ' members</p><div class="card-footer"><span class="status-pill"><span class="status-dot ' + (group.enabled === false ? "disabled" : "running") + '"></span>' + (group.enabled === false ? "Disabled" : "Enabled") + '</span><div class="card-actions"><button class="toggle ' + (group.enabled !== false ? "on" : "") + '" data-group-action="toggle" data-group-id="' + group.id + '" aria-label="' + (group.enabled !== false ? "Disable" : "Enable") + ' group"><span></span></button></div></div></article>').join("") + '</div>' : '<p class="quiet-state padded">No groups yet. Create one to organize users.</p>'); }
function renderGroups() { const tabs = document.querySelector(".admin-tabs"); const usersPanel = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !usersPanel) return; let tab = tabs.querySelector('[data-admin-tab="groups"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "groups"; tab.textContent = "Groups"; tabs.insertBefore(tab, tabs.children[1]); } let panel = document.querySelector('[data-admin-panel="groups"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "groups"; panel.className = "settings-panel hidden"; usersPanel.parentElement.insertBefore(panel, usersPanel.nextElementSibling); } const enabledCount = state.groups.filter(group => group.enabled !== false).length, disabledCount = state.groups.length - enabledCount; const summaryHtml = '<div id="group-summary" class="summary">' + [["Enabled", enabledCount, "#62e6a7"], ["Disabled", disabledCount, "#ff7185"]].map(([label, count, color]) => `<div><span class="status-dot" style="${count ? `background:${color}` : ""}"></span><strong>${count}</strong><span>${label}</span></div>`).join("") + '</div>'; panel.innerHTML = '<div class="panel-heading"><div><h2>Groups</h2><p class="muted">Organize users for Access List permissions.</p></div></div>' + summaryHtml + (state.groups.length ? '<div class="user-grid">' + state.groups.map(group => '<article class="site-card group-card"><div class="card-top"><div class="site-icon">GR</div><div class="menu-wrap"><button class="icon-button menu-button" aria-label="Group options" aria-expanded="false">•••</button><div class="menu"><button data-group-action="edit" data-group-id="' + group.id + '">Edit group</button><button data-group-action="icon" data-group-id="' + group.id + '">Change icon</button><button data-group-action="delete" data-group-id="' + group.id + '" class="danger-text">Delete group</button></div></div></div><h2>' + extendedEscape(group.name) + '</h2><p class="address">' + (group.members?.length || 0) + ' members</p><div class="card-footer"><span class="status-pill"><span class="status-dot ' + (group.enabled === false ? "disabled" : "running") + '"></span>' + (group.enabled === false ? "Disabled" : "Enabled") + '</span><div class="card-actions"><button class="toggle ' + (group.enabled !== false ? "on" : "") + '" data-group-action="toggle" data-group-id="' + group.id + '" aria-label="' + (group.enabled !== false ? "Disable" : "Enable") + ' group"><span></span></button></div></div></article>').join("") + '</div>' : '<p class="quiet-state padded">No groups yet. Create one to organize users.</p>'); }
function openGroupEditor(group) { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '<form class="dialog-card group-editor"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>Edit group</h2></div></div><label>Group name<input name="name" required maxlength="80"></label><label>Members <span class="optional">Optional</span></label><p class="muted">Select Site Gateway users who should belong to this group.</p><div class="group-member-options">' + (state.users || []).filter(user => user.status !== "disabled").map(user => '<label class="check-control"><input type="checkbox" name="members" value="' + user.id + '"><span>' + extendedEscape(user.username) + ' <small>' + extendedEscape(user.role || "Standard User") + '</small></span></label>').join("") + '</div><p class="error" data-group-error></p><div class="dialog-actions"><button type="button" class="button secondary close-group-dialog">Cancel</button><button class="button primary">Save group</button></div></form>'; dialog.querySelector('[name="name"]').value = group.name; dialog.querySelectorAll('[name="members"]').forEach(input => { input.checked = (group.memberIds || group.members || []).includes(input.value) || (group.members || []).some(value => value === state.users?.find(user => user.id === input.value)?.username); }); dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups/" + group.id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:form.get("name"), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group updated."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); }
document.addEventListener("click", event => { const button = event.target.closest('[data-admin-panel="groups"] .group-card .menu-button'); if (!button) return; const card = button.closest(".group-card"); const opening = !card.classList.contains("menu-open"); document.querySelectorAll('[data-admin-panel="groups"] .group-card.menu-open').forEach(item => { item.classList.remove("menu-open"); item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); card.classList.toggle("menu-open", opening); button.setAttribute("aria-expanded", String(opening)); event.preventDefault(); event.stopImmediatePropagation(); }, true);
function openNewGroupEditor() { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '<form class="dialog-card group-editor"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>Create group</h2></div></div><label>Group name<input name="name" required maxlength="80" placeholder="Home users"></label><label>Members <span class="optional">Optional</span></label><p class="muted">Select Site Gateway users who should belong to this group.</p><div class="group-member-options">' + (state.users || []).filter(user => user.status !== "disabled").map(user => '<label class="check-control"><input type="checkbox" name="members" value="' + user.id + '"><span>' + extendedEscape(user.username) + ' <small>' + extendedEscape(user.role || "Standard User") + '</small></span></label>').join("") + '</div><p class="error" data-group-error></p><div class="dialog-actions"><button type="button" class="button secondary close-group-dialog">Cancel</button><button class="button primary">Create group</button></div></form>'; dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:String(form.get("name") || "").trim(), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group created."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); }
document.addEventListener("click", async event => { if (event.target.id === "create-group") { openNewGroupEditor(); return; } const button = event.target.closest("[data-group-action]"); if (!button) return; const id = button.dataset.groupId; const group = state.groups.find(value => value.id === id); if (button.dataset.groupAction === "edit") { if (group) openGroupEditor(group); return; } if (button.dataset.groupAction === "icon") return; if (button.dataset.groupAction === "delete" && !confirm("Delete this group?")) return; const isToggle = button.dataset.groupAction === "toggle", wasOn = button.classList.contains("on"); if (isToggle && wasOn) { const assignedLists = (state.accessLists || []).filter(list => (list.groups || []).includes(id) && list.enabled !== false); if (assignedLists.length) { const names = assignedLists.map(list => extendedEscape(list.name)).join(", "); if (!(await themedAccessDialog("Disable group?", `Disabling “${extendedEscape(group?.name || "this group")}” will immediately stop its members from signing in through: ${names}. Continue?`, "Disable", true, "Groups"))) return; } } if (isToggle) { button.classList.toggle("on", !wasOn); button.disabled = true; } try { if (button.dataset.groupAction === "delete") await api("/api/groups/" + id, { method:"DELETE" }); else await api("/api/groups/" + id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled: isToggle ? !wasOn : button.textContent.trim() === "Enable" }) }); await refresh(); toast("Group updated."); } catch (error) { if (isToggle) { button.classList.toggle("on", wasOn); button.disabled = false; } toast(error.message); } });
document.addEventListener("click", async event => { const button = event.target.closest("[data-group-action]"); if (!button) return; const id = button.dataset.groupId; const group = state.groups.find(value => value.id === id); if (button.dataset.groupAction === "edit") { if (group) openGroupEditor(group); return; } if (button.dataset.groupAction === "icon") return; if (button.dataset.groupAction === "delete" && !confirm("Delete this group?")) return; const isToggle = button.dataset.groupAction === "toggle", wasOn = button.classList.contains("on"); if (isToggle && wasOn) { const assignedLists = (state.accessLists || []).filter(list => (list.groups || []).includes(id) && list.enabled !== false); if (assignedLists.length) { const names = assignedLists.map(list => extendedEscape(list.name)).join(", "); if (!(await themedAccessDialog("Disable group?", `Disabling “${extendedEscape(group?.name || "this group")}” will immediately stop its members from signing in through: ${names}. Continue?`, "Disable", true, "Groups"))) return; } } if (isToggle) { button.classList.toggle("on", !wasOn); button.disabled = true; } try { if (button.dataset.groupAction === "delete") await api("/api/groups/" + id, { method:"DELETE" }); else await api("/api/groups/" + id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled: isToggle ? !wasOn : button.textContent.trim() === "Enable" }) }); await refresh(); toast("Group updated."); } catch (error) { if (isToggle) { button.classList.toggle("on", wasOn); button.disabled = false; } toast(error.message); } });
function renderAccessGroupSelector(accessListId) { const summary = document.querySelector("#access-assignment-summary"); if (!summary || !state.groups) return; let field = summary.querySelector(".access-group-selector"); if (!field) { field = document.createElement("section"); field.className = "access-group-selector"; summary.prepend(field); } const selected = state.accessLists.find(item => item.id === accessListId)?.groups || []; field.innerHTML = "<strong>Allowed groups <span class=\"optional\">Optional</span></strong><p class=\"access-group-help\">Members of enabled groups can sign in with their Site Gateway credentials.</p>" + (state.groups.length ? "<div class=\"access-group-options\">" + state.groups.map(group => "<label class=\"check-control access-group-option\"><input type=\"checkbox\" data-group-option=\"" + group.id + "\"" + (selected.includes(group.id) ? " checked" : "") + "><span>" + extendedEscape(group.name) + " <small>" + (group.members?.length || 0) + " members" + (group.enabled === false ? " · Disabled" : "") + "</small></span></label>").join("") + "</div>" : "<p class=\"access-group-empty\">No groups have been created yet.</p>"); }
document.addEventListener("change", async event => { const option = event.target.closest("[data-group-option]"); if (!option) return; const accessListId = document.querySelector("#access-form")?.dataset.editing; if (!accessListId) return; const groups = [...document.querySelectorAll("#access-assignment-summary [data-group-option]:checked")].map(input => input.dataset.groupOption); try { await api("/api/access-lists/" + accessListId + "/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ groups }) }); const item = state.accessLists.find(value => value.id === accessListId); if (item) item.groups = groups; renderAccessLists(); decorateAccessGroups(); toast("Access List groups saved."); } catch (error) { option.checked = !option.checked; toast(error.message); } }, true);
document.addEventListener("click", event => { const button = event.target.closest("#access-list [data-access-action=toggle]"); if (button) event.stopImmediatePropagation(); });
@@ -429,7 +429,6 @@ async function renderBackupHistory() {
panel.append(section);
}
const list = section.querySelector("#backup-history-list");
if (panel.classList.contains("hidden")) return;
try {
const events = await api("/api/backups/history");
list.innerHTML = events.length ? events.map(item => {
@@ -446,16 +445,24 @@ async function renderBackupHistory() {
// saved value, so the integration can never be switched on without its prerequisite.
function renderDockerPanel() {
if (state.user?.role !== "administrator") return;
const panel = document.querySelector('[data-admin-panel="system"] .system-integrations'); if (!panel) return;
const scope = document.querySelector('[data-admin-panel="system"]'); if (!scope) return;
const envStatus = scope.querySelector("#system-env-status"), integrations = scope.querySelector(".system-integrations");
if (!envStatus || !integrations) return;
const socketMounted = state.config?.docker?.socketMounted === true;
const enabled = socketMounted && (state.settings?.dockerIntegration?.enabled === true || state.config?.docker?.enabled === true);
let section = panel.querySelector(".docker-integration-section");
if (!section) {
section = document.createElement("div");
section.className = "docker-integration-section";
section.innerHTML = '<p class="docker-integration-heading">Docker container selection</p><div class="health-tile" id="docker-integration-status"><span class="status-dot"></span><span class="health-tile-copy"><strong>Docker socket</strong><small id="docker-integration-help"></small></span></div><label class="check-control"><input id="docker-integration-toggle" type="checkbox"><span>Let Proxy and Streaming hosts pick a running container as their target</span></label>';
panel.append(section);
section.querySelector("#docker-integration-toggle").addEventListener("change", async event => {
let tile = envStatus.querySelector("#docker-integration-status");
if (!tile) {
tile = document.createElement("div");
tile.className = "health-tile";
tile.id = "docker-integration-status";
tile.innerHTML = '<span class="status-dot"></span><span class="health-tile-copy"><strong>Docker socket</strong><small id="docker-integration-help"></small></span>';
envStatus.append(tile);
}
let control = integrations.querySelector(".check-control");
if (!control) {
integrations.innerHTML = '<label class="check-control"><input id="docker-integration-toggle" type="checkbox"><span>Let Proxy and Streaming hosts pick a running container as their target</span></label>';
control = integrations.querySelector(".check-control");
control.querySelector("#docker-integration-toggle").addEventListener("change", async event => {
const checkbox = event.currentTarget;
checkbox.disabled = true;
try { state.settings = await api("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ dockerIntegration: { enabled: checkbox.checked } }) }); toast(checkbox.checked ? "Container selection enabled." : "Container selection disabled."); }
@@ -463,14 +470,14 @@ function renderDockerPanel() {
finally { checkbox.disabled = false; renderDockerPanel(); decorateContainerPickers(); }
});
}
const toggle = section.querySelector("#docker-integration-toggle");
const toggle = integrations.querySelector("#docker-integration-toggle");
toggle.checked = enabled;
toggle.disabled = !socketMounted;
section.querySelector("#docker-integration-status .status-dot").className = `status-dot ${socketMounted ? "running" : "idle"}`;
section.querySelector("#docker-integration-help").textContent = socketMounted
tile.querySelector(".status-dot").className = `status-dot ${socketMounted ? "running" : "idle"}`;
tile.querySelector("#docker-integration-help").textContent = socketMounted
? "Site Gateway reads the Docker socket read-only to list running containers, and only offers containers that share a Docker network with it."
: "Docker socket not detected — mount /var/run/docker.sock into this container to enable container selection.";
section.querySelector(".check-control").classList.toggle("is-disabled", !socketMounted);
control.classList.toggle("is-disabled", !socketMounted);
}
// Adds the "Pick from running containers" button beside every target field, and keeps its
// visibility in step with the integration's current state.
@@ -540,11 +547,10 @@ function renderSystemPanel() {
'<div class="panel-heading"><div><h2>System</h2><p class="muted">What\u2019s configured, what\u2019s running, and what this deployment can do. Nothing here is customizable except the Docker toggle below and the action buttons \u2014 everything else is status.</p></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Environment</p><h2>Integrations</h2></div></div><div id="system-env-status" class="health-grid"></div><div class="system-integrations"></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Environment</p><h2>Security status</h2></div></div><div id="system-security" class="health-grid"></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Gateway</p><h2>Sync</h2></div><button type="button" id="system-resync" class="button secondary">Resync now</button></div><p id="system-sync-status" class="muted"></p></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div id="system-jobs" class="dashboard-jobs-list"></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div id="system-jobs" class="health-grid"></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Storage</p><h2>Disk usage</h2></div></div><div id="system-storage" class="health-grid"></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Build</p><h2>Version</h2></div></div><div id="system-version" class="muted"></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Gateway</p><h2>Reload & restart</h2></div></div><p class="muted">Reloading re-applies the current configuration to Caddy with no downtime. Restarting stops and restarts the whole application \u2014 only available when a restart policy is set on the container.</p><div class="row-actions"><button type="button" id="system-reload" class="button secondary">Reload gateway config</button><button type="button" id="system-restart" class="button secondary danger-text" disabled>Restart application</button></div><p id="system-restart-status" class="muted"></p></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Gateway</p><h2>Sync & control</h2></div></div><p id="system-sync-status" class="muted"></p><p class="muted">Reloading re-applies the current configuration to Caddy with no downtime. Restarting stops and restarts the whole application \u2014 only available when a restart policy is set on the container.</p><div class="row-actions"><button type="button" id="system-resync" class="button secondary">Resync now</button><button type="button" id="system-reload" class="button secondary">Reload gateway config</button><button type="button" id="system-restart" class="button secondary danger-text" disabled>Restart application</button></div><p id="system-restart-status" class="muted"></p></div>',
].join("");
panel.querySelector("#system-resync").addEventListener("click", async event => {
const button = event.currentTarget; button.disabled = true; const original = button.textContent; button.textContent = "Resyncing\u2026";
@@ -574,7 +580,11 @@ async function renderSystemStatus(panel) {
version = document.querySelector("#system-version"), jobs = document.querySelector("#system-jobs"),
syncStatus = document.querySelector("#system-sync-status"), restartButton = document.querySelector("#system-restart"),
restartStatus = document.querySelector("#system-restart-status");
if (jobs) jobs.innerHTML = (state.dashboard?.jobs || []).map(job => `<div class="dashboard-list-item"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span><strong>${extendedEscape(job.name)}</strong><small>${job.enabled ? `Active \u00b7 ${extendedEscape(job.schedule)}` : "Disabled"}</small></span></div>`).join("") || '<p class="quiet-state">No scheduled jobs reported.</p>';
if (jobs) jobs.innerHTML = (state.dashboard?.system?.jobs || []).map(job => {
const status = job.enabled ? `Active \u00b7 ${extendedEscape(job.schedule)}` : "Disabled";
const lastRun = job.lastRunAt ? `Last run ${extendedEscape(formatTime(job.lastRunAt))}${job.lastStatus && job.lastStatus !== "ok" ? ` \u00b7 ${extendedEscape(job.lastStatus)}` : ""}` : "No run recorded yet";
return `<div class="health-tile"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span class="health-tile-copy"><strong>${extendedEscape(job.name)}</strong><small>${status} \u00b7 ${lastRun}</small></span></div>`;
}).join("") || '<p class="quiet-state">No scheduled jobs reported.</p>';
const envStatus = document.querySelector("#system-env-status");
if (envStatus) {
const encryptionAvailable = Boolean(state.config?.backup?.encryptionAvailable);
+4 -4
View File
@@ -8,7 +8,7 @@
<title>Site Gateway</title>
<meta name="description" content="Host sites, proxy services, and manage HTTPS from one simple dashboard.">
<link rel="icon" type="image/png" href="/site-gateway-icon-approved.png">
<link rel="stylesheet" href="/styles.css?v=0.16.5">
<link rel="stylesheet" href="/styles.css?v=0.16.13">
</head>
<!-- ================================================================
@@ -208,9 +208,9 @@
Audit log, and Logs & Retention, is inserted dynamically by features.js's
renderSystemPanel(). -->
<section id="users-view" class="feature-view hidden">
<div class="admin-tabs"><button class="tab-active" data-admin-tab="users">Users</button><button data-admin-tab="groups">Groups</button><button data-admin-tab="defaults">Gateway defaults</button><button data-admin-tab="audit">Audit log</button><button data-admin-tab="backups">Backup & restore</button><button data-admin-tab="retention">Logs & Retention</button><button data-admin-tab="danger" class="danger-tab">Danger Zone</button></div>
<div class="admin-tabs"><button data-admin-tab="system">System</button><button class="tab-active" data-admin-tab="users">Users</button><button data-admin-tab="groups">Groups</button><button data-admin-tab="defaults">Gateway defaults</button><button data-admin-tab="audit">Audit log</button><button data-admin-tab="backups">Backup & restore</button><button data-admin-tab="retention">Logs & Retention</button><button data-admin-tab="api">API Access</button><button data-admin-tab="danger" class="danger-tab">Danger Zone</button></div>
<!-- Users tab -->
<section data-admin-panel="users">
<section data-admin-panel="system" class="hidden settings-panel"></section><section data-admin-panel="api" class="hidden settings-panel"></section><section data-admin-panel="users">
<div id="user-summary" class="summary user-summary" aria-label="User summary"></div>
<div id="user-list" class="user-grid"><p class="quiet-state">Loading users…</p></div>
</section>
@@ -439,6 +439,6 @@
<div id="toast" class="toast" role="status"></div>
<div id="update-banner" class="update-banner hidden" role="status"><span>A new version of Site Gateway is available.</span><div class="update-banner-actions"><button id="update-banner-refresh" class="button primary">Refresh</button><button id="update-banner-dismiss" class="text-button">Dismiss</button></div></div>
<!-- App scripts: core (app.js) then extended views/admin (features.js) -->
<script src="/app.js?v=0.16.5" defer></script><script src="/features.js?v=0.16.5" defer></script><script src="/select-enhance.js?v=0.16.5" defer></script>
<script src="/app.js?v=0.16.13" defer></script><script src="/features.js?v=0.16.13" defer></script><script src="/select-enhance.js?v=0.16.13" defer></script>
</body>
</html>
+5 -4
View File
@@ -149,6 +149,7 @@ header{align-items:flex-end}
/* Dashboard */
.mobile-nav{display:none}
.dashboard-view{margin-top:38px}
#certificates-view,#performance-view,#logs-view{margin-top:var(--space-7)}
.metric-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--space-4)}
.metric-card{min-width:0;padding:20px;border:1px solid var(--line);border-radius:var(--radius-2xl);background:linear-gradient(145deg,rgba(var(--panel2-rgb),.95),rgba(var(--card-shade-rgb),.95));color:var(--text);text-align:left;position:relative;overflow:hidden}
.metric-card::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--card-accent,var(--green));opacity:.85}
@@ -598,6 +599,7 @@ dialog{max-height:calc(100vh - 28px);overflow:auto}
/* Backup & restore form layout */
.settings-form .form-section{grid-column:1/-1;padding:var(--space-1) 0 20px;border-bottom:1px solid var(--line)}
#default-site-help{grid-column:1/-1;margin:0 0 var(--space-4)}
.settings-form .form-section+.form-section{padding-top:20px}
.settings-form .form-section:last-of-type{border-bottom:0}
.settings-form .form-section .eyebrow{margin-bottom:var(--space-3)}
@@ -773,10 +775,9 @@ label.check-control:has(input[name="upstreamTlsInsecure"]){position:relative;hei
label.check-control:has(input[name="upstreamTlsInsecure"]) span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
label.check-control:has(input[name="upstreamTlsInsecure"]) small{position:absolute;left:0;top:calc(100% + 7px);width:100%;padding:0!important;white-space:normal}
.docker-integration-section{margin-top:14px;padding-top:14px;border-top:1px solid var(--line)}
.docker-integration-heading{font-weight:600;margin:0 0 var(--space-2)}
.docker-integration-section .health-tile{margin-bottom:var(--space-3)}
.docker-integration-section .check-control{margin-top:var(--space-3)}
.system-integrations:not(:empty){margin-top:var(--space-3)}
.system-integrations .check-control{margin:0}
#system-sync-status+p{margin-top:var(--space-3)}
#system-restart-status{margin-top:var(--space-3)}
/* Custom-drawn select (Task #20): native <select> popups can't be reliably themed dark across
+29 -7
View File
@@ -663,6 +663,8 @@ async function syncCaddy() {
let configDrift = { checkedAt: null, drift: false, detail: null };
let lastUpstreamCheckAt = null;
let lastAccessLogImportAt = null;
let lastKnownGoodCaddyConfig = null;
function caddyAdminRequest(options, body) {
return new Promise((resolve, reject) => {
@@ -1013,7 +1015,14 @@ async function dashboardSnapshot() {
publicIp: publicIpState.address,
publicIpCheckedAt: publicIpState.checkedAt,
publicIpError: publicIpState.error,
jobs: [{ name: "Upstream checks", enabled: true, schedule: "60s" }, { name: "Scheduled backups", enabled: Boolean(settings.backups?.enabled), schedule: settings.backups?.enabled ? settings.backups.frequency : "off" }, { name: "Log pruning", enabled: Boolean(settings.logsRetention?.pruningEnabled), schedule: settings.logsRetention?.pruningEnabled ? "15m" : "off" }, { name: "Access-log import", enabled: true, schedule: "30s" }, { name: "Public IP check", enabled: true, schedule: "60m" }, { name: "Configuration drift check", enabled: true, schedule: "10m" }]
jobs: [
{ name: "Upstream checks", enabled: true, schedule: "60s", lastRunAt: lastUpstreamCheckAt },
{ name: "Scheduled backups", enabled: Boolean(settings.backups?.enabled), schedule: settings.backups?.enabled ? settings.backups.frequency : "off", lastRunAt: settings.backups?.lastRunAt || null, lastStatus: settings.backups?.lastStatus || null },
{ name: "Log pruning", enabled: Boolean(settings.logsRetention?.pruningEnabled), schedule: settings.logsRetention?.pruningEnabled ? "15m" : "off", lastRunAt: settings.logsRetention?.lastRunAt || null },
{ name: "Access-log import", enabled: true, schedule: "30s", lastRunAt: lastAccessLogImportAt },
{ name: "Public IP check", enabled: true, schedule: "60m", lastRunAt: publicIpState.checkedAt || null },
{ name: "Configuration drift check", enabled: true, schedule: "10m", lastRunAt: configDrift.checkedAt || null },
]
},
activity: recentActivity
};
@@ -2299,7 +2308,19 @@ app.patch("/api/settings", async (req, res, next) => {
recordActivity("Administration settings updated."); res.json({ ...settings, backupDirectory: backupsDir });
} catch (error) { next(error); }
});
app.post("/api/logs/prune", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); if (!settings.logsRetention?.pruningEnabled) return res.status(409).json({ error: "Automatic pruning is disabled. Enable it and save the retention policy first." }); const mode = req.body?.mode === "scheduled" ? "scheduled" : "manual"; const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: mode, lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); recordActivity(`${mode === "scheduled" ? "Scheduled" : "Manual"} log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); res.json({ counts, snapshot }); } catch (error) { next(error); } });
// Pre-prune snapshots (pre-prune-*.sqlite) are safety copies taken before every log prune, scheduled or
// manual. They are not real backups: they never appear in the Backup & Restore list and can't be
// deleted from there (only .sgbackup files can). Without cleanup they accumulate forever and silently
// inflate the Storage breakdown on the System tab. Keep only the most recent few after each prune.
async function cleanupOldPruneSnapshots(keep = 3) {
try {
const names = (await fsp.readdir(backupsDir)).filter(name => name.startsWith("pre-prune-") && name.endsWith(".sqlite"));
const withStats = await Promise.all(names.map(async name => ({ name, mtime: (await fsp.stat(path.join(backupsDir, name))).mtimeMs })));
withStats.sort((a, b) => b.mtime - a.mtime);
for (const item of withStats.slice(keep)) await fsp.rm(path.join(backupsDir, item.name), { force: true });
} catch (error) { console.warn("Could not clean up old pre-prune snapshots:", error.message); }
}
app.post("/api/logs/prune", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); if (!settings.logsRetention?.pruningEnabled) return res.status(409).json({ error: "Automatic pruning is disabled. Enable it and save the retention policy first." }); const mode = req.body?.mode === "scheduled" ? "scheduled" : "manual"; const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: mode, lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); await cleanupOldPruneSnapshots(); recordActivity(`${mode === "scheduled" ? "Scheduled" : "Manual"} log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); res.json({ counts, snapshot }); } catch (error) { next(error); } });
app.get("/api/logs/prune/preview", (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); res.json({ enabled: settings.logsRetention?.pruningEnabled === true, counts: storage.previewPruneEvents(settings.logsRetention || {}) }); } catch (error) { next(error); } });
app.get("/api/logs/download", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const payload = { product: "Site Gateway", generatedAt: new Date().toISOString(), access: storage.listAccessEvents(500), activity: storage.listActivity(500), audit: storage.listAudit({}) }; res.setHeader("Content-Disposition", `attachment; filename="site-gateway-logs-${new Date().toISOString().slice(0, 10)}.json"`); res.json(payload); } catch (error) { next(error); } });
app.post("/api/settings/reset-defaults", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); if (String(req.body.confirmation || "") !== "RESTORE DEFAULT") return res.status(400).json({ error:"Type RESTORE DEFAULT exactly to continue." }); if (String(req.body.username || "").trim().toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(401).json({ error:"Administrator credentials were not accepted." }); settings.defaultSite = { mode:"themed404", redirectUrl:"", redirectCode:302, preservePath:true, title:"Route not found", message:"The gateway is responding, but this address has not been configured.", customHtml:"" }; settings.backups = { enabled:false, frequency:"daily", hour:2, retention:7, type:"complete", includeLogs:false, encrypt:false, lastRunAt:null, lastStatus:null }; settings.certificateHealth = { warningDays:30, criticalDays:7, staleMinutes:10 }; await saveSettings(); recordActivity("Gateway preferences restored to defaults."); res.json({ ...settings, backupDirectory:backupsDir }); } catch (error) { next(error); } });
@@ -2363,8 +2384,9 @@ app.listen(adminPort, "0.0.0.0", () => {
if (adminPassword === "change-this-password") console.warn("WARNING: Change ADMIN_PASSWORD before exposing the dashboard.");
});
setTimeout(() => checkAllProxies().catch(error => console.warn("Initial upstream checks failed:", error.message)), 1500).unref();
setInterval(() => checkAllProxies().catch(error => console.warn("Upstream checks failed:", error.message)), 60000).unref();
setTimeout(() => checkAllProxies().then(() => { lastUpstreamCheckAt = new Date().toISOString(); }).catch(error => console.warn("Initial upstream checks failed:", error.message)), 1500).unref();
setTimeout(() => cleanupOldPruneSnapshots().catch(error => console.warn("Startup pre-prune snapshot cleanup failed:", error.message)), 2000).unref();
setInterval(() => checkAllProxies().then(() => { lastUpstreamCheckAt = new Date().toISOString(); }).catch(error => console.warn("Upstream checks failed:", error.message)), 60000).unref();
setTimeout(() => checkConfigDrift().catch(error => console.warn("Config drift check failed:", error.message)), 10000).unref();
setInterval(() => checkConfigDrift().catch(error => console.warn("Config drift check failed:", error.message)), 10 * 60000).unref();
@@ -2386,10 +2408,10 @@ async function runScheduledBackup() {
}
setTimeout(() => runScheduledBackup().catch(error => console.warn("Scheduled backup check failed:", error.message)), 5000).unref();
setInterval(() => runScheduledBackup().catch(error => console.warn("Scheduled backup check failed:", error.message)), 15 * 60000).unref();
async function runScheduledPruning() { if (!settings.logsRetention?.pruningEnabled || !storage?.pruneEvents) return; try { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: "scheduled", lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); recordActivity(`Scheduled log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); } catch (error) { recordActivity(`Scheduled log pruning failed: ${error.message}`, "error"); } }
async function runScheduledPruning() { if (!settings.logsRetention?.pruningEnabled || !storage?.pruneEvents) return; try { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: "scheduled", lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); await cleanupOldPruneSnapshots(); recordActivity(`Scheduled log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); } catch (error) { recordActivity(`Scheduled log pruning failed: ${error.message}`, "error"); } }
setInterval(() => runScheduledPruning(), 15 * 60000).unref();
setTimeout(() => importAccessLogsToSqlite(), 8000).unref();
setInterval(() => importAccessLogsToSqlite(), 30000).unref();
setTimeout(() => importAccessLogsToSqlite().then(() => { lastAccessLogImportAt = new Date().toISOString(); }).catch(error => console.warn("Access-log import failed:", error.message)), 8000).unref();
setInterval(() => importAccessLogsToSqlite().then(() => { lastAccessLogImportAt = new Date().toISOString(); }).catch(error => console.warn("Access-log import failed:", error.message)), 30000).unref();
async function checkPublicIp() {
try {