This commit is contained in:
Mateusz Gruszczyński committed 2026-11-07 08:16:11 +01:00
1 parent c038ad44b3
commit cd3c28d390
33 files changed
+807 -221

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.35.3",
"version": "0.35.4",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
},
"servers": [
+69 -5
View File
@@ -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");
+176 -33
View File
@@ -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);
+9 -33
View File
@@ -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 });
+1 -1
View File
@@ -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 }) => {
+1 -1
View File
@@ -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";
+96 -5
View File
@@ -175,6 +175,37 @@ nav {
align-items: center;
}
.app-health-button {
width: 100%;
border: 0;
background: transparent;
padding: 6px 0;
text-align: left;
cursor: pointer;
}
.app-health-button i {
flex: 0 0 auto;
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.app-health-button.is-checking,
.app-health-summary.is-checking { color: var(--muted); }
.app-health-button.is-ok,
.app-health-summary.is-ok,
.app-health-check.is-ok { color: var(--green); }
.app-health-button.is-warning,
.app-health-summary.is-warning,
.app-health-check.is-warning { color: var(--notice-text); }
.app-health-button.is-error,
.app-health-summary.is-error,
.app-health-check.is-error { color: var(--red); }
.app-health-button:hover { opacity: .82; }
.nav {
display: flex;
align-items: center;
@@ -216,11 +247,71 @@ nav {
padding-top: 32px;
}
.sidebar-bottom p {
font-size: 11px;
line-height: 1.9;
color: var(--text-subtle);
margin: 13px 0 26px;
.app-health-dialog {
width: min(620px, calc(100vw - 24px));
}
.app-health-card {
display: grid;
gap: 14px;
}
.app-health-summary {
display: flex;
align-items: flex-start;
gap: 10px;
}
.app-health-summary>i {
flex: 0 0 auto;
width: 7px;
height: 7px;
margin-top: 6px;
border-radius: 50%;
background: currentColor;
}
.app-health-summary strong {
color: currentColor;
font-size: 15px;
}
.app-health-summary .fine {
margin: 3px 0 0;
}
.app-health-checks {
display: grid;
gap: 7px;
}
.app-health-check {
display: grid;
grid-template-columns: 9px minmax(0, 1fr);
gap: 10px;
align-items: start;
padding: 9px 10px;
border: 1px solid var(--line);
background: var(--neutral-soft);
}
.app-health-check>i {
flex: 0 0 auto;
width: 7px;
height: 7px;
margin-top: 5px;
border-radius: 50%;
background: currentColor;
}
.app-health-check strong {
color: var(--text);
font-size: 12px;
}
.app-health-check .fine {
margin: 2px 0 0;
color: var(--muted);
}
main {
+88 -29
View File
@@ -15,6 +15,18 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
margin-bottom: var(--ui-gap);
}
/* Summary tiles outside the Overview dashboard (Cache, Traffic, Transform) fill
every row instead of leaving empty grid columns after the last tile. */
.stats-grid.cache-stats {
display: flex;
flex-wrap: wrap;
}
.stats-grid.cache-stats>.stat {
flex: 1 1 190px;
min-width: 0;
}
.stat {
border: 1px solid var(--line);
background: var(--panel);
@@ -549,6 +561,25 @@ body[data-density=compact] .pool-card .table-scroll {
margin: 0;
}
/* Keep the block title clear of the corner button whatever padding the grid applies. */
#panel-overview .stat-with-action>span:first-child {
display: block;
padding-right: 104px;
}
#panel-overview .stat-with-action .stat-corner-action {
max-width: 96px;
padding: 3px 7px;
font-size: 11px;
line-height: 1.25;
white-space: normal;
text-align: center;
}
#panel-overview .dashboard-overview-grid.is-unlocked .stat-with-action .stat-corner-action {
top: 44px;
}
.stat-chart strong {
margin-bottom: 3px;
}
@@ -1154,44 +1185,83 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
position: relative;
outline: 1px dashed var(--accent);
outline-offset: -2px;
padding-top: 101px;
padding-top: 40px !important;
}
#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-dragging {
opacity: .45;
#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget]:hover,
#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget]:focus-within {
outline-style: solid;
}
#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-drop-before {
box-shadow: inset 4px 0 var(--accent);
/* While sorting, the grid keeps strict DOM order: the placeholder is exactly where the block lands. */
#panel-overview .dashboard-overview-grid.is-overview-sorting {
grid-auto-flow: row !important;
user-select: none;
cursor: grabbing;
}
#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-drop-after {
box-shadow: inset -4px 0 var(--accent);
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder {
grid-column: auto/span 2;
box-sizing: border-box;
border: 2px dashed var(--accent);
border-radius: var(--rp-radius-surface, 9px);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="3"] { grid-column: auto/span 3; }
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="4"] { grid-column: auto/span 4; }
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="6"] { grid-column: auto/span 6; }
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="12"] { grid-column: 1/-1; }
#panel-overview .dashboard-overview-grid>.is-overview-lifted,
#panel-overview .dashboard-overview-grid>.is-overview-lifted * {
pointer-events: none !important;
}
#panel-overview .dashboard-overview-grid>.is-overview-lifted {
box-shadow: 0 18px 40px rgb(0 0 0 / .28), 0 0 0 2px var(--accent);
opacity: .94;
transition: none;
will-change: transform;
overflow: hidden;
}
.overview-inline-toolbar {
position: absolute;
top: 3px;
left: 4px;
right: 4px;
top: 5px;
left: 5px;
right: 5px;
z-index: 4;
display: flex;
align-items: center;
flex-wrap: wrap;
flex-wrap: nowrap;
overflow: hidden;
gap: 3px;
pointer-events: auto;
}
#panel-overview .overview-inline-toolbar button {
min-width: 28px;
min-height: 27px;
padding: 2px 7px;
min-width: 26px;
min-height: 26px;
padding: 2px 6px;
font-size: 12px;
flex: 0 0 auto;
}
/* Narrow blocks keep only the essentials; arrow keys on the grip still move them. */
#panel-overview .dashboard-overview-grid>[data-dashboard-span="2"] .overview-inline-toolbar :is(.overview-widget-type, .overview-inline-up, .overview-inline-down),
#panel-overview .dashboard-overview-grid>[data-dashboard-span="3"] .overview-inline-toolbar :is(.overview-inline-up, .overview-inline-down) {
display: none;
}
#panel-overview .dashboard-overview-grid>[data-dashboard-span="2"] .overview-inline-toolbar .overview-inline-hide {
margin-left: auto;
}
#panel-overview .overview-inline-toolbar .overview-inline-grip {
cursor: grab;
user-select: none;
touch-action: none;
}
#panel-overview .overview-inline-toolbar .overview-inline-grip:active {
@@ -1690,26 +1760,13 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
flex-wrap: wrap;
max-width: 100%;
margin: 0 0 0 auto;
flex: 0 0 auto;
}
.page-intro .overview-samples-toolbar .function-live-chart-samples select {
min-width: 100px;
}
.page-intro .overview-samples-toolbar button {
width: auto;
flex: none;
min-height: 28px;
padding: 5px 10px;
white-space: nowrap;
}
.page-intro .overview-samples-toolbar .fine {
font-size: 10px;
color: var(--muted);
white-space: nowrap;
}
#panel-overview .stat-donut .overview-donut-info {
display: flex;
flex-direction: column;
@@ -1821,8 +1878,10 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
margin: 4px 10px 8px;
}
/* Custom layouts follow the saved order exactly (no implicit back-filling of gaps);
"Fill gaps" in the layout editor compacts explicitly. */
#panel-overview .dashboard-overview-grid.is-aligned[data-overview-layout-mode=custom] {
grid-auto-flow: row dense;
grid-auto-flow: row;
}
#panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget][data-dashboard-span="2"] {
+31 -3
View File
@@ -286,6 +286,14 @@
display: none;
}
.sidebar.mobile-nav-open .sidebar-bottom {
grid-column: 1/-1;
display: flex;
width: 100%;
padding-top: 8px;
border-top: 1px solid var(--line);
}
.topbar {
height: 46px;
}
@@ -702,8 +710,8 @@
}
.page-intro .overview-samples-toolbar {
margin-left: 0;
justify-content: flex-start;
margin-left: auto;
justify-content: flex-end;
}
}
@@ -1457,6 +1465,10 @@
}
@media (max-width:550px) {
.stats-grid.cache-stats>.stat {
flex-basis: 140px;
}
.overview-layout-footer {
flex-wrap: wrap;
}
@@ -1474,7 +1486,19 @@
}
#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget] {
padding-top: 135px;
padding-top: 40px !important;
}
#panel-overview .dashboard-overview-grid.is-unlocked .overview-inline-toolbar :is(.overview-widget-type, .overview-inline-up, .overview-inline-down) {
display: none;
}
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder:is([data-dashboard-span="2"], [data-dashboard-span="3"]) {
grid-column: auto/span 1;
}
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder:is([data-dashboard-span="4"], [data-dashboard-span="6"], [data-dashboard-span="12"]) {
grid-column: 1/-1;
}
}
@@ -1623,6 +1647,10 @@
padding-right: 11px;
}
#panel-overview .stat-with-action>span:first-child {
padding-right: 0;
}
#panel-overview .dashboard-overview-grid.is-aligned .stat-with-action .stat-corner-action {
position: static;
display: block;
+1 -1
View File
@@ -156,7 +156,7 @@
const enabledLabel = el("label", undefined, "check"); enabledLabel.append(enabled, el("span", t("enabled")));
const state = agent ? obs(agent.id) : null, stateBadge = el("span", state?.ok ? t("dockerConnected") : state ? t("dockerError") : t("dockerWaiting"), `pill docker-agent-status-badge ${state?.ok ? "live" : ""}`.trim());
const remove = action(t("remove"), () => { row.remove(); setSettingsDirty("docker-agents-form"); }), actions = el("div", undefined, "entity-actions docker-agent-heading-actions"); actions.append(enabledLabel, stateBadge, remove); head.append(title, actions);
const id = input(value.id, "text", "docker-agent-id"); id.maxLength = 64; id.required = true; id.pattern = "[A-Za-z0-9_-]{1,64}"; if (agent) id.readOnly = true;
const id = input(value.id, "text", "docker-agent-id"); id.maxLength = 64; id.required = true; id.pattern = "[A-Za-z0-9_\\-]{1,64}"; if (agent) id.readOnly = true;
const name = input(value.name, "text", "docker-agent-name"); name.maxLength = 96; name.required = true;
const endpoint = input(value.endpoint, "url", "docker-agent-endpoint"); endpoint.required = true; endpoint.placeholder = "https://10.0.0.20:9876";
const token = input(value.token, "password", "docker-agent-token"); token.minLength = 24; token.maxLength = 512; token.required = true; token.autocomplete = "new-password";
+37 -20
View File
@@ -58,9 +58,11 @@
data-i18n="transform">Transform</b></button>
<button class="nav" data-panel="settings"><span>13</span><b data-i18n="settings">Settings</b></button>
</nav>
<div class="sidebar-bottom"><span class="small-label" data-i18n="runtime">RUNTIME</span>
<p>Rust / Tokio / Hyper<br />Axum / Tower / SQLite</p>
<div class="local-tag"><i></i><span data-i18n="controlPlane">CONTROL PLANE</span></div>
<div class="sidebar-bottom" id="app-health-slot" hidden>
<button class="local-tag app-health-button is-checking" id="app-health-open" type="button"
aria-haspopup="dialog" aria-controls="app-health-dialog">
<i aria-hidden="true"></i><span id="app-health-label" data-i18n="appHealthSidebarChecking">CHECKING</span>
</button>
</div>
</aside>
<main>
@@ -97,7 +99,7 @@
</div>
<label data-i18n="username" for="username">Username</label><input autocapitalize="none"
autocomplete="username" id="username" maxlength="64" minlength="3"
pattern="[A-Za-z0-9_.-]{3,64}" required="" spellcheck="false" />
pattern="[A-Za-z0-9_.\-]{3,64}" required="" spellcheck="false" />
<label data-i18n="password" for="password">Password</label><input autocomplete="current-password"
id="password" maxlength="128" required="" type="password" />
<div hidden="" id="confirm-field"><label data-i18n="confirmPassword" for="password-confirm">Confirm
@@ -121,17 +123,13 @@
aria-label="Overview chart samples" data-i18n-aria="overviewSamplesTitle">
<label class="function-live-chart-samples" for="overview-live-samples"><span
data-i18n="liveChartSamples">Samples</span>
<select id="overview-live-samples" aria-describedby="overview-samples-state">
<select id="overview-live-samples">
<option value="60">60</option>
<option value="120">120</option>
<option value="300">300</option>
<option value="600">600</option>
</select>
</label>
<button class="quiet" id="overview-samples-save" type="button"
data-i18n="overviewSamplesRemember">Remember choice</button>
<small class="fine" id="overview-samples-state" role="status" aria-live="polite"
data-i18n="overviewSamplesSaved">Saved default</small>
</div>
</div>
<div class="page-intro-title-row">
@@ -145,6 +143,9 @@
<div class="overview-inline-actions" id="overview-inline-actions" hidden>
<button class="primary" id="overview-inline-save" type="button"
data-i18n="overviewLayoutInlineSave">Save and lock</button>
<button class="quiet" id="overview-inline-compact" type="button"
data-i18n="overviewLayoutInlineCompact"
title="Move blocks into empty grid space once">Fill gaps</button>
<button class="quiet" id="overview-inline-cancel" type="button"
data-i18n="overviewLayoutInlineCancel">Cancel changes</button>
</div>
@@ -771,7 +772,7 @@
</div>
<div class="field-grid">
<label><span data-i18n="aclProfileId">Profile ID</span><input id="acl-profile-id"
maxlength="64" pattern="[A-Za-z0-9_-]{1,64}" required /></label>
maxlength="64" pattern="[A-Za-z0-9_\-]{1,64}" required /></label>
<label><span data-i18n="aclProfileName">Display name</span><input id="acl-profile-name"
maxlength="96" required /></label>
</div>
@@ -982,7 +983,7 @@
<div class="vh-grid">
<label><span data-i18n="certificateId">Certificate ID</span><input
autocomplete="off" id="acme-name" maxlength="64"
pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
pattern="[A-Za-z0-9_\-]{1,64}" required="" /></label>
<label><span data-i18n="acmeEmail">ACME contact email</span><input
autocomplete="off" id="acme-email" maxlength="254" required=""
type="email" /></label>
@@ -1063,7 +1064,7 @@
<h3 data-i18n="certImport"></h3>
<p class="fine" data-i18n="certLibraryHint"></p>
<label data-i18n="certificateId" for="cert-import-name"></label><input
id="cert-import-name" maxlength="64" pattern="[A-Za-z0-9_-]{1,64}"
id="cert-import-name" maxlength="64" pattern="[A-Za-z0-9_\-]{1,64}"
required="" />
<label data-i18n="certCheckDomains" for="cert-import-domains"></label><input
id="cert-import-domains" placeholder="app.example.com" required="" />
@@ -1081,7 +1082,7 @@
<form class="edit-form" id="certificate-form">
<h3 data-i18n="addCertificate"></h3>
<label data-i18n="certificateId" for="certificate-id"></label><input
id="certificate-id" maxlength="64" pattern="[A-Za-z0-9_.-]+"
id="certificate-id" maxlength="64" pattern="[A-Za-z0-9_.\-]+"
placeholder="app" required="" />
<label data-i18n="certLegacyNames" for="certificate-hosts"></label><input
id="certificate-hosts" placeholder="app.localhost" />
@@ -1094,10 +1095,10 @@
<p class="fine" id="certificate-file-source-help"
data-i18n="certificateSourceManagedHelp"></p>
<label data-i18n="certificateFile" for="certificate-file"></label><input
id="certificate-file" maxlength="96" pattern="[A-Za-z0-9_.-]+"
id="certificate-file" maxlength="96" pattern="[A-Za-z0-9_.\-]+"
placeholder="app.pem" required="" />
<label data-i18n="privateKeyFile" for="certificate-key"></label><input
id="certificate-key" maxlength="96" pattern="[A-Za-z0-9_.-]+"
id="certificate-key" maxlength="96" pattern="[A-Za-z0-9_.\-]+"
placeholder="app.key" required="" />
<div hidden id="certificate-external-acl-example"></div>
<div class="form-actions"><button type="button" class="quiet"
@@ -3298,8 +3299,7 @@
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>__RUSTPROXY_VERSION__</b></span><span
id="footer-status">API - /
dropped: 0</span></footer>
id="footer-status">API -</span></footer>
</div>
</main>
</div>
@@ -3668,7 +3668,7 @@
</div>
</fieldset>
<label><span data-i18n="webServiceId">Service ID</span><input id="web-service-id" maxlength="64"
pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
pattern="[A-Za-z0-9_\-]{1,64}" required="" /></label>
<label><span data-i18n="webServiceName">Name</span><input id="web-service-name" maxlength="96"
required="" /></label>
<label class="vh-wide"><span data-i18n="webServiceRoot">Document root</span><input
@@ -4158,7 +4158,7 @@ log_errors = On</code></pre>
<legend data-i18n="hostIdentitySection">Host and domains</legend>
<div class="vh-grid">
<label><span data-i18n="redirectId">Redirect ID</span><input id="redirect-id" maxlength="64"
pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
pattern="[A-Za-z0-9_\-]{1,64}" required="" /></label>
<label><span data-i18n="redirectName">Display name</span><input id="redirect-name"
maxlength="96" /></label>
<label class="check"><input id="redirect-enabled" type="checkbox" /><span
@@ -4216,7 +4216,7 @@ log_errors = On</code></pre>
<legend data-i18n="hostIdentitySection">Host and domains</legend>
<div class="vh-grid">
<label><span data-i18n="notfoundId">Host ID</span><input id="notfound-id" maxlength="64"
pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
pattern="[A-Za-z0-9_\-]{1,64}" required="" /></label>
<label><span data-i18n="notfoundName">Display name</span><input id="notfound-name"
maxlength="96" /></label>
<label class="check"><input id="notfound-enabled" type="checkbox" /><span
@@ -4290,6 +4290,23 @@ log_errors = On</code></pre>
<p class="fine" data-i18n="streamRestartHelp">Changing a stream listener requires a RustProxy restart.</p>
</form>
</dialog>
<dialog aria-labelledby="app-health-title" class="vhost-dialog app-health-dialog" id="app-health-dialog">
<div class="window-heading">
<div><span class="small-label" data-i18n="appHealthSelfCheck">SELF-CHECK</span>
<h2 data-i18n="appHealthTitle" id="app-health-title">Application status</h2>
</div>
<button class="outline" data-i18n="close" id="app-health-close" type="button">Close</button>
</div>
<article class="card app-health-card">
<div class="app-health-summary" id="app-health-summary-row">
<i aria-hidden="true"></i>
<div><strong id="app-health-summary" data-i18n="appHealthChecking">Checking application status</strong>
<p class="fine" id="app-health-updated"></p>
</div>
</div>
<div class="app-health-checks" id="app-health-checks"></div>
</article>
</dialog>
<dialog aria-labelledby="about-title" class="vhost-dialog about-dialog" id="about-dialog">
<div class="window-heading">
<h2 data-i18n="aboutTitle" id="about-title">About RustProxy</h2>
+1 -1
View File
@@ -31,7 +31,7 @@
type="password" />
</div>
<label data-i18n="username" for="username">Username</label><input autocapitalize="none"
autocomplete="username" id="username" maxlength="64" minlength="3" pattern="[A-Za-z0-9_.-]{3,64}"
autocomplete="username" id="username" maxlength="64" minlength="3" pattern="[A-Za-z0-9_.\-]{3,64}"
required="" spellcheck="false" />
<label data-i18n="password" for="password">Password</label><input autocomplete="current-password"
id="password" maxlength="128" required="" type="password" />
+1 -1
View File
@@ -26,7 +26,7 @@
const input = (labelKey, name, type = "text") => { const label = el("label"); label.append(el("span", t(labelKey))); const node = el("input"); node.type = type; node.dataset.field = name; label.append(node); grid.append(label); return node; };
const select = (labelKey, name, values) => { const label = el("label"); label.append(el("span", t(labelKey))); const node = el("select"); node.dataset.field = name; for (const [v, k] of values) node.append(option(v, k)); label.append(node); grid.append(label); return node; };
const check = (labelKey, name) => { const label = el("label", undefined, "check"); const node = el("input"); node.type = "checkbox"; node.dataset.field = name; label.append(node, el("span", t(labelKey))); grid.append(label); return node; };
const id = input("redirectRuleId", "id"); id.required = true; id.maxLength = 64; id.pattern = "[A-Za-z0-9_-]{1,64}"; id.value = data.id;
const id = input("redirectRuleId", "id"); id.required = true; id.maxLength = 64; id.pattern = "[A-Za-z0-9_\\-]{1,64}"; id.value = data.id;
const enabled = check("enabled", "enabled"); enabled.checked = data.enabled !== false;
const sourceScheme = select("redirectSourceScheme", "source_scheme", [["any", "redirectSchemeAny"], ["http", "redirectSchemeHttp"], ["https", "redirectSchemeHttps"]]); sourceScheme.value = data.source_scheme;
const matchType = select("redirectMatchType", "match_type", [["prefix", "redirectMatchPrefix"], ["exact", "redirectMatchExact"]]); matchType.value = data.match_type;