diff --git a/README.md b/README.md
index c6255df..c246a70 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-
+
Why Site Gateway ·
diff --git a/compose.release.yaml b/compose.release.yaml
index d2c1630..5c9492a 100644
--- a/compose.release.yaml
+++ b/compose.release.yaml
@@ -32,5 +32,11 @@ services:
# - "25565:25565/udp"
volumes:
- ${SITE_GATEWAY_DATA:-/DATA/AppData/site-gateway}:/data
+ # Optional: enables "Pick from running containers" for Proxy and Streaming
+ # host targets (Administration > Gateway defaults > Docker container selection).
+ # Read-only, but be deliberate: access to the Docker socket is effectively root
+ # on the host -- anything that can talk to it can start privileged containers and
+ # mount the host filesystem. Leave this commented out unless you want the feature.
+ # - /var/run/docker.sock:/var/run/docker.sock:ro
labels:
com.centurylinklabs.watchtower.enable: "true"
diff --git a/compose.yaml b/compose.yaml
index 5b1eab6..0d53445 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -56,3 +56,9 @@ services:
# - "25565:25565/udp"
volumes:
- ./data:/data
+ # Optional: enables "Pick from running containers" for Proxy and Streaming
+ # host targets (Administration > Gateway defaults > Docker container selection).
+ # Read-only, but be deliberate: access to the Docker socket is effectively root
+ # on the host -- anything that can talk to it can start privileged containers and
+ # mount the host filesystem. Leave this commented out unless you want the feature.
+ # - /var/run/docker.sock:/var/run/docker.sock:ro
diff --git a/package.json b/package.json
index afaa34d..32feff8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "site-gateway",
- "version": "0.14.0",
+ "version": "0.15.0",
"private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module",
diff --git a/src/public/app.js b/src/public/app.js
index a3d6200..83a7283 100644
--- a/src/public/app.js
+++ b/src/public/app.js
@@ -8,7 +8,7 @@
// --- Shared DOM shortcut and app state ----------------------------------------
const $ = selector => document.querySelector(selector);
-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, updateCheckTimer: null, loadedVersion: null, updateAvailable: false, performanceErrorBreakdowns: {} };
+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, updateCheckTimer: null, loadedVersion: null, updateAvailable: false, performanceErrorBreakdowns: {}, performanceTopPaths: {}, performancePoints: [], performanceCoords: [] };
// One-time DOM patches: move the Access List field into the create/settings
// forms (features.js owns the Access List data, this file owns these forms).
@@ -148,6 +148,33 @@ function probeCopy(service, ready, error, unconfigured = "Not configured") {
function renderDashboardJobs(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"), health = columns?.firstElementChild; if (!columns) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; columns.insertBefore(panel, columns.children[1] || null); } if (health && health.parentElement === columns) columns.parentElement.insertBefore(health, columns); panel.innerHTML = `
${(system.jobs || []).map(job => `
${escapeHtml(job.name)} ${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}
`).join("")}
`; }
function updateDashboardUptime(seconds) { const started = window.__dashboardStartedAt || (window.__dashboardStartedAt = Date.now() - Number(seconds || 0) * 1000); const target = document.querySelector("#system-uptime"); if (!target) return; const elapsed = Math.max(0, Math.floor((Date.now() - started) / 1000)); target.textContent = formatDuration(elapsed); }
+// Dashboard tiles share one baseline accent (green) and switch to the existing
+// --warning / --danger tokens when the thing they count is actually in trouble --
+// the same mechanism the "Needs attention" chip already used.
+const TILE_ACCENT_CLASSES = ["accent-green", "accent-blue", "accent-amber", "accent-purple", "accent-warning", "accent-danger"];
+function setTileAccent(valueId, level) {
+ const tile = $(valueId)?.closest(".metric-card, .metric-chip");
+ if (!tile) return;
+ tile.classList.remove(...TILE_ACCENT_CLASSES);
+ tile.classList.add(level === "danger" ? "accent-danger" : level === "warning" ? "accent-warning" : "accent-green");
+}
+function applyTileAccents(data) {
+ const group = value => !value?.total || !value.errors ? "green" : value.errors >= value.total ? "danger" : "warning";
+ setTileAccent("#dash-hosted-total", group(data.hosted));
+ const upstreams = data.upstreams || { total: 0, unhealthy: 0 };
+ const proxyLevel = group(data.proxies);
+ setTileAccent("#dash-proxy-total", proxyLevel !== "green" ? proxyLevel : upstreams.unhealthy > 0 ? (upstreams.unhealthy >= upstreams.total ? "danger" : "warning") : "green");
+ const certificates = data.certificates || {};
+ const certificateLevel = (certificates.expired || 0) + (certificates.mismatch || 0) > 0 ? "danger" : (certificates.warning || 0) + (certificates.critical || 0) > 0 ? "warning" : "green";
+ setTileAccent("#dash-tls-total", certificateLevel);
+ // Redirect hosts have no runtime failure state of their own, so they stay on the baseline.
+ setTileAccent("#dash-redirect-total", "green");
+ const streaming = data.streamingPorts || { total: 0, listening: 0 };
+ setTileAccent("#dash-stream-total", !streaming.total || streaming.listening === streaming.total ? "green" : streaming.listening === 0 ? "danger" : "warning");
+ // Throughput is a rate, not a health signal: there is no "bad" value to react to.
+ setTileAccent("#dash-throughput-total", "green");
+}
+
function renderDashboard() {
const data = state.dashboard; if (!data) return;
if (data.system) renderDashboardJobsSafe(data.system);
@@ -161,6 +188,7 @@ function renderDashboard() {
$("#dash-stream-total").textContent = state.streams?.length || 0;
$("#dash-attention-total").textContent = data.attention.length;
$("#dash-attention-detail").textContent = data.attention.length ? `${data.attention.length} item${data.attention.length === 1 ? "" : "s"} to review` : "No current issues";
+ applyTileAccents(data);
$("#dash-attention-chip").classList.toggle("accent-warning", data.attention.length > 0);
$("#dash-attention-chip").classList.toggle("accent-green", data.attention.length === 0);
$("#dash-attention-icon").textContent = data.attention.length > 0 ? "!" : "✓";
@@ -221,14 +249,14 @@ function canAdmin() { return state.user?.role === "administrator"; }
function hostedCard(site) {
const status = site.status === "running" ? "running" : site.status === "error" ? "error" : "disabled";
const upstream = !site.enabled || site.upstream?.status === "unmonitored" ? "Monitoring paused" : !site.upstream || site.upstream.status === "pending" ? "Upstream check pending" : site.upstream.status === "healthy" ? `Upstream ${site.upstream.httpStatus} · ${site.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(site.upstream.error || "check failed")}`;
- const menu = canManage() ? `` : "";
+ const menu = canManage() ? `` : "";
const toggle = canManage() ? ` ` : "";
return `${iconMarkup(site)}
${menu}
${escapeHtml(site.name)} ${escapeHtml(site.domain || `Port ${site.port}`)}
${site.domain ? `${escapeHtml(publicUrl(site))}
` : ""}${upstream}
`;
}
function proxyCard(proxy) {
const status = proxy.status === "running" ? "running" : proxy.status === "error" ? "error" : "disabled";
const upstream = !proxy.enabled || proxy.upstream?.status === "unmonitored" ? "Monitoring paused" : !proxy.upstream || proxy.upstream.status === "pending" ? "Upstream check pending" : proxy.upstream.status === "healthy" ? `Upstream ${proxy.upstream.httpStatus} · ${proxy.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(proxy.upstream.error || "check failed")}`;
- const menu = canManage() ? `` : "";
+ const menu = canManage() ? `` : "";
const toggle = canManage() ? ` ` : "";
const access = proxy.accessListId ? (state.accessLists.find(item => item.id === proxy.accessListId)?.name || "Access List") : "Public · no Access List";
return `${iconMarkup(proxy)}
${menu}
${escapeHtml(proxy.name)} ${escapeHtml(proxy.target)}
${escapeHtml(publicUrl(proxy))}
${upstream}
${escapeHtml(access)}
`;
@@ -279,6 +307,20 @@ function renderLogs() {
// --- Performance view: summary, request trend chart (hand-drawn SVG sparkline),
// and the per-domain throughput table -----------------------------------------------
+// Clock-boundary label spacing per selected range (hours -> minutes between labels).
+const PERFORMANCE_LABEL_MINUTES = { 1: 15, 3: 30, 6: 60, 12: 120, 24: 180, 72: 720, 168: 1440 };
+function formatChartTime(value, intervalMinutes) {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return "";
+ if (intervalMinutes >= 1440) return date.toLocaleDateString([], { month: "short", day: "numeric" });
+ if (intervalMinutes >= 720) return date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric" });
+ return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
+}
+function formatLatency(ms) { return ms == null ? "—" : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${ms} ms`; }
+
+// Geometry shared by the chart renderer and the hover tooltip.
+const PERFORMANCE_CHART = { left: 34, right: 8, top: 10, bottom: 20, width: 600, height: 140 };
+
function renderPerformance() {
const data = state.performance; if (!data) return;
const selected = $("#performance-host").value;
@@ -288,9 +330,11 @@ function renderPerformance() {
$("#performance-summary").innerHTML = `${data.liveRequests} request${data.liveRequests === 1 ? "" : "s"} in the last minute across ${label} · Checked ${escapeHtml(formatTime(data.checkedAt))} `;
const rangeLabel = $("#performance-range").selectedOptions[0]?.textContent || "Last 6 hours";
$("#performance-trend-title").textContent = `Requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`;
+ $("#performance-slowest-title").textContent = `Slowest requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`;
const points = data.trend || [];
+ state.performancePoints = points;
const max = Math.max(1, ...points.map(point => point.count));
- const left = 34, right = 8, top = 10, bottom = 20, width = 600, height = 140;
+ const { left, right, top, bottom, width, height } = PERFORMANCE_CHART;
const plotWidth = width - left - right, plotHeight = height - top - bottom;
const xAt = index => left + (points.length > 1 ? (index / (points.length - 1)) * plotWidth : plotWidth);
const yAt = count => top + plotHeight - (count / max) * plotHeight;
@@ -299,16 +343,34 @@ function renderPerformance() {
const y = (top + plotHeight * (1 - fraction)).toFixed(1);
return ` `;
}).join("");
- const leftPct = (left / width) * 100, topPct = 0, plotHeightPct = (plotHeight / height) * 100, topInsetPct = (top / height) * 100;
+ const leftPct = (left / width) * 100, plotWidthPct = (plotWidth / width) * 100, plotHeightPct = (plotHeight / height) * 100, topInsetPct = (top / height) * 100;
const axisLabels = gridFractions.map(fraction => {
const value = Math.round(max * fraction);
const yPct = topInsetPct + plotHeightPct * (1 - fraction);
return `${value} `;
}).join("");
- const firstPoint = points[0], lastPoint = points[points.length - 1];
- const timeLabels = points.length ? `${escapeHtml(formatTime(firstPoint.at))} ${escapeHtml(formatTime(lastPoint.at))} ` : "";
+ // Time axis: labels land on real clock boundaries scaled to the selected range, and the
+ // true first and last sample are always labelled so the window's edges stay readable.
+ const hours = Number($("#performance-range").value) || 6;
+ const intervalMinutes = PERFORMANCE_LABEL_MINUTES[hours] || 60;
+ const intervalMs = intervalMinutes * 60000;
+ let timeLabels = "";
+ if (points.length) {
+ const candidates = new Set([0, points.length - 1]);
+ const aligned = [];
+ points.forEach((point, index) => { const time = new Date(point.at).getTime(); if (!Number.isNaN(time) && time % intervalMs === 0) aligned.push(index); });
+ const stride = Math.max(1, Math.ceil(aligned.length / 8));
+ aligned.forEach((index, position) => { if (position % stride === 0) candidates.add(index); });
+ const ordered = [...candidates].sort((a, b) => a - b);
+ timeLabels = ordered.map(index => {
+ const leftEdge = leftPct + (points.length > 1 ? (index / (points.length - 1)) * plotWidthPct : plotWidthPct);
+ const alignment = index === 0 ? "" : index === points.length - 1 ? " time-label-end" : " time-label-mid";
+ return `${escapeHtml(formatChartTime(points[index].at, intervalMinutes))} `;
+ }).join("");
+ }
$("#performance-sparkline-labels").innerHTML = points.length ? `${axisLabels}${timeLabels}` : "";
const coords = points.map((point, index) => [xAt(index), yAt(point.count)]);
+ state.performanceCoords = coords;
const smoothLine = coords.length < 2 ? "" : coords.reduce((d, point, index) => {
if (index === 0) return `M${point[0].toFixed(1)},${point[1].toFixed(1)}`;
const p0 = coords[index - 2 >= 0 ? index - 2 : index - 1];
@@ -322,16 +384,61 @@ function renderPerformance() {
const baseline = (top + plotHeight).toFixed(1);
const areaPath = coords.length ? `${smoothLine} L${coords[coords.length - 1][0].toFixed(1)},${baseline} L${coords[0][0].toFixed(1)},${baseline} Z` : "";
$("#performance-sparkline").setAttribute("viewBox", `0 0 ${width} ${height}`);
- $("#performance-sparkline").innerHTML = points.length ? `${gridLines} ` : "";
+ $("#performance-sparkline").innerHTML = points.length ? `${gridLines} ` : "";
if (!points.length) $("#performance-sparkline-labels").innerHTML = 'No request data for this window yet. ';
+ hidePerformanceTooltip();
const routes = (data.routes || []).filter(route => !selected || route.host === selected);
state.performanceErrorBreakdowns = {};
+ state.performanceTopPaths = {};
const countCell = (count, errors, breakdown, host) => { if (!errors) return `${count.toLocaleString()}`; if (!breakdown?.length) return `${count.toLocaleString()} · ${errors.toLocaleString()} `; state.performanceErrorBreakdowns[host] = { total: errors, breakdown }; return `${count.toLocaleString()} · ${errors.toLocaleString()} `; };
- const formatAvgMs = ms => ms == null ? "—" : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${ms} ms`;
- $("#performance-rows").innerHTML = routes.length ? routes.map(route => `${escapeHtml(route.host)} ${countCell(route.hourRequests, route.hourErrors)} ${countCell(route.dayRequests, route.dayErrors, route.errorBreakdown, route.host)} ${formatAvgMs(route.dayAvgMs)} `).join("") : 'No requests have been logged yet. ';
+ const pathsCell = route => { if (!route.topPaths?.length) return "—"; state.performanceTopPaths[route.host] = route.topPaths; return `View `; };
+ $("#performance-rows").innerHTML = routes.length ? routes.map(route => `${escapeHtml(route.host)} ${countCell(route.hourRequests, route.hourErrors)} ${countCell(route.dayRequests, route.dayErrors, route.errorBreakdown, route.host)} ${formatLatency(route.dayAvgMs)} ${formatLatency(route.dayP95Ms)} ${route.dayBytes ? escapeHtml(formatBytes(route.dayBytes)) : "—"} ${(route.dayVisitors || 0).toLocaleString()} ${pathsCell(route)} `).join("") : 'No requests have been logged yet. ';
if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" });
+ renderSlowestRequests(data.slowest || []);
}
+// --- Performance: slowest individual requests -------------------------------------------
+function renderSlowestRequests(entries) {
+ const list = $("#performance-slowest"); if (!list) return;
+ list.innerHTML = entries.length ? entries.map(entry => `${escapeHtml(entry.method || "GET")} ${escapeHtml(entry.uri || "/")} ${escapeHtml(entry.host || "—")} · ${entry.status ?? "—"} · ${escapeHtml(formatTime(entry.at))} ${escapeHtml(formatLatency(entry.durationMs))}
`).join("") : 'No timed requests in this window yet.
';
+}
+
+// --- Performance: hover tooltip on the request-trend chart -------------------------------
+function hidePerformanceTooltip() {
+ $("#performance-tooltip")?.classList.add("hidden");
+ document.querySelector("#performance-hover-dot")?.classList.add("hidden");
+}
+function showPerformanceTooltip(event) {
+ const svg = $("#performance-sparkline"), tooltip = $("#performance-tooltip"), points = state.performancePoints || [], coords = state.performanceCoords || [];
+ if (!svg || !tooltip || !points.length || !coords.length) return;
+ const rect = svg.getBoundingClientRect();
+ if (!rect.width || !rect.height) return;
+ const { left, right, width, height } = PERFORMANCE_CHART;
+ const plotWidth = width - left - right;
+ const viewX = ((event.clientX - rect.left) / rect.width) * width;
+ const fraction = Math.min(1, Math.max(0, (viewX - left) / plotWidth));
+ const index = Math.min(points.length - 1, Math.max(0, Math.round(fraction * (points.length - 1))));
+ const point = points[index], coordinate = coords[index];
+ const pixelX = (coordinate[0] / width) * rect.width;
+ const pixelY = (coordinate[1] / height) * rect.height;
+ tooltip.innerHTML = `${point.count.toLocaleString()} request${point.count === 1 ? "" : "s"} ${(point.errors || 0).toLocaleString()} error${point.errors === 1 ? "" : "s"} ${escapeHtml(formatTime(point.at))}`;
+ tooltip.style.left = `${pixelX}px`;
+ tooltip.style.top = `${pixelY}px`;
+ tooltip.classList.remove("hidden");
+ const dot = document.querySelector("#performance-hover-dot");
+ if (dot) {
+ // preserveAspectRatio="none" stretches the viewBox, so compensate to keep the dot round.
+ dot.setAttribute("cx", coordinate[0].toFixed(1));
+ dot.setAttribute("cy", coordinate[1].toFixed(1));
+ dot.setAttribute("rx", (3.5 * (width / rect.width)).toFixed(2));
+ dot.setAttribute("ry", (3.5 * (height / rect.height)).toFixed(2));
+ dot.classList.remove("hidden");
+ }
+}
+$("#performance-sparkline")?.addEventListener("mousemove", showPerformanceTooltip);
+$("#performance-sparkline")?.addEventListener("mouseleave", hidePerformanceTooltip);
+
+
// --- Administration > Users view ---------------------------------------------------------
function renderUsers() {
@@ -490,7 +597,7 @@ $("#logout").addEventListener("click", async () => { await fetch("/api/logout",
// an attention item's view -------------------------------------------------------------
$("#check-health").addEventListener("click", async event => { const button = event.currentTarget; button.disabled = true; button.textContent = "Checking…"; try { const result = await api("/api/health/check", { method:"POST" }); state.dashboard = result.dashboard; state.certificates = result.certificates; state.readiness = { routes:result.readiness }; renderCertificates(); toast("Certificate and domain checks completed."); } catch (error) { toast(error.message, "error"); } finally { button.disabled = false; button.textContent = "Run certificate check"; } });
$("#download-support")?.addEventListener("click", () => { location.href = "/api/support-report"; });
-$("#attention-list").addEventListener("click", event => { const target = event.target.closest("[data-issue-target]")?.dataset.issueTarget; if (target) { state.view = target; render(); loadFeatureView().catch(error => toast(error.message, "error")); } });
+$("#attention-list").addEventListener("click", event => { const target = event.target.closest("[data-issue-target]")?.dataset.issueTarget; if (target) { const [view, adminTab] = target.split("/"); state.view = view; if (view === "administration" && adminTab) state.adminTab = adminTab; render(); loadFeatureView().catch(error => toast(error.message, "error")); } });
// --- Primary navigation (sidebar view switching) -------------------------------------------
function closeMenus() { document.querySelectorAll(".menu-open").forEach(card => { card.classList.remove("menu-open"); card.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); }
@@ -504,8 +611,34 @@ $("#performance-host").addEventListener("change", () => loadFeatureView().catch(
$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
// --- Performance: themed error-breakdown popup, replacing the old hover tooltip ------------
-function showErrorBreakdown(host, total, breakdown) { let dialog = document.querySelector("#error-breakdown-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "error-breakdown-dialog"; document.body.append(dialog); } const rows = breakdown.map(item => `${escapeHtml(item.status)} ${item.count.toLocaleString()}
`).join(""); dialog.innerHTML = ``; dialog.showModal(); }
-$("#performance-rows").addEventListener("click", event => { const button = event.target.closest("[data-error-host]"); if (!button) return; const entry = state.performanceErrorBreakdowns[button.dataset.errorHost]; if (!entry) return; showErrorBreakdown(button.dataset.errorHost, entry.total, entry.breakdown); });
+function showErrorBreakdown(host, total, breakdown) {
+ let dialog = document.querySelector("#error-breakdown-dialog");
+ if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "error-breakdown-dialog"; document.body.append(dialog); }
+ // Client (4xx) and server (5xx) failures mean very different things, so they are grouped
+ // and coloured separately instead of appearing as one flat red list.
+ const client = breakdown.filter(item => Number(item.status) < 500), server = breakdown.filter(item => Number(item.status) >= 500);
+ const rowsFor = (items, kind) => items.map(item => `${escapeHtml(item.status)} ${item.count.toLocaleString()}
`).join("");
+ const clientTotal = client.reduce((sum, item) => sum + item.count, 0), serverTotal = server.reduce((sum, item) => sum + item.count, 0);
+ const sections = [
+ client.length ? `Client errors · 4xx · ${clientTotal.toLocaleString()}
${rowsFor(client, "client-error")}
` : "",
+ server.length ? `Server errors · 5xx · ${serverTotal.toLocaleString()}
${rowsFor(server, "server-error")}
` : ""
+ ].join("");
+ dialog.innerHTML = ``;
+ dialog.showModal();
+}
+function showTopPaths(host, paths) {
+ let dialog = document.querySelector("#top-paths-dialog");
+ if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "top-paths-dialog"; document.body.append(dialog); }
+ const rows = paths.map(item => `${escapeHtml(item.uri)} ${item.count.toLocaleString()}
`).join("");
+ dialog.innerHTML = ``;
+ dialog.showModal();
+}
+$("#performance-rows").addEventListener("click", event => {
+ const errorButton = event.target.closest("[data-error-host]");
+ if (errorButton) { const entry = state.performanceErrorBreakdowns[errorButton.dataset.errorHost]; if (entry) showErrorBreakdown(errorButton.dataset.errorHost, entry.total, entry.breakdown); return; }
+ const pathsButton = event.target.closest("[data-paths-host]");
+ if (pathsButton) { const paths = state.performanceTopPaths?.[pathsButton.dataset.pathsHost]; if (paths) showTopPaths(pathsButton.dataset.pathsHost, paths); }
+});
$("#log-status").addEventListener("change", renderLogs);
$("#event-severity").addEventListener("change", renderLogs);
$("#event-category").addEventListener("change", renderLogs);
@@ -568,6 +701,7 @@ $("#site-grid").addEventListener("click", async event => {
if (action === "delete") { state.pendingDelete = { kind, id: card.dataset.id }; $("#confirm-title").textContent = kind === "proxy" ? "Delete this proxy host?" : "Delete this hosted site?"; $("#confirm-copy").textContent = kind === "proxy" ? "Its domain route will be removed from the gateway." : "Its route and uploaded files will be permanently removed."; $("#confirm-dialog").showModal(); }
if (action === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); }
if (action === "icon") openIconPicker(kind, card.dataset.id);
+ if (action === "caddy-config") openCaddyConfig(kind === "proxy" ? "proxies" : "sites", card.dataset.id);
});
// --- Redirect card actions delegated from the site grid (menu open/close, edit/
@@ -576,8 +710,35 @@ document.querySelector("#redirect-list")?.addEventListener("click", event => {
const card = event.target.closest(".redirect-card"); if (!card) return;
if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button")?.setAttribute("aria-expanded", String(opening)); return; }
const action = event.target.closest("[data-redirect-action]")?.dataset.redirectAction; if (action === "icon") { closeMenus(); openIconPicker("redirect", card.dataset.redirectId); }
+ if (action === "caddy-config") { closeMenus(); openCaddyConfig("redirects", card.dataset.redirectId); }
});
+// --- "View Caddy config" popout --------------------------------------------------------------
+// Two overlapping rectangles, inline so it inherits currentColor from .icon-button.
+const COPY_ICON_SVG = ' ';
+function caddyConfigDialog() {
+ let dialog = document.querySelector("#caddy-config-dialog");
+ if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "caddy-config-dialog"; document.body.append(dialog); }
+ return dialog;
+}
+async function openCaddyConfig(kind, id) {
+ const dialog = caddyConfigDialog();
+ dialog.innerHTML = '';
+ if (!dialog.open) dialog.showModal();
+ try {
+ const result = await api(`/api/${kind}/${encodeURIComponent(id)}/caddy-config`);
+ dialog.innerHTML = ``;
+ dialog.querySelector("#copy-caddy-config").addEventListener("click", async () => {
+ try { await navigator.clipboard.writeText(result.config); toast("Configuration copied."); }
+ catch { toast("Your browser blocked clipboard access.", "error"); }
+ });
+ } catch (error) {
+ dialog.innerHTML = ``;
+ }
+}
+window.openCaddyConfig = openCaddyConfig;
+
+
// --- Delete confirmation dialog and replace-files handler ------------------------------------
$("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await refresh(); toast("Entry deleted and gateway updated."); } state.pendingDelete = null; });
$("#replace-files").addEventListener("change", async event => { if (!event.target.files[0] || !state.pendingReplace) return; const data = new FormData(); data.append("files", event.target.files[0]); try { await api(`/api/sites/${state.pendingReplace}/files`, { method: "POST", body: data }); toast("Site files updated."); } catch (error) { toast(error.message, "error"); } event.target.value = ""; state.pendingReplace = null; });
diff --git a/src/public/features.js b/src/public/features.js
index e7b4e14..eae90f2 100644
--- a/src/public/features.js
+++ b/src/public/features.js
@@ -39,7 +39,7 @@ function renderRedirects() {
// The completed response is the only point at which this view should be replaced.
if (!state.loaded) return;
empty.classList.toggle("hidden", !state.loaded || state.redirects.length > 0);
- list.innerHTML = state.redirects.map(item => `${featureIcon(item,"RD")}
${extendedEscape(item.name)} ${extendedEscape(item.domain)}
→ ${extendedEscape(item.target)}${item.preservePath ? " · preserves path" : ""}
`).join("");
+ list.innerHTML = state.redirects.map(item => `${featureIcon(item,"RD")}
${extendedEscape(item.name)} ${extendedEscape(item.domain)}
→ ${extendedEscape(item.target)}${item.preservePath ? " · preserves path" : ""}
`).join("");
}
@@ -193,7 +193,7 @@ window.renderCredentialEditor = renderCredentialEditor;
// Redirect card options menu actions: edit / change icon / delete.
document.querySelector("#redirect-list").addEventListener("click", async event => {
const button = event.target.closest("[data-redirect-action]"), card = button?.closest("[data-redirect-id]"); if (!button || !card) return; const item = state.redirects.find(value => value.id === card.dataset.redirectId); if (!item) return;
- try { if (button.dataset.redirectAction === "edit") { const form = document.querySelector("#redirect-form"); form.reset(); form.dataset.editing = item.id; for (const key of ["name","domain","target","code","tls"]) form.elements[key].value = item[key] || ""; form.elements.preservePath.checked = item.preservePath !== false; document.querySelector("#redirect-dialog").showModal(); return; } if (button.dataset.redirectAction === "delete") { if (!confirm(`Delete redirect “${item.name}”?`)) return; await api(`/api/redirects/${item.id}`, { method:"DELETE" }); } else await api(`/api/redirects/${item.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled:!item.enabled }) }); await refresh(); toast("Redirect Host updated."); } catch (error) { toast(error.message); }
+ try { if (button.dataset.redirectAction === "edit") { const form = document.querySelector("#redirect-form"); form.reset(); form.dataset.editing = item.id; for (const key of ["name","domain","target","code","tls"]) form.elements[key].value = item[key] || ""; form.elements.preservePath.checked = item.preservePath !== false; document.querySelector("#redirect-dialog").showModal(); return; } if (button.dataset.redirectAction === "delete") { if (!confirm(`Delete redirect “${item.name}”?`)) return; await api(`/api/redirects/${item.id}`, { method:"DELETE" }); } else if (button.dataset.redirectAction === "toggle") await api(`/api/redirects/${item.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled:!item.enabled }) }); else return; await refresh(); toast("Redirect Host updated."); } catch (error) { toast(error.message); }
});
@@ -275,7 +275,7 @@ document.addEventListener("click", event => { if (event.target.closest(".create-
function decorateAccessToggles() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); const footer = card.querySelector(".card-footer"); if (!footer || !item) return; card.querySelectorAll(".menu [data-access-action=toggle]").forEach(button => button.remove()); if (footer.querySelector("[data-access-action=toggle]")) return; let actions = footer.querySelector(".card-actions"); if (!actions) { actions = document.createElement("div"); actions.className = "card-actions"; footer.append(actions); } const toggle = document.createElement("button"); toggle.className = "toggle " + (item.enabled !== false ? "on" : ""); toggle.dataset.accessAction = "toggle"; toggle.setAttribute("aria-label", (item.enabled !== false ? "Disable" : "Enable") + " Access List"); toggle.innerHTML = " "; actions.append(toggle); }); }
function decorateGroupCards() { document.querySelectorAll('[data-admin-panel="groups"] .group-card').forEach(card => { const group = state.groups.find(value => value.id === card.querySelector("[data-group-action]")?.dataset.groupId); if (!group) return; const icon = card.querySelector(".site-icon"); if (icon && icon.textContent.trim() === "GR") icon.innerHTML = featureIcon(group, "GR"); const menu = card.querySelector(".menu"); if (menu && !menu.querySelector("[data-group-action=icon]")) { const button = document.createElement("button"); button.dataset.groupAction = "icon"; button.dataset.groupId = group.id; button.textContent = "Change icon"; menu.prepend(button); } }); }
document.addEventListener("click", event => { const button = event.target.closest("[data-group-action=icon]"); if (!button) return; event.preventDefault(); event.stopImmediatePropagation(); openIconPicker("groups", button.dataset.groupId); }, true);
-function normalizeAdminTabOrder() { const tabs = document.querySelector(".admin-tabs"); if (!tabs) return; const order = ["users","groups","defaults","audit","backups","retention","danger"]; order.forEach((name, index) => { const button = tabs.querySelector(`[data-admin-tab="${name}"]`); if (button) { if (name === "retention") button.textContent = "Logs & Retention"; tabs.append(button); } }); }
+function normalizeAdminTabOrder() { const tabs = document.querySelector(".admin-tabs"); if (!tabs) return; const order = ["users","groups","defaults","audit","backups","retention","api","danger"]; order.forEach((name, index) => { const button = tabs.querySelector(`[data-admin-tab="${name}"]`); if (button) { if (name === "retention") button.textContent = "Logs & Retention"; tabs.append(button); } }); }
document.addEventListener("click", event => { if (event.target.closest(".admin-tabs")) setTimeout(normalizeAdminTabOrder, 0); });
// --- Backup encryption password field: placeholder/visibility polish -------------
@@ -345,3 +345,214 @@ document.addEventListener("click", async event => {
renderConfigDriftCallout();
} catch (error) { toast(error.message); } finally { button.disabled = false; }
});
+
+
+// ============================================================================================
+// v0.15.0 additions: API access tokens, backup history, and the Docker container picker.
+// ============================================================================================
+
+// Two overlapping rectangles, inline so it inherits currentColor from .icon-button.
+const featureCopyIcon = ' ';
+function featureDialog(id) {
+ let dialog = document.querySelector(`#${id}`);
+ if (!dialog) { dialog = document.createElement("dialog"); dialog.id = id; document.body.append(dialog); }
+ return dialog;
+}
+
+
+// --- Administration > API Access ------------------------------------------------------------
+// Follows the renderAuditPanel()/renderRetentionPanel() pattern: the tab and its panel are
+// created once, then the table is re-rendered from /api/tokens on demand.
+function apiTokenStatus(token) {
+ if (token.revoked) return { dot: "disabled", label: "Revoked" };
+ if (token.expiresAt && new Date(token.expiresAt).getTime() <= Date.now()) return { dot: "error", label: "Expired" };
+ return { dot: "running", label: "Active" };
+}
+async function loadApiTokens() {
+ const list = document.querySelector("#api-token-list"); if (!list) return;
+ try {
+ const tokens = await api("/api/tokens");
+ list.innerHTML = tokens.length ? tokens.map(token => {
+ const status = apiTokenStatus(token);
+ return `${extendedEscape(token.name)} ${extendedEscape(status.label)} · ${extendedEscape(token.ownerUsername || "unknown")}
${extendedEscape(token.prefix)}… ${token.scope === "read-only" ? "Read-only" : "Full access"}
${extendedEscape(formatTime(token.createdAt))} ${token.lastUsedAt ? `Last used ${extendedEscape(formatTime(token.lastUsedAt))}` : "Never used"}${token.expiresAt ? ` · expires ${extendedEscape(formatTime(token.expiresAt))}` : ""}
${token.revoked ? "" : 'Revoke '}
`;
+ }).join("") : 'No API tokens have been issued yet.
';
+ } catch (error) { list.innerHTML = `${extendedEscape(error.message)}
`; }
+}
+function renderApiTokensPanel() {
+ 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="api"]');
+ if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "api"; tab.textContent = "API Access"; tabs.append(tab); }
+ let panel = document.querySelector('[data-admin-panel="api"]');
+ if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "api"; panel.className = "settings-panel hidden"; users.parentElement.append(panel); }
+ if (panel.dataset.ready) return;
+ panel.dataset.ready = "1";
+ panel.innerHTML = 'Programmatic access
API access tokens Issue bearer tokens for scripts and integrations. A token acts as the administrator who issued it, and is shown in full only once. Changing that administrator’s password, or disabling their account, revokes every token they issued.
Create token
Open this tab to load API tokens.
';
+ 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 loadApiTokens();
+ });
+}
+// Shows a freshly issued token exactly once. No "x" close button -- Close only.
+function showIssuedApiToken(result) {
+ const dialog = featureDialog("api-token-created-dialog");
+ dialog.innerHTML = ``;
+ dialog.querySelector("#copy-api-token").addEventListener("click", async () => {
+ try { await navigator.clipboard.writeText(result.token); toast("API token copied."); }
+ catch { toast("Your browser blocked clipboard access.", "error"); }
+ });
+ dialog.showModal();
+}
+document.addEventListener("click", async event => {
+ if (!event.target.closest("#create-api-token")) return;
+ const dialog = featureDialog("create-api-token-dialog");
+ dialog.innerHTML = '';
+ dialog.showModal();
+ const outcome = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), { once: true }));
+ if (outcome !== "confirm") return;
+ const form = new FormData(dialog.querySelector("form"));
+ const expiresInDays = Number(form.get("expiresInDays"));
+ try {
+ const result = await api("/api/tokens", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: form.get("name"), scope: form.get("scope"), expiresInDays: Number.isFinite(expiresInDays) && expiresInDays > 0 ? expiresInDays : null, username: form.get("username"), password: form.get("password") }) });
+ await loadApiTokens();
+ showIssuedApiToken(result);
+ } catch (error) { toast(error.message, "error"); }
+});
+document.addEventListener("click", async event => {
+ const button = event.target.closest('[data-token-action="revoke"]'); if (!button) return;
+ const row = button.closest("[data-token-id]"); if (!row) return;
+ try { await api(`/api/tokens/${encodeURIComponent(row.dataset.tokenId)}`, { method: "DELETE" }); await loadApiTokens(); toast("API token revoked."); }
+ catch (error) { toast(error.message, "error"); }
+});
+
+
+// --- Administration > Backup & restore: history timeline --------------------------------------
+// Deliberately never renders a raw .sgbackup filename: every entry is described by what it
+// was and when it happened. Filenames stay in the data for the restore/download actions above.
+function backupHistoryLabel(event) {
+ const kind = event.backupType === "complete" ? "Complete backup" : event.backupType === "configuration" ? "Configuration backup" : event.backupType === "safety" ? "Safety backup (pre-restore)" : "Backup";
+ const when = formatTime(event.createdAt);
+ const lower = `${kind.charAt(0).toLowerCase()}${kind.slice(1)}`;
+ if (event.type === "restored") return `Restored from ${lower} — ${when}`;
+ if (event.type === "deleted") return `Deleted ${lower} — ${when}`;
+ if (event.type === "imported") return `Imported ${lower} — ${when}`;
+ return `${kind} — ${when}`;
+}
+async function renderBackupHistory() {
+ const panel = document.querySelector('[data-admin-panel="backups"]'); if (!panel || state.user?.role !== "administrator") return;
+ let section = panel.querySelector(".backup-history-section");
+ if (!section) {
+ section = document.createElement("div");
+ section.className = "dashboard-panel backup-history-section";
+ section.innerHTML = 'History
Backup history Every backup, restore, import, and deletion — including failed attempts — recorded independently of what is currently stored on disk.
';
+ 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 => {
+ const failed = item.status === "failed";
+ const detail = failed ? `Failed — ${item.errorMessage || "no further detail recorded"}` : [item.sizeBytes ? formatBytes(item.sizeBytes) : "", item.safetyBackupFilename ? "A safety backup was taken first" : ""].filter(Boolean).join(" · ") || "Completed";
+ return `${failed ? "!" : "✓"} ${extendedEscape(backupHistoryLabel(item))} ${extendedEscape(detail)}
`;
+ }).join("") : 'No backup activity recorded yet.
';
+ } catch (error) { list.innerHTML = `${extendedEscape(error.message)}
`; }
+}
+
+
+// --- Docker container picker ------------------------------------------------------------------
+// The Administration toggle is disabled whenever the socket is not mounted, regardless of the
+// 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="defaults"]'); if (!panel) 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 = "dashboard-panel docker-integration-section";
+ section.innerHTML = 'Integrations
Docker container selection
Let Proxy and Streaming hosts pick a running container as their target ';
+ panel.append(section);
+ section.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."); }
+ catch (error) { checkbox.checked = !checkbox.checked; toast(error.message, "error"); }
+ finally { checkbox.disabled = false; renderDockerPanel(); decorateContainerPickers(); }
+ });
+ }
+ const toggle = section.querySelector("#docker-integration-toggle");
+ toggle.checked = enabled;
+ toggle.disabled = !socketMounted;
+ section.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);
+}
+// Adds the "Pick from running containers" button beside every target field, and keeps its
+// visibility in step with the integration's current state.
+function decorateContainerPickers() {
+ const available = state.config?.docker?.socketMounted === true && (state.settings?.dockerIntegration?.enabled === true || state.config?.docker?.enabled === true);
+ for (const selector of ["#proxy-form [name=target]", "#settings-form [name=target]", "#stream-form [name=target]"]) {
+ const input = document.querySelector(selector); if (!input) continue;
+ let wrap = input.closest(".target-with-picker");
+ if (!wrap) {
+ wrap = document.createElement("span");
+ wrap.className = "target-with-picker";
+ input.replaceWith(wrap);
+ wrap.append(input);
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "button secondary container-picker-trigger";
+ button.textContent = "Pick container";
+ wrap.append(button);
+ }
+ wrap.querySelector(".container-picker-trigger").classList.toggle("hidden", !available);
+ }
+}
+document.addEventListener("click", async event => {
+ const trigger = event.target.closest(".container-picker-trigger"); if (!trigger) return;
+ event.preventDefault();
+ const input = trigger.closest(".target-with-picker")?.querySelector("input"); if (!input) return;
+ const dialog = featureDialog("container-picker-dialog");
+ dialog.innerHTML = '';
+ dialog.showModal();
+ let containers = [];
+ try { containers = (await api("/api/docker/containers")).containers || []; }
+ catch (error) {
+ dialog.innerHTML = ``;
+ return;
+ }
+ const choices = containers.map(container => {
+ const ports = container.ports?.length ? container.ports.join(", ") : "no published container ports";
+ const detail = container.reachable ? `${container.image} · ports ${ports}` : `${container.image} · ${container.reason}`;
+ return `${extendedEscape(container.name)} ${extendedEscape(detail)} `;
+ }).join("");
+ dialog.innerHTML = ``;
+ dialog.querySelector(".container-picker-list")?.addEventListener("click", pickEvent => {
+ const choice = pickEvent.target.closest("[data-container-name]"); if (!choice) return;
+ const name = choice.dataset.containerName, port = choice.dataset.containerPort || "80";
+ // Docker's embedded DNS resolves the container name on a shared network, so use the
+ // name rather than an IP address, which changes whenever the container restarts.
+ input.value = input.type === "url" ? `http://${name}:${port}` : `${name}:${port}`;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ dialog.close();
+ toast(`Target set to ${name}:${port}.`);
+ });
+});
+
+
+// --- Wire the new panels into the shared refresh entry point ----------------------------------
+const baseRenderExtendedViews = window.renderExtendedViews;
+window.renderExtendedViews = function () {
+ baseRenderExtendedViews();
+ renderApiTokensPanel();
+ renderDockerPanel();
+ decorateContainerPickers();
+ renderBackupHistory();
+ normalizeAdminTabOrder();
+ hideRestrictedControls();
+};
diff --git a/src/public/index.html b/src/public/index.html
index ab190c4..a473864 100644
--- a/src/public/index.html
+++ b/src/public/index.html
@@ -7,7 +7,7 @@
Site Gateway
-
+
-
+