From dbc8f3490ba5b9b6187934434085f3c8be9f5d92 Mon Sep 17 00:00:00 2001 From: marvin Date: Sun, 13 Sep 2026 03:31:00 +0000 Subject: [PATCH] Add Streaming Hosts: native TCP/UDP port forwarding with monitoring Also adds scripts/apply-patch.sh to automate applying patches dropped in ~/Downloads/Claude outputs. --- package.json | 2 +- scripts/apply-patch.sh | 54 ++++++++++ src/public/app.js | 44 +++++---- src/public/features.js | 31 +++++- src/public/index.html | 2 + src/server.js | 217 ++++++++++++++++++++++++++++++++++++++--- 6 files changed, 315 insertions(+), 35 deletions(-) create mode 100755 scripts/apply-patch.sh diff --git a/package.json b/package.json index bf43d1a..f4d2edd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "site-gateway", - "version": "0.11.34", + "version": "0.11.35", "private": true, "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "type": "module", diff --git a/scripts/apply-patch.sh b/scripts/apply-patch.sh new file mode 100755 index 0000000..560d323 --- /dev/null +++ b/scripts/apply-patch.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# apply-patch.sh — picks up the newest Claude-generated .patch file from +# ~/Downloads/Claude outputs, copies it into this repo, applies it, and +# cleans up both copies. Run from anywhere; it finds the repo root itself. +# +# Usage: ./scripts/apply-patch.sh +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +SOURCE_DIR="$HOME/Downloads/Claude outputs" + +if [ ! -d "$SOURCE_DIR" ]; then + echo "Can't find $SOURCE_DIR" >&2 + exit 1 +fi + +PATCH=$(ls -t "$SOURCE_DIR"/*.patch 2>/dev/null | head -n1 || true) +if [ -z "$PATCH" ]; then + echo "No .patch file found in $SOURCE_DIR" + exit 1 +fi + +NAME=$(basename "$PATCH") +DEST="$REPO_ROOT/$NAME" +echo "Found patch: $NAME" +cp "$PATCH" "$DEST" + +echo "Checking that it applies cleanly..." +if ! git apply --check "$DEST" 2>/tmp/apply-patch-check.log; then + echo + echo "Patch does NOT apply cleanly against the current branch. Nothing was changed." + echo "Details:" + cat /tmp/apply-patch-check.log + echo + echo "The copied patch is left at: $DEST" + echo "The original is untouched at: $PATCH" + exit 1 +fi + +echo "Applying..." +git apply "$DEST" + +echo "Cleaning up..." +rm -f "$DEST" +rm -f "$PATCH" + +echo +echo "Applied and cleaned up ($NAME removed from both this repo and Claude outputs)." +echo "Changed files:" +git status --short +echo +echo "Review with: git diff --stat" +echo "Then commit and push yourself when ready." diff --git a/src/public/app.js b/src/public/app.js index 39e0d13..a876cd6 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -2,7 +2,7 @@ const $ = selector => document.querySelector(selector); const summaryBar = document.querySelector("#management-summary"); const redirectView = document.querySelector("#redirects-view"); if (summaryBar && redirectView) redirectView.parentElement.insertBefore(summaryBar, redirectView); -const state = { sites: [], proxies: [], redirects: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null }; +const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null }; document.querySelector("#create-form [name=domain]")?.closest("label")?.childNodes[0] && (document.querySelector("#create-form [name=domain]").closest("label").childNodes[0].textContent = "Primary domain "); if (!document.querySelector("#create-form [name=accessListId]")) { const anchor = document.querySelector("#create-form [name=tls]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = 'Access List OptionalProtect this hosted site and all of its domains.'; anchor.before(label); } } if (!document.querySelector("#settings-access-list")) { const anchor = document.querySelector("#settings-form [name=domain]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = 'Access List OptionalProtect this route and all of its domains.'; anchor.after(label); } } @@ -246,17 +246,17 @@ async function loadFeatureView() { function render() { const viewHash = state.view === "administration" ? `administration/${state.adminTab || "users"}` : state.view; if (location.hash !== `#${viewHash}`) history.replaceState(null, "", `${location.pathname}${location.search}#${viewHash}`); - $("#hosted-count").textContent = state.sites.length; $("#proxy-count").textContent = state.proxies.length; $("#streaming-count").textContent = "0"; $("#redirect-count").textContent = state.redirects.length; $("#access-count").textContent = state.accessLists.length; $("#certificate-count").textContent = state.certificates?.summary.total || 0; + $("#hosted-count").textContent = state.sites.length; $("#proxy-count").textContent = state.proxies.length; $("#streaming-count").textContent = state.streams.length; $("#redirect-count").textContent = state.redirects.length; $("#access-count").textContent = state.accessLists.length; $("#certificate-count").textContent = state.certificates?.summary.total || 0; document.querySelectorAll("nav [data-view], .aside-utilities [data-view]").forEach(button => button.classList.toggle("nav-active", button.dataset.view === state.view)); const overview = state.view === "overview"; $("#dashboard-view").classList.toggle("hidden", !overview); - const management = state.view === "hosted" || state.view === "proxies" || state.view === "streaming"; - $("#management-view").classList.toggle("hidden", !management); $("#management-summary").classList.toggle("hidden", !(management || state.view === "redirects" || state.view === "access")); + const management = state.view === "hosted" || state.view === "proxies"; + $("#management-view").classList.toggle("hidden", !management); $("#management-summary").classList.toggle("hidden", !(management || state.view === "streaming" || state.view === "redirects" || state.view === "access")); $("#certificates-view").classList.toggle("hidden", state.view !== "certificates"); $("#logs-view").classList.toggle("hidden", state.view !== "logs"); $("#users-view").classList.toggle("hidden", state.view !== "administration"); 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)); } - $("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation"); + $("#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 || ["redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates"); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs"); + $("#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"); if (overview) { $("#page-title").textContent = "Dashboard"; $("#page-subtitle").textContent = "Health, activity, and system status at a glance."; @@ -264,33 +264,35 @@ function render() { return; } 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."], administration:["Administration","Users, gateway defaults, backups, security, and updates."], 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."] }; + 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."], administration:["Administration","Users, gateway defaults, backups, security, 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."] }; 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 === "redirects" ? "+ New redirect host" : state.view === "access" ? "+ New Access List" : $("#open-create").textContent; + $("#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; + 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"; if (state.view === "redirects") $("#redirect-empty").classList.toggle("hidden", !state.loaded || state.redirects.length > 0); if (state.view === "redirects") { const items = state.redirects; const running = items.filter(item => item.enabled !== false).length, disabled = items.length - running; $("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = 0; $("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = "No issues"; $("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = "status-dot inactive"; $(".port-note").classList.add("hidden"); } if (state.view === "certificates") renderCertificates(); else if (state.view === "administration") renderUsers(); else if (state.view === "logs") renderLogs(); return; } - const items = state.view === "hosted" ? state.sites : state.view === "proxies" ? state.proxies : []; + const items = state.view === "hosted" ? state.sites : state.proxies; $("#site-grid").innerHTML = items.map(state.view === "hosted" ? hostedCard : proxyCard).join(""); $("#empty").classList.toggle("hidden", !state.loaded || items.length > 0); - $("#empty h2").textContent = state.view === "hosted" ? "Publish your first site" : state.view === "proxies" ? "Create your first proxy host" : "Create your first streaming host"; - $("#empty p").textContent = state.view === "hosted" ? "Upload a ZIP and optionally connect a domain with automatic HTTPS." : state.view === "proxies" ? "Connect a domain to another container, application, or LAN service." : "Streaming host management is coming soon."; - $("#page-title").textContent = state.view === "hosted" ? "Hosted sites" : state.view === "proxies" ? "Proxy hosts" : "Streaming hosts"; - $("#page-subtitle").textContent = state.view === "hosted" ? "Upload and publish websites on a port or domain." : state.view === "proxies" ? "Route domains securely to applications and containers." : "Prepare and monitor streaming services from one place."; + $("#empty h2").textContent = state.view === "hosted" ? "Publish your first site" : "Create your first proxy host"; + $("#empty p").textContent = state.view === "hosted" ? "Upload a ZIP and optionally connect a domain with automatic HTTPS." : "Connect a domain to another container, application, or LAN service."; + $("#page-title").textContent = state.view === "hosted" ? "Hosted sites" : "Proxy hosts"; + $("#page-subtitle").textContent = state.view === "hosted" ? "Upload and publish websites on a port or domain." : "Route domains securely to applications and containers."; $("#open-create").textContent = state.view === "hosted" ? "+ New hosted site" : "+ New proxy host"; - $("#open-create").classList.toggle("hidden", state.view === "streaming" || !canManage()); - $("#empty .create-trigger").textContent = state.view === "hosted" ? "Create a hosted site" : state.view === "proxies" ? "Create a proxy host" : "Streaming hosts coming soon"; - $("#empty .create-trigger").disabled = state.view === "streaming"; + $("#open-create").classList.toggle("hidden", !canManage()); + $("#empty .create-trigger").textContent = state.view === "hosted" ? "Create a hosted site" : "Create a proxy host"; + $("#empty .create-trigger").disabled = false; $(".port-note").classList.toggle("hidden", state.view === "proxies"); 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"}`; } -async function refresh() { const requests = [api("/api/sites"), api("/api/proxies"), api("/api/redirects"), api("/api/access-lists"), canAdmin() ? api("/api/groups") : Promise.resolve([]), api("/api/dashboard"), api("/api/certificates")]; const results = await Promise.allSettled(requests); results.forEach((result, index) => { if (result.status !== "fulfilled") return; const keys = ["sites", "proxies", "redirects", "accessLists", "groups", "dashboard", "certificates"]; state[keys[index]] = result.value; }); state.loaded = true; render(); window.renderExtendedViews?.(); const pending = state.proxies.filter(proxy => proxy.enabled !== false && !proxy.upstream).map(proxy => proxy.id); if (pending.length && !state.pendingProxyRefresh) { state.pendingProxyRefresh = true; refreshPendingProxies(pending).finally(() => { state.pendingProxyRefresh = false; }); } } +async function refresh() { const requests = [api("/api/sites"), api("/api/proxies"), api("/api/redirects"), api("/api/streams"), api("/api/access-lists"), canAdmin() ? api("/api/groups") : Promise.resolve([]), api("/api/dashboard"), api("/api/certificates")]; const results = await Promise.allSettled(requests); results.forEach((result, index) => { if (result.status !== "fulfilled") return; const keys = ["sites", "proxies", "redirects", "streams", "accessLists", "groups", "dashboard", "certificates"]; state[keys[index]] = result.value; }); state.loaded = true; render(); window.renderExtendedViews?.(); const pending = state.proxies.filter(proxy => proxy.enabled !== false && !proxy.upstream).map(proxy => proxy.id); if (pending.length && !state.pendingProxyRefresh) { state.pendingProxyRefresh = true; refreshPendingProxies(pending).finally(() => { state.pendingProxyRefresh = false; }); } } async function refreshPendingProxies(ids = []) { const pending = new Set(ids.map(String)); for (const delay of [1000, 2000, 3000]) { @@ -336,8 +338,8 @@ $("#log-status").addEventListener("change", renderLogs); $("#event-severity").addEventListener("change", renderLogs); $("#event-category").addEventListener("change", renderLogs); function openCreate() { - if (state.view === "streaming") return toast("Streaming host management is coming soon."); 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(); } if (state.view === "access") { $("#access-form").reset(); delete $("#access-form").dataset.editing; $("#access-error").textContent = ""; $("#access-form .access-create-guidance")?.remove(); const assignmentSummary = $("#access-assignment-summary"); assignmentSummary?.classList.add("hidden"); if (assignmentSummary) assignmentSummary.innerHTML = ""; window.renderCredentialEditor?.([]); return $("#access-dialog").showModal(); } if (state.view === "proxies") { $("#proxy-form").reset(); $("#custom-certificate-fields").classList.remove("custom-certificate-visible"); $("#proxy-error").textContent = ""; return $("#proxy-dialog").showModal(); } @@ -408,7 +410,7 @@ $("#icon-search").addEventListener("input", event => { }, 280); }); async function saveIcon(slug) { - if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; + if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; $("#icon-error").textContent = ""; try { await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug }) }); @@ -420,12 +422,12 @@ $("#reset-icon").addEventListener("click", event => { event.preventDefault(); sa $("#icon-upload").addEventListener("change", async event => { const file = event.target.files[0]; if (!file || !state.iconTarget) return; const data = new FormData(); data.append("icon", file); $("#icon-error").textContent = ""; - try { const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "POST", body: data }); $("#icon-dialog").close(); await refresh(); toast("Custom icon saved locally."); } + try { const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "POST", body: data }); $("#icon-dialog").close(); await refresh(); toast("Custom icon saved locally."); } catch (error) { $("#icon-error").textContent = error.message; } }); $("#save-icon-url").addEventListener("click", async () => { const value = $("#icon-url").value.trim(); if (!/^https:\/\//i.test(value)) { $("#icon-error").textContent = "Enter a trusted HTTPS image URL."; return; } - if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; + if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; try { await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: value }) }); $("#icon-dialog").close(); await refresh(); toast("Icon URL saved."); } catch (error) { $("#icon-error").textContent = error.message; } }); diff --git a/src/public/features.js b/src/public/features.js index 7a6a4de..535e642 100644 --- a/src/public/features.js +++ b/src/public/features.js @@ -3,6 +3,18 @@ function featureIcon(item, fallback) { return item.icon ? ` { if (node.textContent.includes("Hosted Site files")) node.textContent = node.textContent.replaceAll("Hosted Site files", "uploaded hosted-site files"); }); }); backupDialogTextFix.observe(document.body, { childList:true, subtree:true }); +function renderStreams() { + const list = document.querySelector("#stream-list"), empty = document.querySelector("#stream-empty"); + if (!state.loaded) return; + empty.classList.toggle("hidden", !state.loaded || state.streams.length > 0); + list.innerHTML = state.streams.map(item => { + const status = item.status === "running" ? "running" : item.status === "error" ? "error" : "disabled"; + const upstream = item.enabled === false || item.upstream?.status === "unmonitored" ? "Monitoring paused" : !item.upstream || item.upstream.status === "pending" ? "Target check pending" : item.upstream.status === "healthy" ? `Target reachable · ${item.upstream.responseMs} ms` : `Target unreachable · ${extendedEscape(item.upstream.error || "check failed")}`; + const protocols = [item.tcp !== false ? "TCP" : null, item.udp ? "UDP" : null].filter(Boolean).map(value => `${value}`).join(""); + return `
${featureIcon(item,"SH")}

${extendedEscape(item.name)}

Port ${item.port}

→ ${extendedEscape(item.target)}

${upstream}

`; + }).join(""); +} + function renderRedirects() { const list = document.querySelector("#redirect-list"), empty = document.querySelector("#redirect-empty"); // Keep the existing cards or empty state mounted while the shared refresh is pending. @@ -43,12 +55,27 @@ function renderHealthSettings() { function decorateAccessAssignments() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); if (!item || card.querySelector(".access-assignment-preview")) return; const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); const preview = document.createElement("p"); preview.className = "access-assignment-preview"; preview.textContent = assigned.length ? `Protects: ${assigned.map(host => host.name || host.domain).join(" · ")}` : "Not assigned to a host"; card.querySelector(".card-footer")?.before(preview); }); } function renderAuditPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users || document.querySelector('[data-admin-panel="audit"]')) return; const tab = document.createElement("button"); tab.dataset.adminTab = "audit"; tab.textContent = "Audit log"; tabs.insertBefore(tab, tabs.children[1]); const panel = document.createElement("section"); panel.dataset.adminPanel = "audit"; panel.className = "settings-panel hidden"; panel.innerHTML = '

Configuration audit log

A history of Site Gateway configuration changes. Audit records cannot be edited or deleted.

Open this tab to load audit records.

'; users.parentElement.insertBefore(panel, users.nextElementSibling); const load = async () => { const records = await api(`/api/audit?action=${encodeURIComponent(document.querySelector("#audit-action").value)}&status=${encodeURIComponent(document.querySelector("#audit-status").value)}`); document.querySelector("#audit-list").innerHTML = records.length ? records.map(item => `
${item.status === "error" ? "!" : "✓"}${extendedEscape(item.action)}${extendedEscape(item.actor || "System")} · ${extendedEscape(item.status === "error" ? "Failed" : "Success")} · ${extendedEscape(formatTime(item.created_at))}
`).join("") : '

No matching audit records.

'; }; let timer; tab.addEventListener("click", async () => { document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === tab)); document.querySelectorAll("[data-admin-panel]").forEach(item => item.classList.toggle("hidden", item !== panel)); await load(); }); panel.querySelector("#audit-action").addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 300); }); panel.querySelector("#audit-status").addEventListener("change", load); } -function hideRestrictedControls() { if (state.user?.role !== "viewer") return; document.querySelectorAll("#access-list .menu-wrap, #redirect-list .menu-wrap, #access-list [data-access-action=toggle], #redirect-list [data-redirect-action=toggle], .create-trigger, #open-create, #create-backup, #import-backup").forEach(element => { element.classList.add("hidden"); element.setAttribute("aria-hidden", "true"); }); } +function hideRestrictedControls() { if (state.user?.role !== "viewer") return; document.querySelectorAll("#access-list .menu-wrap, #redirect-list .menu-wrap, #stream-list .menu-wrap, #access-list [data-access-action=toggle], #redirect-list [data-redirect-action=toggle], #stream-list [data-stream-action=toggle], .create-trigger, #open-create, #create-backup, #import-backup").forEach(element => { element.classList.add("hidden"); element.setAttribute("aria-hidden", "true"); }); } function renderRetentionPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users) return; let tab = tabs.querySelector('[data-admin-tab="retention"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "retention"; tab.textContent = "Logs & retention"; tabs.append(tab); } let panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "retention"; panel.className = "settings-panel hidden"; users.parentElement.append(panel); } const policy = state.settings?.logsRetention || { accessDays:30, activityDays:90, auditDays:365, certificateDays:365, securityDays:365, pruningEnabled:false }; panel.innerHTML = `

Logs & retention

Choose how long Site Gateway keeps operational and administrative records. Pruning is disabled until you enable it.

`; panel.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { const updated = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ logsRetention:{ accessDays:Number(form.get("accessDays")), activityDays:Number(form.get("activityDays")), auditDays:Number(form.get("auditDays")), certificateDays:Number(form.get("certificateDays")), securityDays:Number(form.get("securityDays")), pruningEnabled:form.has("pruningEnabled") } }) }); state.settings = updated; toast("Log retention policy saved."); } catch (error) { toast(error.message); } }); } -window.renderExtendedViews = function () { renderRedirects(); renderAccessLists(); decorateAccessAssignments(); decorateAccessGroups(); decorateAccessToggles(); renderBackups(); renderDefaultSettings(); renderHealthSettings(); renderGroups(); decorateGroupCards(); renderAuditPanel(); renderRetentionPanel(); const retentionPanel = document.querySelector('[data-admin-panel="retention"]'); const retentionHeading = retentionPanel?.querySelector('.panel-heading > div'); if (retentionHeading && !retentionHeading.querySelector('.retention-eyebrow')) retentionHeading.insertAdjacentHTML("afterbegin", '

AUTOMATIC LOG PRUNING

'); const retentionActions = retentionPanel?.querySelector('.retention-actions'); if (retentionPanel && !retentionActions) { retentionPanel.querySelector('.panel-heading')?.insertAdjacentHTML('beforeend', '
'); retentionPanel.querySelector('[data-retention-action="prune"]')?.addEventListener('click', () => toast('Pruning will run when automatic pruning is enabled and the policy is saved.')); retentionPanel.querySelector('[data-retention-action="download"]')?.addEventListener('click', () => toast('Log download is not available yet.')); } normalizeAdminTabOrder(); hideRestrictedControls(); }; +window.renderExtendedViews = function () { renderStreams(); renderRedirects(); renderAccessLists(); decorateAccessAssignments(); decorateAccessGroups(); decorateAccessToggles(); renderBackups(); renderDefaultSettings(); renderHealthSettings(); renderGroups(); decorateGroupCards(); renderAuditPanel(); renderRetentionPanel(); const retentionPanel = document.querySelector('[data-admin-panel="retention"]'); const retentionHeading = retentionPanel?.querySelector('.panel-heading > div'); if (retentionHeading && !retentionHeading.querySelector('.retention-eyebrow')) retentionHeading.insertAdjacentHTML("afterbegin", '

AUTOMATIC LOG PRUNING

'); const retentionActions = retentionPanel?.querySelector('.retention-actions'); if (retentionPanel && !retentionActions) { retentionPanel.querySelector('.panel-heading')?.insertAdjacentHTML('beforeend', '
'); retentionPanel.querySelector('[data-retention-action="prune"]')?.addEventListener('click', () => toast('Pruning will run when automatic pruning is enabled and the policy is saved.')); retentionPanel.querySelector('[data-retention-action="download"]')?.addEventListener('click', () => toast('Log download is not available yet.')); } normalizeAdminTabOrder(); hideRestrictedControls(); }; for (let hour = 0; hour < 24; hour++) document.querySelector('#backup-settings-form [name="hour"]').insertAdjacentHTML("beforeend", ``); +document.querySelector("#stream-form").addEventListener("submit", async event => { + event.preventDefault(); const form = new FormData(event.target), body = { name: form.get("name"), port: Number(form.get("port")), target: form.get("target"), tcp: form.has("tcp"), udp: form.has("udp"), healthEnabled: form.has("healthEnabled") }; document.querySelector("#stream-error").textContent = ""; + try { const id = event.target.dataset.editing; await api(id ? `/api/streams/${id}` : "/api/streams", { method: id ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); delete event.target.dataset.editing; document.querySelector("#stream-dialog").close(); await refresh(); toast(`Streaming host ${id ? "updated" : "created"} and applied.`); } catch (error) { document.querySelector("#stream-error").textContent = error.message; } +}); +document.querySelector("#stream-list").addEventListener("click", async event => { + const button = event.target.closest("[data-stream-action]"), card = button?.closest("[data-stream-id]"); if (!button || !card) return; const item = state.streams.find(value => value.id === card.dataset.streamId); if (!item) return; + try { + if (button.dataset.streamAction === "edit") { const form = document.querySelector("#stream-form"); form.reset(); form.dataset.editing = item.id; form.elements.name.value = item.name || ""; form.elements.port.value = item.port; form.elements.target.value = item.target || ""; form.elements.tcp.checked = item.tcp !== false; form.elements.udp.checked = Boolean(item.udp); form.elements.healthEnabled.checked = item.healthEnabled !== false; document.querySelector("#stream-title").textContent = "Edit streaming host"; document.querySelector("#stream-form .button.primary").textContent = "Save & apply"; document.querySelector("#stream-error").textContent = ""; document.querySelector("#stream-dialog").showModal(); return; } + if (button.dataset.streamAction === "icon") { card.classList.remove("menu-open"); return openIconPicker("streams", item.id); } + if (button.dataset.streamAction === "delete") { if (!confirm(`Delete streaming host “${item.name}”?`)) return; await api(`/api/streams/${item.id}`, { method: "DELETE" }); await refresh(); toast("Streaming host deleted."); return; } + await api(`/api/streams/${item.id}/toggle`, { method: "POST" }); await refresh(); toast("Streaming host updated."); + } catch (error) { toast(error.message); } +}); +document.querySelector("#stream-list").addEventListener("click", event => { if (event.target.closest(".menu-button")) { const card = event.target.closest("[data-stream-id]"); const opening = !card.classList.contains("menu-open"); document.querySelectorAll("#stream-list .menu-open").forEach(item => item.classList.remove("menu-open")); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button")?.setAttribute("aria-expanded", String(opening)); } }); + document.querySelector("#redirect-form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target), body = Object.fromEntries(form); body.domains = String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean); body.preservePath = form.has("preservePath"); document.querySelector("#redirect-error").textContent = ""; try { const id = event.target.dataset.editing; await api(id ? `/api/redirects/${id}` : "/api/redirects", { method:id ? "PATCH" : "POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(body) }); delete event.target.dataset.editing; document.querySelector("#redirect-dialog").close(); await refresh(); toast(`Redirect Host ${id ? "updated" : "created"} and applied.`); } catch (error) { document.querySelector("#redirect-error").textContent = error.message; } diff --git a/src/public/index.html b/src/public/index.html index cbcd92a..71db724 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -144,6 +144,7 @@ +