diff --git a/README.md b/README.md index c6255df..c246a70 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Docker Architectures Caddy - Version + Version

Why Site Gateway · diff --git a/compose.release.yaml b/compose.release.yaml index d2c1630..5c9492a 100644 --- a/compose.release.yaml +++ b/compose.release.yaml @@ -32,5 +32,11 @@ services: # - "25565:25565/udp" volumes: - ${SITE_GATEWAY_DATA:-/DATA/AppData/site-gateway}:/data + # Optional: enables "Pick from running containers" for Proxy and Streaming + # host targets (Administration > Gateway defaults > Docker container selection). + # Read-only, but be deliberate: access to the Docker socket is effectively root + # on the host -- anything that can talk to it can start privileged containers and + # mount the host filesystem. Leave this commented out unless you want the feature. + # - /var/run/docker.sock:/var/run/docker.sock:ro labels: com.centurylinklabs.watchtower.enable: "true" diff --git a/compose.yaml b/compose.yaml index 5b1eab6..0d53445 100644 --- a/compose.yaml +++ b/compose.yaml @@ -56,3 +56,9 @@ services: # - "25565:25565/udp" volumes: - ./data:/data + # Optional: enables "Pick from running containers" for Proxy and Streaming + # host targets (Administration > Gateway defaults > Docker container selection). + # Read-only, but be deliberate: access to the Docker socket is effectively root + # on the host -- anything that can talk to it can start privileged containers and + # mount the host filesystem. Leave this commented out unless you want the feature. + # - /var/run/docker.sock:/var/run/docker.sock:ro diff --git a/package.json b/package.json index afaa34d..32feff8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "site-gateway", - "version": "0.14.0", + "version": "0.15.0", "private": true, "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "type": "module", diff --git a/src/public/app.js b/src/public/app.js index a3d6200..83a7283 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -8,7 +8,7 @@ // --- Shared DOM shortcut and app state ---------------------------------------- const $ = selector => document.querySelector(selector); -const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null, updateCheckTimer: null, loadedVersion: null, updateAvailable: false, performanceErrorBreakdowns: {} }; +const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null, updateCheckTimer: null, loadedVersion: null, updateAvailable: false, performanceErrorBreakdowns: {}, performanceTopPaths: {}, performancePoints: [], performanceCoords: [] }; // One-time DOM patches: move the Access List field into the create/settings // forms (features.js owns the Access List data, this file owns these forms). @@ -148,6 +148,33 @@ function probeCopy(service, ready, error, unconfigured = "Not configured") { function renderDashboardJobs(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"), health = columns?.firstElementChild; if (!columns) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; columns.insertBefore(panel, columns.children[1] || null); } if (health && health.parentElement === columns) columns.parentElement.insertBefore(health, columns); panel.innerHTML = `

Operations

Scheduled jobs

${(system.jobs || []).map(job => `
${escapeHtml(job.name)}${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}
`).join("")}
`; } function updateDashboardUptime(seconds) { const started = window.__dashboardStartedAt || (window.__dashboardStartedAt = Date.now() - Number(seconds || 0) * 1000); const target = document.querySelector("#system-uptime"); if (!target) return; const elapsed = Math.max(0, Math.floor((Date.now() - started) / 1000)); target.textContent = formatDuration(elapsed); } +// Dashboard tiles share one baseline accent (green) and switch to the existing +// --warning / --danger tokens when the thing they count is actually in trouble -- +// the same mechanism the "Needs attention" chip already used. +const TILE_ACCENT_CLASSES = ["accent-green", "accent-blue", "accent-amber", "accent-purple", "accent-warning", "accent-danger"]; +function setTileAccent(valueId, level) { + const tile = $(valueId)?.closest(".metric-card, .metric-chip"); + if (!tile) return; + tile.classList.remove(...TILE_ACCENT_CLASSES); + tile.classList.add(level === "danger" ? "accent-danger" : level === "warning" ? "accent-warning" : "accent-green"); +} +function applyTileAccents(data) { + const group = value => !value?.total || !value.errors ? "green" : value.errors >= value.total ? "danger" : "warning"; + setTileAccent("#dash-hosted-total", group(data.hosted)); + const upstreams = data.upstreams || { total: 0, unhealthy: 0 }; + const proxyLevel = group(data.proxies); + setTileAccent("#dash-proxy-total", proxyLevel !== "green" ? proxyLevel : upstreams.unhealthy > 0 ? (upstreams.unhealthy >= upstreams.total ? "danger" : "warning") : "green"); + const certificates = data.certificates || {}; + const certificateLevel = (certificates.expired || 0) + (certificates.mismatch || 0) > 0 ? "danger" : (certificates.warning || 0) + (certificates.critical || 0) > 0 ? "warning" : "green"; + setTileAccent("#dash-tls-total", certificateLevel); + // Redirect hosts have no runtime failure state of their own, so they stay on the baseline. + setTileAccent("#dash-redirect-total", "green"); + const streaming = data.streamingPorts || { total: 0, listening: 0 }; + setTileAccent("#dash-stream-total", !streaming.total || streaming.listening === streaming.total ? "green" : streaming.listening === 0 ? "danger" : "warning"); + // Throughput is a rate, not a health signal: there is no "bad" value to react to. + setTileAccent("#dash-throughput-total", "green"); +} + function renderDashboard() { const data = state.dashboard; if (!data) return; if (data.system) renderDashboardJobsSafe(data.system); @@ -161,6 +188,7 @@ function renderDashboard() { $("#dash-stream-total").textContent = state.streams?.length || 0; $("#dash-attention-total").textContent = data.attention.length; $("#dash-attention-detail").textContent = data.attention.length ? `${data.attention.length} item${data.attention.length === 1 ? "" : "s"} to review` : "No current issues"; + applyTileAccents(data); $("#dash-attention-chip").classList.toggle("accent-warning", data.attention.length > 0); $("#dash-attention-chip").classList.toggle("accent-green", data.attention.length === 0); $("#dash-attention-icon").textContent = data.attention.length > 0 ? "!" : "✓"; @@ -221,14 +249,14 @@ function canAdmin() { return state.user?.role === "administrator"; } function hostedCard(site) { const status = site.status === "running" ? "running" : site.status === "error" ? "error" : "disabled"; const upstream = !site.enabled || site.upstream?.status === "unmonitored" ? "Monitoring paused" : !site.upstream || site.upstream.status === "pending" ? "Upstream check pending" : site.upstream.status === "healthy" ? `Upstream ${site.upstream.httpStatus} · ${site.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(site.upstream.error || "check failed")}`; - const menu = canManage() ? `` : ""; + const menu = canManage() ? `` : ""; const toggle = canManage() ? `` : ""; return `
${iconMarkup(site)}
${menu}

${escapeHtml(site.name)}

${escapeHtml(site.domain || `Port ${site.port}`)}

${site.domain ? `

${escapeHtml(publicUrl(site))}

` : ""}

${upstream}

`; } function proxyCard(proxy) { const status = proxy.status === "running" ? "running" : proxy.status === "error" ? "error" : "disabled"; const upstream = !proxy.enabled || proxy.upstream?.status === "unmonitored" ? "Monitoring paused" : !proxy.upstream || proxy.upstream.status === "pending" ? "Upstream check pending" : proxy.upstream.status === "healthy" ? `Upstream ${proxy.upstream.httpStatus} · ${proxy.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(proxy.upstream.error || "check failed")}`; - const menu = canManage() ? `` : ""; + const menu = canManage() ? `` : ""; const toggle = canManage() ? `` : ""; const access = proxy.accessListId ? (state.accessLists.find(item => item.id === proxy.accessListId)?.name || "Access List") : "Public · no Access List"; return `
${iconMarkup(proxy)}
${menu}

${escapeHtml(proxy.name)}

${escapeHtml(proxy.target)}

${escapeHtml(publicUrl(proxy))}

${upstream}

${escapeHtml(access)}

`; @@ -279,6 +307,20 @@ function renderLogs() { // --- Performance view: summary, request trend chart (hand-drawn SVG sparkline), // and the per-domain throughput table ----------------------------------------------- +// Clock-boundary label spacing per selected range (hours -> minutes between labels). +const PERFORMANCE_LABEL_MINUTES = { 1: 15, 3: 30, 6: 60, 12: 120, 24: 180, 72: 720, 168: 1440 }; +function formatChartTime(value, intervalMinutes) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + if (intervalMinutes >= 1440) return date.toLocaleDateString([], { month: "short", day: "numeric" }); + if (intervalMinutes >= 720) return date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric" }); + return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); +} +function formatLatency(ms) { return ms == null ? "—" : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${ms} ms`; } + +// Geometry shared by the chart renderer and the hover tooltip. +const PERFORMANCE_CHART = { left: 34, right: 8, top: 10, bottom: 20, width: 600, height: 140 }; + function renderPerformance() { const data = state.performance; if (!data) return; const selected = $("#performance-host").value; @@ -288,9 +330,11 @@ function renderPerformance() { $("#performance-summary").innerHTML = `${data.liveRequests} request${data.liveRequests === 1 ? "" : "s"} in the last minute across ${label} · Checked ${escapeHtml(formatTime(data.checkedAt))}`; const rangeLabel = $("#performance-range").selectedOptions[0]?.textContent || "Last 6 hours"; $("#performance-trend-title").textContent = `Requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`; + $("#performance-slowest-title").textContent = `Slowest requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`; const points = data.trend || []; + state.performancePoints = points; const max = Math.max(1, ...points.map(point => point.count)); - const left = 34, right = 8, top = 10, bottom = 20, width = 600, height = 140; + const { left, right, top, bottom, width, height } = PERFORMANCE_CHART; const plotWidth = width - left - right, plotHeight = height - top - bottom; const xAt = index => left + (points.length > 1 ? (index / (points.length - 1)) * plotWidth : plotWidth); const yAt = count => top + plotHeight - (count / max) * plotHeight; @@ -299,16 +343,34 @@ function renderPerformance() { const y = (top + plotHeight * (1 - fraction)).toFixed(1); return ``; }).join(""); - const leftPct = (left / width) * 100, topPct = 0, plotHeightPct = (plotHeight / height) * 100, topInsetPct = (top / height) * 100; + const leftPct = (left / width) * 100, plotWidthPct = (plotWidth / width) * 100, plotHeightPct = (plotHeight / height) * 100, topInsetPct = (top / height) * 100; const axisLabels = gridFractions.map(fraction => { const value = Math.round(max * fraction); const yPct = topInsetPct + plotHeightPct * (1 - fraction); return `${value}`; }).join(""); - const firstPoint = points[0], lastPoint = points[points.length - 1]; - const timeLabels = points.length ? `${escapeHtml(formatTime(firstPoint.at))}${escapeHtml(formatTime(lastPoint.at))}` : ""; + // Time axis: labels land on real clock boundaries scaled to the selected range, and the + // true first and last sample are always labelled so the window's edges stay readable. + const hours = Number($("#performance-range").value) || 6; + const intervalMinutes = PERFORMANCE_LABEL_MINUTES[hours] || 60; + const intervalMs = intervalMinutes * 60000; + let timeLabels = ""; + if (points.length) { + const candidates = new Set([0, points.length - 1]); + const aligned = []; + points.forEach((point, index) => { const time = new Date(point.at).getTime(); if (!Number.isNaN(time) && time % intervalMs === 0) aligned.push(index); }); + const stride = Math.max(1, Math.ceil(aligned.length / 8)); + aligned.forEach((index, position) => { if (position % stride === 0) candidates.add(index); }); + const ordered = [...candidates].sort((a, b) => a - b); + timeLabels = ordered.map(index => { + const leftEdge = leftPct + (points.length > 1 ? (index / (points.length - 1)) * plotWidthPct : plotWidthPct); + const alignment = index === 0 ? "" : index === points.length - 1 ? " time-label-end" : " time-label-mid"; + return `${escapeHtml(formatChartTime(points[index].at, intervalMinutes))}`; + }).join(""); + } $("#performance-sparkline-labels").innerHTML = points.length ? `${axisLabels}${timeLabels}` : ""; const coords = points.map((point, index) => [xAt(index), yAt(point.count)]); + state.performanceCoords = coords; const smoothLine = coords.length < 2 ? "" : coords.reduce((d, point, index) => { if (index === 0) return `M${point[0].toFixed(1)},${point[1].toFixed(1)}`; const p0 = coords[index - 2 >= 0 ? index - 2 : index - 1]; @@ -322,16 +384,61 @@ function renderPerformance() { const baseline = (top + plotHeight).toFixed(1); const areaPath = coords.length ? `${smoothLine} L${coords[coords.length - 1][0].toFixed(1)},${baseline} L${coords[0][0].toFixed(1)},${baseline} Z` : ""; $("#performance-sparkline").setAttribute("viewBox", `0 0 ${width} ${height}`); - $("#performance-sparkline").innerHTML = points.length ? `${gridLines}` : ""; + $("#performance-sparkline").innerHTML = points.length ? `${gridLines}