0.35.4
This commit is contained in:
1 parent
c038ad44b3
commit
cd3c28d390
33 files changed
+807
-221
No files matched your search
@@ -44,7 +44,7 @@ async function loadLanguages() {
|
||||
$("language").replaceChildren(automatic, ...packs.map(pack => { const option = el("option", pack.meta.native_name); option.value = pack.id; return option; }));
|
||||
$("language").value = languagePreference === "auto" ? "auto" : language;
|
||||
}
|
||||
let latestStatus = null, certificateInventory = new Map();
|
||||
let latestStatus = null, certificateInventory = new Map(), appHealthUpdatedAt = null;
|
||||
let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null;
|
||||
function translate() {
|
||||
clearTimeout(toastTimer); hideToast();
|
||||
@@ -179,6 +179,68 @@ function uptime(value) {
|
||||
}
|
||||
function rateBytes(value) { value = Math.max(0, Number(value) || 0); if (value === 0) return "0.0 KiB"; value /= 1024; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { if (value < 1024 || unit === "TiB") { if (unit === "KiB" && value < 0.1) return "<0.1 KiB"; const digits = value < 10 ? 1 : 0; return `${Number(value.toFixed(digits))} ${unit}`; } value /= 1024; } }
|
||||
function el(tag, text, cls) { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; }
|
||||
function appHealthChecks(status) {
|
||||
if (!connected || !status) return [{ level: "error", label: t("appHealthApi"), detail: t("appHealthApiError") }];
|
||||
const checks = [{ level: "ok", label: t("appHealthApi"), detail: t("appHealthApiOk") }];
|
||||
if (status.listeners?.restart_required) {
|
||||
checks.push({ level: "warning", label: t("appHealthListeners"), detail: t("appHealthListenersRestart") });
|
||||
} else {
|
||||
checks.push({ level: "ok", label: t("appHealthListeners"), detail: t("appHealthListenersOk") });
|
||||
}
|
||||
|
||||
const pools = Array.isArray(status.backend_pools) ? status.backend_pools : [];
|
||||
const members = pools.flatMap(pool => (pool.members || []).map(member => ({ pool: pool.id, ...member })));
|
||||
const down = members.filter(member => member.status === "down");
|
||||
const checking = members.filter(member => member.status === "checking");
|
||||
const unavailablePools = pools.filter(pool => pool.ready === false && (pool.members || []).some(member => ["up", "down", "checking"].includes(member.status))).map(pool => pool.id).filter(Boolean);
|
||||
const affected = [...down, ...checking].slice(0, 5).map(member => `${member.pool}/${member.id}`).join(", ");
|
||||
if (unavailablePools.length) {
|
||||
let detail = t("appHealthBackendsError").replace("{pools}", unavailablePools.slice(0, 5).join(", ") + (unavailablePools.length > 5 ? " …" : ""));
|
||||
if (affected) detail += ` ${t("appHealthBackendsAffected").replace("{members}", affected + (down.length + checking.length > 5 ? " …" : ""))}`;
|
||||
checks.push({ level: "error", label: t("appHealthBackends"), detail });
|
||||
} else if (down.length || checking.length) {
|
||||
checks.push({ level: "warning", label: t("appHealthBackends"), detail: t("appHealthBackendsWarning").replace("{down}", String(down.length)).replace("{checking}", String(checking.length)).replace("{members}", affected + (down.length + checking.length > 5 ? " …" : "")) });
|
||||
} else {
|
||||
checks.push({ level: "ok", label: t("appHealthBackends"), detail: pools.length ? t("appHealthBackendsOk") : t("appHealthBackendsNone") });
|
||||
}
|
||||
|
||||
const sink = status.logging?.sinks || {}, errorFile = status.logging?.error_file || {};
|
||||
const loggingDropped = (Number(sink.queue_dropped) || 0) + (Number(errorFile.queue_dropped) || 0);
|
||||
const loggingErrors = (Number(sink.errors) || 0) + (Number(errorFile.errors) || 0);
|
||||
if (loggingDropped || loggingErrors) {
|
||||
checks.push({ level: "warning", label: t("appHealthLogging"), detail: t("appHealthLoggingWarning").replace("{dropped}", String(loggingDropped)).replace("{errors}", String(loggingErrors)) });
|
||||
} else {
|
||||
checks.push({ level: "ok", label: t("appHealthLogging"), detail: t("appHealthLoggingOk") });
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
function appHealthLevel(checks) {
|
||||
if (!checks.length) return "checking";
|
||||
if (checks.some(check => check.level === "error")) return "error";
|
||||
if (checks.some(check => check.level === "warning")) return "warning";
|
||||
return "ok";
|
||||
}
|
||||
function setAppHealthClass(node, level) {
|
||||
if (!node) return;
|
||||
node.classList.remove("is-checking", "is-ok", "is-warning", "is-error");
|
||||
node.classList.add(`is-${level}`);
|
||||
}
|
||||
function renderAppHealth(status = latestStatus) {
|
||||
const checks = appHealthChecks(status), level = appHealthLevel(checks);
|
||||
const labelKeys = { checking: "appHealthSidebarChecking", ok: "appHealthSidebarOk", warning: "appHealthSidebarWarning", error: "appHealthSidebarError" };
|
||||
const summaryKeys = { checking: "appHealthChecking", ok: "appHealthOk", warning: "appHealthWarning", error: "appHealthError" };
|
||||
const button = $("app-health-open"), label = $("app-health-label"), summaryRow = $("app-health-summary-row"), summary = $("app-health-summary");
|
||||
setAppHealthClass(button, level); setAppHealthClass(summaryRow, level);
|
||||
if (label) { label.dataset.i18n = labelKeys[level]; label.textContent = t(labelKeys[level]); }
|
||||
if (summary) { summary.dataset.i18n = summaryKeys[level]; summary.textContent = t(summaryKeys[level]); }
|
||||
const updated = $("app-health-updated");
|
||||
if (updated) updated.textContent = appHealthUpdatedAt ? t("appHealthUpdated").replace("{time}", appHealthUpdatedAt.toLocaleTimeString(locale())) : t("appHealthNoStatus");
|
||||
const list = $("app-health-checks");
|
||||
if (list) list.replaceChildren(...checks.map(check => {
|
||||
const row = el("div", undefined, `app-health-check is-${check.level}`), dot = el("i"), copy = el("div");
|
||||
dot.setAttribute("aria-hidden", "true"); copy.append(el("strong", check.label), el("p", check.detail, "fine")); row.append(dot, copy); return row;
|
||||
}));
|
||||
}
|
||||
function enhanceEnabledControl(input, hintKey = "entityEnableHint") {
|
||||
const label = input?.closest("label"); if (!label) return () => { };
|
||||
label.classList.add("vh-enable-control", "vh-wide");
|
||||
@@ -257,7 +319,7 @@ function renderStatusNow(s, systemSample = false) {
|
||||
logging: s.logging ? { ...(latestStatus?.logging || {}), ...s.logging } : latestStatus?.logging
|
||||
};
|
||||
s = latestStatus; window.rustProxySettings?.render(); if (firstStatus) renderSettings();
|
||||
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); const m = s.metrics;
|
||||
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); $("app-health-slot").hidden = false; appHealthUpdatedAt = new Date(); renderAppHealth(s); const m = s.metrics;
|
||||
$("metric-ws").textContent = m.websocket_active || 0;
|
||||
$("metric-ws-h1").textContent = m.websocket_h1_active || 0;
|
||||
$("metric-ws-h2").textContent = m.websocket_h2_active || 0;
|
||||
@@ -299,7 +361,7 @@ function renderStatusNow(s, systemSample = false) {
|
||||
function renderFooter(s) {
|
||||
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
|
||||
const footer = $("footer-status");
|
||||
footer.replaceChildren(document.createTextNode(`${apiLatency} / ${t("logsDropped")}: ${s.logs_dropped}`));
|
||||
footer.replaceChildren(document.createTextNode(apiLatency));
|
||||
const update = s.software_update;
|
||||
if (update?.update_available && update.latest_version) {
|
||||
footer.append(document.createTextNode(" / "));
|
||||
@@ -421,12 +483,12 @@ function lock(notify = true) {
|
||||
window.rustProxyInlineLayout?.cancel?.();
|
||||
window.rustProxyRefresh?.stop();
|
||||
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
|
||||
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; configStorage = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
|
||||
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; $("app-health-slot").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; configStorage = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
|
||||
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
|
||||
$("workspace").querySelectorAll("form").forEach(form => form.reset());
|
||||
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
|
||||
document.querySelectorAll("dialog").forEach(dialog => { dialog.close(); dialog.querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => input.value = ""); });
|
||||
renderSession(); syncAuthForm();
|
||||
appHealthUpdatedAt = null; renderAppHealth(null); renderSession(); syncAuthForm();
|
||||
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
|
||||
}
|
||||
|
||||
@@ -506,6 +568,8 @@ function stopTunnelRefresh() { window.rustProxyRefresh?.sync(); }
|
||||
function startTunnelRefresh() { window.rustProxyRefresh?.sync(); }
|
||||
$("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } });
|
||||
$("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } });
|
||||
$("app-health-open")?.addEventListener("click", () => { renderAppHealth(connected ? latestStatus : null); const dialog = $("app-health-dialog"); if (!dialog.open) dialog.showModal(); });
|
||||
$("app-health-close")?.addEventListener("click", () => $("app-health-dialog").close());
|
||||
$("about-open").addEventListener("click", () => { const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); });
|
||||
$("about-close").addEventListener("click", () => $("about-dialog").close());
|
||||
const mobileMenuToggle = $("mobile-menu-toggle"), dashboardSidebar = document.querySelector(".sidebar");
|
||||
|
||||
@@ -9,9 +9,9 @@ window.rustProxyInlineLayout = (() => {
|
||||
const hiddenList = $("overview-inline-hidden-list");
|
||||
const hiddenCount = $("overview-inline-hidden-count");
|
||||
const modalButton = $("overview-layout-open");
|
||||
const compactButton = $("overview-inline-compact");
|
||||
let draft = null;
|
||||
let original = null;
|
||||
let dragging = null;
|
||||
let opening = false;
|
||||
let saving = false;
|
||||
|
||||
@@ -72,10 +72,7 @@ window.rustProxyInlineLayout = (() => {
|
||||
if (widen) widen.disabled = at === widths.length - 1;
|
||||
}
|
||||
}
|
||||
function clearDrag() {
|
||||
dragging = null;
|
||||
for (const node of nodes()) node.classList.remove("is-overview-dragging", "is-overview-drop-before", "is-overview-drop-after");
|
||||
}
|
||||
function clearDrag() { if (pointerDrag) settle(false); }
|
||||
function move(id, target, after = false) {
|
||||
if (!draft || id === target) return;
|
||||
if (!visibleIds().includes(id) || !visibleIds().includes(target)) return;
|
||||
@@ -118,7 +115,7 @@ window.rustProxyInlineLayout = (() => {
|
||||
const title = label(id);
|
||||
const bar = el("div", undefined, "overview-inline-toolbar");
|
||||
const grip = control("⠿", `${t("overviewLayoutInlineDrag")}: ${title}`, "overview-inline-grip", () => { });
|
||||
grip.draggable = true;
|
||||
grip.setAttribute("aria-describedby", "overview-inline-hint");
|
||||
grip.dataset.overviewDragHandle = id;
|
||||
const sizeControls = el("span", undefined, "overview-inline-width");
|
||||
const widthValue = el("span", "", "overview-inline-width-value");
|
||||
@@ -142,6 +139,7 @@ window.rustProxyInlineLayout = (() => {
|
||||
actions.hidden = false;
|
||||
hint.hidden = false;
|
||||
hint.textContent = t("overviewLayoutInlineHint");
|
||||
if (compactButton) compactButton.title = t("overviewLayoutInlineCompactHint");
|
||||
if (hiddenShelf) { hiddenShelf.hidden = false; hiddenShelf.open = false; }
|
||||
if (modalButton) modalButton.disabled = true;
|
||||
for (const node of nodes()) toolbar(node);
|
||||
@@ -195,40 +193,185 @@ window.rustProxyInlineLayout = (() => {
|
||||
finally { saving = false; saveButton.disabled = cancelButton.disabled = unlock.disabled = false; }
|
||||
}
|
||||
function cancel() { if (draft && !saving) finish(false); }
|
||||
function dropTarget(event) {
|
||||
const widget = event.target?.closest?.("[data-dashboard-widget]");
|
||||
return widget && grid.contains(widget) && !widget.hidden && visibleIds().includes(widget.dataset.dashboardWidget) ? widget : null;
|
||||
// Pointer-driven sorting with a live placeholder. The grid never back-fills
|
||||
// holes while a block is lifted, so the placeholder shows exactly where the
|
||||
// block lands; neighbours glide (FLIP) instead of jumping.
|
||||
const DRAG_THRESHOLD = 5, EDGE = 64, MOVE_COOLDOWN_MS = 90;
|
||||
const reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
|
||||
let pointerDrag = null;
|
||||
function flip(mutate) {
|
||||
const before = new Map(nodes().filter(node => !node.hidden && node !== pointerDrag?.node)
|
||||
.map(node => [node, node.getBoundingClientRect()]));
|
||||
mutate();
|
||||
if (reducedMotion()) return;
|
||||
for (const [node, rect] of before) {
|
||||
const after = node.getBoundingClientRect();
|
||||
const dx = rect.left - after.left, dy = rect.top - after.top;
|
||||
if (!dx && !dy) continue;
|
||||
node.animate?.([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }],
|
||||
{ duration: 160, easing: "cubic-bezier(.2, .7, .3, 1)" });
|
||||
}
|
||||
}
|
||||
function positionAfter(widget, event) {
|
||||
const rect = widget.getBoundingClientRect();
|
||||
return event.clientX >= rect.left + rect.width / 2;
|
||||
function placeholderTarget(x, y) {
|
||||
const drag = pointerDrag;
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
const widget = hit?.closest?.("[data-dashboard-widget]");
|
||||
if (widget && grid.contains(widget) && widget !== drag.node && !widget.hidden) return { node: widget };
|
||||
if (hit === drag.placeholder || drag.placeholder.contains(hit)) return null;
|
||||
if (hit !== grid) return null;
|
||||
// Empty grid area (gap or hole at a row end): attach to the nearest block in that row.
|
||||
const candidates = nodes().filter(node => node !== drag.node && !node.hidden);
|
||||
let best = null, bestDistance = Infinity;
|
||||
for (const node of candidates) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (y < rect.top - 8 || y > rect.bottom + 8) continue;
|
||||
const distance = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;
|
||||
if (distance < bestDistance) { bestDistance = distance; best = { node, after: x > rect.left + rect.width / 2 }; }
|
||||
}
|
||||
if (best) return best;
|
||||
const last = candidates.at(-1);
|
||||
if (last && y > last.getBoundingClientRect().bottom) return { node: last, after: true };
|
||||
return null;
|
||||
}
|
||||
grid?.addEventListener("dragstart", event => {
|
||||
function updatePlaceholder(x, y) {
|
||||
const drag = pointerDrag;
|
||||
if (performance.now() < drag.cooldownUntil) return;
|
||||
const target = placeholderTarget(x, y);
|
||||
if (!target) return;
|
||||
const children = [...grid.children];
|
||||
const placeholderIndex = children.indexOf(drag.placeholder), targetIndex = children.indexOf(target.node);
|
||||
// Entering a block swaps the placeholder past it; this is stable and never oscillates.
|
||||
const after = target.after ?? placeholderIndex < targetIndex;
|
||||
const reference = after ? target.node.nextSibling : target.node;
|
||||
if (reference === drag.placeholder || (after ? target.node.nextSibling === drag.placeholder : target.node.previousSibling === drag.placeholder)) return;
|
||||
flip(() => grid.insertBefore(drag.placeholder, reference));
|
||||
drag.cooldownUntil = performance.now() + MOVE_COOLDOWN_MS;
|
||||
}
|
||||
function autoScroll() {
|
||||
const drag = pointerDrag;
|
||||
if (!drag?.active) return;
|
||||
const height = window.innerHeight;
|
||||
let speed = 0;
|
||||
if (drag.y < EDGE) speed = -Math.ceil((EDGE - drag.y) / 4);
|
||||
else if (drag.y > height - EDGE) speed = Math.ceil((drag.y - (height - EDGE)) / 4);
|
||||
if (speed) {
|
||||
const scroller = document.scrollingElement || document.documentElement;
|
||||
const previous = scroller.scrollTop;
|
||||
scroller.scrollTop += speed;
|
||||
if (scroller.scrollTop !== previous) updatePlaceholder(drag.x, drag.y);
|
||||
}
|
||||
drag.frame = requestAnimationFrame(autoScroll);
|
||||
}
|
||||
function liftBlock() {
|
||||
const drag = pointerDrag;
|
||||
enterFreeLayout();
|
||||
paint();
|
||||
const rect = drag.node.getBoundingClientRect();
|
||||
const placeholder = el("div", undefined, "overview-drop-placeholder");
|
||||
placeholder.dataset.dashboardSpan = drag.node.dataset.dashboardSpan || "2";
|
||||
placeholder.style.minHeight = `${Math.round(rect.height)}px`;
|
||||
placeholder.setAttribute("aria-hidden", "true");
|
||||
drag.origin = drag.node.nextSibling;
|
||||
drag.placeholder = placeholder;
|
||||
drag.offsetX = drag.startX - rect.left;
|
||||
drag.offsetY = drag.startY - rect.top;
|
||||
drag.width = rect.width;
|
||||
drag.height = rect.height;
|
||||
drag.active = true;
|
||||
drag.cooldownUntil = 0;
|
||||
grid.insertBefore(placeholder, drag.node);
|
||||
grid.classList.add("is-overview-sorting");
|
||||
drag.node.classList.add("is-overview-lifted");
|
||||
Object.assign(drag.node.style, {
|
||||
position: "fixed", left: "0px", top: "0px", width: `${rect.width}px`, height: `${rect.height}px`,
|
||||
zIndex: "60", pointerEvents: "none", margin: "0",
|
||||
});
|
||||
moveLifted();
|
||||
hint.textContent = t("overviewLayoutInlineDragging");
|
||||
drag.frame = requestAnimationFrame(autoScroll);
|
||||
}
|
||||
function moveLifted() {
|
||||
const drag = pointerDrag;
|
||||
drag.node.style.transform = `translate(${drag.x - drag.offsetX}px, ${drag.y - drag.offsetY}px) rotate(.6deg) scale(1.015)`;
|
||||
}
|
||||
function settle(commit) {
|
||||
const drag = pointerDrag;
|
||||
pointerDrag = null;
|
||||
if (!drag) return;
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", onPointerUp);
|
||||
window.removeEventListener("pointercancel", onPointerCancel);
|
||||
window.removeEventListener("keydown", onDragKey, true);
|
||||
if (!drag.active) return;
|
||||
cancelAnimationFrame(drag.frame);
|
||||
for (const key of ["position", "left", "top", "width", "height", "zIndex", "pointerEvents", "margin", "transform"]) drag.node.style[key] = "";
|
||||
drag.node.classList.remove("is-overview-lifted");
|
||||
grid.classList.remove("is-overview-sorting");
|
||||
if (commit) drag.placeholder.replaceWith(drag.node);
|
||||
else { drag.placeholder.remove(); grid.insertBefore(drag.node, drag.origin?.parentNode === grid ? drag.origin : null); }
|
||||
hint.textContent = t("overviewLayoutInlineHint");
|
||||
if (commit) {
|
||||
const domOrder = nodes().map(node => node.dataset.dashboardWidget).filter(id => !draft.hidden.has(id));
|
||||
const iter = domOrder.values();
|
||||
draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value);
|
||||
}
|
||||
paint();
|
||||
drag.node.querySelector(".overview-inline-grip")?.focus({ preventScroll: true });
|
||||
drag.node.animate?.([{ boxShadow: "0 0 0 3px var(--accent)" }, { boxShadow: "0 0 0 0 transparent" }], { duration: 450 });
|
||||
}
|
||||
function onPointerMove(event) {
|
||||
const drag = pointerDrag;
|
||||
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||
drag.x = event.clientX; drag.y = event.clientY;
|
||||
if (!drag.active) {
|
||||
if (Math.hypot(drag.x - drag.startX, drag.y - drag.startY) < DRAG_THRESHOLD) return;
|
||||
liftBlock();
|
||||
}
|
||||
event.preventDefault();
|
||||
moveLifted();
|
||||
updatePlaceholder(drag.x, drag.y);
|
||||
}
|
||||
function onPointerUp(event) { if (pointerDrag && event.pointerId === pointerDrag.pointerId) settle(true); }
|
||||
function onPointerCancel(event) { if (pointerDrag && event.pointerId === pointerDrag.pointerId) settle(false); }
|
||||
function onDragKey(event) { if (event.key === "Escape" && pointerDrag) { event.preventDefault(); event.stopPropagation(); settle(false); } }
|
||||
grid?.addEventListener("pointerdown", event => {
|
||||
const handle = event.target?.closest?.("[data-overview-drag-handle]");
|
||||
if (!draft || !handle || !grid.contains(handle)) { event.preventDefault(); return; }
|
||||
dragging = handle.dataset.overviewDragHandle;
|
||||
event.dataTransfer?.setData("text/plain", dragging);
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
||||
nodeFor(dragging)?.classList.add("is-overview-dragging");
|
||||
});
|
||||
grid?.addEventListener("dragover", event => {
|
||||
if (!draft || !dragging) return;
|
||||
const target = dropTarget(event);
|
||||
if (!target || target.dataset.dashboardWidget === dragging) return;
|
||||
if (!draft || saving || pointerDrag || !handle || !grid.contains(handle) || event.button !== 0) return;
|
||||
const node = nodeFor(handle.dataset.overviewDragHandle);
|
||||
if (!node) return;
|
||||
event.preventDefault();
|
||||
for (const node of nodes()) node.classList.remove("is-overview-drop-before", "is-overview-drop-after");
|
||||
target.classList.add(positionAfter(target, event) ? "is-overview-drop-after" : "is-overview-drop-before");
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
||||
pointerDrag = { node, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: event.clientX, y: event.clientY, active: false };
|
||||
window.addEventListener("pointermove", onPointerMove, { passive: false });
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
window.addEventListener("pointercancel", onPointerCancel);
|
||||
window.addEventListener("keydown", onDragKey, true);
|
||||
});
|
||||
grid?.addEventListener("drop", event => {
|
||||
if (!draft || !dragging) return;
|
||||
grid?.addEventListener("keydown", event => {
|
||||
const handle = event.target?.closest?.("[data-overview-drag-handle]");
|
||||
if (!draft || !handle) return;
|
||||
const delta = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key];
|
||||
if (!delta) return;
|
||||
event.preventDefault();
|
||||
const target = dropTarget(event), from = dragging;
|
||||
clearDrag();
|
||||
if (target && target.dataset.dashboardWidget !== from)
|
||||
move(from, target.dataset.dashboardWidget, positionAfter(target, event));
|
||||
nudge(handle.dataset.overviewDragHandle, delta);
|
||||
nodeFor(handle.dataset.overviewDragHandle)?.querySelector(".overview-inline-grip")?.focus();
|
||||
});
|
||||
grid?.addEventListener("dragend", clearDrag);
|
||||
function compact() {
|
||||
// Explicit, one-off gap filling: reads the dense packing the browser would
|
||||
// produce and adopts it as the new order, so nothing moves unexpectedly later.
|
||||
if (!draft) return;
|
||||
enterFreeLayout();
|
||||
paint();
|
||||
flip(() => {
|
||||
grid.style.gridAutoFlow = "row dense";
|
||||
const placed = nodes().filter(node => !node.hidden).map(node => ({ id: node.dataset.dashboardWidget, rect: node.getBoundingClientRect() }));
|
||||
grid.style.gridAutoFlow = "";
|
||||
placed.sort((a, b) => Math.round(a.rect.top) - Math.round(b.rect.top) || a.rect.left - b.rect.left);
|
||||
const iter = placed.map(item => item.id).values();
|
||||
draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value);
|
||||
paint();
|
||||
});
|
||||
}
|
||||
compactButton?.addEventListener("click", compact);
|
||||
unlock?.addEventListener("click", () => { if (draft) void save(); else void begin(); });
|
||||
saveButton?.addEventListener("click", () => { void save(); });
|
||||
cancelButton?.addEventListener("click", cancel);
|
||||
|
||||
@@ -2,64 +2,40 @@ window.rustProxyOverviewSamples = (() => {
|
||||
const allowed = new Set([60, 120, 300, 600]);
|
||||
const storageKey = "rustproxy.overview.liveChartSamples";
|
||||
const factory = typeof ADMIN_DASHBOARD_DEFAULTS === "object" ? Number(ADMIN_DASHBOARD_DEFAULTS.overview_samples) : 60;
|
||||
let saved = allowed.has(factory) ? factory : 60;
|
||||
let limit = allowed.has(factory) ? factory : 60;
|
||||
try {
|
||||
const stored = Number(localStorage.getItem(storageKey));
|
||||
if (allowed.has(stored)) saved = stored;
|
||||
} catch { /* Private browsing or storage policy: temporary selection still works. */ }
|
||||
let limit = saved;
|
||||
if (allowed.has(stored)) limit = stored;
|
||||
} catch { /* Private browsing or storage policy: selection still works for this visit. */ }
|
||||
const callbacks = new Set();
|
||||
let confirmationTimer = null;
|
||||
const getLimit = () => limit;
|
||||
const trim = array => {
|
||||
if (Array.isArray(array) && array.length > limit) array.splice(0, array.length - limit);
|
||||
return array;
|
||||
};
|
||||
// The status is shown only for a temporary choice, a storage problem or briefly after saving.
|
||||
function sync(messageKey = limit === saved ? null : "overviewSamplesTemporary", confirmation = false) {
|
||||
function sync() {
|
||||
const select = document.getElementById("overview-live-samples");
|
||||
if (select) select.value = String(limit);
|
||||
const state = document.getElementById("overview-samples-state");
|
||||
if (state) {
|
||||
clearTimeout(confirmationTimer);
|
||||
if (messageKey) {
|
||||
state.dataset.i18n = messageKey;
|
||||
state.textContent = t(messageKey);
|
||||
state.style.visibility = "visible";
|
||||
} else {
|
||||
state.style.visibility = "hidden";
|
||||
}
|
||||
if (confirmation) confirmationTimer = setTimeout(() => { state.style.visibility = "hidden"; }, 2500);
|
||||
}
|
||||
const save = document.getElementById("overview-samples-save");
|
||||
if (save) save.disabled = limit === saved;
|
||||
}
|
||||
function setLimit(value, persist = false) {
|
||||
const count = Number(value);
|
||||
if (!allowed.has(count)) return false;
|
||||
const changed = count !== limit;
|
||||
limit = count;
|
||||
if (persist) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, String(count));
|
||||
saved = count;
|
||||
} catch {
|
||||
const changed = count !== limit;
|
||||
limit = count;
|
||||
if (changed) for (const callback of callbacks) callback(limit);
|
||||
sync("overviewSamplesStorageUnavailable");
|
||||
return false;
|
||||
if (typeof toast === "function") toast(t("overviewSamplesStorageUnavailable"), true);
|
||||
}
|
||||
}
|
||||
const changed = count !== limit;
|
||||
limit = count;
|
||||
if (changed) for (const callback of callbacks) callback(limit);
|
||||
if (persist) sync("overviewSamplesSaved", true); else sync();
|
||||
sync();
|
||||
return true;
|
||||
}
|
||||
function init() {
|
||||
const select = document.getElementById("overview-live-samples");
|
||||
const save = document.getElementById("overview-samples-save");
|
||||
select?.addEventListener("change", () => setLimit(select.value));
|
||||
save?.addEventListener("click", () => setLimit(select.value, true));
|
||||
select?.addEventListener("change", () => setLimit(select.value, true));
|
||||
sync();
|
||||
}
|
||||
if (document.readyState !== "complete") document.addEventListener("DOMContentLoaded", init, { once: true });
|
||||
|
||||
@@ -167,7 +167,7 @@ const rustProxyRefresh = (() => {
|
||||
interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/status?view=live", { signal });
|
||||
if (isCurrent()) renderLiveStatus(value);
|
||||
}, onError: error => { if (error?.status !== 401) $("connection-label").textContent = t("disconnected"); }
|
||||
}, onError: error => { if (error?.status !== 401) { connected = false; $("connection-label").textContent = t("disconnected"); $("signal").classList.add("status-error"); $("app-health-slot").hidden = false; renderAppHealth(null); } }
|
||||
});
|
||||
register("logs-fallback", {
|
||||
interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||
|
||||
@@ -48,7 +48,7 @@ function syncCertificateFileSource() {
|
||||
for (const input of [cert, key]) {
|
||||
input.maxLength = external ? 4096 : 96;
|
||||
if (external) input.removeAttribute("pattern");
|
||||
else input.pattern = "[A-Za-z0-9_.-]+";
|
||||
else input.pattern = "[A-Za-z0-9_.\\-]+";
|
||||
}
|
||||
cert.placeholder = external ? "/etc/letsencrypt/live/example.com/fullchain.pem" : "app.pem";
|
||||
key.placeholder = external ? "/etc/letsencrypt/live/example.com/privkey.pem" : "app.key";
|
||||
|
||||
Reference in new issue
Block a user