Add REST API tokens, backup history, Docker container picker, Caddy config popout, Performance overhaul, and dashboard tile unification (v0.15.0)
This commit is contained in:
@@ -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.14.0-62E6A7">
|
||||
<img alt="Version" src="https://img.shields.io/badge/version-0.15.0-62E6A7">
|
||||
</p>
|
||||
<p>
|
||||
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+174
-13
@@ -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 = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="dashboard-jobs-list">${(system.jobs || []).map(job => `<div class="dashboard-list-item"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
|
||||
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() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Site options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Domain & TLS</button><button data-action="icon">Change icon</button><button data-action="replace">Replace files</button><button data-action="delete" class="danger-text">Delete site</button></div></div>` : "";
|
||||
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Site options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Domain & TLS</button><button data-action="icon">Change icon</button><button data-action="caddy-config">View Caddy config</button><button data-action="replace">Replace files</button><button data-action="delete" class="danger-text">Delete site</button></div></div>` : "";
|
||||
const toggle = canManage() ? `<button class="toggle ${site.enabled ? "on" : ""}" data-action="toggle" aria-label="${site.enabled ? "Disable" : "Enable"} ${escapeHtml(site.name)}"><span></span></button>` : "";
|
||||
return `<article class="site-card" data-id="${site.id}" data-kind="hosted"><div class="card-top"><div class="site-icon">${iconMarkup(site)}</div>${menu}</div><h2>${escapeHtml(site.name)}</h2><p class="address">${escapeHtml(site.domain || `Port ${site.port}`)}</p>${site.domain ? `<p class="gateway-address ${site.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(site))}</p>` : ""}<p class="upstream-copy ${site.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(site)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(site.name)}">↗</a></div></div></article>`;
|
||||
}
|
||||
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() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Proxy options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Edit proxy</button><button data-action="icon">Change icon</button><button data-action="delete" class="danger-text">Delete proxy</button></div></div>` : "";
|
||||
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Proxy options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Edit proxy</button><button data-action="icon">Change icon</button><button data-action="caddy-config">View Caddy config</button><button data-action="delete" class="danger-text">Delete proxy</button></div></div>` : "";
|
||||
const toggle = canManage() ? `<button class="toggle ${proxy.enabled ? "on" : ""}" data-action="toggle" aria-label="${proxy.enabled ? "Disable" : "Enable"} ${escapeHtml(proxy.name)}"><span></span></button>` : "";
|
||||
const access = proxy.accessListId ? (state.accessLists.find(item => item.id === proxy.accessListId)?.name || "Access List") : "Public · no Access List";
|
||||
return `<article class="site-card proxy" data-id="${proxy.id}" data-kind="proxy"><div class="card-top"><div class="site-icon">${iconMarkup(proxy)}</div>${menu}</div><h2>${escapeHtml(proxy.name)}</h2><p class="address">${escapeHtml(proxy.target)}</p><p class="gateway-address ${proxy.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(proxy))}</p><p class="upstream-copy ${proxy.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><p class="access-summary">${escapeHtml(access)}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(proxy)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(proxy.name)}">↗</a></div></div></article>`;
|
||||
@@ -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} · <span id="performance-last-checked">Checked ${escapeHtml(formatTime(data.checkedAt))}</span>`;
|
||||
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 `<line x1="${left}" y1="${y}" x2="${width - right}" y2="${y}" stroke="var(--line)" stroke-width="1" />`;
|
||||
}).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 `<span class="axis-label" style="left:0;width:${(leftPct - 2).toFixed(2)}%;top:${yPct.toFixed(2)}%;text-align:right">${value}</span>`;
|
||||
}).join("");
|
||||
const firstPoint = points[0], lastPoint = points[points.length - 1];
|
||||
const timeLabels = points.length ? `<span class="time-label" style="left:${leftPct.toFixed(2)}%">${escapeHtml(formatTime(firstPoint.at))}</span><span class="time-label time-label-end" style="left:${(100 - (right / width) * 100).toFixed(2)}%">${escapeHtml(formatTime(lastPoint.at))}</span>` : "";
|
||||
// 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 `<span class="time-label${alignment}" style="left:${leftEdge.toFixed(2)}%">${escapeHtml(formatChartTime(points[index].at, intervalMinutes))}</span>`;
|
||||
}).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}<path d="${areaPath}" fill="var(--green)" opacity="0.12" stroke="none" /><path d="${smoothLine}" fill="none" stroke="var(--green)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />` : "";
|
||||
$("#performance-sparkline").innerHTML = points.length ? `${gridLines}<path d="${areaPath}" fill="var(--green)" opacity="0.12" stroke="none" /><path d="${smoothLine}" fill="none" stroke="var(--green)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><ellipse id="performance-hover-dot" class="hidden" cx="0" cy="0" rx="3" ry="3" fill="var(--green)" stroke="var(--panel)" stroke-width="1.5" />` : "";
|
||||
if (!points.length) $("#performance-sparkline-labels").innerHTML = '<span class="axis-label" style="left:0;width:100%;top:45%;text-align:center">No request data for this window yet.</span>';
|
||||
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()} <span class="count-divider">·</span> <span class="http-status bad">${errors.toLocaleString()}</span>`; state.performanceErrorBreakdowns[host] = { total: errors, breakdown }; return `${count.toLocaleString()} <span class="count-divider">·</span> <button type="button" class="http-status bad count-link-button" data-error-host="${escapeHtml(host)}">${errors.toLocaleString()}</button>`; };
|
||||
const formatAvgMs = ms => ms == null ? "—" : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${ms} ms`;
|
||||
$("#performance-rows").innerHTML = routes.length ? routes.map(route => `<tr class="${selected && route.host === selected ? "row-highlight" : ""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}</td><td>${countCell(route.hourRequests, route.hourErrors)}</td><td>${countCell(route.dayRequests, route.dayErrors, route.errorBreakdown, route.host)}</td><td>${formatAvgMs(route.dayAvgMs)}</td></tr>`).join("") : '<tr><td colspan="4" class="quiet-state">No requests have been logged yet.</td></tr>';
|
||||
const pathsCell = route => { if (!route.topPaths?.length) return "—"; state.performanceTopPaths[route.host] = route.topPaths; return `<button type="button" class="count-link-button neutral" data-paths-host="${escapeHtml(route.host)}">View</button>`; };
|
||||
$("#performance-rows").innerHTML = routes.length ? routes.map(route => `<tr class="${selected && route.host === selected ? "row-highlight" : ""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}</td><td>${countCell(route.hourRequests, route.hourErrors)}</td><td>${countCell(route.dayRequests, route.dayErrors, route.errorBreakdown, route.host)}</td><td>${formatLatency(route.dayAvgMs)}</td><td>${formatLatency(route.dayP95Ms)}</td><td>${route.dayBytes ? escapeHtml(formatBytes(route.dayBytes)) : "—"}</td><td>${(route.dayVisitors || 0).toLocaleString()}</td><td>${pathsCell(route)}</td></tr>`).join("") : '<tr><td colspan="8" class="quiet-state">No requests have been logged yet.</td></tr>';
|
||||
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 => `<div class="activity-tile"><span class="slowest-copy"><strong title="${escapeHtml(`${entry.method || ""} ${entry.uri || ""}`)}">${escapeHtml(entry.method || "GET")} ${escapeHtml(entry.uri || "/")}</strong><small>${escapeHtml(entry.host || "—")} · ${entry.status ?? "—"} · ${escapeHtml(formatTime(entry.at))}</small></span><span class="slowest-duration">${escapeHtml(formatLatency(entry.durationMs))}</span></div>`).join("") : '<p class="quiet-state">No timed requests in this window yet.</p>';
|
||||
}
|
||||
|
||||
// --- 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 = `<strong>${point.count.toLocaleString()} request${point.count === 1 ? "" : "s"}</strong><span class="tooltip-errors${point.errors ? "" : " none"}">${(point.errors || 0).toLocaleString()} error${point.errors === 1 ? "" : "s"}</span><br>${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 => `<div class="error-breakdown-row"><span>${escapeHtml(item.status)}</span><span>${item.count.toLocaleString()}</span></div>`).join(""); dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Performance · Last 24h</p><h2>${escapeHtml(host)}</h2></div></div><p class="muted">${total.toLocaleString()} error response${total === 1 ? "" : "s"} in the last 24 hours, by status code.</p><div class="error-breakdown-list">${rows}</div><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`; 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 => `<div class="error-breakdown-row ${kind}"><span>${escapeHtml(item.status)}</span><span>${item.count.toLocaleString()}</span></div>`).join("");
|
||||
const clientTotal = client.reduce((sum, item) => sum + item.count, 0), serverTotal = server.reduce((sum, item) => sum + item.count, 0);
|
||||
const sections = [
|
||||
client.length ? `<p class="error-breakdown-group">Client errors · 4xx · ${clientTotal.toLocaleString()}</p><div class="error-breakdown-list">${rowsFor(client, "client-error")}</div>` : "",
|
||||
server.length ? `<p class="error-breakdown-group">Server errors · 5xx · ${serverTotal.toLocaleString()}</p><div class="error-breakdown-list">${rowsFor(server, "server-error")}</div>` : ""
|
||||
].join("");
|
||||
dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Performance · Last 24h</p><h2>${escapeHtml(host)}</h2></div></div><p class="muted">${total.toLocaleString()} error response${total === 1 ? "" : "s"} in the last 24 hours, by status code.</p>${sections || '<p class="quiet-state">No status codes recorded.</p>'}<div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
|
||||
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 => `<div class="top-paths-row"><span title="${escapeHtml(item.uri)}">${escapeHtml(item.uri)}</span><span>${item.count.toLocaleString()}</span></div>`).join("");
|
||||
dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Performance · Last 24h</p><h2>${escapeHtml(host)}</h2></div></div><p class="muted">The most requested paths on this domain in the last 24 hours.</p><div class="top-paths-list">${rows || '<div class="top-paths-row"><span>No requests recorded.</span><span>0</span></div>'}</div><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
|
||||
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 = '<svg class="copy-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><rect x="5.4" y="1.4" width="9.2" height="9.2" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.4"></rect><rect x="1.4" y="5.4" width="9.2" height="9.2" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.4"></rect></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 = '<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Gateway configuration</p><h2>Loading…</h2></div></div><p class="muted">Reading the deployed configuration for this route.</p><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>';
|
||||
if (!dialog.open) dialog.showModal();
|
||||
try {
|
||||
const result = await api(`/api/${kind}/${encodeURIComponent(id)}/caddy-config`);
|
||||
dialog.innerHTML = `<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Gateway configuration</p><h2>${escapeHtml(result.name)}</h2></div><button type="button" class="icon-button" id="copy-caddy-config" aria-label="Copy configuration" title="Copy configuration">${COPY_ICON_SVG}</button></div><p class="muted">This is the exact block Site Gateway writes into the Caddyfile for this route, annotated with what each directive does.</p><pre class="caddy-config-pre">${escapeHtml(result.config)}</pre><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
|
||||
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 = `<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Gateway configuration</p><h2>Not available</h2></div></div><p class="muted">${escapeHtml(error.message)}</p><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
|
||||
}
|
||||
}
|
||||
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; });
|
||||
|
||||
+214
-3
@@ -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 => `<article class="site-card redirect-card" data-redirect-id="${item.id}" data-kind="redirect"><div class="card-top"><div class="site-icon">${featureIcon(item,"RD")}</div><div class="menu-wrap"><button class="icon-button menu-button" aria-label="Redirect options" aria-expanded="false">•••</button><div class="menu"><button data-redirect-action="edit">Edit redirect host</button><button data-redirect-action="icon">Change icon</button><button data-redirect-action="toggle">${item.enabled ? "Disable" : "Enable"}</button><button data-redirect-action="delete" class="danger-text">Delete redirect host</button></div></div></div><h2>${extendedEscape(item.name)}</h2><p class="address">${extendedEscape(item.domain)}</p><p class="gateway-address">→ ${extendedEscape(item.target)}${item.preservePath ? " · preserves path" : ""}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${item.enabled ? "running" : "disabled"}"></span>${item.enabled ? "Running" : "Disabled"}</span><div class="card-actions"><span class="chip">HTTP ${item.code}</span></div></div></article>`).join("");
|
||||
list.innerHTML = state.redirects.map(item => `<article class="site-card redirect-card" data-redirect-id="${item.id}" data-kind="redirect"><div class="card-top"><div class="site-icon">${featureIcon(item,"RD")}</div><div class="menu-wrap"><button class="icon-button menu-button" aria-label="Redirect options" aria-expanded="false">•••</button><div class="menu"><button data-redirect-action="edit">Edit redirect host</button><button data-redirect-action="icon">Change icon</button><button data-redirect-action="caddy-config">View Caddy config</button><button data-redirect-action="toggle">${item.enabled ? "Disable" : "Enable"}</button><button data-redirect-action="delete" class="danger-text">Delete redirect host</button></div></div></div><h2>${extendedEscape(item.name)}</h2><p class="address">${extendedEscape(item.domain)}</p><p class="gateway-address">→ ${extendedEscape(item.target)}${item.preservePath ? " · preserves path" : ""}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${item.enabled ? "running" : "disabled"}"></span>${item.enabled ? "Running" : "Disabled"}</span><div class="card-actions"><span class="chip">HTTP ${item.code}</span></div></div></article>`).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 = "<span></span>"; 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 = '<svg class="copy-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><rect x="5.4" y="1.4" width="9.2" height="9.2" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.4"></rect><rect x="1.4" y="5.4" width="9.2" height="9.2" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.4"></rect></svg>';
|
||||
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 `<article class="data-row api-token-row ${token.revoked ? "revoked" : ""}" data-token-id="${extendedEscape(token.id)}"><span class="status-dot ${status.dot}"></span><div><strong>${extendedEscape(token.name)}</strong><small>${extendedEscape(status.label)} · ${extendedEscape(token.ownerUsername || "unknown")}</small></div><div><span class="chip api-token-chip">${extendedEscape(token.prefix)}…</span><small>${token.scope === "read-only" ? "Read-only" : "Full access"}</small></div><div><strong>${extendedEscape(formatTime(token.createdAt))}</strong><small>${token.lastUsedAt ? `Last used ${extendedEscape(formatTime(token.lastUsedAt))}` : "Never used"}${token.expiresAt ? ` · expires ${extendedEscape(formatTime(token.expiresAt))}` : ""}</small></div><div class="row-actions">${token.revoked ? "" : '<button class="button secondary danger-text" data-token-action="revoke">Revoke</button>'}</div></article>`;
|
||||
}).join("") : '<p class="quiet-state padded">No API tokens have been issued yet.</p>';
|
||||
} catch (error) { list.innerHTML = `<p class="quiet-state padded">${extendedEscape(error.message)}</p>`; }
|
||||
}
|
||||
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 = '<div class="panel-heading"><div><p class="eyebrow">Programmatic access</p><h2>API access tokens</h2><p class="muted">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.</p></div><div class="row-actions"><button id="create-api-token" class="button primary" type="button">Create token</button></div></div><div id="api-token-list" class="data-list"><p class="quiet-state padded">Open this tab to load API tokens.</p></div>';
|
||||
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 = `<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">API access</p><h2>Copy your token now</h2></div><button type="button" class="icon-button" id="copy-api-token" aria-label="Copy token" title="Copy token">${featureCopyIcon}</button></div><p class="muted">This is the only time Site Gateway will show this token. Store it somewhere safe — only its hash is kept.</p><code class="api-token-secret">${extendedEscape(result.token)}</code><div class="dialog-actions"><button value="cancel" class="button primary">Close</button></div></form>`;
|
||||
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 = '<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">API access</p><h2>Create an API token</h2></div></div><p class="muted">Re-enter your administrator credentials to confirm. The token inherits your role.</p><label>Token name<input name="name" maxlength="60" required placeholder="Home Assistant integration"></label><label>Scope<select name="scope"><option value="full">Full access — read and change configuration</option><option value="read-only">Read-only — GET requests only</option></select></label><label>Expires after <span class="optional">Optional</span><input name="expiresInDays" type="number" min="1" max="3650" placeholder="Leave empty for no expiry"><small>Number of days. Leave empty for a token that never expires.</small></label><label>Administrator username<input name="username" autocomplete="username" required></label><label>Administrator password<input name="password" type="password" autocomplete="current-password" required></label><p class="error" id="api-token-error"></p><div class="dialog-actions"><button value="cancel" formnovalidate class="button secondary">Cancel</button><button value="confirm" class="button primary">Create token</button></div></form>';
|
||||
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 = '<div class="panel-heading"><div><p class="eyebrow">History</p><h2>Backup history</h2><p class="muted">Every backup, restore, import, and deletion — including failed attempts — recorded independently of what is currently stored on disk.</p></div></div><div id="backup-history-list" class="backup-history-list"><p class="quiet-state">Loading backup history…</p></div>';
|
||||
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 `<div class="activity-tile"><span class="activity-mark ${failed ? "bad" : ""}">${failed ? "!" : "✓"}</span><span class="backup-history-copy"><strong>${extendedEscape(backupHistoryLabel(item))}</strong><small class="${failed ? "failed" : ""}" title="${extendedEscape(formatTime(item.createdAt))}">${extendedEscape(detail)}</small></span></div>`;
|
||||
}).join("") : '<p class="quiet-state">No backup activity recorded yet.</p>';
|
||||
} catch (error) { list.innerHTML = `<p class="quiet-state">${extendedEscape(error.message)}</p>`; }
|
||||
}
|
||||
|
||||
|
||||
// --- 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 = '<div class="panel-heading"><div><p class="eyebrow">Integrations</p><h2>Docker container selection</h2></div></div><p class="muted" id="docker-integration-help"></p><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 => {
|
||||
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 = '<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Docker</p><h2>Running containers</h2></div></div><p class="muted">Loading containers…</p><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button></div></form>';
|
||||
dialog.showModal();
|
||||
let containers = [];
|
||||
try { containers = (await api("/api/docker/containers")).containers || []; }
|
||||
catch (error) {
|
||||
dialog.innerHTML = `<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Docker</p><h2>Containers unavailable</h2></div></div><p class="muted">${extendedEscape(error.message)}</p><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
|
||||
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 `<button type="button" class="container-choice ${container.reachable ? "" : "unreachable"}" ${container.reachable ? `data-container-name="${extendedEscape(container.name)}" data-container-port="${container.ports?.[0] || ""}"` : "disabled"}><strong>${extendedEscape(container.name)}</strong><small>${extendedEscape(detail)}</small></button>`;
|
||||
}).join("");
|
||||
dialog.innerHTML = `<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Docker</p><h2>Running containers</h2></div></div><p class="muted">Containers that do not share a Docker network with Site Gateway are dimmed — Site Gateway could not reach them by name. You can always type a target by hand instead.</p><div class="container-picker-list">${choices || '<p class="quiet-state">No running containers were reported.</p>'}</div><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button></div></form>`;
|
||||
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();
|
||||
};
|
||||
|
||||
+16
-9
@@ -7,7 +7,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.14.0">
|
||||
<link rel="stylesheet" href="/styles.css?v=0.15.0">
|
||||
</head>
|
||||
|
||||
<!-- ================================================================
|
||||
@@ -107,14 +107,14 @@
|
||||
<section id="dashboard-view" class="dashboard-view" aria-label="Gateway dashboard">
|
||||
<div class="metric-grid">
|
||||
<button class="metric-card accent-green" data-target="hosted"><span class="metric-icon">↗</span><span class="metric-label">Hosted sites</span><strong id="dash-hosted-total">0</strong><span id="dash-hosted-detail">None configured</span></button>
|
||||
<button class="metric-card accent-blue" data-target="proxies"><span class="metric-icon">⇌</span><span class="metric-label">Proxy hosts</span><strong id="dash-proxy-total">0</strong><span id="dash-proxy-detail">None configured</span></button>
|
||||
<button class="metric-card accent-blue" data-target="certificates"><span class="metric-icon">▣</span><span class="metric-label">Certificates</span><strong id="dash-tls-total">0</strong><span id="dash-tls-detail">No TLS domains</span></button>
|
||||
<button class="metric-card accent-green" data-target="proxies"><span class="metric-icon">⇌</span><span class="metric-label">Proxy hosts</span><strong id="dash-proxy-total">0</strong><span id="dash-proxy-detail">None configured</span></button>
|
||||
<button class="metric-card accent-green" data-target="certificates"><span class="metric-icon">▣</span><span class="metric-label">Certificates</span><strong id="dash-tls-total">0</strong><span id="dash-tls-detail">No TLS domains</span></button>
|
||||
</div>
|
||||
<div class="metric-strip">
|
||||
<button class="metric-chip accent-amber" data-target="redirects"><span class="chip-icon">↪</span><span class="chip-copy"><span class="metric-label">Redirect hosts</span><strong id="dash-redirect-total">0</strong></span></button>
|
||||
<button class="metric-chip accent-purple" data-target="streaming"><span class="chip-icon">⇄</span><span class="chip-copy"><span class="metric-label">Streaming hosts</span><strong id="dash-stream-total">0</strong></span></button>
|
||||
<button class="metric-chip accent-green" data-target="redirects"><span class="chip-icon">↪</span><span class="chip-copy"><span class="metric-label">Redirect hosts</span><strong id="dash-redirect-total">0</strong></span></button>
|
||||
<button class="metric-chip accent-green" data-target="streaming"><span class="chip-icon">⇄</span><span class="chip-copy"><span class="metric-label">Streaming hosts</span><strong id="dash-stream-total">0</strong></span></button>
|
||||
<div class="metric-chip" id="dash-attention-chip"><span class="chip-icon" id="dash-attention-icon">◈</span><span class="chip-copy"><span class="metric-label">Needs attention</span><strong id="dash-attention-total">0</strong><small id="dash-attention-detail">No current issues</small></span></div>
|
||||
<button class="metric-chip accent-blue" data-target="performance"><span class="chip-icon">∿</span><span class="chip-copy"><span class="metric-label">Throughput</span><strong id="dash-throughput-total">0</strong><small id="dash-throughput-detail">requests / min</small></span></button>
|
||||
<button class="metric-chip accent-green" data-target="performance"><span class="chip-icon">∿</span><span class="chip-copy"><span class="metric-label">Throughput</span><strong id="dash-throughput-total">0</strong><small id="dash-throughput-detail">requests / min</small></span></button>
|
||||
</div>
|
||||
<div class="dashboard-columns">
|
||||
<section class="dashboard-panel health-panel status-healthy" id="health-panel">
|
||||
@@ -183,15 +183,22 @@
|
||||
<div class="panel-heading"><div><p class="eyebrow">Trend</p><h2 id="performance-trend-title">Requests · last 6 hours</h2></div></div>
|
||||
<div class="log-filters"><label>Domain<select id="performance-host"><option value="">All domains</option></select></label><label>Range<select id="performance-range"><option value="1">Last hour</option><option value="3">Last 3 hours</option><option value="6" selected>Last 6 hours</option><option value="12">Last 12 hours</option><option value="24">Last 24 hours</option><option value="72">Last 3 days</option><option value="168">Last 7 days</option></select></label></div>
|
||||
<p id="performance-summary" class="muted feature-note">No requests recorded yet. <span id="performance-last-checked">Not checked yet.</span></p>
|
||||
<p class="chart-axis-unit">Requests</p>
|
||||
<div class="performance-sparkline-wrap">
|
||||
<svg id="performance-sparkline" class="performance-sparkline" viewBox="0 0 600 140" preserveAspectRatio="none" aria-label="Request volume trend"></svg>
|
||||
<div id="performance-tooltip" class="performance-tooltip hidden" aria-hidden="true"></div>
|
||||
<div id="performance-sparkline-labels" class="performance-sparkline-labels"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="dashboard-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">Per-route</p><h2>Throughput by domain</h2></div></div>
|
||||
<p class="muted feature-note">Requests, error rate, and average response time for each configured domain.</p>
|
||||
<div class="table-wrap performance-table-wrap"><table class="performance-table"><thead><tr><th>Domain</th><th>Last hour</th><th>Last 24h</th><th>Avg. response</th></tr></thead><tbody id="performance-rows"></tbody></table></div>
|
||||
<p class="muted feature-note">Requests, error rate, latency, bandwidth, and unique visitors for each configured domain, over the last 24 hours.</p>
|
||||
<div class="table-wrap performance-table-wrap"><table class="performance-table"><thead><tr><th>Domain</th><th>Last hour</th><th>Last 24h</th><th>Avg. response</th><th>p95 response</th><th>Data transferred</th><th>Unique visitors</th><th>Top paths</th></tr></thead><tbody id="performance-rows"></tbody></table></div>
|
||||
</section>
|
||||
<section class="dashboard-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">Outliers</p><h2 id="performance-slowest-title">Slowest requests</h2></div></div>
|
||||
<p class="muted feature-note">The individual requests that took longest in the selected window and domain filter.</p>
|
||||
<div id="performance-slowest" class="slowest-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -430,6 +437,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.14.0" defer></script><script src="/features.js?v=0.14.0" defer></script>
|
||||
<script src="/app.js?v=0.15.0" defer></script><script src="/features.js?v=0.15.0" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+68
-5
@@ -158,6 +158,10 @@ header{align-items:flex-end}
|
||||
.metric-card:is(button):hover{transform:translateY(-2px);border-color:var(--border-hover-alt);box-shadow:0 16px 40px rgba(var(--black-rgb),.22)}
|
||||
.metric-card.accent-green{--card-accent:var(--green)}
|
||||
.metric-card.accent-blue{--card-accent:var(--blue)}
|
||||
.metric-card.accent-warning{--card-accent:var(--warning)}
|
||||
.metric-card.accent-warning strong{color:var(--warning)}
|
||||
.metric-card.accent-danger{--card-accent:var(--danger)}
|
||||
.metric-card.accent-danger strong{color:var(--danger)}
|
||||
.metric-card .metric-label,.metric-card>span:last-child{display:block}
|
||||
.metric-card .metric-label{color:var(--muted);font-size:var(--font-size-sm);font-weight:700}
|
||||
.metric-card .metric-icon{position:absolute;top:var(--space-4);right:var(--space-4);width:32px;height:32px;display:grid;place-items:center;border-radius:var(--radius-sm);background:color-mix(in srgb,var(--card-accent,var(--muted)) 20%,transparent);color:var(--card-accent,var(--muted));font-size:.9rem;font-weight:800}
|
||||
@@ -183,6 +187,8 @@ header{align-items:flex-end}
|
||||
.metric-chip.accent-green{--card-accent:var(--green)}
|
||||
.metric-chip.accent-green strong{color:var(--green)}
|
||||
.metric-chip.accent-blue{--card-accent:var(--blue)}
|
||||
.metric-chip.accent-danger{--card-accent:var(--danger)}
|
||||
.metric-chip.accent-danger strong{color:var(--danger)}
|
||||
.dashboard-columns{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}
|
||||
.dashboard-columns.lower.attention-clear{grid-template-columns:1fr}
|
||||
.dashboard-jobs-slot:not(:empty){margin-top:18px}
|
||||
@@ -270,6 +276,7 @@ header{align-items:flex-end}
|
||||
.performance-sparkline-labels .axis-label{position:absolute;transform:translateY(-50%);color:var(--muted);font-size:var(--font-size-xs);white-space:nowrap}
|
||||
.performance-sparkline-labels .time-label{position:absolute;bottom:0;color:var(--muted);font-size:var(--font-size-xs);white-space:nowrap}
|
||||
.performance-sparkline-labels .time-label.time-label-end{transform:translateX(-100%)}
|
||||
.performance-sparkline-labels .time-label.time-label-mid{transform:translateX(-50%)}
|
||||
.row-highlight{background:rgba(var(--green-rgb),.08)}
|
||||
.user-head-actions{display:flex;align-items:center;gap:10px}
|
||||
[data-admin-panel="backups"]>.dashboard-panel{margin-top:var(--space-5)}
|
||||
@@ -864,17 +871,15 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
|
||||
.performance-table{width:100%;border-collapse:collapse;font-size:var(--font-size-md);table-layout:fixed}
|
||||
.performance-table th,.performance-table td{padding:13px 15px;text-align:left;border-top:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.performance-table thead th{border-top:0;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;white-space:normal}
|
||||
.performance-table th:nth-child(1),.performance-table td:nth-child(1){width:34%}
|
||||
.performance-table th:nth-child(2),.performance-table td:nth-child(2){width:20%;text-align:center}
|
||||
.performance-table th:nth-child(3),.performance-table td:nth-child(3){width:23%;text-align:center}
|
||||
.performance-table th:nth-child(4),.performance-table td:nth-child(4){width:23%;text-align:center}
|
||||
.performance-table th:nth-child(1),.performance-table td:nth-child(1){width:22%}
|
||||
.performance-table th:nth-child(n+2),.performance-table td:nth-child(n+2){width:11.1%;text-align:center}
|
||||
.performance-table .count-divider{color:var(--muted);margin:0 2px}
|
||||
.performance-table .count-link-button{border:0;background:none;padding:0;font:inherit;color:var(--danger);cursor:pointer;text-decoration:underline dotted;text-underline-offset:3px}
|
||||
.performance-table .count-link-button:hover{text-decoration-style:solid}
|
||||
.error-breakdown-list{margin-top:var(--space-4);border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.28);padding:0 var(--space-4)}
|
||||
.error-breakdown-row{display:flex;justify-content:space-between;gap:var(--space-4);padding:var(--space-3) 0;border-top:1px solid var(--line);font-size:var(--font-size-md)}
|
||||
.error-breakdown-row:first-child{border-top:0}
|
||||
@media(max-width:760px){.performance-table th:nth-child(2),.performance-table td:nth-child(2){display:none}.performance-table th:nth-child(1),.performance-table td:nth-child(1){width:40%}.performance-table th:nth-child(3),.performance-table td:nth-child(3){width:30%}.performance-table th:nth-child(4),.performance-table td:nth-child(4){width:30%}}
|
||||
@media(max-width:760px){.performance-table th:nth-child(2),.performance-table td:nth-child(2),.performance-table th:nth-child(5),.performance-table td:nth-child(5),.performance-table th:nth-child(6),.performance-table td:nth-child(6),.performance-table th:nth-child(7),.performance-table td:nth-child(7){display:none}.performance-table th:nth-child(1),.performance-table td:nth-child(1){width:40%}.performance-table th:nth-child(3),.performance-table td:nth-child(3){width:30%}.performance-table th:nth-child(4),.performance-table td:nth-child(4){width:15%}.performance-table th:nth-child(8),.performance-table td:nth-child(8){width:15%}}
|
||||
|
||||
/* MFA setup: QR code & recovery codes */
|
||||
.mfa-qr{display:flex;justify-content:center;padding:var(--space-4);background:var(--white);border-radius:var(--radius-md);margin:var(--space-4) 0}
|
||||
@@ -882,3 +887,61 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
|
||||
.mfa-recovery-codes{background:var(--panel2);color:var(--text);border:1px solid var(--line);border-radius:var(--radius-2xs);padding:var(--space-4);font-size:14px;line-height:1.8;letter-spacing:.02em;white-space:pre-wrap;user-select:all}
|
||||
#account-view>.dashboard-panel+.dashboard-panel{margin-top:18px}
|
||||
|
||||
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
v0.15.0 additions: API access tokens, backup history, Docker container
|
||||
picker, "View Caddy config" popout, and the Performance screen overhaul.
|
||||
Every colour, radius, and spacing value below resolves to a :root token.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
/* Performance: chart axis unit, hover tooltip, and the slowest-requests panel */
|
||||
.chart-axis-unit{margin:0 0 var(--space-2);color:var(--muted);font-size:var(--font-size-xs);font-weight:800;letter-spacing:.1em;text-transform:uppercase}
|
||||
.performance-tooltip{position:absolute;z-index:4;pointer-events:none;transform:translate(-50%,-118%);padding:var(--space-2) var(--space-3);border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--panel2);color:var(--text);font-size:var(--font-size-xs);line-height:1.5;white-space:nowrap;box-shadow:0 10px 30px rgba(var(--black-rgb),.35)}
|
||||
.performance-tooltip strong{display:block;font-size:var(--font-size-sm)}
|
||||
.performance-tooltip .tooltip-errors{color:var(--warning)}
|
||||
.performance-tooltip .tooltip-errors.none{color:var(--muted)}
|
||||
.slowest-list{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-4);max-height:min(46vh,520px);overflow:auto}
|
||||
.slowest-copy{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.slowest-copy strong{font-size:var(--font-size-md);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.slowest-copy small{color:var(--muted);font-size:var(--font-size-xs);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.slowest-duration{flex:0 0 auto;font-weight:800;color:var(--warning)}
|
||||
.performance-table .count-link-button.neutral{color:var(--blue)}
|
||||
.top-paths-list{margin-top:var(--space-4);border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.28);padding:0 var(--space-4)}
|
||||
.top-paths-row{display:flex;justify-content:space-between;gap:var(--space-4);padding:var(--space-3) 0;border-top:1px solid var(--line);font-size:var(--font-size-md)}
|
||||
.top-paths-row:first-child{border-top:0}
|
||||
.top-paths-row span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.error-breakdown-group{margin:var(--space-4) 0 0;color:var(--muted);font-size:var(--font-size-xs);font-weight:800;letter-spacing:.08em;text-transform:uppercase}
|
||||
.error-breakdown-row.client-error span:first-child{color:var(--warning)}
|
||||
.error-breakdown-row.server-error span:first-child{color:var(--danger)}
|
||||
|
||||
/* "View Caddy config" popout */
|
||||
.caddy-config-pre{margin:var(--space-4) 0 0;max-height:52vh;overflow:auto;padding:var(--space-4);border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.4);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--font-size-sm);line-height:1.65;white-space:pre}
|
||||
.dialog-heading .icon-button{flex:0 0 auto;align-self:flex-start}
|
||||
.copy-icon{width:16px;height:16px;display:block}
|
||||
|
||||
/* Docker container picker */
|
||||
.target-with-picker{display:flex;gap:var(--space-2);align-items:center}
|
||||
.target-with-picker input{flex:1;min-width:0}
|
||||
.container-picker-list{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-4);max-height:46vh;overflow:auto}
|
||||
.container-choice{display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:100%;padding:var(--space-3) 14px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.22);color:var(--text);text-align:left;cursor:pointer}
|
||||
.container-choice:hover{border-color:var(--border-hover)}
|
||||
.container-choice small{color:var(--muted);font-size:var(--font-size-xs)}
|
||||
.container-choice.unreachable{opacity:.5;cursor:not-allowed}
|
||||
.container-choice.unreachable:hover{border-color:var(--line)}
|
||||
|
||||
/* API access tokens */
|
||||
.api-token-row{grid-template-columns:auto minmax(150px,1.3fr) minmax(110px,.9fr) minmax(130px,1fr) minmax(150px,1fr)}
|
||||
.api-token-row.revoked{opacity:.6}
|
||||
.api-token-secret{display:block;margin-top:var(--space-3);padding:var(--space-3) var(--space-4);border:1px solid var(--line);border-radius:var(--radius-sm);background:rgba(var(--bg-rgb),.4);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--font-size-sm);line-height:1.6;word-break:break-all}
|
||||
.api-token-chip{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--font-size-sm)}
|
||||
|
||||
/* Backup history timeline */
|
||||
.backup-history-list{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-4);max-height:min(46vh,520px);overflow:auto}
|
||||
.backup-history-copy{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.backup-history-copy strong{font-size:var(--font-size-md)}
|
||||
.backup-history-copy small{color:var(--muted);font-size:var(--font-size-xs)}
|
||||
.backup-history-copy small.failed{color:var(--danger)}
|
||||
.check-control.is-disabled{opacity:.55}
|
||||
.check-control.is-disabled span{color:var(--muted)}
|
||||
.docker-integration-section,.backup-history-section{margin-top:var(--space-5)}
|
||||
|
||||
+328
-34
@@ -64,6 +64,8 @@ const recentActivity = [];
|
||||
const upstreamHealth = new Map();
|
||||
const certificateStatusCache = new Map();
|
||||
const loginAttempts = new Map();
|
||||
const rateLimitBuckets = new Map();
|
||||
let dockerSocketMounted = false;
|
||||
let currentAuditActor = null;
|
||||
const probeFailures = { gateway: 0, http: 0, https: 0 };
|
||||
let iconCatalog = null;
|
||||
@@ -146,6 +148,99 @@ function sessionUser(req) {
|
||||
return user;
|
||||
}
|
||||
|
||||
// Small fixed-window in-memory rate limiter shared by the sign-in routes and the
|
||||
// bearer-token authentication path. Map of key -> { count, windowStart }; no dependency.
|
||||
function rateLimitExceeded(key, limit = 10, windowMs = 60000) {
|
||||
const nowMs = Date.now();
|
||||
const bucket = rateLimitBuckets.get(key);
|
||||
if (!bucket || nowMs - bucket.windowStart >= windowMs) { rateLimitBuckets.set(key, { count: 1, windowStart: nowMs }); return false; }
|
||||
bucket.count += 1;
|
||||
if (rateLimitBuckets.size > 5000) for (const [entryKey, entry] of rateLimitBuckets) if (nowMs - entry.windowStart >= windowMs) rateLimitBuckets.delete(entryKey);
|
||||
return bucket.count > limit;
|
||||
}
|
||||
function requestKey(req) { return req.ip || req.socket?.remoteAddress || "unknown"; }
|
||||
|
||||
// Rotating a user's sessionVersion invalidates every session cookie they hold AND every API
|
||||
// token they issued, because tokens store the version that was current when they were created.
|
||||
// Callers that rotate the CURRENT user's version re-issue their cookie so they stay signed in.
|
||||
function rotateSessionVersion(user) {
|
||||
user.sessionVersion = crypto.randomBytes(16).toString("hex");
|
||||
user.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
// --- REST API tokens ---------------------------------------------------------------------
|
||||
// Only the SHA-256 hash of a token is ever stored; the raw value is shown once at creation.
|
||||
function hashApiToken(rawToken) { return crypto.createHash("sha256").update(String(rawToken)).digest("hex"); }
|
||||
|
||||
// Resolves an `Authorization: Bearer <token>` header to its owning user. Tokens carry the
|
||||
// owner's sessionVersion from the moment they were issued, so a password reset, an MFA
|
||||
// change, or a deactivation invalidates every token that user issued -- exactly like a
|
||||
// session cookie.
|
||||
function bearerTokenUser(req) {
|
||||
const header = String(req.headers.authorization || "");
|
||||
if (!/^Bearer\s+/i.test(header)) return null;
|
||||
const raw = header.replace(/^Bearer\s+/i, "").trim();
|
||||
if (!raw) return null;
|
||||
let record = null;
|
||||
try { record = storage.findApiTokenByHash(hashApiToken(raw)); } catch { return null; }
|
||||
if (!record || record.revokedAt) return null;
|
||||
if (record.expiresAt && new Date(record.expiresAt).getTime() <= Date.now()) return null;
|
||||
const owner = users.find(item => item.id === record.ownerUserId);
|
||||
if (!owner || owner.status !== "active") return null;
|
||||
if (record.sessionVersion && record.sessionVersion !== owner.sessionVersion) return null;
|
||||
return { user: owner, token: record };
|
||||
}
|
||||
|
||||
// --- Docker integration (optional, off by default) ---------------------------------------
|
||||
// Talks to the Docker Engine API over the mounted UNIX socket using Node's built-in http
|
||||
// module -- no client library, and nothing happens at all unless the socket is mounted AND
|
||||
// an administrator has explicitly turned the integration on.
|
||||
const DOCKER_SOCKET_PATH = "/var/run/docker.sock";
|
||||
function detectDockerSocket() {
|
||||
try { if (!fs.existsSync(DOCKER_SOCKET_PATH)) return false; fs.accessSync(DOCKER_SOCKET_PATH, fs.constants.R_OK); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
function dockerRequest(requestPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.request({ socketPath: DOCKER_SOCKET_PATH, path: requestPath, method: "GET", timeout: 5000 }, response => {
|
||||
let data = "";
|
||||
response.on("data", chunk => { data += chunk; });
|
||||
response.on("end", () => {
|
||||
if (response.statusCode >= 400) return reject(Object.assign(new Error(`Docker replied with status ${response.statusCode}.`), { status: 502 }));
|
||||
try { resolve(JSON.parse(data || "null")); } catch (error) { reject(new Error(`Could not read Docker's response: ${error.message}`)); }
|
||||
});
|
||||
});
|
||||
request.on("error", error => reject(Object.assign(new Error(`Could not reach the Docker socket: ${error.message}`), { status: 502 })));
|
||||
request.on("timeout", () => request.destroy(new Error("The Docker socket did not respond in time.")));
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
// Lists running containers, annotated with whether they share a Docker network with Site
|
||||
// Gateway's own container. Unreachable containers are returned too (flagged, with a reason)
|
||||
// so the picker can dim them rather than hide them.
|
||||
async function dockerContainerOptions() {
|
||||
const ownId = String(process.env.HOSTNAME || "").trim();
|
||||
const own = ownId ? await dockerRequest(`/containers/${encodeURIComponent(ownId)}/json`).catch(() => null) : null;
|
||||
const ownNetworks = new Set(Object.keys(own?.NetworkSettings?.Networks || {}));
|
||||
const running = await dockerRequest(`/containers/json?filters=${encodeURIComponent(JSON.stringify({ status: ["running"] }))}`) || [];
|
||||
return running.filter(container => !own?.Id || container.Id !== own.Id).map(container => {
|
||||
const networks = Object.keys(container.NetworkSettings?.Networks || {});
|
||||
const shared = networks.filter(name => ownNetworks.has(name));
|
||||
const reachable = ownNetworks.size > 0 && shared.length > 0;
|
||||
return {
|
||||
id: String(container.Id || "").slice(0, 12),
|
||||
name: (container.Names || []).map(value => String(value).replace(/^\//, "")).filter(Boolean)[0] || String(container.Id || "").slice(0, 12),
|
||||
image: container.Image || "",
|
||||
state: container.State || "running",
|
||||
networks,
|
||||
sharedNetworks: shared,
|
||||
ports: [...new Set((container.Ports || []).map(port => Number(port.PrivatePort)).filter(Boolean))].sort((a, b) => a - b),
|
||||
reachable,
|
||||
reason: reachable ? null : ownNetworks.size ? "Not on a Docker network shared with Site Gateway." : "Site Gateway could not identify its own container, so shared networks are unknown."
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const saveSites = async () => storage.saveCollection("sites", sites);
|
||||
const saveProxies = async () => storage.saveCollection("proxies", proxies);
|
||||
const saveUsers = async () => storage.saveCollection("users", users);
|
||||
@@ -414,23 +509,18 @@ async function writeDefaultSitePage() {
|
||||
|
||||
// renderCaddyfile -- builds the full Caddy JSON/Caddyfile config from current state
|
||||
// (sites, proxies, redirects, streams, access lists, default site settings).
|
||||
function renderCaddyfile() {
|
||||
const email = String(process.env.ACME_EMAIL || "").trim();
|
||||
const lines = ["{", " admin localhost:2019", " persist_config off", ` storage file_system ${managedCertificatesDir}`];
|
||||
if (email) lines.push(` email ${email}`);
|
||||
const logging = [" log {", ` output file ${accessLogPath} {`, " roll_size 10mb", " roll_keep 5", " roll_keep_for 168h", " roll_uncompressed", " }", " format json", " }"];
|
||||
lines.push("}", "", ":80 {", ...logging);
|
||||
const defaultSite = settings.defaultSite || {};
|
||||
if (defaultSite.mode === "abort") lines.push(" abort");
|
||||
else if (defaultSite.mode === "redirect" && defaultSite.redirectUrl) lines.push(` redir ${caddyQuote(`${defaultSite.redirectUrl}${defaultSite.preservePath ? "{uri}" : ""}`)} ${[301, 302, 307, 308].includes(Number(defaultSite.redirectCode)) ? Number(defaultSite.redirectCode) : 302}`);
|
||||
else lines.push(` root * ${defaultSiteDir}`, " rewrite * /index.html", ` file_server {`, ` status ${defaultSite.mode === "welcome" ? 200 : 404}`, " }");
|
||||
lines.push("}");
|
||||
for (const site of sites.filter(item => item.enabled && normalizeDomains(item.domain, item.domains).length)) {
|
||||
lines.push("", `${caddySiteAddress(site)} {`, ...logging, ...commonHostDirectives(site), ` root * ${path.join(sitesDir, site.id)}`, " file_server");
|
||||
lines.push("}");
|
||||
}
|
||||
for (const proxy of proxies.filter(item => item.enabled && item.domain)) {
|
||||
lines.push("", `${caddySiteAddress(proxy)} {`, ...logging, ...commonHostDirectives(proxy));
|
||||
function caddyLoggingDirectives() {
|
||||
return [" log {", ` output file ${accessLogPath} {`, " roll_size 10mb", " roll_keep 5", " roll_keep_for 168h", " roll_uncompressed", " }", " format json", " }"];
|
||||
}
|
||||
|
||||
// One builder per route kind, each returning the exact Caddyfile lines that route
|
||||
// contributes. renderCaddyfile()'s loops and the "View Caddy config" endpoint both call
|
||||
// these, so the popout can never drift from the configuration Caddy actually runs.
|
||||
function renderHostedSiteBlock(site, logging = caddyLoggingDirectives()) {
|
||||
return [`${caddySiteAddress(site)} {`, ...logging, ...commonHostDirectives(site), ` root * ${path.join(sitesDir, site.id)}`, " file_server", "}"];
|
||||
}
|
||||
function renderProxyBlock(proxy, logging = caddyLoggingDirectives()) {
|
||||
const lines = [`${caddySiteAddress(proxy)} {`, ...logging, ...commonHostDirectives(proxy)];
|
||||
for (const location of proxy.locations || []) {
|
||||
lines.push(` ${location.stripPrefix ? "handle_path" : "handle"} ${location.path} {`, ...proxyBlock(location.target, location, " "), " }");
|
||||
}
|
||||
@@ -438,15 +528,101 @@ function renderCaddyfile() {
|
||||
else lines.push(...proxyBlock(proxy.target, proxy));
|
||||
if (proxy.customConfig) lines.push(" # Administrator-provided custom configuration", ...String(proxy.customConfig).split("\n").map(line => ` ${line}`));
|
||||
lines.push("}");
|
||||
}
|
||||
for (const redirect of redirects.filter(item => item.enabled && item.domain)) {
|
||||
return lines;
|
||||
}
|
||||
function renderRedirectBlock(redirect, logging = caddyLoggingDirectives()) {
|
||||
const target = `${redirect.target}${redirect.preservePath ? "{uri}" : ""}`;
|
||||
lines.push("", `${caddySiteAddress(redirect)} {`, ...logging, ...commonHostDirectives(redirect), ` redir ${caddyQuote(target)} ${redirect.code || 302}`, "}");
|
||||
}
|
||||
return [`${caddySiteAddress(redirect)} {`, ...logging, ...commonHostDirectives(redirect), ` redir ${caddyQuote(target)} ${redirect.code || 302}`, "}"];
|
||||
}
|
||||
|
||||
function renderCaddyfile() {
|
||||
const email = String(process.env.ACME_EMAIL || "").trim();
|
||||
const lines = ["{", " admin localhost:2019", " persist_config off", ` storage file_system ${managedCertificatesDir}`];
|
||||
if (email) lines.push(` email ${email}`);
|
||||
const logging = caddyLoggingDirectives();
|
||||
lines.push("}", "", ":80 {", ...logging);
|
||||
const defaultSite = settings.defaultSite || {};
|
||||
if (defaultSite.mode === "abort") lines.push(" abort");
|
||||
else if (defaultSite.mode === "redirect" && defaultSite.redirectUrl) lines.push(` redir ${caddyQuote(`${defaultSite.redirectUrl}${defaultSite.preservePath ? "{uri}" : ""}`)} ${[301, 302, 307, 308].includes(Number(defaultSite.redirectCode)) ? Number(defaultSite.redirectCode) : 302}`);
|
||||
else lines.push(` root * ${defaultSiteDir}`, " rewrite * /index.html", ` file_server {`, ` status ${defaultSite.mode === "welcome" ? 200 : 404}`, " }");
|
||||
lines.push("}");
|
||||
for (const site of sites.filter(item => item.enabled && normalizeDomains(item.domain, item.domains).length)) lines.push("", ...renderHostedSiteBlock(site, logging));
|
||||
for (const proxy of proxies.filter(item => item.enabled && item.domain)) lines.push("", ...renderProxyBlock(proxy, logging));
|
||||
for (const redirect of redirects.filter(item => item.enabled && item.domain)) lines.push("", ...renderRedirectBlock(redirect, logging));
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
|
||||
// --- "View Caddy config" pretty renderer -------------------------------------------------
|
||||
// Re-uses the block builders above and annotates each directive with a plain-language
|
||||
// comment, so the popout explains the real configuration rather than a paraphrase of it.
|
||||
const CADDY_DIRECTIVE_NOTES = [
|
||||
[/^\s*log \{/, "Write this host's requests to the access log Site Gateway reads"],
|
||||
[/^\s*reverse_proxy /, "Reverse proxy to the configured upstream"],
|
||||
[/^\s*lb_policy /, "How requests are spread across the configured upstreams"],
|
||||
[/^\s*transport http \{/, "Connection options used when talking to the upstream"],
|
||||
[/^\s*tls_insecure_skip_verify/, "Accept the upstream's certificate without verifying it"],
|
||||
[/^\s*tls_server_name /, "Certificate name expected on the upstream"],
|
||||
[/^\s*response_header_timeout /, "How long to wait for the upstream's response headers"],
|
||||
[/^\s*header_up /, "Header added to the request before it reaches the upstream"],
|
||||
[/^\s*tls internal/, "Enforce HTTPS using Caddy's own internal certificate authority"],
|
||||
[/^\s*tls "/, "Enforce HTTPS using the certificate and private key you uploaded"],
|
||||
[/^\s*header Strict-Transport-Security/, "Tell browsers to always use HTTPS for this host"],
|
||||
[/^\s*header /, "Response header added to every reply from this host"],
|
||||
[/^\s*encode /, "Compress responses that benefit from it"],
|
||||
[/^\s*root \* /, "Folder this site's files are served from"],
|
||||
[/^\s*file_server/, "Serve the files in that folder directly"],
|
||||
[/^\s*redir /, "Send visitors to the redirect destination"],
|
||||
[/^\s*handle_path /, "Match this path prefix and strip it before forwarding"],
|
||||
[/^\s*handle \{/, "Everything not matched above is handled here"],
|
||||
[/^\s*handle /, "Match this path prefix and forward it unchanged"],
|
||||
[/^\s*abort/, "Close the connection without replying"],
|
||||
[/^\s*respond .* 403/, "Reject requests matching the common-exploit ruleset"],
|
||||
[/^\s*forward_auth /, "Require an Access List sign-in before allowing the request"],
|
||||
[/^\s*path_regexp /, "Pattern of known exploit-probe paths"],
|
||||
[/^\s*@/, "Named matcher used by the directive below"]
|
||||
];
|
||||
// Container-specific absolute paths, flagged so the config is readable outside this container.
|
||||
const CADDY_PATH_NOTES = [
|
||||
[/^\s*root \* /, "container path - this folder lives inside the Site Gateway container"],
|
||||
[/^\s*output file /, "container path - the access log file inside the Site Gateway container"],
|
||||
[/^\s*tls "\//, "container paths - the certificate and key files stored inside the Site Gateway container"]
|
||||
];
|
||||
function annotateCaddyLines(lines) {
|
||||
const output = [];
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) { output.push(""); continue; }
|
||||
const indent = " ".repeat(line.length - line.trimStart().length);
|
||||
const note = CADDY_DIRECTIVE_NOTES.find(([pattern]) => pattern.test(line))?.[1];
|
||||
const pathNote = CADDY_PATH_NOTES.find(([pattern]) => pattern.test(line))?.[1];
|
||||
if (note) {
|
||||
const previous = output[output.length - 1];
|
||||
if (previous !== undefined && previous.trim() && !/\{$/.test(previous)) output.push("");
|
||||
output.push(`${indent}# ${note}`);
|
||||
}
|
||||
output.push(pathNote ? `${line} # ${pathNote}` : line);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
function prettyCaddyConfig(kind, item) {
|
||||
const builder = kind === "sites" ? renderHostedSiteBlock : kind === "proxies" ? renderProxyBlock : renderRedirectBlock;
|
||||
const heading = kind === "sites" ? "Hosted site" : kind === "proxies" ? "Proxy host" : "Redirect host";
|
||||
const tlsNote = item.tls === "http" ? "# Served over plain HTTP - no certificate is requested for this host."
|
||||
: item.tls === "internal" ? "# Enforce HTTPS with Caddy's internal certificate authority."
|
||||
: item.tls === "custom" ? "# Enforce HTTPS with the certificate and private key you uploaded."
|
||||
: "# Enforce HTTPS with an automatically-issued certificate.";
|
||||
return [
|
||||
`# ${heading} - ${item.name || item.domain || item.id}`,
|
||||
`# Generated by Site Gateway v${appVersion}. This is the exact block this route`,
|
||||
"# contributes to the deployed Caddyfile, with explanatory comments added.",
|
||||
tlsNote,
|
||||
"",
|
||||
...annotateCaddyLines(builder(item)),
|
||||
""
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
|
||||
// syncCaddy -- applies the generated config to the running Caddy instance and
|
||||
// records success/failure (gatewayError, lastGatewayReload) for the dashboard.
|
||||
async function syncCaddy() {
|
||||
@@ -792,7 +968,7 @@ async function dashboardSnapshot() {
|
||||
for (const proxy of proxyHosts.filter(item => item.status === "error")) attention.push({ kind: "proxy", name: proxy.name, message: "Proxy route needs attention." });
|
||||
for (const proxy of proxyHosts.filter(item => item.enabled && item.upstream?.status === "unhealthy")) attention.push({ kind: "upstream", name: proxy.name, message: `Upstream is unavailable${proxy.upstream.error ? ` · ${proxy.upstream.error}` : ""}.` });
|
||||
for (const certificate of certificates.certificates.filter(item => ["warning", "critical", "expired", "mismatch"].includes(item.status))) attention.push({ kind: "certificate", target: "certificates", name: certificate.domain, message: certificate.status === "expired" ? "Certificate has expired." : certificate.status === "mismatch" ? "The uploaded certificate does not cover this domain." : `Certificate expires in ${certificate.daysRemaining} day${certificate.daysRemaining === 1 ? "" : "s"}.` });
|
||||
if (configDrift.drift) attention.push({ kind: "drift", name: "Configuration drift", message: "Caddy\u2019s live configuration no longer matches the saved configuration.", target: "administration" });
|
||||
if (configDrift.drift) attention.push({ kind: "drift", name: "Configuration drift", message: "Caddy\u2019s live configuration no longer matches the saved configuration.", target: "administration/defaults" });
|
||||
const disk = await fsp.statfs(dataDir).catch(() => null);
|
||||
const databaseIntegrity = storage.integrity();
|
||||
return {
|
||||
@@ -1012,7 +1188,7 @@ async function openBackup(filename, password = "") {
|
||||
return { zip: new AdmZip(buffer), encrypted };
|
||||
}
|
||||
|
||||
async function createBackup(type = "configuration", includeLogs = false, prefix = "site-gateway-backup", password = "") {
|
||||
async function createBackupInternal(type = "configuration", includeLogs = false, prefix = "site-gateway-backup", password = "") {
|
||||
const safeType = type === "complete" ? "complete" : "configuration";
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const filename = `${prefix}-${stamp}.sgbackup`;
|
||||
@@ -1036,6 +1212,22 @@ async function createBackup(type = "configuration", includeLogs = false, prefix
|
||||
return { filename, path: destination, ...manifest, size: (await fsp.stat(destination)).size };
|
||||
}
|
||||
|
||||
// Wraps createBackupInternal so every attempt -- successful or not -- is written to the
|
||||
// backup_events history table, which is independent of what currently exists on disk.
|
||||
async function createBackup(type = "configuration", includeLogs = false, prefix = "site-gateway-backup", password = "") {
|
||||
const safeType = type === "complete" ? "complete" : "configuration";
|
||||
const backupType = prefix === "pre-restore" ? "safety" : safeType;
|
||||
try {
|
||||
const result = await createBackupInternal(type, includeLogs, prefix, password);
|
||||
try { storage.recordBackupEvent({ type: "created", filename: result.filename, backupType, sizeBytes: result.size, actorUserId: currentAuditActor, status: "success" }); } catch (error) { console.warn("Could not record backup history event:", error.message); }
|
||||
return result;
|
||||
} catch (error) {
|
||||
try { storage.recordBackupEvent({ type: "created", filename: null, backupType, actorUserId: currentAuditActor, status: "failed", errorMessage: error.message }); } catch { /* History is best effort. */ }
|
||||
recordActivity(`${backupType === "safety" ? "Pre-restore safety" : safeType === "complete" ? "Complete" : "Configuration"} backup failed: ${error.message}`, "error");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function listBackups() {
|
||||
const names = (await fsp.readdir(backupsDir)).filter(name => name.endsWith(".sgbackup"));
|
||||
return Promise.all(names.map(async filename => {
|
||||
@@ -1088,7 +1280,10 @@ async function restoreBackup(filename, password = "", createSafetyBackup = true)
|
||||
for (const site of sites.filter(item => item.enabled)) await startSite(site);
|
||||
for (const stream of streams.filter(item => item.enabled !== false)) { try { await startStream(stream); } catch (error) { console.error(`Could not start streaming host “${stream.name}”:`, error.message); } }
|
||||
await syncCaddy(); recordActivity(`Backup ${filename} restored.`);
|
||||
try { storage.recordBackupEvent({ type: "restored", filename, backupType: manifest.type || "unknown", actorUserId: currentAuditActor, safetyBackupFilename: safetyBackup?.filename || null, status: "success" }); } catch (error) { console.warn("Could not record backup history event:", error.message); }
|
||||
} catch (error) {
|
||||
recordActivity(`Restore of ${filename} failed: ${error.message}`, "error");
|
||||
try { storage.recordBackupEvent({ type: "restored", filename, backupType: manifest.type || "unknown", actorUserId: currentAuditActor, safetyBackupFilename: safetyBackup?.filename || null, status: "failed", errorMessage: error.message }); } catch { /* History is best effort. */ }
|
||||
if (safetyBackup) {
|
||||
try { await restoreBackup(safetyBackup.filename, "", false); recordActivity(`Restore of ${filename} failed; the pre-restore state was recovered.`, "error"); }
|
||||
catch (rollbackError) { error.message = `${error.message} Automatic rollback also failed: ${rollbackError.message}`; }
|
||||
@@ -1119,6 +1314,9 @@ for (let attempt = 0; attempt < 10; attempt++) {
|
||||
}
|
||||
}
|
||||
|
||||
dockerSocketMounted = detectDockerSocket();
|
||||
if (!dockerSocketMounted) console.log("Docker socket not detected at /var/run/docker.sock - container selection stays unavailable.");
|
||||
|
||||
const app = express();
|
||||
const upload = multer({ dest: uploadDir, limits: { fileSize: 250 * 1024 * 1024, files: 1 } });
|
||||
const certificateUpload = multer({ dest: uploadDir, limits: { fileSize: 5 * 1024 * 1024, files: 2 } });
|
||||
@@ -1173,6 +1371,7 @@ function pendingMfaUser(req) {
|
||||
app.post("/api/login", async (req, res, next) => {
|
||||
try {
|
||||
const key = req.ip || req.socket.remoteAddress || "unknown";
|
||||
if (rateLimitExceeded(`login:${key}`, 10, 60000)) { recordActivity(`Security: sign-in request rate limit reached for ${key}.`, "error"); return res.status(429).json({ error: "Too many sign-in requests. Try again in a minute." }); }
|
||||
const attempt = checkLoginRateLimit(key);
|
||||
if (attempt.count >= 8) { recordActivity(`Security: sign-in rate limit reached for ${key}.`, "error"); return res.status(429).json({ error: "Too many sign-in attempts. Try again in 15 minutes." }); }
|
||||
const username = String(req.body.username || "").trim().toLowerCase();
|
||||
@@ -1244,6 +1443,7 @@ app.get("/_site-gateway/login", (req, res) => {
|
||||
});
|
||||
app.post("/_site-gateway/login", async (req, res, next) => {
|
||||
try {
|
||||
if (rateLimitExceeded(`access-login:${requestKey(req)}`, 10, 60000)) return res.status(429).type("text").send("Too many sign-in requests. Try again in a minute.");
|
||||
const listId = String(req.body.list || ""), list = accessLists.find(item => item.id === listId && item.enabled !== false), username = String(req.body.username || "").trim(); const credential = list?.credentials?.find(item => item.username === username) || ((list && accessUserAllowed(list, username)) ? users.find(user => user.username === username && user.status === "active") : null);
|
||||
const safeReturn = String(req.body.return || "/").startsWith("/") && !String(req.body.return).startsWith("//") ? String(req.body.return) : "/";
|
||||
if (!credential?.password || !await passwordMatches(req.body.password || "", credential.password)) return res.redirect(303, `/_site-gateway/login?list=${encodeURIComponent(listId)}&return=${encodeURIComponent(safeReturn)}&error=1`);
|
||||
@@ -1254,6 +1454,19 @@ app.post("/_site-gateway/login", async (req, res, next) => {
|
||||
|
||||
// --- First-run admin setup ---------------------------------------------------------------------------------
|
||||
app.use("/api", (req, res, next) => {
|
||||
// A bearer token authenticates as its owner and inherits that owner's role for every
|
||||
// role check further down. Cookie sessions remain the fallback.
|
||||
if (/^Bearer\s+/i.test(String(req.headers.authorization || ""))) {
|
||||
if (rateLimitExceeded(`bearer:${requestKey(req)}`, 10, 60000)) return res.status(429).json({ error: "Too many API requests. Try again in a minute." });
|
||||
const match = bearerTokenUser(req);
|
||||
if (!match) return res.status(401).json({ error: "That API token is not valid, has expired, or has been revoked." });
|
||||
if (match.token.scope === "read-only" && req.method !== "GET") return res.status(403).json({ error: "This API token is read-only." });
|
||||
if (!match.token.lastUsedAt || Date.now() - new Date(match.token.lastUsedAt).getTime() > 60000) {
|
||||
try { storage.touchApiToken(match.token.id); } catch { /* Last-used tracking is best effort. */ }
|
||||
}
|
||||
req.user = match.user; req.apiToken = match.token;
|
||||
return next();
|
||||
}
|
||||
const user = sessionUser(req);
|
||||
if (!user) return res.status(401).json({ error: "Please sign in." });
|
||||
req.user = user;
|
||||
@@ -1291,8 +1504,8 @@ app.post("/api/account/password", async (req, res, next) => {
|
||||
if (!await passwordMatches(currentPassword, req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." });
|
||||
if (newPassword.length < 8) return res.status(400).json({ error: "New password must contain at least 8 characters." });
|
||||
req.user.password = await passwordRecord(newPassword);
|
||||
req.user.updatedAt = new Date().toISOString();
|
||||
await saveUsers(); recordActivity(`User “${req.user.username}” changed their password.`);
|
||||
rotateSessionVersion(req.user);
|
||||
await saveUsers(); recordActivity(`User “${req.user.username}” changed their password. Other sessions and API tokens were signed out.`);
|
||||
issueSessionCookie(res, req.user);
|
||||
res.json({ ok: true });
|
||||
} catch (error) { next(error); }
|
||||
@@ -1326,8 +1539,9 @@ app.post("/api/account/mfa/disable", async (req, res, next) => {
|
||||
try {
|
||||
if (!await passwordMatches(req.body.password || "", req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." });
|
||||
req.user.mfaEnabled = false; req.user.mfaSecret = null; req.user.mfaPendingSecret = null; req.user.mfaRecoveryCodes = [];
|
||||
req.user.updatedAt = new Date().toISOString();
|
||||
await saveUsers(); recordActivity(`User “${req.user.username}” disabled two-factor authentication.`, "warning");
|
||||
rotateSessionVersion(req.user);
|
||||
await saveUsers(); recordActivity(`User “${req.user.username}” disabled two-factor authentication. Other sessions and API tokens were signed out.`, "warning");
|
||||
issueSessionCookie(res, req.user);
|
||||
res.json({ ok: true });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
@@ -1344,7 +1558,7 @@ app.post("/api/account/mfa/recovery-codes", async (req, res, next) => {
|
||||
});
|
||||
|
||||
// --- Config, Users, Audit log, Groups, Access List <-> Group assignment --------------------------------------
|
||||
app.get("/api/config", (req, res) => res.json({ version: appVersion, minPort, maxPort, adminPort, storage: { engine: "sqlite", databasePath: storage.databasePath, instanceId: LOCAL_INSTANCE_ID, backupsPath: backupsDir, certificatesPath: certificatesRoot }, gateway: { enabled: true, error: gatewayError }, backup: { encryptionAvailable: Boolean(scheduledBackupPassword) } }));
|
||||
app.get("/api/config", (req, res) => res.json({ version: appVersion, minPort, maxPort, adminPort, storage: { engine: "sqlite", databasePath: storage.databasePath, instanceId: LOCAL_INSTANCE_ID, backupsPath: backupsDir, certificatesPath: certificatesRoot }, gateway: { enabled: true, error: gatewayError }, backup: { encryptionAvailable: Boolean(scheduledBackupPassword) }, docker: { socketMounted: dockerSocketMounted, enabled: dockerSocketMounted && settings.dockerIntegration?.enabled === true } }));
|
||||
app.post("/api/gateway/resync", async (req, res, next) => {
|
||||
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
|
||||
try {
|
||||
@@ -1356,6 +1570,41 @@ app.post("/api/gateway/resync", async (req, res, next) => {
|
||||
});
|
||||
app.get("/api/users", (req, res) => req.user.role === "administrator" ? res.json(users.map(publicUser)) : res.status(403).json({ error: "Administrator access is required." }));
|
||||
app.get("/api/audit", (req, res) => req.user.role === "administrator" ? res.json(storage.listAudit({ user: req.query.user, action: req.query.action, status: req.query.status }).map(item => ({ ...item, actor: users.find(user => user.id === item.actor_id)?.username || "System" }))) : res.status(403).json({ error: "Administrator access is required." }));
|
||||
// --- REST API access tokens: list / issue / revoke -----------------------------------------
|
||||
app.get("/api/tokens", (req, res, next) => {
|
||||
try {
|
||||
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
|
||||
res.json(storage.listApiTokens().map(token => ({ id: token.id, name: token.name, prefix: token.prefix, scope: token.scope, ownerUserId: token.ownerUserId, ownerUsername: users.find(item => item.id === token.ownerUserId)?.username || "unknown", createdAt: token.createdAt, lastUsedAt: token.lastUsedAt, expiresAt: token.expiresAt, revokedAt: token.revokedAt, revoked: Boolean(token.revokedAt) })));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
app.post("/api/tokens", async (req, res, next) => {
|
||||
try {
|
||||
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
|
||||
if (req.apiToken) return res.status(403).json({ error: "New API tokens can only be issued from a signed-in session." });
|
||||
if (String(req.body.username || "").trim().toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(422).json({ error: "Administrator username or password is incorrect." });
|
||||
const name = String(req.body.name || "").trim();
|
||||
if (!name || name.length > 60) return res.status(400).json({ error: "Enter a name of 60 characters or fewer for this token." });
|
||||
const scope = req.body.scope === "read-only" ? "read-only" : "full";
|
||||
const requestedDays = Number(req.body.expiresInDays);
|
||||
const expiresAt = Number.isFinite(requestedDays) && requestedDays > 0 ? new Date(Date.now() + Math.min(requestedDays, 3650) * 86400000).toISOString() : null;
|
||||
if (!req.user.sessionVersion) { req.user.sessionVersion = crypto.randomBytes(16).toString("hex"); req.user.updatedAt = new Date().toISOString(); await saveUsers(); }
|
||||
const rawToken = `sgt_${crypto.randomBytes(32).toString("base64url")}`;
|
||||
const record = storage.createApiToken({ id: crypto.randomUUID(), name, tokenHash: hashApiToken(rawToken), prefix: rawToken.slice(0, 8), ownerUserId: req.user.id, scope, sessionVersion: req.user.sessionVersion, expiresAt });
|
||||
recordActivity(`Security: API token \u201c${name}\u201d issued for \u201c${req.user.username}\u201d.`);
|
||||
res.status(201).json({ ...record, ownerUsername: req.user.username, revoked: false, token: rawToken });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
app.delete("/api/tokens/:id", (req, res, next) => {
|
||||
try {
|
||||
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
|
||||
const existing = storage.listApiTokens().find(token => token.id === req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: "API token not found." });
|
||||
if (!storage.revokeApiToken(req.params.id)) return res.status(409).json({ error: "That API token has already been revoked." });
|
||||
recordActivity(`Security: API token \u201c${existing.name}\u201d revoked.`);
|
||||
res.status(204).end();
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.post("/api/users", async (req, res, next) => {
|
||||
try {
|
||||
const username = String(req.body.username || "").trim().toLowerCase();
|
||||
@@ -1390,12 +1639,17 @@ app.patch("/api/users/:id", async (req, res, next) => {
|
||||
if (!displayName || displayName.length > 80) return res.status(400).json({ error: "Display name is required and must be 80 characters or fewer." });
|
||||
user.displayName = displayName;
|
||||
}
|
||||
let invalidateSessions = nextStatus !== "active";
|
||||
if (req.body.password !== undefined) {
|
||||
const password = String(req.body.password);
|
||||
if (password.length < 8) return res.status(400).json({ error: "Password must contain at least 8 characters." });
|
||||
user.password = await passwordRecord(password);
|
||||
invalidateSessions = true;
|
||||
}
|
||||
user.updatedAt = new Date().toISOString(); await saveUsers(); recordActivity(`User “${user.username}” updated · ${user.role === "administrator" ? "Administrator" : user.role === "viewer" ? "Viewer" : "Standard User"} · ${user.status}.`);
|
||||
// A password reset or a deactivation must also drop this user's API tokens.
|
||||
if (invalidateSessions) rotateSessionVersion(user);
|
||||
user.updatedAt = new Date().toISOString(); await saveUsers();
|
||||
if (invalidateSessions && user.id === req.user.id) issueSessionCookie(res, user); recordActivity(`User “${user.username}” updated · ${user.role === "administrator" ? "Administrator" : user.role === "viewer" ? "Viewer" : "Standard User"} · ${user.status}.`);
|
||||
res.json(publicUser(user));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
@@ -1419,9 +1673,10 @@ app.post("/api/users/:id/mfa/disable", async (req, res, next) => {
|
||||
if (!user) return res.status(404).json({ error: "User not found." });
|
||||
if (!user.mfaEnabled) return res.status(400).json({ error: "Two-factor authentication isn\u2019t enabled for this user." });
|
||||
user.mfaEnabled = false; user.mfaSecret = null; user.mfaPendingSecret = null; user.mfaRecoveryCodes = [];
|
||||
user.updatedAt = new Date().toISOString();
|
||||
rotateSessionVersion(user);
|
||||
await saveUsers();
|
||||
recordActivity(`Administrator “${req.user.username}” disabled two-factor authentication for “${user.username}”.`, "warning");
|
||||
if (user.id === req.user.id) issueSessionCookie(res, user);
|
||||
recordActivity(`Administrator “${req.user.username}” disabled two-factor authentication for “${user.username}”. That user’s sessions and API tokens were signed out.`, "warning");
|
||||
res.json({ ok: true });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
@@ -1467,6 +1722,16 @@ app.get("/api/support-report", async (req, res, next) => {
|
||||
});
|
||||
|
||||
// --- Upstream health, Logs, and Performance (request throughput/trend) endpoints --------------------------------
|
||||
// --- Docker: list running containers for the target picker -----------------------------------
|
||||
app.get("/api/docker/containers", async (req, res, next) => {
|
||||
try {
|
||||
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
|
||||
if (!dockerSocketMounted) return res.status(409).json({ error: "Docker socket not detected \u2014 mount /var/run/docker.sock into this container to enable container selection." });
|
||||
if (settings.dockerIntegration?.enabled !== true) return res.status(409).json({ error: "Docker integration is turned off. Enable it in Administration \u2192 Gateway defaults." });
|
||||
res.json({ checkedAt: new Date().toISOString(), containers: await dockerContainerOptions() });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.get("/api/upstreams", (req, res) => res.json(proxies.map(publicProxy)));
|
||||
app.post("/api/upstreams/check", async (req, res, next) => {
|
||||
try { res.json(await checkAllProxies()); }
|
||||
@@ -1486,17 +1751,34 @@ app.get("/api/performance", (req, res, next) => {
|
||||
const bucketMinutes = hours > 24 ? 60 : 15;
|
||||
const breakdownByHost = new Map();
|
||||
for (const row of storage.performanceErrorBreakdown()) { if (!breakdownByHost.has(row.host)) breakdownByHost.set(row.host, []); breakdownByHost.get(row.host).push({ status: row.status, count: row.count }); }
|
||||
const percentiles = storage.performancePercentiles(0.95);
|
||||
const topPaths = storage.performanceTopPaths(10);
|
||||
res.json({
|
||||
checkedAt: new Date().toISOString(),
|
||||
liveRequests: storage.performanceLiveCount(60),
|
||||
routes: storage.performanceRoutes().map(row => ({ host: row.host, hourRequests: row.hourRequests || 0, hourErrors: row.hourErrors || 0, hourAvgMs: row.hourAvgMs != null ? Math.round(row.hourAvgMs) : null, dayRequests: row.dayRequests || 0, dayErrors: row.dayErrors || 0, dayAvgMs: row.dayAvgMs != null ? Math.round(row.dayAvgMs) : null, errorBreakdown: (breakdownByHost.get(row.host) || []).slice(0, 3) })),
|
||||
routes: storage.performanceRoutes().map(row => ({ host: row.host, hourRequests: row.hourRequests || 0, hourErrors: row.hourErrors || 0, hourAvgMs: row.hourAvgMs != null ? Math.round(row.hourAvgMs) : null, hourBytes: row.hourBytes || 0, hourVisitors: row.hourVisitors || 0, dayRequests: row.dayRequests || 0, dayErrors: row.dayErrors || 0, dayAvgMs: row.dayAvgMs != null ? Math.round(row.dayAvgMs) : null, dayBytes: row.dayBytes || 0, dayVisitors: row.dayVisitors || 0, hourP95Ms: percentiles[row.host]?.hourP95 ?? null, dayP95Ms: percentiles[row.host]?.dayP95 ?? null, topPaths: topPaths[row.host] || [], errorBreakdown: breakdownByHost.get(row.host) || [] })),
|
||||
trend: storage.performanceTrend(host, hours, bucketMinutes),
|
||||
slowest: storage.performanceSlowest(host, hours, 20),
|
||||
hosts: [...new Set([...sites, ...proxies, ...redirects].flatMap(item => normalizeDomains(item.domain, item.domains)))].sort()
|
||||
});
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
// --- Icon search and per-entity icon upload/URL/removal -----------------------------------------------------------
|
||||
// --- "View Caddy config": the annotated Caddyfile block for one route -------------------------
|
||||
// Streaming hosts are deliberately excluded: they are raw TCP/UDP forwards handled by
|
||||
// startStream() through net/dgram, and never appear in the Caddyfile at all.
|
||||
app.get("/api/:kind/:id/caddy-config", (req, res, next) => {
|
||||
try {
|
||||
const kind = String(req.params.kind);
|
||||
if (!["sites", "proxies", "redirects"].includes(kind)) return res.status(400).json({ error: "A Caddy configuration view is only available for hosted sites, proxy hosts, and redirect hosts. Streaming hosts forward raw TCP/UDP traffic and are not routed through Caddy." });
|
||||
const collection = kind === "sites" ? sites : kind === "proxies" ? proxies : redirects;
|
||||
const item = collection.find(value => value.id === req.params.id);
|
||||
if (!item) return res.status(404).json({ error: "That route no longer exists." });
|
||||
res.json({ kind, id: item.id, name: item.name || item.domain || item.id, config: prettyCaddyConfig(kind, item) });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.get("/api/icons/search", async (req, res, next) => {
|
||||
try {
|
||||
const query = String(req.query.q || "").trim().toLowerCase().slice(0, 80);
|
||||
@@ -1929,6 +2211,7 @@ app.patch("/api/settings", async (req, res, next) => {
|
||||
const value = req.body.defaultSite; const mode = ["welcome","themed404","abort","redirect","custom"].includes(value.mode) ? value.mode : "themed404";
|
||||
settings.defaultSite = { mode, redirectUrl: String(value.redirectUrl || "").trim(), redirectCode: [301,302,307,308].includes(Number(value.redirectCode)) ? Number(value.redirectCode) : 302, preservePath: value.preservePath !== false, title: String(value.title || "").slice(0, 100), message: String(value.message || "").slice(0, 500), customHtml: String(value.customHtml || "").slice(0, 250000) };
|
||||
}
|
||||
if (req.body.dockerIntegration) settings.dockerIntegration = { enabled: req.body.dockerIntegration.enabled === true && dockerSocketMounted };
|
||||
if (req.body.backups) settings.backups = { ...settings.backups, ...req.body.backups, hour: Math.min(Math.max(Number(req.body.backups.hour) || 0, 0), 23), retention: Math.min(Math.max(Number(req.body.backups.retention) || 7, 1), 100) };
|
||||
if (req.body.certificateHealth) {
|
||||
const warningDays = Math.min(Math.max(Number(req.body.certificateHealth.warningDays) || 30, 8), 120);
|
||||
@@ -1952,6 +2235,7 @@ app.post("/api/factory-reset", async (req, res, next) => { try { if (String(req.
|
||||
// --- Backups: list / create / import / download / restore / delete -----------------------------------------------------
|
||||
app.use("/api/backups", (req, res, next) => req.user.role === "administrator" ? next() : res.status(403).json({ error: "Administrator access is required." }));
|
||||
app.get("/api/backups", async (req, res, next) => { try { res.json(await listBackups()); } catch (error) { next(error); } });
|
||||
app.get("/api/backups/history", (req, res, next) => { try { res.json(storage.listBackupEvents(500)); } catch (error) { next(error); } });
|
||||
app.post("/api/backups", async (req, res, next) => {
|
||||
try { const backup = await createBackup(req.body.type, Boolean(req.body.includeLogs), "site-gateway-backup", String(req.body.password || "")); res.status(201).json(backup); } catch (error) { next(error); }
|
||||
});
|
||||
@@ -1961,7 +2245,9 @@ app.post("/api/backups/import", upload.single("backup"), async (req, res, next)
|
||||
const { zip } = await openBackup(req.file.path, String(req.body.password || "")); const manifest = JSON.parse(zip.readAsText("manifest.json") || "null");
|
||||
if (!manifest || manifest.product !== "Site Gateway" || ![1,2].includes(manifest.format)) throw Object.assign(new Error("This is not a supported Site Gateway backup."), { status: 400 });
|
||||
const filename = `imported-${new Date().toISOString().replace(/[:.]/g, "-")}.sgbackup`; await fsp.rename(req.file.path, path.join(backupsDir, filename));
|
||||
recordActivity(`Backup imported from this computer.`); res.status(201).json({ filename, manifest });
|
||||
recordActivity(`Backup imported from this computer.`);
|
||||
try { storage.recordBackupEvent({ type: "imported", filename, backupType: manifest.type || "unknown", sizeBytes: (await fsp.stat(path.join(backupsDir, filename))).size, actorUserId: req.user.id, status: "success" }); } catch (error) { console.warn("Could not record backup history event:", error.message); }
|
||||
res.status(201).json({ filename, manifest });
|
||||
} catch (error) { if (req.file) await fsp.rm(req.file.path, { force: true }); next(error); }
|
||||
});
|
||||
app.get("/api/backups/:filename/download", async (req, res, next) => {
|
||||
@@ -1971,7 +2257,15 @@ app.post("/api/backups/:filename/restore", async (req, res, next) => {
|
||||
try { res.json({ ok: true, manifest: await restoreBackup(path.basename(req.params.filename), String(req.body.password || "")) }); } catch (error) { next(error); }
|
||||
});
|
||||
app.delete("/api/backups/:filename", async (req, res, next) => {
|
||||
try { const filename = path.basename(req.params.filename); if (!filename.endsWith(".sgbackup")) return res.status(400).json({ error: "Invalid backup." }); await fsp.rm(path.join(backupsDir, filename)); recordActivity(`Backup ${filename} deleted.`); res.status(204).end(); } catch (error) { next(error); }
|
||||
try {
|
||||
const filename = path.basename(req.params.filename);
|
||||
if (!filename.endsWith(".sgbackup")) return res.status(400).json({ error: "Invalid backup." });
|
||||
const existing = (await listBackups()).find(item => item.filename === filename);
|
||||
await fsp.rm(path.join(backupsDir, filename));
|
||||
recordActivity(`Backup ${filename} deleted.`);
|
||||
try { storage.recordBackupEvent({ type: "deleted", filename, backupType: existing?.type || (filename.startsWith("pre-restore") ? "safety" : "unknown"), sizeBytes: existing?.size ?? null, actorUserId: req.user.id, status: "success" }); } catch (error) { console.warn("Could not record backup history event:", error.message); }
|
||||
res.status(204).end();
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
function humanizeGatewayActivityError(message) { const text = String(message || "Unexpected gateway error"); if (/upstream address scheme is HTTP but transport is configured for HTTP\+TLS/i.test(text)) return "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; if (/upstream address scheme is HTTPS but transport is configured for plain HTTP/i.test(text)) return "Gateway configuration rejected: HTTPS upstream requires HTTPS transport settings. Change the upstream URL or transport setting."; if (/duplicate.*address|already.*site address/i.test(text)) return "Gateway configuration rejected: This hostname or address is already used by another host. Choose a unique hostname and port."; if (/dial tcp|no such host|lookup .* no such host|upstream.*(invalid|malformed)/i.test(text)) return "Gateway configuration rejected: The upstream address could not be reached or is invalid. Check the hostname, IP address, and port."; if (/invalid hostname|host name.*invalid|malformed.*host/i.test(text)) return "Gateway configuration rejected: The hostname is not valid. Use a valid domain name without a protocol or path."; if (/unrecognized directive|unknown directive|parsing caddyfile tokens/i.test(text)) return "Gateway configuration rejected: The gateway configuration contains an unsupported or malformed directive. Check the selected host settings."; if (/certificate|tls.*(config|handshake)|no certificate/i.test(text)) return "Gateway configuration rejected: The TLS certificate configuration is invalid or unavailable. Check the certificate, key, and HTTPS settings."; return text.replace(/^Gateway configuration was rejected:\s*/i, "Gateway configuration rejected: ").replace(/\s+Details:\s+[\s\S]*$/i, ""); }
|
||||
|
||||
+48
-6
@@ -68,6 +68,11 @@ export async function openStorage(dataDir, backupsDir) {
|
||||
CREATE INDEX IF NOT EXISTS activity_events_instance_created ON activity_events(instance_id,created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS access_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), at TEXT, host TEXT, method TEXT, uri TEXT, status INTEGER, size INTEGER, duration_ms INTEGER, remote_ip TEXT, source TEXT, UNIQUE(instance_id,source));
|
||||
CREATE INDEX IF NOT EXISTS access_events_instance_at ON access_events(instance_id,at DESC);
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (id TEXT PRIMARY KEY, instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE, name TEXT NOT NULL, token_hash TEXT NOT NULL, prefix TEXT NOT NULL, owner_user_id TEXT NOT NULL, scope TEXT NOT NULL DEFAULT 'full', session_version TEXT, created_at TEXT NOT NULL, last_used_at TEXT, expires_at TEXT, revoked_at TEXT);
|
||||
CREATE INDEX IF NOT EXISTS api_tokens_hash ON api_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS api_tokens_instance ON api_tokens(instance_id);
|
||||
CREATE TABLE IF NOT EXISTS backup_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE, type TEXT NOT NULL, filename TEXT, backup_type TEXT, size_bytes INTEGER, actor_user_id TEXT, created_at TEXT NOT NULL, safety_backup_filename TEXT, status TEXT NOT NULL DEFAULT 'success', error_message TEXT);
|
||||
CREATE INDEX IF NOT EXISTS backup_events_instance_created ON backup_events(instance_id,created_at DESC);
|
||||
`);
|
||||
try { db.exec("ALTER TABLE activity_events ADD COLUMN category TEXT NOT NULL DEFAULT 'activity'"); } catch { /* Column already exists. */ }
|
||||
const timestamp = now();
|
||||
@@ -115,12 +120,16 @@ export async function openStorage(dataDir, backupsDir) {
|
||||
SUM(CASE WHEN at>=? THEN 1 ELSE 0 END) AS hourRequests,
|
||||
SUM(CASE WHEN at>=? AND status>=400 THEN 1 ELSE 0 END) AS hourErrors,
|
||||
AVG(CASE WHEN at>=? THEN duration_ms END) AS hourAvgMs,
|
||||
SUM(CASE WHEN at>=? THEN COALESCE(size,0) ELSE 0 END) AS hourBytes,
|
||||
COUNT(DISTINCT CASE WHEN at>=? THEN remote_ip END) AS hourVisitors,
|
||||
COUNT(*) AS dayRequests,
|
||||
SUM(CASE WHEN status>=400 THEN 1 ELSE 0 END) AS dayErrors,
|
||||
AVG(duration_ms) AS dayAvgMs
|
||||
AVG(duration_ms) AS dayAvgMs,
|
||||
SUM(COALESCE(size,0)) AS dayBytes,
|
||||
COUNT(DISTINCT remote_ip) AS dayVisitors
|
||||
FROM access_events WHERE instance_id=? AND at>=? AND host IS NOT NULL AND host!=''
|
||||
GROUP BY host ORDER BY dayRequests DESC
|
||||
`).all(hourCutoff, hourCutoff, hourCutoff, instanceId, dayCutoff);
|
||||
`).all(hourCutoff, hourCutoff, hourCutoff, hourCutoff, hourCutoff, instanceId, dayCutoff);
|
||||
}
|
||||
function performanceErrorBreakdown(instanceId = LOCAL_INSTANCE_ID) {
|
||||
const dayCutoff = new Date(Date.now() - 86400000).toISOString();
|
||||
@@ -134,17 +143,50 @@ export async function openStorage(dataDir, backupsDir) {
|
||||
const bucketMs = Math.max(1, Number(bucketMinutes) || 15) * 60000;
|
||||
const windowMs = Math.max(1, Number(hours) || 6) * 3600000;
|
||||
const cutoff = new Date(Date.now() - windowMs).toISOString();
|
||||
const rows = db.prepare(`SELECT at FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?)`).all(instanceId, cutoff, host, host);
|
||||
const rows = db.prepare(`SELECT at,status FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?)`).all(instanceId, cutoff, host, host);
|
||||
const buckets = new Map();
|
||||
for (const row of rows) { const t = new Date(row.at).getTime(); if (Number.isNaN(t)) continue; const bucketStart = Math.floor(t / bucketMs) * bucketMs; buckets.set(bucketStart, (buckets.get(bucketStart) || 0) + 1); }
|
||||
for (const row of rows) { const t = new Date(row.at).getTime(); if (Number.isNaN(t)) continue; const bucketStart = Math.floor(t / bucketMs) * bucketMs; const entry = buckets.get(bucketStart) || { count: 0, errors: 0 }; entry.count += 1; if (Number(row.status) >= 400) entry.errors += 1; buckets.set(bucketStart, entry); }
|
||||
const startBucket = Math.floor((Date.now() - windowMs) / bucketMs) * bucketMs, endBucket = Math.floor(Date.now() / bucketMs) * bucketMs;
|
||||
const points = [];
|
||||
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketMs) points.push({ at: new Date(bucket).toISOString(), count: buckets.get(bucket) || 0 });
|
||||
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketMs) { const entry = buckets.get(bucket); points.push({ at: new Date(bucket).toISOString(), count: entry?.count || 0, errors: entry?.errors || 0 }); }
|
||||
return points;
|
||||
}
|
||||
function pruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); return transaction(() => { const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) { const result = db.prepare(`DELETE FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).run(instanceId, cutoff(days)); counts[name] = Number(result.changes || 0); } return counts; }); }
|
||||
function previewPruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) counts[name] = Number(db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).get(instanceId, cutoff(days)).count || 0); return counts; }
|
||||
function listAudit(filters = {}, instanceId = LOCAL_INSTANCE_ID) { const rows = db.prepare("SELECT id,actor_id,action,status,details,created_at FROM audit_events WHERE instance_id=? ORDER BY id DESC LIMIT 500").all(instanceId); return rows.filter(row => (!filters.user || row.actor_id === filters.user) && (!filters.action || row.action.toLowerCase().includes(filters.action.toLowerCase())) && (!filters.status || row.status === filters.status)).map(row => ({ ...row, details: row.details ? JSON.parse(row.details) : null })); }
|
||||
// 95th-percentile latency per host. SQLite has no percentile aggregate, so the
|
||||
// durations come back pre-sorted per host and the index is picked in JavaScript.
|
||||
function performancePercentiles(percentile = 0.95, instanceId = LOCAL_INSTANCE_ID) {
|
||||
const hourCutoff = new Date(Date.now() - 3600000).toISOString(), dayCutoff = new Date(Date.now() - 86400000).toISOString();
|
||||
const rows = db.prepare("SELECT host,at,duration_ms AS durationMs FROM access_events WHERE instance_id=? AND at>=? AND duration_ms IS NOT NULL AND host IS NOT NULL AND host!='' ORDER BY host, duration_ms").all(instanceId, dayCutoff);
|
||||
const pick = values => { if (!values.length) return null; const index = Math.min(values.length - 1, Math.max(0, Math.ceil(percentile * values.length) - 1)); return Math.round(values[index]); };
|
||||
const byHost = new Map();
|
||||
for (const row of rows) { if (!byHost.has(row.host)) byHost.set(row.host, { day: [], hour: [] }); const entry = byHost.get(row.host); entry.day.push(row.durationMs); if (row.at >= hourCutoff) entry.hour.push(row.durationMs); }
|
||||
return Object.fromEntries([...byHost].map(([host, entry]) => [host, { hourP95: pick(entry.hour), dayP95: pick(entry.day) }]));
|
||||
}
|
||||
function performanceTopPaths(limit = 10, instanceId = LOCAL_INSTANCE_ID) {
|
||||
const dayCutoff = new Date(Date.now() - 86400000).toISOString();
|
||||
const cap = Math.max(1, Math.min(Number(limit) || 10, 50));
|
||||
const rows = db.prepare("SELECT host,uri,COUNT(*) AS count FROM access_events WHERE instance_id=? AND at>=? AND host IS NOT NULL AND host!='' GROUP BY host,uri ORDER BY host, count DESC").all(instanceId, dayCutoff);
|
||||
const byHost = new Map();
|
||||
for (const row of rows) { const list = byHost.get(row.host) || []; if (list.length < cap) list.push({ uri: row.uri || "/", count: row.count }); byHost.set(row.host, list); }
|
||||
return Object.fromEntries(byHost);
|
||||
}
|
||||
function performanceSlowest(host = "", hours = 6, limit = 20, instanceId = LOCAL_INSTANCE_ID) {
|
||||
const cutoff = new Date(Date.now() - Math.max(1, Number(hours) || 6) * 3600000).toISOString();
|
||||
return db.prepare("SELECT host,uri,method,status,duration_ms AS durationMs,at FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?) AND duration_ms IS NOT NULL ORDER BY duration_ms DESC LIMIT ?").all(instanceId, cutoff, host, host, Math.max(1, Math.min(Number(limit) || 20, 50)));
|
||||
}
|
||||
// --- API tokens. Dedicated table (not the generic JSON-collection pattern) because
|
||||
// every authenticated API request looks a token up by its SHA-256 hash.
|
||||
function listApiTokens(instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,name,prefix,owner_user_id AS ownerUserId,scope,created_at AS createdAt,last_used_at AS lastUsedAt,expires_at AS expiresAt,revoked_at AS revokedAt FROM api_tokens WHERE instance_id=? ORDER BY created_at DESC").all(instanceId); }
|
||||
function createApiToken(row, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO api_tokens(id,instance_id,name,token_hash,prefix,owner_user_id,scope,session_version,created_at,last_used_at,expires_at,revoked_at) VALUES(?,?,?,?,?,?,?,?,?,NULL,?,NULL)").run(row.id, instanceId, String(row.name), String(row.tokenHash), String(row.prefix), String(row.ownerUserId), row.scope === "read-only" ? "read-only" : "full", row.sessionVersion || null, now(), row.expiresAt || null); return listApiTokens(instanceId).find(item => item.id === row.id) || null; }
|
||||
function findApiTokenByHash(tokenHash, instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,name,prefix,owner_user_id AS ownerUserId,scope,session_version AS sessionVersion,created_at AS createdAt,last_used_at AS lastUsedAt,expires_at AS expiresAt,revoked_at AS revokedAt FROM api_tokens WHERE instance_id=? AND token_hash=?").get(instanceId, String(tokenHash)) || null; }
|
||||
function revokeApiToken(id, instanceId = LOCAL_INSTANCE_ID) { return Number(db.prepare("UPDATE api_tokens SET revoked_at=? WHERE instance_id=? AND id=? AND revoked_at IS NULL").run(now(), instanceId, id).changes || 0) > 0; }
|
||||
function touchApiToken(id, instanceId = LOCAL_INSTANCE_ID) { db.prepare("UPDATE api_tokens SET last_used_at=? WHERE instance_id=? AND id=?").run(now(), instanceId, id); }
|
||||
// --- Backup history. Independent of what is on disk, so deleted backups and failed
|
||||
// attempts stay visible in the timeline.
|
||||
function recordBackupEvent(event, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO backup_events(instance_id,type,filename,backup_type,size_bytes,actor_user_id,created_at,safety_backup_filename,status,error_message) VALUES(?,?,?,?,?,?,?,?,?,?)").run(instanceId, String(event.type), event.filename || null, event.backupType || null, event.sizeBytes ?? null, event.actorUserId || null, event.createdAt || now(), event.safetyBackupFilename || null, event.status === "failed" ? "failed" : "success", event.errorMessage ? String(event.errorMessage).slice(0, 500) : null); }
|
||||
function listBackupEvents(limit = 500, instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,type,filename,backup_type AS backupType,size_bytes AS sizeBytes,actor_user_id AS actorUserId,created_at AS createdAt,safety_backup_filename AS safetyBackupFilename,status,error_message AS errorMessage FROM backup_events WHERE instance_id=? ORDER BY id DESC LIMIT ?").all(instanceId, Math.max(1, Math.min(Number(limit) || 500, 500))); }
|
||||
function backupTo(filename) { try { fs.rmSync(filename, { force: true }); db.exec(`VACUUM INTO '${String(filename).replaceAll("'", "''")}'`); } catch (error) { throw new Error(`Could not create a consistent SQLite backup: ${error.message}`); } }
|
||||
|
||||
if (isNew) {
|
||||
@@ -170,5 +212,5 @@ export async function openStorage(dataDir, backupsDir) {
|
||||
}
|
||||
function humanizeGatewayErrors(instanceId = LOCAL_INSTANCE_ID) { const friendly = "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; const activity = db.prepare("SELECT id FROM activity_events WHERE instance_id=? AND message LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateActivity = db.prepare("UPDATE activity_events SET message=? WHERE id=?"); for (const row of activity) updateActivity.run(friendly, row.id); const audit = db.prepare("SELECT id FROM audit_events WHERE instance_id=? AND action LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateAudit = db.prepare("UPDATE audit_events SET action=? WHERE id=?"); for (const row of audit) updateAudit.run(friendly, row.id); return activity.length + audit.length; }
|
||||
const result = integrity(); if (result.length !== 1 || result[0] !== "ok") { db.close(); throw new Error(`SQLite integrity check failed: ${result.join(", ")}`); }
|
||||
return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, performanceLiveCount, performanceRoutes, performanceErrorBreakdown, performanceTrend, close: () => db.close() };
|
||||
return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, performanceLiveCount, performanceRoutes, performanceErrorBreakdown, performanceTrend, performancePercentiles, performanceTopPaths, performanceSlowest, listApiTokens, createApiToken, findApiTokenByHash, revokeApiToken, touchApiToken, recordBackupEvent, listBackupEvents, close: () => db.close() };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user