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:
+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)}
|
||||
|
||||
Reference in New Issue
Block a user