0.36.4
This commit is contained in:
1 parent
17e314e159
commit
34490fd5fc
22 files changed
+798
-613
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# RustProxy source and documentation maintenance
|
||||
|
||||
Current source version: **0.36.3**. The Cargo package and binary are named `rust-proxy`.
|
||||
Current source version: **0.36.4**. The Cargo package and binary are named `rust-proxy`.
|
||||
|
||||
Before editing, read [README.md](README.md), the relevant [documentation guide](docs/README.md) and the actual code and tests. Historical release experiments are not current specifications. Use the bundled `src/admin/openapi.json` for API routes and payloads; the source implementation is authoritative for runtime behavior.
|
||||
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in the latest release.
|
||||
|
||||
## 0.36.4 — cache readability and editor UX polish
|
||||
|
||||
- **Cache → Domains and effectiveness:** decision counters are one compact line (`name count`, top three, the rest in a `+N` tooltip) instead of a wall of chips; the rest of the table keeps its 0.36.3 look.
|
||||
- **Cache → Static response transformation:** the "Transformations by type" and "Vhost effectiveness" tables use the same presentation as Domains and effectiveness (right-aligned tabular numbers, emphasised vhost name, non-zero errors in red, reduction meter) and the skip reasons are compact `name count` lines.
|
||||
- **Cache → Stored objects:** the tier is a badge in the same style as the other badges (follows the corner-radius setting), numeric columns are right-aligned, long targets and cache keys are truncated (full text on hover, click still copies), rows highlight on hover, and the Previous / range / Next pager is a single segmented control.
|
||||
- **Docker vhosts:** the per-service link is now **Open** (same wording as in Vhosts); the expandable per-application forms and their protocol, ACL, timeout and health sections have a chevron, keyboard focus outline and a framed panel when open.
|
||||
- **Automatic IDs:** when creating a vhost, web service, stream, redirect, 404 host or ACL profile, the ID is generated from the name (for vhosts, from the first host name) as a unique slug; the ID field comes after the name (after the hosts for vhosts) and is shown as a plain value with a **Change** link — the input only appears when you ask for it (or when validation needs it) — `example.com` becomes `example-com`, Polish and other accented letters are folded to ASCII, and `-2`, `-3`… is appended when the ID is taken. Typing in the ID field switches to a manual ID; a "Generate automatically" button (or clearing the field) restores it. Existing IDs are never changed.
|
||||
- **Vhost, Web service, Stream, Redirect and ACL forms:** section tabs wrap onto a second line instead of scrolling horizontally, and use a readable 12 px underlined tab style with a clear active state and hover; form action bars are aligned consistently.
|
||||
|
||||
## 0.36.3 — settings, cache and dashboard layout polish
|
||||
|
||||
- **More chart styles:** Settings → Appearance → Chart style now offers eight additional presets: **Sunset**, **Aurora**, **Ocean**, **Forest**, **Pastel**, **Terminal** (green phosphor, square lines), **Blueprint** (thin technical lines on a blue tint) and **Colorblind safe** (Okabe–Ito palette with dashed grid). The `chart_style` preference accepts the new values (`sunset`, `aurora`, `ocean`, `forest`, `pastel`, `terminal`, `blueprint`, `colorblind`); existing values and the default are unchanged.
|
||||
|
||||
Generated
+1
-1
@@ -1943,7 +1943,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust-proxy"
|
||||
version = "0.36.3"
|
||||
version = "0.36.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rust-proxy"
|
||||
version = "0.36.3"
|
||||
version = "0.36.4"
|
||||
edition = "2021"
|
||||
rust-version = "1.99.1"
|
||||
description = "RustProxy - independent reverse proxy with local administration"
|
||||
|
||||
+572
-572
File diff suppressed because it is too large.
Load diff
@@ -77,10 +77,10 @@ ENV_FILE=integrations/routeros-oci-build/.env \
|
||||
bash integrations/routeros-oci-build/build-routeros-oci.sh push
|
||||
```
|
||||
|
||||
For example, with RustProxy version `0.36.3` and the defaults this publishes:
|
||||
For example, with RustProxy version `0.36.4` and the defaults this publishes:
|
||||
|
||||
```text
|
||||
zot.example.net/network/rustproxy:0.36.3
|
||||
zot.example.net/network/rustproxy:0.36.4
|
||||
zot.example.net/network/rustproxy:latest
|
||||
zot.example.net/network/rustproxy:routeros
|
||||
```
|
||||
|
||||
+6
-1
@@ -1726,7 +1726,12 @@
|
||||
"dockerAliasesShort": "aliases",
|
||||
"dockerAgentDetails": "Agent details",
|
||||
"dockerServices": "Services",
|
||||
"dockerOpenApp": "Open app",
|
||||
"dockerOpenApp": "Open",
|
||||
"autoIdHint": "Generated automatically from the name. Edit it to set your own ID.",
|
||||
"autoIdReset": "Generate automatically",
|
||||
"autoIdChange": "Change",
|
||||
"autoIdPending": "Generated from the name",
|
||||
"autoIdBadge": "auto",
|
||||
"dockerLiveAccessLogsHelp": "Live HTTP access logs for this Docker vhost, not the container stdout/stderr.",
|
||||
"networkInterfacesHistory": "Traffic by interface",
|
||||
"networkInterfaceRate": "RX / TX",
|
||||
|
||||
+6
-1
@@ -1726,7 +1726,12 @@
|
||||
"dockerAliasesShort": "aliasów",
|
||||
"dockerAgentDetails": "Szczegóły agenta",
|
||||
"dockerServices": "Usługi",
|
||||
"dockerOpenApp": "Otwórz aplikację",
|
||||
"dockerOpenApp": "Otwórz",
|
||||
"autoIdHint": "Generowane automatycznie z nazwy. Zmień, aby ustawić własne ID.",
|
||||
"autoIdReset": "Generuj automatycznie",
|
||||
"autoIdChange": "Zmień",
|
||||
"autoIdPending": "Generowane z nazwy",
|
||||
"autoIdBadge": "auto",
|
||||
"dockerLiveAccessLogsHelp": "Logi żądań HTTP dla tego Docker vhosta, nie stdout/stderr kontenera.",
|
||||
"networkInterfacesHistory": "Ruch według interfejsów",
|
||||
"networkInterfaceRate": "RX / TX",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.36.3",
|
||||
"version": "0.36.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": [
|
||||
|
||||
@@ -668,3 +668,62 @@ $("tunnels-dialog").addEventListener("close", stopTunnelRefresh);
|
||||
$("config-editor").addEventListener("input", () => setSettingsDirty("config-editor"));
|
||||
$("save-json").addEventListener("click", async event => { const editor = $("config-editor"), page = editor.closest("[data-settings-page]"); if (!envelope || busy) return; try { await withFormSubmitState(page, event.currentTarget, async () => { let config; try { config = JSON.parse(editor.value); } catch { throw fieldError(editor, t("vhJsonInvalid")); } if (await save(config)) setSettingsDirty("config-editor", false); }); } catch (e) { toast(e.message, true); } });
|
||||
$("export-config").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify(envelope.config, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = `RustProxy-config-r${envelope.revision}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); });
|
||||
|
||||
// Auto-generated identifiers: a new entry's ID follows its name (or first host) as a unique slug until the user edits the ID by hand.
|
||||
const rustProxyAutoId = (() => {
|
||||
const FOLD = { "ł": "l", "đ": "d", "ø": "o", "ß": "ss", "æ": "ae", "œ": "oe", "þ": "th", "ð": "d" };
|
||||
const states = new WeakMap();
|
||||
function slugify(value, max = 64) {
|
||||
let slug = String(value ?? "").trim().toLowerCase().replace(/^\*\./, "").replace(/[łđøßæœþð]/g, ch => FOLD[ch]).normalize("NFKD").replace(/[̀-ͯ]/g, "");
|
||||
slug = slug.replace(/[^a-z0-9_-]+/g, "-").replace(/-{2,}/g, "-").replace(/^[-_]+/, "").slice(0, max).replace(/[-_]+$/, "");
|
||||
return slug;
|
||||
}
|
||||
function unique(base, taken) {
|
||||
if (!base) return "";
|
||||
const used = new Set([...taken].map(value => String(value).toLowerCase()));
|
||||
if (!used.has(base)) return base;
|
||||
for (let n = 2; n < 10000; n++) { const suffix = `-${n}`, candidate = base.slice(0, 64 - suffix.length).replace(/[-_]+$/, "") + suffix; if (!used.has(candidate)) return candidate; }
|
||||
return base;
|
||||
}
|
||||
const locked = input => input.readOnly || input.disabled;
|
||||
function suggestion(state) { return unique(slugify(state.text()), state.taken()); }
|
||||
// The ID is shown as a plain value with a "Change" link; the input only appears after the user asks for it (or when validation needs it).
|
||||
function paint(input, state) {
|
||||
const isLocked = locked(input), wanted = suggestion(state), value = input.value.trim(), compact = !state.open || isLocked;
|
||||
state.holder.classList.toggle("is-compact", compact);
|
||||
state.value.textContent = value || t("autoIdPending");
|
||||
state.value.classList.toggle("is-pending", !value);
|
||||
state.badge.hidden = !state.auto || !value; state.badge.textContent = t("autoIdBadge");
|
||||
state.change.hidden = isLocked; state.change.textContent = t("autoIdChange");
|
||||
state.row.hidden = !compact;
|
||||
state.hint.hidden = compact || !state.auto; state.hint.textContent = t("autoIdHint");
|
||||
state.reset.hidden = compact || state.auto || state.resettable === false || !wanted || wanted === value; state.reset.textContent = t("autoIdReset");
|
||||
}
|
||||
function regenerate(input, state) { if (!state.auto || locked(input)) return; input.value = suggestion(state); input.setCustomValidity?.(""); paint(input, state); }
|
||||
// options: { elements: inputs whose changes drive the ID, text: () => source string, taken: () => existing IDs }
|
||||
function attach(input, options) {
|
||||
let state = states.get(input);
|
||||
if (!state) {
|
||||
state = { auto: true, open: false, holder: input.closest("label") || input.parentElement, hint: document.createElement("small"), reset: document.createElement("button"), row: document.createElement("div"), value: document.createElement("code"), badge: document.createElement("span"), change: document.createElement("button") };
|
||||
state.holder.classList.add("auto-id-field");
|
||||
state.hint.className = "fine auto-id-hint"; state.reset.type = "button"; state.reset.className = "quiet auto-id-reset";
|
||||
state.row.className = "auto-id-row"; state.value.className = "auto-id-value"; state.badge.className = "auto-id-badge"; state.change.type = "button"; state.change.className = "quiet auto-id-change";
|
||||
state.row.append(state.value, state.badge, state.change); state.holder.append(state.row, state.hint, state.reset);
|
||||
input.addEventListener("input", event => { if (!event.isTrusted) return; state.auto = input.value.trim() === ""; if (state.auto) regenerate(input, state); else paint(input, state); });
|
||||
input.addEventListener("invalid", () => { if (!state.open) { state.open = true; paint(input, state); } });
|
||||
input.addEventListener("blur", () => { if (input.value.trim() && input.validity.valid) { state.open = false; paint(input, state); } });
|
||||
state.change.addEventListener("click", () => { state.open = true; paint(input, state); input.focus(); input.select?.(); });
|
||||
state.reset.addEventListener("mousedown", event => event.preventDefault());
|
||||
state.reset.addEventListener("click", () => { state.auto = true; regenerate(input, state); input.dispatchEvent(new Event("input", { bubbles: true })); });
|
||||
states.set(input, state);
|
||||
for (const source of options.elements || []) source.addEventListener("input", () => { const current = states.get(input); if (current) regenerate(input, current); });
|
||||
}
|
||||
state.text = options.text || (() => (options.elements || []).map(node => node.value).find(value => value.trim()) || "");
|
||||
state.taken = options.taken || (() => []); state.resettable = options.resettable;
|
||||
state.auto = !locked(input) && input.value.trim() === ""; state.open = false;
|
||||
paint(input, state);
|
||||
return { refresh() { const current = states.get(input); if (current) regenerate(input, current); } };
|
||||
}
|
||||
return { slugify, unique, attach };
|
||||
})();
|
||||
window.rustProxyAutoId = rustProxyAutoId;
|
||||
@@ -67,6 +67,20 @@
|
||||
return groups.filter(row => cacheEnabledFor(data, row) === wanted);
|
||||
};
|
||||
const reason = value => { const key = `cacheReason_${value}`, text = t(key); return text === key ? value : text; };
|
||||
|
||||
// Decision counters: label + count + share bar; top three visible, the rest folded into a details toggle.
|
||||
function reasonsCell(reasons) {
|
||||
const td = el("td", undefined, "cache-reasons"), entries = Object.entries(reasons || {}).sort((a, b) => b[1] - a[1]);
|
||||
if (!entries.length) { td.append(el("span", "-", "cache-reason-none")); return td; }
|
||||
const max = entries[0][1] || 1, line = ([key, count]) => {
|
||||
const item = el("div", undefined, "cache-reason-line"), bar = el("i"); bar.style.width = `${Math.max(4, Math.round(count / max * 100))}%`;
|
||||
const track = el("span", undefined, "cache-reason-track"); track.append(bar);
|
||||
item.append(el("span", reason(key), "cache-reason-name"), el("b", number(count), "cache-reason-count"), track); item.title = key; return item;
|
||||
};
|
||||
entries.slice(0, 3).forEach(entry => td.append(line(entry)));
|
||||
if (entries.length > 3) { const more = el("details", undefined, "cache-reason-more"); more.append(el("summary", `+${entries.length - 3}`)); entries.slice(3).forEach(entry => more.append(line(entry))); td.append(more); }
|
||||
return td;
|
||||
}
|
||||
function resetLiveChart(selected = scope()) {
|
||||
liveChartScope = selected; liveChartPrevious = null; liveChartPreviousAt = null; liveChartGeneration = null;
|
||||
for (const values of Object.values(liveChartSamples)) values.length = 0;
|
||||
@@ -168,7 +182,7 @@
|
||||
[[row.hits, "cache-outcome-hit"], [row.misses, "cache-outcome-miss"], [row.bypasses, "cache-outcome-bypass"]].forEach(([count, cls], index) => { if (index) outcomes.append(el("span", "/", "cache-outcome-sep")); outcomes.append(el("span", number(count), cls)); });
|
||||
const ratioPercent = Math.max(0, Math.min(100, (row.hit_ratio || 0) * 100)), ratio = el("td", undefined, "cache-ratio-cell"), meter = el("span", undefined, "cache-ratio-meter"), fill = el("i"); fill.style.width = `${ratioPercent}%`; meter.append(fill); ratio.append(el("span", `${fmt(ratioPercent, 1)}%`, "cache-ratio-value"), meter);
|
||||
tr.append(outcomes, ratio);
|
||||
const td = el("td", undefined, "cache-reasons"); for (const [key, count] of Object.entries(row.reasons || {}).sort((a, b) => b[1] - a[1])) { const tag = el("span", `${reason(key)}: ${number(count)}`, "cache-reason"); tag.title = key; td.append(tag); } tr.append(td); return tr;
|
||||
tr.append(reasonsCell(row.reasons)); return tr;
|
||||
}));
|
||||
const domainEmpty = $("cache-domains-empty");
|
||||
domainEmpty.textContent = groups.length ? t("cacheNoDomainsFiltered") : t("cacheNoDomains");
|
||||
@@ -181,7 +195,8 @@
|
||||
tr.append(target);
|
||||
const keyTags = `${row.cache_key || "-"}${row.tags?.length ? ` · ${row.tags.join(", ")}` : ""}`;
|
||||
tr.append(makeCopyCell(keyTags));
|
||||
for (const value of [row.tier === "cold" ? t("cacheCold") : t("cacheHot"), fmtBytes(body), fmtBytes(storage), fmtBytes(Math.max(0, storage - body)), number(row.hits), `${number(row.age_seconds)} s`, `${number(row.resident_age_seconds)} s`, `${number(row.remaining_ttl_seconds)} s`]) tr.append(el("td", value));
|
||||
tr.append(el("td", undefined)); tr.lastChild.append(el("span", row.tier === "cold" ? t("cacheCold") : t("cacheHot"), `cache-tier-pill ${row.tier === "cold" ? "is-cold" : "is-hot"}`));
|
||||
for (const value of [fmtBytes(body), fmtBytes(storage), fmtBytes(Math.max(0, storage - body)), number(row.hits), `${number(row.age_seconds)} s`, `${number(row.resident_age_seconds)} s`, `${number(row.remaining_ttl_seconds)} s`]) tr.append(el("td", value));
|
||||
const action = el("td"), button = el("button", t("cachePurgeUrl"), "quiet"); button.type = "button"; button.addEventListener("click", () => void purgeObject(row)); action.append(button); tr.append(action); return tr;
|
||||
}));
|
||||
const bans = data.bans || [];
|
||||
|
||||
@@ -1147,3 +1147,41 @@ html[data-table-density=ultra] .file-log-inline-detail pre {
|
||||
background: var(--panel2);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Cache → decision counters, stored objects and pager (0.36.4) */
|
||||
#panel-cache .cache-table .cache-reasons { min-width: 200px; max-width: 320px; }
|
||||
.cache-reason-line { display: inline-flex; align-items: baseline; gap: 4px; margin: 0 10px 0 0; font-size: 11px; line-height: 1.5; white-space: nowrap; }
|
||||
.cache-reason-name { color: var(--muted); }
|
||||
.cache-reason-count { font-variant-numeric: tabular-nums; font-weight: 600; }
|
||||
.cache-reason-more { color: var(--muted); cursor: help; margin-right: 0; }
|
||||
.cache-reason-none { color: var(--muted); }
|
||||
|
||||
#cache-object-scroll .cache-table td:nth-child(n+6):nth-child(-n+12),
|
||||
#cache-object-scroll .cache-table th:nth-child(n+6):nth-child(-n+12) { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
#cache-object-scroll .cache-table td:nth-child(3),
|
||||
#cache-object-scroll .cache-table td:nth-child(4) { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; }
|
||||
#cache-object-scroll .cache-table td:nth-child(3):hover,
|
||||
#cache-object-scroll .cache-table td:nth-child(4):hover { white-space: normal; overflow-wrap: anywhere; }
|
||||
#cache-object-scroll .cache-table tbody tr:hover { background: var(--panel2); }
|
||||
.cache-tier-pill { display: inline-flex; align-items: center; padding: 2px 6px; border: 1px solid var(--pill-line); border-radius: var(--rp-radius-control, 0); color: var(--muted); font: 9px var(--mono); letter-spacing: 1px; text-transform: uppercase; }
|
||||
.cache-tier-pill.is-hot { color: var(--green); border-color: var(--pill-live-line); }
|
||||
|
||||
.cache-pager { display: inline-flex; align-items: center; gap: 0; border: 1px solid var(--line); border-radius: var(--rp-radius-control, 6px); overflow: hidden; }
|
||||
.cache-pager button { border: 0; border-radius: 0; min-width: 88px; margin: 0; }
|
||||
.cache-pager button:disabled { opacity: .45; }
|
||||
.cache-pager #cache-page { padding: 0 14px; min-width: 120px; text-align: center; font-variant-numeric: tabular-nums; border-inline: 1px solid var(--line); align-self: stretch; display: flex; align-items: center; justify-content: center; }
|
||||
.cache-pager #cache-prev::before { content: "‹ "; }
|
||||
.cache-pager #cache-next::after { content: " ›"; }
|
||||
|
||||
/* Static transformation tables (0.36.4): same presentation as Cache → Domains and effectiveness */
|
||||
.transform-vhost-table :is(th, td):nth-child(n+2), .transform-type-table :is(th, td):nth-child(2) { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.transform-vhost-table td, .transform-type-table td { vertical-align: middle; }
|
||||
.transform-vhost-table td:first-child { color: var(--text); font-weight: 600; }
|
||||
.transform-type-table td:first-child { color: var(--muted); }
|
||||
.transform-vhost-table tbody tr:hover, .transform-type-table tbody tr:hover { background: var(--panel2); }
|
||||
.transform-zero { color: var(--muted); }
|
||||
.transform-errors-nonzero { color: var(--red, #e5534b); font-weight: 600; }
|
||||
.transform-reason-list { gap: 4px 12px; }
|
||||
.transform-reason-list .cache-reason-line { margin: 0; }
|
||||
|
||||
.cache-ratio-meter, .cache-ratio-meter>i { border-radius: var(--rp-progress-radius, 0); }
|
||||
@@ -1731,3 +1731,54 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Editors lift (0.36.4): Docker per-app forms and shared vhost/web/stream/redirect/ACL form chrome */
|
||||
.docker-service-editor:not(.docker-service-metrics)>summary,
|
||||
.docker-health-editor>summary, .docker-acl-editor>summary, .docker-protocol-editor>summary, .docker-timeout-editor>summary, .docker-passive-health-editor>summary {
|
||||
list-style: none;
|
||||
}
|
||||
.docker-service-editor>summary::-webkit-details-marker, .docker-health-editor>summary::-webkit-details-marker, .docker-acl-editor>summary::-webkit-details-marker,
|
||||
.docker-protocol-editor>summary::-webkit-details-marker, .docker-timeout-editor>summary::-webkit-details-marker, .docker-passive-health-editor>summary::-webkit-details-marker { display: none; }
|
||||
.docker-service-editor>summary::before, .docker-health-editor>summary::before, .docker-acl-editor>summary::before,
|
||||
.docker-protocol-editor>summary::before, .docker-timeout-editor>summary::before, .docker-passive-health-editor>summary::before {
|
||||
content: "›"; display: inline-block; width: 14px; flex: 0 0 14px; color: var(--muted); font-size: 14px; line-height: 1; transition: transform .15s ease;
|
||||
}
|
||||
.docker-service-editor[open]>summary::before, .docker-health-editor[open]>summary::before, .docker-acl-editor[open]>summary::before,
|
||||
.docker-protocol-editor[open]>summary::before, .docker-timeout-editor[open]>summary::before, .docker-passive-health-editor[open]>summary::before { transform: rotate(90deg); color: var(--text); }
|
||||
.docker-service-editor>summary>span { flex: 0 0 auto; }
|
||||
.docker-service-editor>summary .docker-config-hint { flex: 1; min-width: 0; }
|
||||
.docker-service-editor:not(.docker-service-metrics)>summary:hover>span { color: var(--green); }
|
||||
.docker-service-editor[open]:not(.docker-service-metrics) { border: 1px solid var(--line); border-radius: var(--rp-radius-surface, 6px); background: var(--panel2); padding: 10px 12px 12px; }
|
||||
.docker-service-editor[open]:not(.docker-service-metrics)>.docker-health-editor,
|
||||
.docker-service-editor[open]:not(.docker-service-metrics)>.docker-acl-editor,
|
||||
.docker-service-editor[open]:not(.docker-service-metrics)>.docker-protocol-editor,
|
||||
.docker-service-editor[open]:not(.docker-service-metrics)>.docker-timeout-editor { margin-top: 10px; }
|
||||
.docker-service-editor>summary:focus-visible, .docker-acl-editor>summary:focus-visible, .docker-protocol-editor>summary:focus-visible, .docker-timeout-editor>summary:focus-visible, .docker-health-editor>summary:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; border-radius: 4px; }
|
||||
|
||||
.vh-tabs { gap: 2px; padding: 4px 0 0; border-bottom: 1px solid var(--line); margin-bottom: 12px; }
|
||||
.vh-tabs button { font-size: 12px; padding: 8px 12px; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; color: var(--muted); margin-bottom: -1px; }
|
||||
.vh-tabs button:hover { color: var(--text); background: var(--panel2); }
|
||||
.vh-tabs button.active { color: var(--text); background: transparent; border-bottom-color: var(--green); font-weight: 600; }
|
||||
.vh-tabs button:focus-visible { outline: 2px solid var(--green); outline-offset: -2px; }
|
||||
.vh-form>.card-heading { gap: 10px; align-items: center; }
|
||||
.vh-form .form-actions { display: flex; gap: 8px; align-items: center; }
|
||||
.vh-form .form-actions .primary { order: 0; }
|
||||
.vh-form .form-actions .quiet { order: 1; }
|
||||
.vh-setting-section>h3 { letter-spacing: .01em; }
|
||||
.vh-tabs { flex-wrap: wrap; overflow: visible; row-gap: 0; }
|
||||
@media (max-width:700px) { .vh-tabs button { font-size: 11px; padding: 7px 8px; } }
|
||||
.auto-id-hint { display: block; margin-top: 3px; color: var(--muted); font-size: 11px; line-height: 1.35; }
|
||||
.auto-id-reset { justify-self: start; margin-top: 3px; padding: 2px 8px; font-size: 11px; }
|
||||
.auto-id-hint[hidden], .auto-id-reset[hidden] { display: none; }
|
||||
.auto-id-field>span:first-child { color: var(--muted); }
|
||||
.auto-id-field.is-compact>input { display: none; }
|
||||
.auto-id-row { display: flex; align-items: center; gap: 8px; min-height: 32px; padding: 0 2px; }
|
||||
.auto-id-value { font-family: var(--mono, monospace); font-size: 13px; color: var(--text); overflow-wrap: anywhere; }
|
||||
.auto-id-value.is-pending { color: var(--muted); font-style: italic; font-family: inherit; font-size: 12px; }
|
||||
.auto-id-badge { display: inline-flex; align-items: center; padding: 2px 6px; border: 1px solid var(--pill-line); border-radius: var(--rp-radius-control, 0); color: var(--accent); font: 9px var(--mono); letter-spacing: 1px; text-transform: uppercase; }
|
||||
.auto-id-change { padding: 2px 8px; font-size: 11px; margin-left: auto; }
|
||||
.auto-id-row[hidden], .auto-id-change[hidden], .auto-id-badge[hidden] { display: none; }
|
||||
.auto-id-field input { font-family: var(--mono, monospace); font-size: 12px; }
|
||||
.auto-id-hint { display: block; margin-top: 3px; color: var(--muted); font-size: 11px; line-height: 1.35; }
|
||||
.auto-id-reset { justify-self: start; margin-top: 3px; padding: 2px 8px; font-size: 11px; }
|
||||
.auto-id-hint[hidden], .auto-id-reset[hidden] { display: none; }
|
||||
@@ -44,6 +44,7 @@
|
||||
editing = item.id || null; revision = envelope?.revision ?? null; dirty = false;
|
||||
$("notfound-editing").textContent = editing || t("notfoundNewLabel");
|
||||
$("notfound-id").value = item.id || ""; $("notfound-id").disabled = Boolean(editing); $("notfound-name").value = item.name || "";
|
||||
window.rustProxyAutoId.attach($("notfound-id"), { elements: [$("notfound-name")], taken: () => (envelope?.config.not_found_hosts || []).map(value => value.id) });
|
||||
$("notfound-enabled").checked = item.enabled !== false; syncEnabledControl(); $("notfound-http2").checked = item.http2_enabled !== false; $("notfound-http3").checked = item.http3_enabled !== false;
|
||||
const hostHolder = $("notfound-hosts-editor"); hostHolder.replaceChildren(); readHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "notfoundHosts", helpKey: "vhHostsHelp", requiredKey: "notfoundHostsRequired", onChange: changed });
|
||||
listenerChoices(item.frontend?.listener_ids || []); certificateChoices(item.certificate_id);
|
||||
|
||||
+12
-12
@@ -772,10 +772,10 @@
|
||||
</div>
|
||||
</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>
|
||||
<label><span data-i18n="aclProfileName">Display name</span><input id="acl-profile-name"
|
||||
maxlength="96" required /></label>
|
||||
<label class="auto-id-field"><span data-i18n="aclProfileId">Profile ID</span><input id="acl-profile-id"
|
||||
maxlength="64" pattern="[A-Za-z0-9_\-]{1,64}" required /></label>
|
||||
</div>
|
||||
<section class="acl-policy-builder" aria-labelledby="acl-policy-mode-label">
|
||||
<div class="acl-policy-step-head">
|
||||
@@ -1398,8 +1398,8 @@
|
||||
<article class="card">
|
||||
<div class="card-heading">
|
||||
<h2 data-i18n="cacheObjectList">Stored objects</h2>
|
||||
<div class="entity-actions"><button class="quiet" data-i18n="cachePrevious" id="cache-prev"
|
||||
type="button">Previous</button><span class="fine" id="cache-page"></span><button
|
||||
<div class="entity-actions cache-pager"><button class="quiet" data-i18n="cachePrevious" id="cache-prev"
|
||||
type="button">Previous</button><span class="fine" id="cache-page" aria-live="polite"></span><button
|
||||
class="quiet" data-i18n="cacheNext" id="cache-next" type="button">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1519,7 +1519,7 @@
|
||||
<h2 data-i18n="transformByType">Transformations by type</h2>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="cache-table">
|
||||
<table class="cache-table transform-type-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="transformType">Type</th>
|
||||
@@ -1546,7 +1546,7 @@
|
||||
<p class="fine" data-i18n="transformScopeHint">Counters are in-memory runtime diagnostics. A
|
||||
transformed cache HIT does not execute the transformer again.</p>
|
||||
<div class="table-scroll">
|
||||
<table class="cache-table">
|
||||
<table class="cache-table transform-vhost-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="vhVhost">Vhost</th>
|
||||
@@ -3799,10 +3799,10 @@
|
||||
service ID/name and PHP-FPM endpoint are not replaced.</p>
|
||||
</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>
|
||||
<label><span data-i18n="webServiceName">Name</span><input id="web-service-name" maxlength="96"
|
||||
required="" /></label>
|
||||
<label class="auto-id-field"><span data-i18n="webServiceId">Service ID</span><input id="web-service-id" maxlength="64"
|
||||
pattern="[A-Za-z0-9_\-]{1,64}" required="" /></label>
|
||||
<label class="vh-wide"><span data-i18n="webServiceRoot">Document root</span><input
|
||||
id="web-service-root" maxlength="4096" placeholder="/var/www/main" required="" /></label>
|
||||
<div class="vh-wide" hidden id="web-service-permissions"></div>
|
||||
@@ -4289,10 +4289,10 @@ log_errors = On</code></pre>
|
||||
<fieldset class="vh-group editor-identity-section">
|
||||
<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>
|
||||
<label><span data-i18n="redirectName">Display name</span><input id="redirect-name"
|
||||
maxlength="96" /></label>
|
||||
<label class="auto-id-field"><span data-i18n="redirectId">Redirect ID</span><input id="redirect-id" maxlength="64"
|
||||
pattern="[A-Za-z0-9_\-]{1,64}" required="" /></label>
|
||||
<label class="check"><input id="redirect-enabled" type="checkbox" /><span
|
||||
data-i18n="enabled">Enabled</span></label>
|
||||
<div class="vh-wide" id="redirect-hosts-editor"></div>
|
||||
@@ -4347,10 +4347,10 @@ log_errors = On</code></pre>
|
||||
<fieldset class="vh-group editor-identity-section">
|
||||
<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>
|
||||
<label><span data-i18n="notfoundName">Display name</span><input id="notfound-name"
|
||||
maxlength="96" /></label>
|
||||
<label class="auto-id-field"><span data-i18n="notfoundId">Host ID</span><input id="notfound-id" maxlength="64"
|
||||
pattern="[A-Za-z0-9_\-]{1,64}" required="" /></label>
|
||||
<label class="check"><input id="notfound-enabled" type="checkbox" /><span
|
||||
data-i18n="enabled">Enabled</span></label>
|
||||
<div class="vh-wide" id="notfound-hosts-editor"></div>
|
||||
|
||||
@@ -366,11 +366,12 @@
|
||||
rules.placeholder = custom ? "allow 10.0.0.0/8\ndeny 10.21.0.0/16" : "10.0.0.0/8\n203.0.113.5";
|
||||
syncRuleRows(convertRules); renderPreview();
|
||||
}
|
||||
function attachAclProfileId() { window.rustProxyAutoId.attach($("acl-profile-id"), { elements: [$("acl-profile-name")], taken: () => (envelope?.config.acl_profiles || []).map(value => value.id) }); }
|
||||
function clear() {
|
||||
editing = null; dirty = false; form.reset(); setMode("allow_exceptions"); aclDefault.value = "deny"; countryMode.value = "off"; countryEnabled.checked = false; countriesInput.value = ""; $("acl-profile-id").readOnly = false; $("acl-profile-editing").textContent = t("aclProfileNew"); renderRuleRows([]); rules.value = ""; syncPolicy();
|
||||
editing = null; dirty = false; form.reset(); setMode("allow_exceptions"); aclDefault.value = "deny"; countryMode.value = "off"; countryEnabled.checked = false; countriesInput.value = ""; $("acl-profile-id").readOnly = false; attachAclProfileId(); $("acl-profile-editing").textContent = t("aclProfileNew"); renderRuleRows([]); rules.value = ""; syncPolicy();
|
||||
}
|
||||
function edit(profile) {
|
||||
editing = profile.id; dirty = false; $("acl-profile-id").value = profile.id; $("acl-profile-id").readOnly = true; $("acl-profile-name").value = profile.name || profile.id;
|
||||
editing = profile.id; dirty = false; $("acl-profile-id").value = profile.id; $("acl-profile-id").readOnly = true; $("acl-profile-name").value = profile.name || profile.id; attachAclProfileId();
|
||||
const cMode = profile.country_access?.mode || "off"; aclDefault.value = profile.acl?.default || (cMode === "allow" ? "deny" : "allow"); countryMode.value = cMode;
|
||||
countryEnabled.checked = cMode !== "off"; countriesInput.value = (profile.country_access?.countries || []).join("\n");
|
||||
setMode(inferMode(profile)); renderRuleRows(profile.acl?.rules || []); $("acl-profile-editing").textContent = t("aclProfileEdit"); dirty = false; syncPolicy();
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
$("redirect-editing").textContent = editing ? editing : t("redirectNewLabel");
|
||||
$("redirect-id").value = item.id || ""; $("redirect-id").disabled = Boolean(editing);
|
||||
$("redirect-name").value = item.name || "";
|
||||
window.rustProxyAutoId.attach($("redirect-id"), { elements: [$("redirect-name")], taken: () => (envelope?.config.redirects || []).map(value => value.id) });
|
||||
const hostHolder = $("redirect-hosts-editor"); hostHolder.replaceChildren(); readRedirectHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "redirectHosts", helpKey: "vhHostsHelp", requiredKey: "redirectHostsRequired", onChange: changed });
|
||||
$("redirect-enabled").checked = item.enabled !== false; syncEnabledControl(); $("redirect-http2").checked = item.http2_enabled !== false; $("redirect-http3").checked = item.http3_enabled !== false;
|
||||
listenerChoices(item.frontend?.listener_ids || []); certificateChoices(item.certificate_id);
|
||||
|
||||
@@ -127,8 +127,9 @@
|
||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true; container.replaceChildren(); initTabs();
|
||||
$("stream-editing").textContent = t(editing ? "streamEditLabel" : "streamNewLabel");
|
||||
let g = group("streamEditor", "details");
|
||||
field(g, "id", "streamId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}", readOnly: Boolean(editing) });
|
||||
field(g, "name", "streamName", value.name, "text", { maxLength: 96 });
|
||||
const streamName = field(g, "name", "streamName", value.name, "text", { maxLength: 96 });
|
||||
const streamIdInput = field(g, "id", "streamId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}", readOnly: Boolean(editing) });
|
||||
window.rustProxyAutoId.attach(streamIdInput, { elements: [streamName], taken: () => (envelope?.config.streams || []).map(item => item.id) });
|
||||
const protocol = field(g, "protocol", "streamProtocol", value.protocol || "tcp", "select", { values: [["tcp", "streamTcp"], ["udp", "streamUdp"]] });
|
||||
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
||||
window.rustProxyEnableControl(enabledControl);
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
function renderReasons(counters) {
|
||||
const entries = Object.entries(counters?.reasons || {}).sort((a, b) => b[1] - a[1]);
|
||||
const target = $("transform-reason-list"); target.replaceChildren(...entries.map(([key, count]) => {
|
||||
const tag = el("span", `${key}: ${number(count)}`, "cache-reason"); tag.title = key; return tag;
|
||||
const tag = el("span", undefined, "cache-reason-line"); tag.append(el("span", key, "cache-reason-name"), el("b", number(count), "cache-reason-count")); tag.title = key; return tag;
|
||||
}));
|
||||
$("transform-reasons-empty").hidden = entries.length > 0;
|
||||
$("tp-skip-top").textContent = entries.length ? entries.slice(0, 2).map(([key, count]) => `${key}: ${number(count)}`).join(" · ") : t("transformNoSkipsShort");
|
||||
@@ -113,16 +113,12 @@
|
||||
const rows = (data?.metrics?.scopes || []).filter(row => wanted === "*" || row.vhost === wanted);
|
||||
$("transform-vhost-rows").replaceChildren(...rows.map(row => {
|
||||
const tr = el("tr");
|
||||
for (const value of [
|
||||
row.vhost,
|
||||
number(row.applied),
|
||||
number(row.cache_hits),
|
||||
number(row.skipped),
|
||||
number(row.errors),
|
||||
fmtBytes(row.saved_bytes || 0),
|
||||
percent(row),
|
||||
averageMs(row)
|
||||
]) tr.append(el("td", value));
|
||||
tr.append(el("td", row.vhost, "transform-vhost-name"));
|
||||
for (const value of [number(row.applied), number(row.cache_hits), number(row.skipped)]) tr.append(el("td", value));
|
||||
tr.append(el("td", number(row.errors), Number(row.errors) > 0 ? "transform-errors-nonzero" : "transform-zero"), el("td", fmtBytes(row.saved_bytes || 0)));
|
||||
const original = Number(row.original_bytes) || 0, share = original > 0 ? Math.max(0, Math.min(100, (Number(row.saved_bytes) || 0) / original * 100)) : 0;
|
||||
const reduction = el("td", undefined, "cache-ratio-cell"), meter = el("span", undefined, "cache-ratio-meter"), fill = el("i"); fill.style.width = `${share}%`; meter.append(fill); reduction.append(el("span", percent(row), "cache-ratio-value"), meter);
|
||||
tr.append(reduction, el("td", averageMs(row)));
|
||||
return tr;
|
||||
}));
|
||||
$("transform-vhosts-empty").hidden = rows.length > 0;
|
||||
|
||||
@@ -69,8 +69,9 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
|
||||
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
||||
let g = group("vhEditor"); const basicFields = subsection(g, "vhGeneralDetailsSection");
|
||||
field(basicFields, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(basicFields, "vhIdRenameHelp");
|
||||
let refreshCanonical = () => { };
|
||||
const vhostIdInput = field(basicFields, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(basicFields, "vhIdRenameHelp");
|
||||
let refreshCanonical = () => { }, vhostAutoId = null;
|
||||
vhostAutoId = window.rustProxyAutoId.attach(vhostIdInput, { text: () => lines(read("hosts")?.value || "")[0] || "", taken: () => (envelope?.config.vhosts || []).map(item => item.id), resettable: !editing });
|
||||
const enabledControl = field(basicFields, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
||||
window.rustProxyEnableControl(enabledControl, "vhEnableHint");
|
||||
const enabledLabel = enabledControl.closest("label");
|
||||
@@ -88,7 +89,8 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
const websocketLegacy = field(websocketFields, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
|
||||
note(websocketFields, "vhWebsocketLegacySupportHelp");
|
||||
g = group("vhFrontends");
|
||||
readHosts = hostTagEditor(g, value.frontend?.hosts || [], hosts => refreshCanonical(hosts));
|
||||
readHosts = hostTagEditor(g, value.frontend?.hosts || [], hosts => { refreshCanonical(hosts); vhostAutoId?.refresh(); });
|
||||
if (!editing) g.lastElementChild?.after(vhostIdInput.closest("label"));
|
||||
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
|
||||
note(g, "listenerDefaultPortsHint");
|
||||
field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox");
|
||||
|
||||
@@ -330,6 +330,7 @@
|
||||
$("web-service-id").value = item.id || "";
|
||||
$("web-service-id").readOnly = !!editing;
|
||||
$("web-service-name").value = item.name || "";
|
||||
window.rustProxyAutoId.attach($("web-service-id"), { elements: [$("web-service-name")], taken: () => (envelope?.config.web_services || []).map(value => value.id) });
|
||||
$("web-service-root").value = item.document_root || "";
|
||||
populateSettings(item);
|
||||
$("web-service-preset").value = "custom";
|
||||
|
||||
@@ -51,5 +51,5 @@ const events = openapi.paths["/api/events"].get;
|
||||
const logsFlag = (events.parameters || []).find(parameter => parameter.name === "logs");
|
||||
assert.equal(logsFlag?.schema?.type, "boolean");
|
||||
assert.equal(logsFlag?.schema?.default, true);
|
||||
assert.equal(openapi.info.version, "0.36.3");
|
||||
assert.equal(openapi.info.version, "0.36.4");
|
||||
console.log("Panel refresh/API contract checks: OK");
|
||||
Reference in new issue
Block a user