r/p
-
RustProxy0.26.2
+ RustProxy0.26.3
diff --git a/src/admin/web/login.js b/src/admin/web/login.js
index 294f9c8..1448487 100644
--- a/src/admin/web/login.js
+++ b/src/admin/web/login.js
@@ -5,7 +5,7 @@
const themes = new Set(["dark", "light"]);
try { const cached = localStorage.getItem("rp-theme"); document.documentElement.dataset.theme = themes.has(cached) ? cached : "dark"; } catch { document.documentElement.dataset.theme = "dark"; }
const panelPaths = new Set(["/overview", "/vhosts", "/docker-vhosts", "/redirects", "/404-hosts", "/streams", "/acl-profiles", "/configuration", "/tls", "/traffic", "/cache", "/transform", "/logging", "/logs", "/settings"]);
- const settingsTabs = new Set(["listeners", "connections", "docker-agents", "response", "cache", "transform", "headers", "forwarding", "pages", "logging", "prometheus", "administration", "tls-policy", "manual-config", "security"]);
+ const settingsTabs = new Set(["appearance", "listeners", "connections", "docker-agents", "response", "cache", "transform", "headers", "forwarding", "pages", "logging", "prometheus", "administration", "tls-policy", "manual-config", "security"]);
let packs = new Map(), fallback = null, language = null, setupRequired = false, ready = false;
try { language = localStorage.getItem("rp-language"); } catch { }
const t = key => packs.get(language)?.translations?.[key] ?? packs.get(fallback)?.translations?.[key] ?? key;
diff --git a/src/admin/web/management.js b/src/admin/web/management.js
index 1909196..a42f73f 100644
--- a/src/admin/web/management.js
+++ b/src/admin/web/management.js
@@ -39,9 +39,6 @@
bindSettingsForm("pages-form", next => { next.pages = global.read(); }, "pages");
$("settings-security-open").addEventListener("click", () => setPanel("security"));
$("settings-vhost-open").addEventListener("click", () => setPanel("vhosts"));
- const density = $("ui-density"); let chosen = "compact"; try { chosen = localStorage.getItem("rp-density") || chosen; } catch { }
- if (!["compact", "dense", "comfortable"].includes(chosen)) chosen = "compact"; density.value = chosen; document.body.dataset.density = chosen;
- density.addEventListener("change", () => { document.body.dataset.density = density.value; try { localStorage.setItem("rp-density", density.value); } catch { } });
if (envelope) render();
})();
@@ -49,7 +46,7 @@
const dialog = $("logs-dialog"); let paused = null, expanded = null, docked = false, pinning = false, preferencesLoaded = false, preferencesLoading = null, preferenceSaveRunning = false, preferenceSaveQueued = false;
const filters = ["log-vhost", "log-method", "log-status", "log-outcome", "log-filter"];
const columns = [
- { id: "time", label: "time", value: e => new Date(e.time_ms).toLocaleTimeString(locale()) },
+ { id: "time", label: "time", value: e => formatTime(e.time_ms) },
{ id: "request", label: "request", value: e => `${e.method} ${e.path}` },
{ id: "host", label: "logHost", value: e => e.host },
{ id: "source_ip", label: "sourceIp", value: e => e.client_ip || "-" },
diff --git a/src/admin/web/redirects.js b/src/admin/web/redirects.js
index eddd25e..c0f6733 100644
--- a/src/admin/web/redirects.js
+++ b/src/admin/web/redirects.js
@@ -74,6 +74,21 @@
const get = name => box.querySelector(`[data-field="${name}"]`);
return { id: get("id").value.trim(), enabled: get("enabled").checked, source_scheme: get("source_scheme").value, match_type: get("match_type").value, path_prefix: get("path_prefix").value.trim(), target_type: get("target_type").value, target: get("target").value.trim(), target_scheme: get("target_scheme").value, path_mode: get("path_mode").value, query_mode: get("query_mode").value, status_code: Number(get("status_code").value), cache_seconds: Number(get("cache_seconds").value) || 0 };
}
+ function suspendDraft() {
+ const state = {
+ editing, revision, label: $("redirect-editing").textContent, controls: window.rustProxyDrafts.captureControls(form),
+ hosts: window.rustProxyDrafts.takeChildren($("redirect-hosts-editor")), listeners: window.rustProxyDrafts.takeChildren($("redirect-listeners")), rules: window.rustProxyDrafts.takeChildren(rules),
+ readRedirectBindings, readRedirectHosts
+ };
+ const title = value("redirect-id") || editing || t("draftUntitled"); editing = null; revision = null; dirty = false; dialog.close(); return { title, state };
+ }
+ function restoreDraft(state) {
+ window.rustProxyDrafts.restoreChildren($("redirect-hosts-editor"), state.hosts); window.rustProxyDrafts.restoreChildren($("redirect-listeners"), state.listeners); window.rustProxyDrafts.restoreChildren(rules, state.rules);
+ readRedirectBindings = state.readRedirectBindings; readRedirectHosts = state.readRedirectHosts; editing = state.editing; revision = state.revision; dirty = true; $("redirect-editing").textContent = state.label;
+ window.rustProxyDrafts.restoreControls(form, state.controls); if (!dialog.open) dialog.showModal();
+ }
+ function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("redirect"); if (dialog.open) dialog.close(); return true; }
+
function collect() {
const id = value("redirect-id"), name = value("redirect-name") || id, hosts = readRedirectHosts();
const listener_ids = readRedirectBindings();
@@ -87,7 +102,7 @@
list.replaceChildren(...entries.map(item => {
const card = el("article", undefined, "vhost-card redirect-card"); const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
title.append(el("strong", item.name || item.id, "entity-title")); if (item.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); identity.append(title, el("div", (item.frontend?.hosts || []).join(" · "), "vhost-hosts")); head.append(identity);
- const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => load(structuredClone(item))), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.redirects || []).filter(value => value.id !== item.id); if (await saveSection("redirects", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head);
+ const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => { window.rustProxyDrafts.prepare(); load(structuredClone(item)); }), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.redirects || []).filter(value => value.id !== item.id); if (await saveSection("redirects", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head);
const targets = [...new Set((item.rules || []).filter(rule => rule.enabled !== false).map(rule => rule.target).filter(Boolean))];
const targetSummary = targets.slice(0, 2).join(" · ") + (targets.length > 2 ? ` · +${targets.length - 2}` : "");
const detail = el("div", undefined, "vhost-detail-strip"); for (const [k, v] of [["redirectTarget", targetSummary || "-"], ["redirectRules", String((item.rules || []).length)], ["redirectListeners", (item.frontend?.listener_ids || []).join(", ")], ["vhCertificate", item.certificate_id || t("vhCertificateDefault")]]) { const node = el("div", undefined, "vhost-detail"); node.append(el("span", t(k), "small-label"), el("b", v)); detail.append(node); } card.append(detail); return card;
@@ -100,9 +115,10 @@
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
try { const item = collect(), next = structuredClone(envelope.config.redirects || []); if (!editing && next.some(value => value.id === item.id)) throw new Error(t("redirectDuplicate")); const index = next.findIndex(value => value.id === editing); if (index < 0) next.push(item); else next[index] = item; if (await saveSection("redirects", next)) { dirty = false; editing = item.id; dialog.close(); render(); } } catch (error) { toast(error.message, true); }
});
- $("redirect-new").addEventListener("click", async () => { if (await allowDiscard()) load(); }); $("redirect-search").addEventListener("input", render); $("redirect-rule-add").addEventListener("click", () => { addRule({ id: `rule-${rules.children.length + 1}` }); changed(); });
- $("redirect-close").addEventListener("click", async () => { if (await allowDiscard()) dialog.close(); }); $("redirect-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.redirects || []).find(item => item.id === editing) || defaults()) : defaults()); });
- dialog.addEventListener("cancel", event => { if (!dirty) return; event.preventDefault(); void allowDiscard().then(ok => { if (ok) dialog.close(); }); });
+ $("redirect-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); }); $("redirect-search").addEventListener("input", render); $("redirect-rule-add").addEventListener("click", () => { addRule({ id: `rule-${rules.children.length + 1}` }); changed(); });
+ $("redirect-close").addEventListener("click", closeEditor); $("redirect-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.redirects || []).find(item => item.id === editing) || defaults()) : defaults()); });
+ dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
+ window.rustProxyDrafts.register("redirect", { labelKey: "redirects", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
window.rustProxyRedirects = { render, reset() { dialog.close(); editing = null; revision = null; dirty = false; list.replaceChildren(); rules.replaceChildren(); $("redirect-search").value = ""; } };
if (envelope) render();
})();
diff --git a/src/admin/web/settings.js b/src/admin/web/settings.js
index ff517b3..8afc002 100644
--- a/src/admin/web/settings.js
+++ b/src/admin/web/settings.js
@@ -2,6 +2,7 @@
// Persistent tab DOM: switching tabs never discards unsaved form values.
(() => {
const buttons = [...document.querySelectorAll("[data-settings-tab]")];
+ const search = $("settings-search"), searchStatus = $("settings-search-status");
const defaultTab = (typeof location !== "undefined" && location.pathname === "/configuration") ? "manual-config" : "listeners";
const requestedTab = typeof location !== "undefined" && location.pathname === "/logging"
? "logging"
@@ -9,6 +10,17 @@
const tabAliases = { http2: "connections" };
const normalizedRequestedTab = tabAliases[requestedTab] || requestedTab;
let active = buttons.some(button => button.dataset.settingsTab === normalizedRequestedTab) ? normalizedRequestedTab : defaultTab;
+ function applySettingsSearch() {
+ const query = String(search?.value || "").trim().toLocaleLowerCase();
+ const matches = [];
+ for (const button of buttons) {
+ const page = document.querySelector(`[data-settings-page="${CSS.escape(button.dataset.settingsTab)}"]`);
+ const haystack = `${button.textContent || ""} ${page?.textContent || ""}`.toLocaleLowerCase();
+ const match = !query || haystack.includes(query); button.hidden = !match; if (match) matches.push(button);
+ }
+ if (searchStatus) searchStatus.textContent = query ? `${t("settingsSearchResults")}: ${matches.length}` : "";
+ if (query && !matches.some(button => button.dataset.settingsTab === active) && matches[0]) select(matches[0].dataset.settingsTab, false, false);
+ }
function syncLocation() {
if (typeof location === "undefined" || !["/settings", "/configuration", "/logging"].includes(location.pathname) || !["http:", "https:"].includes(location.protocol)) return;
const url = new URL(location.href); const alreadyCanonical = url.pathname === "/settings" && url.searchParams.get("tab") === active; if (alreadyCanonical) return;
@@ -16,18 +28,21 @@
history.replaceState({ ...(history.state || {}), panel: "settings", settingsTab: active }, "", `${url.pathname}${url.search}${url.hash}`);
}
function select(name, focus = false, persist = true) {
- if (!buttons.some(b => b.dataset.settingsTab === name)) return; active = name;
+ const target = buttons.find(button => button.dataset.settingsTab === name); if (!target) return;
+ if (target.hidden && search?.value) { search.value = ""; applySettingsSearch(); }
+ active = name;
for (const b of buttons) { const chosen = b.dataset.settingsTab === name; b.setAttribute("aria-selected", String(chosen)); b.tabIndex = chosen ? 0 : -1; if (chosen && focus) b.focus(); }
document.querySelectorAll("[data-settings-page]").forEach(p => p.hidden = p.dataset.settingsPage !== name);
if (persist) syncLocation();
}
for (const b of buttons) {
b.addEventListener("click", () => select(b.dataset.settingsTab)); b.addEventListener("keydown", event => {
- const index = buttons.indexOf(b); let next;
- if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % buttons.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + buttons.length - 1) % buttons.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = buttons.length - 1; else return;
- event.preventDefault(); select(buttons[next].dataset.settingsTab, true);
+ const visible = buttons.filter(button => !button.hidden), index = visible.indexOf(b); let next;
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % visible.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + visible.length - 1) % visible.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = visible.length - 1; else return;
+ if (!visible.length) return; event.preventDefault(); select(visible[next].dataset.settingsTab, true);
});
}
+ search?.addEventListener("input", applySettingsSearch);
const cacheTabs = [...document.querySelectorAll("[data-cache-settings-tab]")];
function selectCacheTab(name, focus = false) {
if (!cacheTabs.some(button => button.dataset.cacheSettingsTab === name)) return;
@@ -101,7 +116,7 @@ curl --fail --show-error --request POST \
const info = el("div", undefined, "certificate-api-credential-info");
info.append(el("b", credential.key_id || "-", "mono"));
if (credential.comment) info.append(el("span", credential.comment, "fine"));
- const lastUsed = credential.last_used_at ? new Date(Number(credential.last_used_at) * 1000).toLocaleString() : t("certificateApiNeverUsed");
+ const lastUsed = credential.last_used_at ? formatDateTime(Number(credential.last_used_at) * 1000) : t("certificateApiNeverUsed");
info.append(el("span", `${t("certificateApiLastUsed")}: ${lastUsed}`, "fine"));
const remove = action(t("certificateApiRevoke"), async () => {
if (!await appConfirm(t("certificateApiRevokeOneConfirm").replace("{key}", credential.key_id), { danger: true })) return;
@@ -206,6 +221,6 @@ curl --fail --show-error --request POST \
const text = await response.text(); if (epoch !== authEpoch || !authenticated) return; $("prom-preview-output").textContent = text.slice(0, 262144); $("prom-preview-output").hidden = false;
} catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { button.disabled = false; }
});
- window.rustProxySettings = { select, selectHttpTab, syncLocation, render, current: () => active, reset() { select("listeners", false, false); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
+ window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select("listeners", false, false); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
select(active); render();
})();
diff --git a/src/admin/web/streams.js b/src/admin/web/streams.js
index c27ed17..3901df2 100644
--- a/src/admin/web/streams.js
+++ b/src/admin/web/streams.js
@@ -83,7 +83,15 @@
idle_timeout_seconds: num("idle-timeout"), max_connections: num("max-connections")
};
}
- async function closeEditor() { if (!(await allowDiscard())) return false; dialog.close(); return true; }
+ function suspendDraft() {
+ const state = { editing, revision, label: $("stream-editing").textContent, fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("stream-tabs")), readListen };
+ const title = read("id")?.value.trim() || editing || t("draftUntitled"); editing = null; revision = null; dirty = false; dialog.close(); return { title, state };
+ }
+ function restoreDraft(state) {
+ window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("stream-tabs"), state.tabs); readListen = state.readListen;
+ editing = state.editing; revision = state.revision; initialized = true; dirty = true; $("stream-editing").textContent = state.label; if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
+ }
+ function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("stream"); if (dialog.open) dialog.close(); return true; }
function render() {
if (!envelope) return;
@@ -100,7 +108,7 @@
if (stream.enabled === false) title.append(el("span", t("vhDisabled"), "pill"));
identity.append(title, el("div", stream.id, "vhost-hosts")); head.append(identity);
const actions = el("div", undefined, "entity-actions");
- actions.append(button("edit", async () => { if (await allowDiscard()) load(structuredClone(stream)); }), button("remove", async () => {
+ actions.append(button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(stream)); }), button("remove", async () => {
if (busy || !(await appConfirm(t("streamDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.streams = (next.streams || []).filter(item => item.id !== stream.id); if (await saveSection("streams", next.streams || [])) { if (editing === stream.id) load(); render(); }
}));
head.append(actions); box.append(head);
@@ -166,12 +174,13 @@
if (await saveSection("streams", next.streams || [])) { dirty = false; load(value, false); dialog.close(); render(); }
} catch (error) { toast(error.message, true); }
});
- $("stream-new").addEventListener("click", async () => { if (await allowDiscard()) load(); });
- $("stream-listener-add").addEventListener("click", async () => { if (await allowDiscard()) load(); });
+ $("stream-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); });
+ $("stream-listener-add").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); });
$("stream-search").addEventListener("input", render);
$("stream-close").addEventListener("click", closeEditor);
$("stream-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.streams || []).find(item => item.id === editing) || defaults()) : defaults()); });
- dialog.addEventListener("cancel", event => { if (!dirty) return; event.preventDefault(); void allowDiscard().then(ok => { if (ok) dialog.close(); }); });
+ dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
+ window.rustProxyDrafts.register("stream", { labelKey: "streams", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
window.rustProxyStreams = { render, renderStatus, reset() { dialog.close(); editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); container.replaceChildren(); $("stream-search").value = ""; $("stream-list").replaceChildren(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
})();
diff --git a/src/admin/web/style.css b/src/admin/web/style.css
index d48cb97..af3b0fb 100644
--- a/src/admin/web/style.css
+++ b/src/admin/web/style.css
@@ -53,6 +53,25 @@
--scrollbar-track: #151a16;
--scrollbar-thumb: #4f5d50;
--scrollbar-thumb-hover: #6b7b69;
+ --chart-accent: var(--accent);
+ --chart-secondary: var(--green);
+ --chart-tertiary: var(--blue);
+ --chart-quaternary: var(--transform-chart);
+ --chart-quinary: var(--red);
+ --chart-senary: var(--notice-text);
+ --chart-septenary: var(--border-hover);
+ --chart-octonary: var(--separator);
+ --chart-surface: var(--panel2);
+ --chart-surface-alt: var(--panel);
+ --chart-line-width: 2;
+ --chart-linecap: round;
+ --chart-linejoin: round;
+ --chart-grid-opacity: .28;
+ --chart-grid-dash: none;
+ --chart-svg-filter: none;
+ --traffic-h1: var(--chart-accent);
+ --traffic-h2: var(--chart-secondary);
+ --traffic-h3: var(--chart-tertiary);
--mono: ui-monospace, SFMono-Regular, Consolas, monospace;
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif
}
@@ -149,7 +168,8 @@ body {
color: var(--text);
font-family: var(--sans);
font-size: 14px;
- line-height: 1.5
+ line-height: 1.5;
+ zoom: var(--ui-scale, 1)
}
button,
@@ -578,11 +598,12 @@ h1 {
stroke: var(--chart-grid);
stroke-width: 1;
fill: none;
- stroke-dasharray: 2 5
+ opacity: var(--chart-grid-opacity, .55);
+ stroke-dasharray: var(--chart-grid-dash, 2 5)
}
#sparkline {
- stroke: var(--accent);
+ stroke: var(--chart-accent);
stroke-width: 2;
fill: none;
vector-effect: non-scaling-stroke
@@ -1686,7 +1707,7 @@ h3 {
#system-sparkline {
fill: none;
- stroke: var(--green);
+ stroke: var(--chart-secondary);
stroke-width: 2;
vector-effect: non-scaling-stroke
}
@@ -1801,34 +1822,37 @@ progress {
display: block;
width: 100%;
height: 60px;
- overflow: visible
+ overflow: visible;
+ filter: var(--chart-svg-filter)
}
.diagnostic-line {
fill: none;
- stroke: var(--accent);
- stroke-width: 2;
+ stroke: var(--chart-accent);
+ stroke-width: var(--chart-line-width);
+ stroke-linecap: var(--chart-linecap);
+ stroke-linejoin: var(--chart-linejoin);
vector-effect: non-scaling-stroke
}
.network-tx-line {
- stroke: var(--muted)
+ stroke: var(--chart-secondary)
}
.cache-hit-line {
- stroke: var(--green)
+ stroke: var(--chart-secondary)
}
.cache-miss-line {
- stroke: var(--accent)
+ stroke: var(--chart-accent)
}
.cache-bypass-line {
- stroke: var(--muted)
+ stroke: var(--chart-tertiary)
}
.process-rss-line {
- stroke: var(--green)
+ stroke: var(--chart-secondary)
}
.mini-chart-legend {
@@ -2797,6 +2821,9 @@ body {
--ui-card: 8px;
--ui-font: 12px;
--ui-control: 27px;
+ --button-padding: 6px 10px;
+ --button-font-size: 11px;
+ --button-min-height: var(--ui-control);
font-size: var(--ui-font)
}
@@ -2985,9 +3012,15 @@ button,
.primary,
.outline,
.quiet {
- padding: 6px 10px;
- font-size: 11px;
- min-height: var(--ui-control)
+ padding: var(--button-padding);
+ font-size: var(--button-font-size);
+ min-height: var(--button-min-height)
+}
+
+html[data-button-style="compact"] body {
+ --button-padding: 3px 7px;
+ --button-font-size: 10px;
+ --button-min-height: 22px;
}
.check {
@@ -4732,7 +4765,7 @@ dialog .window-heading {
width: 148px;
aspect-ratio: 1;
border-radius: 50%;
- background: var(--panel2);
+ background: var(--chart-surface);
box-shadow: inset 0 0 0 1px var(--line)
}
@@ -4741,7 +4774,7 @@ dialog .window-heading {
position: absolute;
inset: 23%;
border-radius: 50%;
- background: var(--panel);
+ background: var(--chart-surface-alt);
box-shadow: 0 0 0 1px var(--line)
}
@@ -4834,7 +4867,7 @@ dialog .window-heading {
.traffic-bar-track {
height: 10px;
border-radius: 999px;
- background: var(--panel2);
+ background: var(--chart-surface);
box-shadow: inset 0 0 0 1px var(--line);
overflow: hidden
}
@@ -4844,7 +4877,7 @@ dialog .window-heading {
height: 100%;
min-width: 2px;
border-radius: inherit;
- background: var(--accent)
+ background: var(--chart-accent)
}
.traffic-bar-value {
@@ -5171,7 +5204,7 @@ dialog .window-heading {
.vhost-chart {
margin-top: 9px;
border: 1px solid var(--line);
- background: var(--bg)
+ background: var(--chart-surface-alt)
}
.vhost-chart-head {
@@ -5185,13 +5218,16 @@ dialog .window-heading {
.vhost-chart svg {
display: block;
width: 100%;
- height: 54px
+ height: 54px;
+ filter: var(--chart-svg-filter)
}
.vhost-chart-line {
fill: none;
- stroke: var(--accent);
- stroke-width: 2;
+ stroke: var(--chart-accent);
+ stroke-width: var(--chart-line-width);
+ stroke-linecap: var(--chart-linecap);
+ stroke-linejoin: var(--chart-linejoin);
vector-effect: non-scaling-stroke
}
@@ -5346,16 +5382,18 @@ dialog .window-heading {
.stream-chart-rx,
.stream-chart-tx {
fill: none;
- stroke-width: 2;
+ stroke-width: var(--chart-line-width);
+ stroke-linecap: var(--chart-linecap);
+ stroke-linejoin: var(--chart-linejoin);
vector-effect: non-scaling-stroke
}
.stream-chart-rx {
- stroke: var(--accent)
+ stroke: var(--chart-accent)
}
.stream-chart-tx {
- stroke: var(--green)
+ stroke: var(--chart-secondary)
}
.stream-chart-legend {
@@ -5984,6 +6022,28 @@ dialog .window-heading {
width: 100%
}
+#panel-settings .settings-filter-row {
+ grid-column: 1 / -1;
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 12px;
+ width: 100%;
+ padding: 8px 10px;
+ border: 1px solid var(--line);
+ background: var(--panel)
+}
+
+#panel-settings .settings-filter {
+ display: grid;
+ gap: 5px;
+ width: min(520px, 100%)
+}
+
+#panel-settings .settings-filter input {
+ width: 100%
+}
+
#panel-settings .settings-tabs {
display: flex;
flex-direction: column;
@@ -6012,6 +6072,12 @@ dialog .window-heading {
margin-bottom: 10px;
}
+ #panel-settings .settings-filter-row {
+ align-items: stretch;
+ flex-direction: column;
+ margin-bottom: 10px
+ }
+
#panel-settings .settings-tabs {
display: flex;
flex-direction: row;
@@ -6339,28 +6405,28 @@ dialog .window-heading {
}
.traffic-line-total {
- stroke: var(--muted);
+ stroke: var(--chart-septenary);
stroke-width: 1.25;
opacity: .72;
}
.traffic-line-h1 {
- stroke: var(--accent);
+ stroke: var(--chart-accent);
stroke-width: 2;
}
.traffic-line-h2 {
- stroke: var(--green);
+ stroke: var(--chart-secondary);
stroke-width: 2;
}
.traffic-line-h3 {
- stroke: var(--blue);
+ stroke: var(--chart-tertiary);
stroke-width: 2;
}
.traffic-line-transform {
- stroke: var(--transform-chart);
+ stroke: var(--chart-quaternary);
stroke-width: 2.4;
stroke-dasharray: 5 3;
}
@@ -6578,21 +6644,21 @@ body[data-density="compact"] .page-footer {
}
#sparkline.traffic-line-total {
- stroke: var(--muted);
+ stroke: var(--chart-septenary);
stroke-width: 1.25;
opacity: .72;
}
#sparkline-h1.traffic-line-h1 {
- stroke: var(--accent);
+ stroke: var(--chart-accent);
}
#sparkline-h2.traffic-line-h2 {
- stroke: var(--green);
+ stroke: var(--chart-secondary);
}
#sparkline-h3.traffic-line-h3 {
- stroke: var(--blue);
+ stroke: var(--chart-tertiary);
}
.docker-open-app {
@@ -7317,23 +7383,26 @@ body[data-density="compact"] .page-footer {
.proto-h1-line {
stroke: var(--traffic-h1);
fill: none;
- stroke-width: 2;
+ stroke-width: var(--chart-line-width);
+ stroke-linecap: var(--chart-linecap);
}
.proto-h2-line {
stroke: var(--traffic-h2);
fill: none;
- stroke-width: 2;
+ stroke-width: var(--chart-line-width);
+ stroke-linecap: var(--chart-linecap);
}
.proto-h3-line {
stroke: var(--traffic-h3);
fill: none;
- stroke-width: 2;
+ stroke-width: var(--chart-line-width);
+ stroke-linecap: var(--chart-linecap);
}
.stat-chart .chart-grid {
- opacity: .28;
+ opacity: var(--chart-grid-opacity, .28);
}
.function-live-chart {
@@ -7413,26 +7482,26 @@ body[data-density="compact"] .page-footer {
.transform-applied-line,
.transform-applied-key {
- stroke: var(--transform-chart);
- color: var(--transform-chart)
+ stroke: var(--chart-quaternary);
+ color: var(--chart-quaternary)
}
.transform-cache-line,
.transform-cache-key {
- stroke: var(--green);
- color: var(--green)
+ stroke: var(--chart-secondary);
+ color: var(--chart-secondary)
}
.transform-skipped-line,
.transform-skipped-key {
- stroke: var(--muted);
- color: var(--muted)
+ stroke: var(--chart-septenary);
+ color: var(--chart-septenary)
}
.transform-error-line,
.transform-error-key {
- stroke: var(--red);
- color: var(--red)
+ stroke: var(--chart-quinary);
+ color: var(--chart-quinary)
}
.function-live-chart-footer {
@@ -7577,11 +7646,11 @@ body[data-density="compact"] .page-footer {
}
.connection-open-line {
- stroke: var(--accent);
+ stroke: var(--chart-accent);
}
.connection-exchange-line {
- stroke: var(--green);
+ stroke: var(--chart-secondary);
}
.connection-legend {
@@ -7773,24 +7842,24 @@ body[data-density="compact"] .page-footer {
.cache-hot-line,
.cache-transient-total-line {
- stroke: var(--accent);
+ stroke: var(--chart-accent);
}
.cache-fill-line {
- stroke: var(--green);
+ stroke: var(--chart-secondary);
}
.cache-queue-line,
.cache-cold-line {
- stroke: var(--blue);
+ stroke: var(--chart-tertiary);
}
.cache-io-line {
- stroke: var(--transform-chart);
+ stroke: var(--chart-quaternary);
}
.cache-transient-total-key {
- color: var(--accent);
+ color: var(--chart-accent);
}
.cache-fill-key {
@@ -8500,4 +8569,755 @@ button.config-history-day.is-selected {
.file-log-column-picker .log-column-options {
grid-template-columns: 1fr;
}
+}
+
+/* Account-scoped appearance preferences. */
+
+.appearance-settings-card {
+ max-width: 980px;
+}
+
+.appearance-section {
+ margin-top: 14px;
+ padding: 12px;
+ border: 1px solid var(--line);
+ background: var(--panel2)
+}
+
+.appearance-section-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+ padding-bottom: 9px;
+ border-bottom: 1px solid var(--line)
+}
+
+.appearance-section-heading h3 {
+ margin: 2px 0 0;
+ font-size: 14px
+}
+
+.appearance-section-reset {
+ flex: 0 0 auto
+}
+
+.appearance-settings-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+ align-items: start;
+}
+
+.appearance-settings-grid>label {
+ display: grid;
+ gap: 7px;
+}
+
+.appearance-scale-control>span:first-child {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.appearance-scale-control output {
+ color: var(--accent);
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+}
+
+.appearance-scale-control input[type="range"] {
+ width: 100%;
+}
+
+.appearance-chart-preview {
+ min-height: 118px;
+ display: grid;
+ grid-template-rows: 1fr auto;
+ gap: 6px;
+ padding: 10px;
+ border: 1px solid var(--line);
+ border-radius: 0;
+ background: var(--chart-surface);
+}
+
+.appearance-chart-preview svg {
+ width: 100%;
+ height: 78px;
+ filter: var(--chart-svg-filter)
+}
+
+.appearance-chart-preview polyline {
+ fill: none;
+ stroke-width: var(--chart-line-width);
+ stroke-linecap: var(--chart-linecap);
+ stroke-linejoin: var(--chart-linejoin);
+ vector-effect: non-scaling-stroke;
+}
+
+.appearance-chart-primary {
+ stroke: var(--chart-accent);
+}
+
+.appearance-chart-secondary {
+ stroke: var(--chart-secondary);
+}
+
+.appearance-chart-preview>span {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.chart-style-palette {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 6px;
+ margin-top: 10px
+}
+
+.chart-style-choice {
+ display: grid;
+ grid-template-columns: 42px minmax(0, 1fr);
+ align-items: center;
+ gap: 8px;
+ min-height: 34px;
+ padding: 5px 7px;
+ border: 1px solid var(--line);
+ border-radius: 0;
+ background: var(--panel);
+ color: var(--text);
+ text-align: left;
+ font-size: 10px
+}
+
+.chart-style-choice:hover {
+ border-color: var(--border-hover);
+ background: var(--nav-hover-bg)
+}
+
+.chart-style-choice.active {
+ border-color: var(--accent);
+ box-shadow: inset 3px 0 0 var(--accent)
+}
+
+.chart-style-swatches {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ height: 16px;
+ border: 1px solid var(--line)
+}
+
+.chart-style-swatches i:nth-child(1) {
+ background: var(--sw1);
+}
+
+.chart-style-swatches i:nth-child(2) {
+ background: var(--sw2);
+}
+
+.chart-style-swatches i:nth-child(3) {
+ background: var(--sw3);
+}
+
+.chart-style-swatches i:nth-child(4) {
+ background: var(--sw4);
+}
+
+.chart-style-choice[data-chart-preset="default"] {
+ --sw1: var(--accent);
+ --sw2: var(--green);
+ --sw3: var(--blue);
+ --sw4: var(--transform-chart);
+}
+
+.chart-style-choice[data-chart-preset="soft"] {
+ --sw1: #e8a998;
+ --sw2: #95c293;
+ --sw3: #8daef6;
+ --sw4: #b49de6;
+}
+
+.chart-style-choice[data-chart-preset="technical"] {
+ --sw1: #79c9ff;
+ --sw2: #9adf90;
+ --sw3: #ffd56e;
+ --sw4: #c39cff;
+}
+
+.chart-style-choice[data-chart-preset="high_contrast"] {
+ --sw1: #ffd54f;
+ --sw2: #8dff80;
+ --sw3: #6fd9ff;
+ --sw4: #ff78d7;
+}
+
+.chart-style-choice[data-chart-preset="neon"] {
+ --sw1: #2cf6ff;
+ --sw2: #7eff85;
+ --sw3: #ff5cd0;
+ --sw4: #8b7cff;
+}
+
+.chart-style-choice[data-chart-preset="monochrome"] {
+ --sw1: #f2f2f2;
+ --sw2: #b8b8b8;
+ --sw3: #858585;
+ --sw4: #595959;
+}
+
+.chart-style-choice[data-chart-preset="warm"] {
+ --sw1: #ff9c5c;
+ --sw2: #f4c46b;
+ --sw3: #ff7b72;
+ --sw4: #f4a261;
+}
+
+.chart-style-choice[data-chart-preset="cool"] {
+ --sw1: #5fb9ff;
+ --sw2: #45d3c3;
+ --sw3: #7d8fff;
+ --sw4: #70d7ff;
+}
+
+.dashboard-widget-settings {
+ display: grid;
+ gap: 5px
+}
+
+.dashboard-widget-list {
+ display: grid;
+ gap: 0;
+ margin-top: 4px;
+ border: 1px solid var(--line)
+}
+
+.dashboard-widget-setting {
+ display: grid;
+ grid-template-columns: 22px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 7px;
+ min-height: 34px;
+ padding: 4px 6px;
+ border-bottom: 1px solid var(--line);
+ background: var(--panel)
+}
+
+.dashboard-widget-setting:last-child {
+ border-bottom: 0;
+}
+
+.dashboard-widget-setting-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 11px;
+}
+
+.dashboard-widget-setting-actions {
+ display: inline-flex;
+ gap: 3px;
+}
+
+.dashboard-widget-setting-actions button {
+ min-width: 28px;
+ min-height: 26px;
+ padding: 2px 6px;
+ border-radius: 0;
+}
+
+.appearance-actions {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 14px;
+ padding-top: 12px;
+ border-top: 1px solid var(--line)
+}
+
+html[data-chart-style="default"] {
+ --chart-accent: var(--accent);
+ --chart-secondary: var(--green);
+ --chart-tertiary: var(--blue);
+ --chart-quaternary: var(--transform-chart);
+ --chart-quinary: var(--red);
+ --chart-senary: var(--notice-text);
+ --chart-septenary: var(--border-hover);
+ --chart-octonary: var(--separator);
+ --chart-surface: var(--panel2);
+ --chart-surface-alt: var(--panel);
+ --chart-line-width: 2;
+ --chart-linecap: round;
+ --chart-linejoin: round;
+ --chart-grid-opacity: .28;
+ --chart-grid-dash: 2 5;
+ --chart-svg-filter: none;
+}
+
+html[data-chart-style="soft"] {
+ --chart-accent: #e8a998;
+ --chart-secondary: #95c293;
+ --chart-tertiary: #8daef6;
+ --chart-quaternary: #b49de6;
+ --chart-quinary: #d99490;
+ --chart-senary: #d0b78e;
+ --chart-septenary: #99a790;
+ --chart-octonary: #728070;
+ --chart-surface: color-mix(in srgb, var(--panel2) 88%, white 12%);
+ --chart-surface-alt: color-mix(in srgb, var(--panel) 92%, white 8%);
+ --chart-line-width: 2.8;
+ --chart-linecap: round;
+ --chart-linejoin: round;
+ --chart-grid-opacity: .42;
+ --chart-grid-dash: 1 0;
+ --chart-svg-filter: saturate(.88) brightness(1.04);
+}
+
+html[data-chart-style="technical"] {
+ --chart-accent: #79c9ff;
+ --chart-secondary: #9adf90;
+ --chart-tertiary: #ffd56e;
+ --chart-quaternary: #c39cff;
+ --chart-quinary: #ff8c7b;
+ --chart-senary: #b8d0d7;
+ --chart-septenary: #7e9aac;
+ --chart-octonary: #6a7884;
+ --chart-surface: color-mix(in srgb, var(--panel2) 90%, #0b1a27 10%);
+ --chart-surface-alt: color-mix(in srgb, var(--panel) 90%, #09151f 10%);
+ --chart-line-width: 1.35;
+ --chart-linecap: square;
+ --chart-linejoin: miter;
+ --chart-grid-opacity: .82;
+ --chart-grid-dash: 3 4;
+ --chart-svg-filter: saturate(.76) contrast(1.08);
+}
+
+html[data-chart-style="high_contrast"] {
+ --chart-accent: #ffd54f;
+ --chart-secondary: #8dff80;
+ --chart-tertiary: #6fd9ff;
+ --chart-quaternary: #ff78d7;
+ --chart-quinary: #ff8b6f;
+ --chart-senary: #f2e48f;
+ --chart-septenary: #d7f0d9;
+ --chart-octonary: #cfcfcf;
+ --chart-surface: color-mix(in srgb, var(--panel2) 94%, black 6%);
+ --chart-surface-alt: color-mix(in srgb, var(--panel) 94%, black 6%);
+ --chart-line-width: 3;
+ --chart-linecap: round;
+ --chart-linejoin: round;
+ --chart-grid-opacity: .95;
+ --chart-grid-dash: 1 0;
+ --chart-svg-filter: saturate(1.6) contrast(1.22) brightness(1.08);
+}
+
+html[data-chart-style="neon"] {
+ --chart-accent: #2cf6ff;
+ --chart-secondary: #7eff85;
+ --chart-tertiary: #ff5cd0;
+ --chart-quaternary: #8b7cff;
+ --chart-quinary: #ffb454;
+ --chart-senary: #ffe66b;
+ --chart-septenary: #7edcff;
+ --chart-octonary: #d8b3ff;
+ --chart-surface: #0b1117;
+ --chart-surface-alt: #101720;
+ --chart-line-width: 2.45;
+ --chart-linecap: round;
+ --chart-linejoin: round;
+ --chart-grid-opacity: .62;
+ --chart-grid-dash: 2 4;
+ --chart-svg-filter: saturate(1.25) brightness(1.12);
+}
+
+html[data-chart-style="monochrome"] {
+ --chart-accent: color-mix(in srgb, var(--text) 92%, white 8%);
+ --chart-secondary: color-mix(in srgb, var(--muted) 80%, var(--text) 20%);
+ --chart-tertiary: color-mix(in srgb, var(--separator) 72%, var(--text) 28%);
+ --chart-quaternary: color-mix(in srgb, var(--line) 45%, var(--text) 55%);
+ --chart-quinary: color-mix(in srgb, var(--muted) 55%, white 45%);
+ --chart-senary: color-mix(in srgb, var(--outline-line) 58%, var(--text) 42%);
+ --chart-septenary: color-mix(in srgb, var(--field-line) 70%, var(--text) 30%);
+ --chart-octonary: color-mix(in srgb, var(--scrollbar-thumb) 75%, var(--text) 25%);
+ --chart-surface: color-mix(in srgb, var(--panel2) 96%, var(--text) 4%);
+ --chart-surface-alt: color-mix(in srgb, var(--panel) 96%, var(--text) 4%);
+ --chart-line-width: 2.1;
+ --chart-linecap: round;
+ --chart-linejoin: round;
+ --chart-grid-opacity: .4;
+ --chart-grid-dash: 2 3;
+ --chart-svg-filter: saturate(0) contrast(1.05);
+}
+
+html[data-chart-style="warm"] {
+ --chart-accent: #ff9c5c;
+ --chart-secondary: #f4c46b;
+ --chart-tertiary: #ff7b72;
+ --chart-quaternary: #f4a261;
+ --chart-quinary: #e76f51;
+ --chart-senary: #d9a15e;
+ --chart-septenary: #c9895c;
+ --chart-octonary: #b37258;
+ --chart-surface: color-mix(in srgb, var(--panel2) 88%, #3d1f10 12%);
+ --chart-surface-alt: color-mix(in srgb, var(--panel) 90%, #32180d 10%);
+ --chart-line-width: 2.4;
+ --chart-linecap: round;
+ --chart-linejoin: round;
+ --chart-grid-opacity: .36;
+ --chart-grid-dash: 1 0;
+ --chart-svg-filter: saturate(1.1) brightness(1.04);
+}
+
+html[data-chart-style="cool"] {
+ --chart-accent: #5fb9ff;
+ --chart-secondary: #45d3c3;
+ --chart-tertiary: #7d8fff;
+ --chart-quaternary: #70d7ff;
+ --chart-quinary: #54a0ff;
+ --chart-senary: #8bcbe0;
+ --chart-septenary: #6ea6bf;
+ --chart-octonary: #7d91a9;
+ --chart-surface: color-mix(in srgb, var(--panel2) 88%, #0d2740 12%);
+ --chart-surface-alt: color-mix(in srgb, var(--panel) 90%, #0d2033 10%);
+ --chart-line-width: 2.2;
+ --chart-linecap: round;
+ --chart-linejoin: round;
+ --chart-grid-opacity: .46;
+ --chart-grid-dash: 2 4;
+ --chart-svg-filter: saturate(1.08) brightness(1.03);
+}
+
+html[data-chart-style] {
+ --traffic-h1: var(--chart-accent);
+ --traffic-h2: var(--chart-secondary);
+ --traffic-h3: var(--chart-tertiary);
+}
+
+:is(.stat-chart, .activity-card, .system-card, .function-live-chart) svg,
+:is(.traffic-donut, .traffic-bar-chart span) {
+ filter: var(--chart-svg-filter);
+}
+
+html[data-dashboard-density="normal"] #panel-overview .stats-grid {
+ gap: 10px;
+}
+
+html[data-dashboard-density="normal"] #panel-overview .stat {
+ padding: 12px 13px;
+}
+
+html[data-dashboard-density="normal"] #panel-overview .stat>span {
+ font-size: 11px;
+}
+
+html[data-dashboard-density="normal"] #panel-overview .stat strong {
+ font-size: 25px;
+ margin: 5px 0 3px;
+ line-height: 1.3;
+}
+
+html[data-dashboard-density="normal"] #panel-overview .stat small {
+ font-size: 9px;
+}
+
+html[data-dashboard-density="compact"] #panel-overview .stats-grid {
+ gap: 8px;
+}
+
+html[data-dashboard-density="compact"] #panel-overview .stat {
+ padding: 10px 11px;
+}
+
+html[data-dashboard-density="compact"] #panel-overview .stat>span {
+ font-size: 10px;
+ line-height: 1.15;
+}
+
+html[data-dashboard-density="compact"] #panel-overview .stat strong {
+ font-size: 22px;
+ margin: 4px 0 2px;
+ line-height: 1.18;
+}
+
+html[data-dashboard-density="compact"] #panel-overview .stat small {
+ font-size: 8.5px;
+ line-height: 1.24;
+}
+
+html[data-dashboard-density="ultra"] #panel-overview .stats-grid {
+ gap: 6px;
+}
+
+html[data-dashboard-density="ultra"] #panel-overview .stat {
+ padding: 7px 8px;
+}
+
+html[data-dashboard-density="ultra"] #panel-overview .stat>span {
+ font-size: 9px;
+ line-height: 1.08;
+}
+
+html[data-dashboard-density="ultra"] #panel-overview .stat strong {
+ font-size: 18px;
+ margin: 2px 0 1px;
+ line-height: 1.08;
+}
+
+html[data-dashboard-density="ultra"] #panel-overview .stat small {
+ font-size: 7.5px;
+ line-height: 1.12;
+}
+
+html[data-dashboard-density="ultra"] #panel-overview .stat-chart svg {
+ height: 38px;
+}
+
+html[data-table-density="compact"] :is(table th, table td) {
+ padding-top: 6px;
+ padding-bottom: 6px;
+ font-size: 11px;
+ line-height: 1.28;
+}
+
+html[data-table-density="compact"] .file-log-field {
+ padding: 2px 6px;
+ line-height: 1.18;
+}
+
+html[data-table-density="compact"] .file-log-inline-detail pre {
+ line-height: 1.18;
+}
+
+html[data-table-density="compact"] .log-row {
+ min-height: 30px;
+}
+
+html[data-table-density="ultra"] :is(table th, table td) {
+ padding-top: 3px;
+ padding-bottom: 3px;
+ font-size: 10px;
+ line-height: 1.15;
+}
+
+html[data-table-density="ultra"] .file-log-inline-detail {
+ padding: 5px 7px;
+}
+
+html[data-table-density="ultra"] .file-log-field-grid {
+ margin-top: 2px;
+}
+
+html[data-table-density="ultra"] .file-log-field {
+ gap: 6px;
+ padding: 1px 5px;
+ font-size: 10px;
+ line-height: 1.08;
+}
+
+html[data-table-density="ultra"] .file-log-raw-json {
+ margin-top: 3px;
+}
+
+html[data-table-density="ultra"] .file-log-inline-detail pre {
+ margin-top: 3px;
+ line-height: 1.08;
+}
+
+html[data-table-density="ultra"] .log-row {
+ min-height: 24px;
+}
+
+.file-log-inline-detail {
+ padding: 8px 10px;
+}
+
+.file-log-field-grid {
+ margin-top: 5px;
+}
+
+.file-log-field {
+ gap: 9px;
+ padding: 3px 7px;
+ line-height: 1.25;
+}
+
+.file-log-raw-json {
+ margin-top: 6px;
+}
+
+.file-log-inline-detail pre {
+ max-height: 24vh;
+ margin-top: 5px;
+ line-height: 1.28;
+}
+
+@media (max-width: 760px) {
+ .appearance-settings-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .chart-style-palette {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.draft-dock {
+ position: fixed;
+ right: 14px;
+ bottom: 14px;
+ z-index: 900;
+ width: min(330px, calc(100vw - 28px));
+ padding: 0;
+ border: 1px solid var(--border-hover);
+ border-radius: 0;
+ background: var(--panel);
+ box-shadow: 0 12px 32px var(--shadow-strong);
+}
+
+.draft-dock-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-height: 34px;
+ padding: 7px 9px;
+ border-bottom: 1px solid var(--line);
+ background: var(--panel2);
+}
+
+.draft-dock.is-collapsed .draft-dock-heading {
+ border-bottom: 0
+}
+
+.draft-dock-heading>strong {
+ font: 700 10px var(--mono);
+ letter-spacing: .12em;
+ text-transform: uppercase
+}
+
+.draft-dock-heading .pill {
+ min-width: 24px;
+ padding: 2px 6px;
+ text-align: center;
+ border-radius: 0
+}
+
+.draft-dock-heading-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px
+}
+
+.draft-dock-toggle {
+ display: inline-grid;
+ place-items: center;
+ width: 26px;
+ min-width: 26px;
+ height: 24px;
+ padding: 0;
+ border: 1px solid var(--line);
+ border-radius: 0;
+ background: var(--panel);
+ color: var(--text);
+ font: 700 14px var(--mono)
+}
+
+.draft-dock-toggle:hover {
+ border-color: var(--border-hover);
+ background: var(--nav-hover-bg)
+}
+
+.draft-dock-list {
+ display: grid;
+ gap: 0;
+ max-height: min(42vh, 360px);
+ overflow: auto;
+}
+
+.draft-dock-list[hidden] {
+ display: none
+}
+
+.draft-dock-item {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 32px;
+ gap: 0;
+ align-items: stretch;
+ border-bottom: 1px solid var(--line)
+}
+
+.draft-dock-item:last-child {
+ border-bottom: 0
+}
+
+.draft-dock-open,
+.draft-dock-remove {
+ min-height: 42px;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+}
+
+.draft-dock-open {
+ min-width: 0;
+ padding: 6px 9px;
+ text-align: left;
+ border-right: 1px solid var(--line)
+}
+
+.draft-dock-open:hover,
+.draft-dock-remove:hover {
+ background: var(--nav-hover-bg);
+ color: var(--text)
+}
+
+.draft-dock-open:hover {
+ box-shadow: inset 3px 0 0 var(--accent)
+}
+
+.draft-dock-identity {
+ display: grid;
+ min-width: 0;
+ gap: 1px;
+}
+
+.draft-dock-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-width: 0
+}
+
+.draft-dock-identity small {
+ color: var(--muted);
+ font: 9px var(--mono);
+ text-transform: uppercase;
+ letter-spacing: .08em;
+}
+
+.draft-dock-identity time {
+ flex: 0 0 auto;
+ color: var(--text-subtle);
+ font: 9px var(--mono)
+}
+
+.draft-dock-identity b {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12px;
+ font-weight: 650
+}
+
+.draft-dock-remove {
+ padding: 0;
+ font-size: 18px;
+ color: var(--muted);
+}
+
+html[data-chart-style] :is(#workspace, dialog) svg polyline {
+ stroke-width: var(--chart-line-width);
+ stroke-linecap: var(--chart-linecap);
+ stroke-linejoin: var(--chart-linejoin);
}
\ No newline at end of file
diff --git a/src/admin/web/traffic-panel.js b/src/admin/web/traffic-panel.js
index 4d66f31..333262b 100644
--- a/src/admin/web/traffic-panel.js
+++ b/src/admin/web/traffic-panel.js
@@ -1,7 +1,7 @@
"use strict";
(() => {
let loading = false, timer = null, lastData = null, activeTab = "overview";
- const chartPalette = ["var(--accent)", "var(--green)", "var(--blue)", "var(--transform-chart)", "var(--red)", "var(--notice-text)", "var(--border-hover)", "var(--separator)"];
+ const chartPalette = ["var(--chart-accent)", "var(--chart-secondary)", "var(--chart-tertiary)", "var(--chart-quaternary)", "var(--chart-quinary)", "var(--chart-senary)", "var(--chart-septenary)", "var(--chart-octonary)"];
const scope = () => $("traffic-scope")?.value || "*";
const number = value => fmt(Number(value) || 0);
@@ -38,7 +38,7 @@
const total = values.reduce((sum, item) => sum + Number(item.count || 0), 0);
totalNode.textContent = number(total); if (empty) empty.hidden = total > 0;
if (!total) {
- chart.style.background = "var(--panel2)"; chart.setAttribute("aria-label", t("trafficNoData"));
+ chart.style.background = "var(--chart-surface)"; chart.setAttribute("aria-label", t("trafficNoData"));
legend.replaceChildren(el("li", t("trafficNoData"), "fine")); return;
}
const shown = values.slice(0, 7).map(item => ({ value: item.value || "-", count: Number(item.count) || 0 }));
@@ -98,7 +98,7 @@
{ value: t("trafficRequestBytes"), count: Number(stats.request_bytes) || 0 },
{ value: t("trafficResponseBytes"), count: Number(stats.response_bytes) || 0 },
], true, fmtBytes);
- $("traffic-panel-state").textContent = `${t("trafficUpdated")}: ${new Date().toLocaleTimeString(locale())}`;
+ $("traffic-panel-state").textContent = `${t("trafficUpdated")}: ${formatTime(new Date())}`;
}
async function refresh() {
@@ -113,7 +113,7 @@
document.querySelectorAll(".traffic-bar-chart").forEach(chart => chart.replaceChildren());
for (const id of ["countries", "methods", "methods-detail", "statuses", "statuses-detail", "cache", "cache-detail"]) {
const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), total = $(`traffic-chart-${id}-total`);
- if (chart) chart.style.background = "var(--panel2)"; legend?.replaceChildren(); if (total) total.textContent = "-";
+ if (chart) chart.style.background = "var(--chart-surface)"; legend?.replaceChildren(); if (total) total.textContent = "-";
}
selectTab("overview");
}
diff --git a/src/admin/web/transform-panel.js b/src/admin/web/transform-panel.js
index c9cc329..8958d81 100644
--- a/src/admin/web/transform-panel.js
+++ b/src/admin/web/transform-panel.js
@@ -142,7 +142,7 @@
$("tp-inflight").textContent = Number.isFinite(inflight) ? number(inflight) : "-";
$("tp-inflight-detail").textContent = Number.isFinite(maxInflight) ? `${t("cacheLimitShort")}: ${number(maxInflight)}` : "-";
renderTypes(counters); renderReasons(counters); renderVhosts(data);
- $("transform-panel-state").textContent = `${t("cacheUpdated")}: ${new Date().toLocaleTimeString(locale())}`;
+ $("transform-panel-state").textContent = `${t("cacheUpdated")}: ${formatTime(new Date())}`;
}
async function refresh() {
if (!authenticated || currentPanel !== "transform" || loading) return;
diff --git a/src/admin/web/vhosts/core.js b/src/admin/web/vhosts/core.js
index a0d40b7..d783cc3 100644
--- a/src/admin/web/vhosts/core.js
+++ b/src/admin/web/vhosts/core.js
@@ -132,8 +132,29 @@
note(parent, "locationHeadersHelp");
return { read: () => validateHeaders(JSON.parse(target.value)) };
}
- async function closeEditor() { if (!(await allowDiscard())) return false; draftEpoch++; form.querySelectorAll('[type="password"]').forEach(input => input.value = ""); dialog.close(); return true; }
- $("vhost-close").addEventListener("click", () => void closeEditor()); dialog.addEventListener("cancel", event => { event.preventDefault(); void closeEditor(); });
+ function suspendDraft() {
+ const state = {
+ editing, revision, label: $("vhost-editing").textContent,
+ fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("vhost-tabs")),
+ basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, pagesEditor,
+ stagedCertificates, uploading
+ };
+ const title = read("id")?.value.trim() || editing || t("draftUntitled");
+ draftEpoch++; editing = null; revision = null; dirty = false; stagedCertificates = []; uploading = false; dialog.close();
+ return { title, state };
+ }
+ function restoreDraft(state) {
+ draftEpoch++; window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("vhost-tabs"), state.tabs);
+ editing = state.editing; revision = state.revision; basicEditor = state.basicEditor; locationsReader = state.locationsReader; aclControl = state.aclControl;
+ readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; pagesEditor = state.pagesEditor;
+ stagedCertificates = state.stagedCertificates || []; uploading = state.uploading === true; initialized = true; dirty = true; $("vhost-editing").textContent = state.label;
+ if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
+ }
+ function closeEditor() {
+ if (dirty) return window.rustProxyDrafts.dock("vhost");
+ draftEpoch++; form.querySelectorAll('[type="password"]').forEach(input => input.value = ""); if (dialog.open) dialog.close(); return true;
+ }
+ $("vhost-close").addEventListener("click", closeEditor); dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
function administrationListenCandidates() {
return [
envelope?.config.administration?.listen,
diff --git a/src/admin/web/vhosts/render.js b/src/admin/web/vhosts/render.js
index fc125f5..856fc4e 100644
--- a/src/admin/web/vhosts/render.js
+++ b/src/admin/web/vhosts/render.js
@@ -119,7 +119,7 @@
openButton.disabled = !browseHost || v.enabled === false;
const quickToggle = button(v.enabled === false ? "vhQuickEnable" : "vhQuickDisable", () => toggleVhostEnabled(v), `quiet vhost-quick-toggle ${v.enabled === false ? "is-enable" : "is-disable"}`);
quickToggle.setAttribute("aria-pressed", String(v.enabled !== false));
- actions.append(quickToggle, openButton, button("edit", async () => { if (await allowDiscard()) load(structuredClone(v)); }), button("remove", async () => {
+ actions.append(quickToggle, openButton, button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(v)); }), button("remove", async () => {
if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return;
const next = structuredClone(envelope.config); next.vhosts = (next.vhosts || []).filter(item => item.id !== v.id);
if (await saveSection("vhosts", next.vhosts || [])) { if (editing === v.id) load(); render(); }
@@ -225,8 +225,8 @@
const memberTraffic = backendTrafficRate(pool.id, member, trafficSnapshot, trafficNow, seenTraffic);
const label = vhostDisabled ? t("vhDisabled") : (pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown"));
const activeLimit = Number(member.max_concurrent_requests) > 0 ? `${member.active ?? 0}/${member.max_concurrent_requests}` : `${member.active ?? 0}/∞`;
- const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${new Date(member.last_failure_at_ms).toLocaleTimeString(locale())}` : "-";
- const lastRecovery = Number(member.last_recovered_ms) > 0 ? new Date(member.last_recovered_ms).toLocaleTimeString(locale()) : "-";
+ const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${formatTime(member.last_failure_at_ms)}` : "-";
+ const lastRecovery = Number(member.last_recovered_ms) > 0 ? formatTime(member.last_recovered_ms) : "-";
const healthDetail = `${t("vhActiveHealth")}: ${member.last_result || "-"} · ${t("vhPassiveFailures")}: ${member.passive_failures ?? 0} (${member.last_passive_result || "-"})`;
const generationAgeRaw = member.upstream_client_generation_age_seconds;
const generationAge = Math.max(0, Number(generationAgeRaw));
@@ -322,7 +322,7 @@
if (saved) { load(value, [], false); dialog.close(); render(); }
} catch (error) { toast(error.message, true); }
});
- $("vhost-new").addEventListener("click", async () => { if (await allowDiscard()) load(); });
+ $("vhost-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); });
$("vhost-search").addEventListener("input", render);
$("vhost-reset").addEventListener("click", async () => {
if (!(await allowDiscard())) return;
@@ -332,5 +332,6 @@
$("pool-open").addEventListener("click", () => setPanel("vhosts"));
initPoolTableTools();
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
+ window.rustProxyDrafts.register("vhost", { labelKey: "vhosts", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
window.rustProxyVhosts = { render, renderStatus, applyPreferences: applyPoolPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; poolTablePrefs = defaultPoolTablePrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; applyPoolTableView(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
diff --git a/src/admin/web/web-server.js b/src/admin/web/web-server.js
index 4a5c847..5bb0ace 100644
--- a/src/admin/web/web-server.js
+++ b/src/admin/web/web-server.js
@@ -1,6 +1,6 @@
"use strict";
(() => {
- let editing = null;
+ let editing = null, dirty = false;
const form = $("web-service-form"), dialog = $("web-service-dialog");
const lines = value => value.split(/[\n,]+/).map(item => item.trim()).filter(Boolean);
const strictLines = value => value.split(/\n+/).map(item => item.trim()).filter(Boolean);
@@ -280,7 +280,7 @@
preset.hardening.block_common_attacks = $("web-service-hardening-attacks").checked;
preset.hardening.strip_unnecessary_headers = $("web-service-hardening-headers").checked;
populateSettings(preset);
- updatePresetSummary();
+ updatePresetSummary(); dirty = true;
toast(t("webServicePresetApplied"));
} catch (error) {
toast(error.message, true);
@@ -310,11 +310,24 @@
$("web-service-preset").value = "custom";
updatePresetSummary();
$("web-service-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
- activateTab("general");
+ activateTab("general"); dirty = false;
dialog.showModal();
}
- function close() { editing = null; form.reset(); if (dialog.open) dialog.close(); }
+ function clearEditor() { editing = null; dirty = false; form.reset(); if (dialog.open) dialog.close(); }
+ function suspendDraft() {
+ const activeTab = $("web-service-tabs").querySelector("button.active")?.dataset.tab || "general";
+ const state = { editing, activeTab, label: $("web-service-editing").textContent, controls: window.rustProxyDrafts.captureControls(form) };
+ const title = $("web-service-id").value.trim() || $("web-service-name").value.trim() || editing || t("draftUntitled");
+ editing = null; dirty = false; form.reset(); dialog.close(); return { title, state };
+ }
+ function restoreDraft(state) {
+ const saved = state.editing ? (envelope?.config.web_services || []).find(item => item.id === state.editing) : null;
+ open(saved ? structuredClone(saved) : (state.editing ? { id: state.editing } : null));
+ window.rustProxyDrafts.restoreControls(form, state.controls); editing = state.editing; $("web-service-editing").textContent = state.label;
+ syncPhp(); syncPrecompressed(); syncHtaccess(); updatePresetSummary(); activateTab(state.activeTab || "general"); dirty = true;
+ }
+ function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("web-server"); clearEditor(); return true; }
function render() {
if (!envelope) return;
@@ -327,7 +340,7 @@
title.append(el("strong", service.name || service.id, "entity-title"), el("span", t("webServer"), "pill live"));
identity.append(title, el("div", service.document_root, "vhost-hosts"));
const actions = el("div", undefined, "entity-actions");
- const edit = el("button", t("edit"), "quiet"); edit.type = "button"; edit.addEventListener("click", () => open(structuredClone(service)));
+ const edit = el("button", t("edit"), "quiet"); edit.type = "button"; edit.addEventListener("click", () => { window.rustProxyDrafts.prepare(); open(structuredClone(service)); });
const remove = el("button", t("remove"), "quiet"); remove.type = "button"; remove.addEventListener("click", async () => {
const used = usage(service.id);
if (used.length) { toast(`${t("webServiceInUse")}: ${used.join(", ")}`, true); return; }
@@ -412,13 +425,15 @@
if (duplicate) { toast(t("webServiceDuplicate"), true); return; }
const position = next.findIndex(service => service.id === editing);
if (position >= 0) next[position] = item; else next.push(item);
- if (await saveSection("web-services", next)) { close(); render(); }
+ if (await saveSection("web-services", next)) { dirty = false; clearEditor(); render(); }
} catch (error) {
toast(error.message, true);
}
});
initTabs();
+ form.addEventListener("input", () => { dirty = true; });
+ form.addEventListener("change", () => { dirty = true; });
$("web-service-php-enabled").addEventListener("change", syncPhp);
$("web-service-precompressed").addEventListener("change", syncPrecompressed);
$("web-service-htaccess-enabled").addEventListener("change", syncHtaccess);
@@ -428,11 +443,12 @@
$("htaccess-help-close").addEventListener("click", () => $("htaccess-help-dialog").close());
$("web-service-php-help").addEventListener("click", () => { const help = $("php-help-dialog"); if (!help.open) help.showModal(); });
$("php-help-close").addEventListener("click", () => $("php-help-dialog").close());
- $("web-service-new").addEventListener("click", () => open());
- $("web-service-close").addEventListener("click", close);
- $("web-service-reset").addEventListener("click", close);
+ $("web-service-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); open(); });
+ $("web-service-close").addEventListener("click", closeEditor);
+ $("web-service-reset").addEventListener("click", async () => { if (!dirty || await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") })) clearEditor(); });
$("web-service-search").addEventListener("input", render);
- dialog.addEventListener("cancel", event => { event.preventDefault(); close(); });
- window.rustProxyWebServer = { render, reset: close };
+ dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
+ window.rustProxyDrafts.register("web-server", { labelKey: "webServer", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: clearEditor });
+ window.rustProxyWebServer = { render, reset: clearEditor };
if (envelope) render();
})();
diff --git a/src/main/bootstrap.rs b/src/main/bootstrap.rs
index 27ff265..e301863 100644
--- a/src/main/bootstrap.rs
+++ b/src/main/bootstrap.rs
@@ -41,7 +41,7 @@ use zeroize::Zeroizing;
#[command(
name = "rust-proxy",
version,
- about = "RustProxy - independent Rust reverse proxy and static Web Server 0.26.2"
+ about = "RustProxy - independent Rust reverse proxy and static Web Server 0.26.3"
)]
struct Cli {
#[arg(
diff --git a/src/main/tasks.rs b/src/main/tasks.rs
index 4eb3b20..908fa81 100644
--- a/src/main/tasks.rs
+++ b/src/main/tasks.rs
@@ -239,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
}
});
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
- println!("RustProxy live logs 0.26.2 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
+ println!("RustProxy live logs 0.26.3 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
if interactive { print!("> "); io::stdout().flush()?; }
let mut filters = LiveLogFilters::default();
let mut history = std::collections::BTreeMap::::new();
diff --git a/src/web_server/fastcgi/protocol.rs b/src/web_server/fastcgi/protocol.rs
index 2ec825b..24112df 100644
--- a/src/web_server/fastcgi/protocol.rs
+++ b/src/web_server/fastcgi/protocol.rs
@@ -159,7 +159,7 @@ pub(super) fn fastcgi_params(
if context.tls { "on" } else { "off" }.into(),
);
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
- params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.26.2".into());
+ params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.26.3".into());
params.insert("REDIRECT_STATUS".into(), "200".into());
if let Some(value) = headers
.get(CONTENT_TYPE)
diff --git a/src/web_server/response/file.rs b/src/web_server/response/file.rs
index b80b3fd..fb66f79 100644
--- a/src/web_server/response/file.rs
+++ b/src/web_server/response/file.rs
@@ -185,7 +185,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec{type_label}{label}{slash}{kind}"
));
}
- html.push_str(r#"