This commit is contained in:
Mateusz Gruszczyński committed 2026-10-07 13:40:21 +02:00
1 parent 3a2eb4fe79
commit e0194fbaff
63 files changed
+1553 -379

No files matched your search

+1
View File
@@ -374,6 +374,7 @@ async fn acme_status(State(state): State<Arc<App>>) -> Json<Value> {
.unwrap_or_default();
Json(json!({
"job":state.acme.view(),
"runtime":state.acme.runtime_view(),
"providers":["http01","cloudflare","ovh","rfc2136"],
"automatic_renewal":true,
"interval_seconds":43200,
+1
View File
@@ -409,6 +409,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"/docs/V0.25.1_AUDIT.md"
)),
),
reader_doc!("docs/V0.27.1_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.27.0_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.26.7_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.26.6_IMPLEMENTATION.md", "technical"),
+113 -3
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.27.0",
"version": "0.27.1",
"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."
},
"servers": [
@@ -3170,6 +3170,101 @@
"type": "boolean",
"default": false,
"description": "Account-scoped sort direction for the Backends diagnostics table."
},
"vhost_list_sort": {
"type": "string",
"enum": [
"id",
"hostname",
"status",
"listeners",
"certificate",
"backends"
],
"default": "id",
"description": "Account-scoped sort key for the Vhosts list."
},
"vhost_list_descending": {
"type": "boolean",
"default": false,
"description": "Account-scoped sort direction for the Vhosts list."
},
"docker_vhost_sort": {
"type": "string",
"enum": [
"service",
"hostname",
"status",
"backend",
"scheme",
"published"
],
"default": "service",
"description": "Account-scoped sort key for Docker vhost service rows."
},
"docker_vhost_descending": {
"type": "boolean",
"default": false,
"description": "Account-scoped sort direction for Docker vhost service rows."
},
"redirect_list_sort": {
"type": "string",
"enum": [
"id",
"name",
"hostname",
"status",
"target",
"rules",
"listeners",
"certificate"
],
"default": "id",
"description": "Account-scoped sort key for the Redirect hosts list."
},
"redirect_list_descending": {
"type": "boolean",
"default": false,
"description": "Account-scoped sort direction for the Redirect hosts list."
},
"notfound_list_sort": {
"type": "string",
"enum": [
"id",
"name",
"hostname",
"status",
"mode",
"listeners",
"certificate"
],
"default": "id",
"description": "Account-scoped sort key for the 404 hosts list."
},
"notfound_list_descending": {
"type": "boolean",
"default": false,
"description": "Account-scoped sort direction for the 404 hosts list."
},
"stream_list_sort": {
"type": "string",
"enum": [
"name",
"id",
"protocol",
"listen",
"upstream",
"status",
"maxConnections",
"idleTimeout"
],
"default": "name",
"description": "Account-scoped sort key for the Streams list."
},
"stream_list_descending": {
"type": "boolean",
"default": false,
"description": "Account-scoped sort direction for the Streams list."
}
},
"required": [
@@ -4372,13 +4467,28 @@
"database_path": {
"type": "string",
"nullable": true
},
"certificate_runtime": {
"type": "object",
"description": "Python/pip/Certbot capability used by managed certificate automation.",
"properties": {
"status": { "type": "string", "enum": ["checking", "initializing", "ready", "unavailable"] },
"mode": { "type": "string", "enum": ["none", "managed", "system"] },
"message": { "type": "string" },
"managed_directory": { "type": "string", "nullable": true },
"python": { "type": "boolean" },
"pip": { "type": "boolean" },
"certbot": { "type": "boolean" }
},
"required": ["status", "mode", "message", "managed_directory", "python", "pip", "certbot"]
}
},
"required": [
"platform",
"statistics_source",
"data_directory",
"database_path"
"database_path",
"certificate_runtime"
]
},
"CertificateApiCredential": {
@@ -5528,7 +5638,7 @@
}
}
},
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7\u201330 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7–30 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
"security": [],
"parameters": [
{
+121
View File
@@ -30,6 +30,51 @@ const BACKEND_TABLE_COLUMNS: &[&str] = &[
"lastFailure",
"lastRecovery",
];
const VHOST_LIST_SORT_COLUMNS: &[&str] = &[
"id",
"hostname",
"status",
"listeners",
"certificate",
"backends",
];
const DOCKER_VHOST_SORT_COLUMNS: &[&str] = &[
"service",
"hostname",
"status",
"backend",
"scheme",
"published",
];
const REDIRECT_LIST_SORT_COLUMNS: &[&str] = &[
"id",
"name",
"hostname",
"status",
"target",
"rules",
"listeners",
"certificate",
];
const NOTFOUND_LIST_SORT_COLUMNS: &[&str] = &[
"id",
"name",
"hostname",
"status",
"mode",
"listeners",
"certificate",
];
const STREAM_LIST_SORT_COLUMNS: &[&str] = &[
"name",
"id",
"protocol",
"listen",
"upstream",
"status",
"maxConnections",
"idleTimeout",
];
const DASHBOARD_WIDGETS: &[&str] = &[
"requests",
"active",
@@ -98,6 +143,21 @@ fn default_backend_table_columns() -> Vec<String> {
fn default_backend_table_sort() -> String {
"vhost".to_owned()
}
fn default_vhost_list_sort() -> String {
"id".to_owned()
}
fn default_docker_vhost_sort() -> String {
"service".to_owned()
}
fn default_redirect_list_sort() -> String {
"id".to_owned()
}
fn default_notfound_list_sort() -> String {
"id".to_owned()
}
fn default_stream_list_sort() -> String {
"name".to_owned()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -131,6 +191,26 @@ struct AdminPreferences {
backend_table_sort: String,
#[serde(default)]
backend_table_descending: bool,
#[serde(default = "default_vhost_list_sort")]
vhost_list_sort: String,
#[serde(default)]
vhost_list_descending: bool,
#[serde(default = "default_docker_vhost_sort")]
docker_vhost_sort: String,
#[serde(default)]
docker_vhost_descending: bool,
#[serde(default = "default_redirect_list_sort")]
redirect_list_sort: String,
#[serde(default)]
redirect_list_descending: bool,
#[serde(default = "default_notfound_list_sort")]
notfound_list_sort: String,
#[serde(default)]
notfound_list_descending: bool,
#[serde(default = "default_stream_list_sort")]
stream_list_sort: String,
#[serde(default)]
stream_list_descending: bool,
}
impl Default for AdminPreferences {
@@ -154,6 +234,16 @@ impl Default for AdminPreferences {
backend_table_columns: default_backend_table_columns(),
backend_table_sort: default_backend_table_sort(),
backend_table_descending: false,
vhost_list_sort: default_vhost_list_sort(),
vhost_list_descending: false,
docker_vhost_sort: default_docker_vhost_sort(),
docker_vhost_descending: false,
redirect_list_sort: default_redirect_list_sort(),
redirect_list_descending: false,
notfound_list_sort: default_notfound_list_sort(),
notfound_list_descending: false,
stream_list_sort: default_stream_list_sort(),
stream_list_descending: false,
}
}
}
@@ -323,6 +413,37 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
"invalid backend table sort preference".into(),
));
}
for (value, allowed, message) in [
(
&preferences.vhost_list_sort,
VHOST_LIST_SORT_COLUMNS,
"invalid vhost list sort preference",
),
(
&preferences.docker_vhost_sort,
DOCKER_VHOST_SORT_COLUMNS,
"invalid Docker vhost sort preference",
),
(
&preferences.redirect_list_sort,
REDIRECT_LIST_SORT_COLUMNS,
"invalid redirect list sort preference",
),
(
&preferences.notfound_list_sort,
NOTFOUND_LIST_SORT_COLUMNS,
"invalid 404-host sort preference",
),
(
&preferences.stream_list_sort,
STREAM_LIST_SORT_COLUMNS,
"invalid stream list sort preference",
),
] {
if !allowed.contains(&value.as_str()) {
return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, message.into()));
}
}
Ok(())
}
+2 -1
View File
@@ -182,7 +182,8 @@ fn status_payload(state: &App) -> Value {
"platform": system.platform,
"statistics_source": system.source,
"data_directory": state.policy.data_dir.as_ref().map(|path| path.display().to_string()),
"database_path": state.policy.data_dir.as_ref().map(|path| path.join("config.sqlite3").display().to_string())
"database_path": state.policy.data_dir.as_ref().map(|path| path.join("config.sqlite3").display().to_string()),
"certificate_runtime": state.acme.runtime_view()
});
payload["system"] = json!(system);
payload["backend_pools"] = json!(snapshot.pools.iter().map(|p| p.view()).collect::<Vec<_>>());
+25 -8
View File
@@ -1,6 +1,6 @@
"use strict";
(() => {
let job = null, pending = false, loading = false, renewalLogs = {}, resetIssueFormJob = null;
let job = null, pending = false, loading = false, renewalLogs = {}, resetIssueFormJob = null, runtimeAvailable = true;
const picker = $("certificate-provider");
const secrets = ["acme-secret", "acme-cf-token", "acme-ovh-application-key", "acme-ovh-application-secret", "acme-ovh-consumer-key"];
const tabs = [...document.querySelectorAll("[data-certificate-tab]")];
@@ -44,6 +44,9 @@
clearSecrets();
syncProvider();
}
function syncSubmitState() {
$("acme-submit").disabled = pending || job?.status === "running" || !runtimeAvailable;
}
function syncProvider() {
const provider = picker.value, acme = !["import", "existing"].includes(provider);
$("certificate-acme-editor").hidden = !acme;
@@ -56,6 +59,7 @@
for (const input of node.querySelectorAll("input,select")) input.disabled = !visible;
}
$("acme-domains").placeholder = provider === "http01" ? "example.org, www.example.org" : "example.org, *.example.org";
syncSubmitState();
}
function open(provider = "http01") {
selectTab("generate"); picker.value = provider;
@@ -67,6 +71,19 @@
$("certificate-new").addEventListener("click", () => open());
const stateKey = value => ({ running: "certificateStateRunning", ready: "certificateStateReady", complete: "certificateStateComplete", failed: "certificateStateFailed" }[value?.status] || "certificateStateIdle");
function renderRuntime(value) {
const status = value?.status || "checking", mode = value?.mode || "none";
runtimeAvailable = status !== "unavailable";
$("acme-runtime-card").dataset.state = status;
$("acme-runtime-message").textContent = t(value?.message || "acme.runtime_checking");
$("acme-runtime-state").textContent = t(status === "unavailable" ? "certificateRuntimeUnavailable" : status === "ready" && mode === "managed" ? "certificateRuntimeManaged" : status === "ready" && mode === "system" ? "certificateRuntimeSystem" : "certificateRuntimePreparing");
const path = value?.managed_directory;
$("acme-runtime-path").hidden = !path;
$("acme-runtime-path").textContent = path ? `${t("certificateRuntimePath")}: ${path}` : "";
const availability = flag => t(flag === true ? "runtimeAvailable" : flag === false ? "runtimeUnavailable" : "certificateRuntimePreparing");
$("acme-runtime-components").textContent = `${t("runtimePython")}: ${availability(value?.python)} · ${t("runtimePip")}: ${availability(value?.pip)} · ${t("runtimeCertbot")}: ${availability(value?.certbot)}`;
syncSubmitState();
}
function renderOperation(cardId, messageId, stateId, logId, value, idleKey) {
const card = $(cardId), message = $(messageId), state = $(stateId), log = $(logId);
card.dataset.state = value?.status || "idle";
@@ -83,7 +100,7 @@
if (value.status === "ready") { resetIssueForm(); resetIssueFormJob = null; }
else if (value.status === "failed") resetIssueFormJob = null;
}
$("acme-submit").disabled = pending || value?.status === "running";
syncSubmitState();
const issue = value?.kind === "issue" ? value : null;
const renewal = value?.kind?.startsWith("renewal") ? value : null;
renderOperation("acme-issue-status-card", "acme-job", "acme-issue-state", "acme-log-path", issue, "acmeIdle");
@@ -131,7 +148,7 @@
if (epoch === authEpoch) toast(error.message, true);
} finally { node.disabled = false; }
});
node.disabled = job?.status === "running";
node.disabled = job?.status === "running" || !runtimeAvailable;
}
entry.append(controls); list.append(entry);
}
@@ -142,14 +159,14 @@
if (!authenticated || currentPanel !== "tls" || loading) return; loading = true; const epoch = authEpoch;
try {
const data = await api("/api/acme");
if (authenticated && epoch === authEpoch) { render(data.job); renderRenewals(data.renewals, data.log_directories); }
if (authenticated && epoch === authEpoch) { renderRuntime(data.runtime); render(data.job); renderRenewals(data.renewals, data.log_directories); }
} catch (error) {
if (epoch === authEpoch) { toast(error.message, true); if (error.status === 401) lock(); }
} finally { loading = false; }
}
$("acme-environment").addEventListener("change", () => { $("acme-confirm-production").required = $("acme-environment").value === "production"; });
$("acme-form").addEventListener("submit", async event => {
event.preventDefault(); if (!authenticated || pending || job?.status === "running") return;
event.preventDefault(); if (!authenticated || pending || job?.status === "running" || !runtimeAvailable) return;
const provider = picker.value, input = {
provider, certificate_id: $("acme-name").value.trim(), email: $("acme-email").value.trim(), domains: $("acme-domains").value.split(/[,\n]/).map(v => v.trim()).filter(Boolean), environment: $("acme-environment").value,
agree_tos: $("acme-tos").checked, confirm_production: $("acme-confirm-production").checked
@@ -168,7 +185,7 @@
} catch (error) { if (epoch === authEpoch) toast(error.message, true); }
finally {
for (const key of ["key_secret", "api_token", "application_key", "application_secret", "consumer_key"]) delete input[key];
clearSecrets(); pending = false; $("acme-submit").disabled = job?.status === "running";
clearSecrets(); pending = false; syncSubmitState();
}
});
$("acme-refresh").addEventListener("click", () => void refresh());
@@ -187,8 +204,8 @@
syncLocation,
current: () => activeTab,
reset() {
job = null; renewalLogs = {}; resetIssueFormJob = null; $("renewal-list").replaceChildren(); resetIssueForm();
render(null); selectTab("installed", false, false);
job = null; renewalLogs = {}; resetIssueFormJob = null; runtimeAvailable = true; $("renewal-list").replaceChildren(); resetIssueForm();
renderRuntime(null); render(null); selectTab("installed", false, false);
}
};
window.rustProxyCertificates = { open, close, select: selectTab, reset() { clearSecrets(); picker.value = "http01"; syncProvider(); selectTab("installed", false, false); } };
+16 -3
View File
@@ -192,8 +192,21 @@ function enhanceEnabledControl(input, hintKey = "entityEnableHint") {
}
window.rustProxyEnableControl = enhanceEnabledControl;
function action(label, fn) { const b = el("button", label, "quiet"); b.type = "button"; b.addEventListener("click", fn); return b; }
function renderAboutRuntime(status = latestStatus) {
const value = status?.runtime?.certificate_runtime || {};
const availability = flag => flag === true ? t("runtimeAvailable") : flag === false ? t("runtimeUnavailable") : t("unavailable");
const mode = value.status === "unavailable" ? t("certificateRuntimeUnavailable") : value.status === "ready" && value.mode === "managed" ? t("certificateRuntimeManaged") : value.status === "ready" && value.mode === "system" ? t("certificateRuntimeSystem") : t("certificateRuntimePreparing");
const modeNode = $("about-acme-mode"), runtimeNode = $("about-python-runtime"), details = $("about-python-details");
if (modeNode) modeNode.textContent = mode;
if (runtimeNode) runtimeNode.textContent = `${t("runtimePython")}: ${availability(value.python)} · ${t("runtimePip")}: ${availability(value.pip)} · ${t("runtimeCertbot")}: ${availability(value.certbot)}`;
if (details) {
const parts = [value.message ? t(value.message) : ""];
if (value.managed_directory) parts.push(`${t("certificateRuntimePath")}: ${value.managed_directory}`);
details.textContent = parts.filter(Boolean).join(" · ");
}
}
function renderStatus(s) {
const firstStatus = latestStatus === null; latestStatus = s; window.rustProxySettings?.render(); if (firstStatus) renderSettings();
const firstStatus = latestStatus === null; latestStatus = s; renderAboutRuntime(s); window.rustProxySettings?.render(); if (firstStatus) renderSettings();
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); const m = s.metrics;
$("metric-ws").textContent = m.websocket_active || 0;
$("metric-ws-h1").textContent = m.websocket_h1_active || 0;
@@ -334,7 +347,7 @@ function lock(notify = true) {
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
}
async function activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-menu").hidden = false; $("logs-open").hidden = false; $("file-logs-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.applyPreferences(preferences); window.rustProxyFileLogs?.applyPreferences(preferences); window.rustProxyVhosts?.applyPreferences(preferences); await loadConfig(); $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false; renderSession(); await refresh(true); startLive(); document.documentElement.classList.remove("app-loading"); }
async function activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-menu").hidden = false; $("logs-open").hidden = false; $("file-logs-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.applyPreferences(preferences); window.rustProxyFileLogs?.applyPreferences(preferences); window.rustProxyVhosts?.applyPreferences(preferences); window.rustProxyDocker?.applyPreferences(preferences); window.rustProxyRedirects?.applyPreferences(preferences); window.rustProxyNotFound?.applyPreferences(preferences); window.rustProxyStreams?.applyPreferences(preferences); await loadConfig(); $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false; renderSession(); await refresh(true); startLive(); document.documentElement.classList.remove("app-loading"); }
async function loadAudit() { if (!authenticated) return; const epoch = authEpoch; try { const data = await api("/api/audit"); if (!authenticated || epoch !== authEpoch) return; const rows = data.entries.map(item => { const tr = el("tr"); for (const value of [formatDateTime(item.time * 1000), item.actor, item.action, item.detail]) { const td = el("td", value); td.title = value; tr.append(td); } return tr; }); $("audit-rows").replaceChildren(...rows); $("empty-audit").hidden = rows.length > 0; } catch (e) { toast(e.message, true); } }
let tunnelRefreshTimer = null;
function tunnelMessage(value = "", error = false) { const node = $("tunnels-status"); if (!node) return; node.textContent = value; node.classList.toggle("error", !!error); }
@@ -371,7 +384,7 @@ function stopTunnelRefresh() { if (tunnelRefreshTimer) clearInterval(tunnelRefre
function startTunnelRefresh() { stopTunnelRefresh(); tunnelRefreshTimer = setInterval(() => { if ($("tunnels-dialog")?.open) void loadTunnels(); }, 2000); }
$("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } });
$("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } });
$("about-open").addEventListener("click", () => { const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); });
$("about-open").addEventListener("click", () => { renderAboutRuntime(); const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); });
$("about-close").addEventListener("click", () => $("about-dialog").close());
const mobileMenuToggle = $("mobile-menu-toggle"), dashboardSidebar = document.querySelector(".sidebar");
function setMobileMenu(open) {
+4
View File
@@ -136,6 +136,10 @@ function applyAdminPreferences(value, cache = true) {
};
adminPreferences = normalized;
window.rustProxyVhosts?.applyPreferences(normalized);
window.rustProxyDocker?.applyPreferences(normalized);
window.rustProxyRedirects?.applyPreferences(normalized);
window.rustProxyNotFound?.applyPreferences(normalized);
window.rustProxyStreams?.applyPreferences(normalized);
if (languagePacks.size) translate();
refreshAppearanceUi(); return normalized;
}
+23 -3
View File
@@ -1,6 +1,11 @@
"use strict";
(() => {
let observations = [], pools = [], poolRoutes = [], poolIndex = new Map(), showHidden = false;
const sortColumns = ["service", "hostname", "status", "backend", "scheme", "published"];
let observations = [], pools = [], poolRoutes = [], poolIndex = new Map(), showHidden = false, listPrefs = { sort: "service", descending: false };
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.docker_vhost_sort) ? preferences.docker_vhost_sort : "service", descending: preferences?.docker_vhost_descending === true }; }
function syncSortDirection() { const control = $("docker-vhost-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); }
function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); applyViewFilters(); }
async function saveListPrefs() { if (!authenticated) return; try { listPrefs = normalizeListPrefs(await window.rustProxyPreferences.update({ docker_vhost_sort: listPrefs.sort, docker_vhost_descending: listPrefs.descending })); if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); applyViewFilters(); } catch (error) { if (error.status !== 401) toast(error.message, true); } }
const LIVE_KEY = "rustproxy.docker.live-view";
const HIDDEN_KEY = "rustproxy.docker.hidden-vhosts";
const cfg = () => envelope?.config?.docker_agents || { agents: [] };
@@ -269,11 +274,17 @@
function assignmentRow(agent, inventory, service, assignment, stale = false) {
const key = assignment?.service_key || service.key, row = el("article", undefined, "docker-service-row"); row.dataset.key = key; row.dataset.viewKey = viewKey(agent.id, key);
const runtime = runtimeFor(agent.id, key), member = runtime?.members?.[0], technicalName = service?.technical_name || assignment?.service_key || "";
row.dockerSort = {
service: technicalName.toLowerCase(), hostname: String(legacyHostname(assignment) || technicalName).toLowerCase(),
status: stale ? 4 : assignment?.enabled === false || !assignment ? 3 : member?.status === "up" ? 0 : member?.status === "down" ? 1 : 2,
backend: String(assignment?.target_ip || agent.default_target_ip || inventory?.public_ip || "").toLowerCase(), scheme: String(assignment?.scheme || "http"), published: assignment && assignment.enabled !== false ? 0 : 1,
};
const top = el("div", undefined, "docker-service-head"), publish = input("", "checkbox", "docker-publish"); publish.checked = !!assignment && assignment.enabled !== false;
const publishLabel = el("label", undefined, "check docker-publish-field"); publishLabel.append(publish, el("span", t("dockerPublish")));
const meta = el("div", undefined, "docker-service-meta"), technical = service?.technical_name || assignment?.service_key || "-", bindings = serviceBindings(service).map(binding => `${binding.bind_ip || "*"}:${binding.public_port}`).join(", ");
meta.append(el("b", technical), el("span", stale ? t("dockerServiceMissing") : `${service.container_name} · ${service.image}`), el("span", stale ? assignment.service_key : `${service.private_port}/tcp → ${bindings || "-"}`));
const runtime = runtimeFor(agent.id, key), visibility = el("button", "", "quiet docker-view-toggle"); visibility.type = "button";
const visibility = el("button", "", "quiet docker-view-toggle"); visibility.type = "button";
const syncVisibility = () => { visibility.textContent = t(hiddenVhosts.has(row.dataset.viewKey) ? "dockerUnhideVhost" : "dockerHideVhost"); }; syncVisibility(); visibility.addEventListener("click", () => { const key = row.dataset.viewKey; if (hiddenVhosts.has(key)) hiddenVhosts.delete(key); else hiddenVhosts.add(key); saveHidden(); syncVisibility(); applyViewFilters(); });
const openApp = el("a", t("dockerOpenApp"), "outline docker-open-app"); openApp.target = "_blank"; openApp.rel = "noopener noreferrer";
const syncOpenApp = hostname => { const url = proxiedUrl(agent, hostname); openApp.href = url || "#"; openApp.hidden = !publish.checked || !url; openApp.title = url || ""; };
@@ -326,9 +337,16 @@
}
applyViewFilters();
}
function sortServiceRows(card) {
const holder = card.querySelector(".docker-service-list"); if (!holder) return;
const rows = [...holder.querySelectorAll(":scope > .docker-service-row")];
rows.sort((left, right) => { const a = left.dockerSort?.[listPrefs.sort] ?? "", b = right.dockerSort?.[listPrefs.sort] ?? ""; const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; });
for (const row of rows) holder.append(row);
}
function applyViewFilters() {
const query = ($("docker-search")?.value || "").trim().toLowerCase();
for (const card of document.querySelectorAll("#docker-discovery-list .docker-agent-card")) {
sortServiceRows(card);
const agentMatch = !!query && (card.dataset.searchText || "").includes(query);
const rows = [...card.querySelectorAll(".docker-service-row")]; let visible = 0;
for (const row of rows) {
@@ -355,10 +373,12 @@
$("docker-live-refresh")?.addEventListener("change", event => { localStorage.setItem(LIVE_KEY, event.target.checked ? "1" : "0"); if (event.target.checked) void refresh(false); });
$("docker-settings-open")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("docker-agents"); });
$("docker-search")?.addEventListener("input", applyViewFilters);
$("docker-vhost-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); applyViewFilters(); });
$("docker-vhost-sort-direction")?.addEventListener("click", () => { listPrefs.descending = !listPrefs.descending; syncSortDirection(); void saveListPrefs(); applyViewFilters(); });
$("docker-show-hidden")?.addEventListener("click", () => { showHidden = !showHidden; applyViewFilters(); });
$("docker-agent-add")?.addEventListener("click", () => { const target = $("docker-agent-settings-list"); target.querySelector(".empty")?.remove(); target.append(agentSettingsRow()); setSettingsDirty("docker-agents-form"); });
$("docker-agents-form")?.addEventListener("input", () => setSettingsDirty("docker-agents-form")); $("docker-agents-form")?.addEventListener("change", () => setSettingsDirty("docker-agents-form"));
$("docker-agents-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { if (await saveSection("docker-agents", collectSettings())) { setSettingsDirty("docker-agents-form", false); await refresh(true); } }); } catch (error) { toast(error.message, true); } });
setInterval(() => { if (authenticated && liveEnabled() && typeof currentPanel !== "undefined" && currentPanel === "docker-vhosts" && canLiveRender()) void refresh(false); }, 5000);
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); }, refresh, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); }, refresh, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
})();
+10 -3
View File
@@ -2,7 +2,12 @@
(() => {
const form = $("notfound-form"), dialog = $("notfound-dialog"), list = $("notfound-list");
if (!form || !dialog || !list) return;
let editing = null, revision = null, dirty = false, readBindings = () => [], readHosts = () => [];
const sortColumns = ["id", "name", "hostname", "status", "mode", "listeners", "certificate"];
let editing = null, revision = null, dirty = false, readBindings = () => [], readHosts = () => [], listPrefs = { sort: "id", descending: false };
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.notfound_list_sort) ? preferences.notfound_list_sort : "id", descending: preferences?.notfound_list_descending === true }; }
function syncSortDirection() { const control = $("notfound-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); }
function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("notfound-sort")) $("notfound-sort").value = listPrefs.sort; syncSortDirection(); render(); }
async function saveListPrefs() { if (!authenticated) return; try { listPrefs = normalizeListPrefs(await window.rustProxyPreferences.update({ notfound_list_sort: listPrefs.sort, notfound_list_descending: listPrefs.descending })); if ($("notfound-sort")) $("notfound-sort").value = listPrefs.sort; syncSortDirection(); } catch (error) { if (error.status !== 401) toast(error.message, true); } }
const syncEnabledControl = window.rustProxyEnableControl($("notfound-enabled"));
const ERROR_STATUSES = [400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504];
const BUILTIN_STATUSES = [401, 403, 404, 429, 503, 504];
@@ -70,6 +75,8 @@
function render() {
if (!envelope) return; const query = ($("notfound-search").value || "").trim().toLowerCase();
const entries = (envelope.config.not_found_hosts || []).filter(item => !query || [item.id, item.name, ...(item.frontend?.hosts || [])].join(" ").toLowerCase().includes(query));
const sortValue = item => ({ id: String(item.id || "").toLowerCase(), name: String(item.name || item.id || "").toLowerCase(), hostname: String(item.frontend?.hosts?.[0] || "").toLowerCase(), status: Number(item.page?.status ?? 404), mode: String(item.page?.kind || "welcome"), listeners: (item.frontend?.listener_ids || []).join(" ").toLowerCase(), certificate: String(item.certificate_id || "").toLowerCase() }[listPrefs.sort]);
entries.sort((left, right) => { const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; });
list.replaceChildren(...entries.map(item => {
const card = el("article", undefined, "vhost-card redirect-card"), 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);
@@ -80,8 +87,8 @@
}
form.addEventListener("input", changed); form.addEventListener("change", changed); $("notfound-mode").addEventListener("change", syncMode); $("notfound-status").addEventListener("change", syncMode);
form.addEventListener("submit", async event => { event.preventDefault(); if (!envelope || busy) return; const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { invalid.reportValidity(); return; } if (revision !== envelope.revision) { toast(t("stale"), true); return; } try { const item = collect(), next = structuredClone(envelope.config.not_found_hosts || []); if (!editing && next.some(value => value.id === item.id)) throw new Error(t("notfoundDuplicate")); const index = next.findIndex(value => value.id === editing); if (index < 0) next.push(item); else next[index] = item; if (await saveSection("not_found_hosts", next)) { dirty = false; editing = item.id; dialog.close(); render(); } } catch (error) { toast(error.message, true); } });
$("notfound-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); }); $("notfound-search").addEventListener("input", render); $("notfound-close").addEventListener("click", closeEditor); $("notfound-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.not_found_hosts || []).find(item => item.id === editing) || defaults()) : defaults()); }); dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
$("notfound-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); }); $("notfound-search").addEventListener("input", render); $("notfound-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); render(); }); $("notfound-sort-direction")?.addEventListener("click", () => { listPrefs.descending = !listPrefs.descending; syncSortDirection(); void saveListPrefs(); render(); }); $("notfound-close").addEventListener("click", closeEditor); $("notfound-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.not_found_hosts || []).find(item => item.id === editing) || defaults()) : defaults()); }); dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
window.rustProxyDrafts.register("notfound", { labelKey: "notfound", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
window.rustProxyNotFound = { render, reset() { dialog.close(); editing = null; revision = null; dirty = false; list.replaceChildren(); $("notfound-search").value = ""; } };
window.rustProxyNotFound = { render, applyPreferences, reset() { dialog.close(); editing = null; revision = null; dirty = false; listPrefs = { sort: "id", descending: false }; list.replaceChildren(); $("notfound-search").value = ""; if ($("notfound-sort")) $("notfound-sort").value = listPrefs.sort; syncSortDirection(); } };
if (envelope) render();
})();
+189 -49
View File
@@ -86,7 +86,9 @@
</div>
<div hidden="" id="setup-fields">
<p class="fine" data-i18n="setupHelp">Create the first administrator using a one-time local
capability.</p><code class="command">rust-proxy --data-dir DATA_DIR admin setup-token</code>
capability.</p><code class="command">rust-proxy --data-dir ./data admin setup-token</code>
<p class="fine" data-i18n="setupCommandExample">Standard service installation example:</p>
<code class="command">rust-proxy --data-dir /var/lib/rustproxy admin setup-token</code>
<label data-i18n="setupToken" for="setup-token">One-time setup token</label><input
autocomplete="off" id="setup-token" maxlength="64" minlength="64" spellcheck="false"
type="password" />
@@ -649,6 +651,17 @@
<label class="sr-only" data-i18n="vhSearch" for="vhost-search">Search vhosts</label>
<input autocomplete="off" data-i18n-aria="vhSearch"
data-i18n-placeholder="vhSearchPlaceholder" id="vhost-search" type="search" />
<label class="backend-sort-control"><span data-i18n="backendSortBy">Sort
by</span><select data-i18n-aria="backendSortBy" id="vhost-sort">
<option data-i18n="vhVhost" value="id">Vhost</option>
<option data-i18n="vhHosts" value="hostname">Hosts</option>
<option data-i18n="status" value="status">Status</option>
<option data-i18n="vhListeners" value="listeners">Listeners</option>
<option data-i18n="vhCertificate" value="certificate">Certificate</option>
<option data-i18n="vhBackendPool" value="backends">Backends</option>
</select></label>
<button class="quiet backend-sort-direction" data-i18n-aria="backendSortAscending"
id="vhost-sort-direction" type="button">↑</button>
<button class="outline" data-i18n="vhNew" id="vhost-new" type="button">New
vhost</button>
</div>
@@ -691,7 +704,18 @@
<div class="toolbar-actions"><label class="sr-only" data-i18n="dockerSearch"
for="docker-search">Search Docker vhosts</label><input autocomplete="off"
data-i18n-aria="dockerSearch" data-i18n-placeholder="dockerSearchPlaceholder"
id="docker-search" type="search" /><label class="check compact-check docker-live-switch"
id="docker-search" type="search" /><label class="backend-sort-control"><span
data-i18n="backendSortBy">Sort by</span><select data-i18n-aria="backendSortBy"
id="docker-vhost-sort">
<option data-i18n="dockerServices" value="service">Service</option>
<option data-i18n="dockerHostname" value="hostname">Hostname</option>
<option data-i18n="status" value="status">Status</option>
<option data-i18n="dockerBackendIp" value="backend">Backend host IP</option>
<option data-i18n="dockerBackendScheme" value="scheme">Backend protocol</option>
<option data-i18n="dockerPublish" value="published">Published</option>
</select></label><button class="quiet backend-sort-direction"
data-i18n-aria="backendSortAscending" id="docker-vhost-sort-direction"
type="button">↑</button><label class="check compact-check docker-live-switch"
title="Refresh this view from RustProxy memory every 5 seconds"><input
id="docker-live-refresh" type="checkbox" /><span data-i18n="dockerLiveView">Live
view</span></label><button class="outline" data-i18n="dockerRefresh"
@@ -710,15 +734,26 @@
<div class="vhost-layout redirect-layout">
<article class="card">
<div class="card-heading">
<div><span class="small-label" data-i18n="redirectLayer">HTTP REDIRECTION</span>
<h2 data-i18n="redirectList">Redirect hosts</h2>
</div>
<h2 data-i18n="redirectList">Redirect hosts</h2>
<div class="vhost-list-tools">
<label class="sr-only" data-i18n="redirectSearch" for="redirect-search">Search
redirects</label>
<input autocomplete="off" data-i18n-aria="redirectSearch"
data-i18n-placeholder="redirectSearchPlaceholder" id="redirect-search"
type="search" />
<label class="backend-sort-control"><span data-i18n="backendSortBy">Sort
by</span><select data-i18n-aria="backendSortBy" id="redirect-sort">
<option data-i18n="redirectId" value="id">Redirect ID</option>
<option data-i18n="redirectName" value="name">Display name</option>
<option data-i18n="redirectHosts" value="hostname">Source hostnames</option>
<option data-i18n="status" value="status">Status</option>
<option data-i18n="redirectTarget" value="target">Target</option>
<option data-i18n="redirectRules" value="rules">Rules</option>
<option data-i18n="redirectListeners" value="listeners">Listeners</option>
<option data-i18n="vhCertificate" value="certificate">Certificate</option>
</select></label>
<button class="quiet backend-sort-direction" data-i18n-aria="backendSortAscending"
id="redirect-sort-direction" type="button">↑</button>
<button class="outline" data-i18n="redirectNew" id="redirect-new" type="button">New
redirect</button>
</div>
@@ -734,15 +769,25 @@
<div class="vhost-layout redirect-layout">
<article class="card">
<div class="card-heading">
<div><span class="small-label" data-i18n="notfoundLayer">LOCAL RESPONSE</span>
<h2 data-i18n="notfoundList">404 hosts</h2>
</div>
<h2 data-i18n="notfoundList">404 hosts</h2>
<div class="vhost-list-tools">
<label class="sr-only" data-i18n="notfoundSearch" for="notfound-search">Search 404
hosts</label>
<input autocomplete="off" data-i18n-aria="notfoundSearch"
data-i18n-placeholder="notfoundSearchPlaceholder" id="notfound-search"
type="search" />
<label class="backend-sort-control"><span data-i18n="backendSortBy">Sort
by</span><select data-i18n-aria="backendSortBy" id="notfound-sort">
<option data-i18n="notfoundId" value="id">Host ID</option>
<option data-i18n="notfoundName" value="name">Display name</option>
<option data-i18n="notfoundHosts" value="hostname">Hostnames</option>
<option data-i18n="notfoundStatus" value="status">HTTP status</option>
<option data-i18n="notfoundMode" value="mode">Response type</option>
<option data-i18n="redirectListeners" value="listeners">Listeners</option>
<option data-i18n="vhCertificate" value="certificate">Certificate</option>
</select></label>
<button class="quiet backend-sort-direction" data-i18n-aria="backendSortAscending"
id="notfound-sort-direction" type="button">↑</button>
<button class="outline" data-i18n="notfoundNew" id="notfound-new" type="button">New
404 host</button>
</div>
@@ -768,6 +813,22 @@
<input autocomplete="off" data-i18n-aria="streamSearch"
data-i18n-placeholder="streamSearchPlaceholder" id="stream-search"
type="search" />
<label class="backend-sort-control"><span data-i18n="backendSortBy">Sort
by</span><select data-i18n-aria="backendSortBy" id="stream-sort">
<option data-i18n="streamName" value="name">Display name</option>
<option data-i18n="streamId" value="id">Stream ID</option>
<option data-i18n="streamProtocol" value="protocol">Protocol</option>
<option data-i18n="streamListen" value="listen">Listen address</option>
<option data-i18n="streamUpstream" value="upstream">Upstream address
</option>
<option data-i18n="status" value="status">Status</option>
<option data-i18n="streamMaxConnections" value="maxConnections">Max
connections</option>
<option data-i18n="streamIdleShort" value="idleTimeout">Idle timeout
</option>
</select></label>
<button class="quiet backend-sort-direction" data-i18n-aria="backendSortAscending"
id="stream-sort-direction" type="button">↑</button>
<button class="outline" data-i18n="streamNew" id="stream-new" type="button">New
stream</button>
</div>
@@ -811,11 +872,14 @@
</div>
<section class="acl-policy-builder" aria-labelledby="acl-policy-mode-label">
<div class="acl-policy-step-head">
<div><span class="acl-policy-step">1</span><div><strong id="acl-policy-mode-label"
data-i18n="aclProfilePolicyMode">Choose the base policy</strong><p
class="fine" data-i18n="aclProfilePolicyModeHelp">Start with a simple
<div><span class="acl-policy-step">1</span>
<div><strong id="acl-policy-mode-label" data-i18n="aclProfilePolicyMode">Choose
the base policy</strong>
<p class="fine" data-i18n="aclProfilePolicyModeHelp">Start with a simple
allow-list or block-list. Use custom mode only when you need mixed
allow and deny rules.</p></div></div>
allow and deny rules.</p>
</div>
</div>
</div>
<div class="acl-policy-modes" id="acl-profile-mode-options">
<label class="acl-policy-mode-card"><input checked name="acl-profile-mode"
@@ -843,28 +907,39 @@
<label><span data-i18n="vhCountryMode">Country access</span><select
id="acl-profile-country-mode">
<option data-i18n="vhOff" value="off">Off</option>
<option data-i18n="vhCountryBlock" value="block">Block listed countries</option>
<option data-i18n="vhCountryAllow" value="allow">Allow only listed countries</option>
<option data-i18n="vhCountryBlock" value="block">Block listed countries
</option>
<option data-i18n="vhCountryAllow" value="allow">Allow only listed countries
</option>
</select></label>
</div>
</section>
<section class="acl-policy-builder">
<div class="acl-policy-step-head"><div><span class="acl-policy-step">2</span><div><strong
data-i18n="aclProfileCountryExceptions">Country exceptions</strong><p
class="fine" id="acl-profile-country-help"></p></div></div><label
class="acl-policy-switch"><input id="acl-profile-country-enabled"
type="checkbox" /><span data-i18n="aclProfileUseCountries">Use countries</span></label></div>
<div class="acl-policy-step-head">
<div><span class="acl-policy-step">2</span>
<div><strong data-i18n="aclProfileCountryExceptions">Country exceptions</strong>
<p class="fine" id="acl-profile-country-help"></p>
</div>
</div><label class="acl-policy-switch"><input id="acl-profile-country-enabled"
type="checkbox" /><span data-i18n="aclProfileUseCountries">Use
countries</span></label>
</div>
<label class="acl-policy-country-list" id="acl-profile-country-field"><span
id="acl-profile-country-label" data-i18n="vhCountryList">Countries</span><textarea
id="acl-profile-countries" rows="4" maxlength="1024" spellcheck="false"
id="acl-profile-country-label"
data-i18n="vhCountryList">Countries</span><textarea id="acl-profile-countries"
rows="4" maxlength="1024" spellcheck="false"
placeholder="CN&#10;PL"></textarea></label>
</section>
<section class="acl-policy-builder">
<div class="acl-policy-step-head"><div><span class="acl-policy-step">3</span><div><strong
data-i18n="aclProfileIpExceptions">IP / CIDR exceptions</strong><p
class="fine" id="acl-profile-ip-help"></p></div></div><button class="quiet"
data-i18n="aclProfileAddIpException" id="acl-profile-rule-add" type="button">Add
IP exception</button></div>
<div class="acl-policy-step-head">
<div><span class="acl-policy-step">3</span>
<div><strong data-i18n="aclProfileIpExceptions">IP / CIDR exceptions</strong>
<p class="fine" id="acl-profile-ip-help"></p>
</div>
</div><button class="quiet" data-i18n="aclProfileAddIpException"
id="acl-profile-rule-add" type="button">Add
IP exception</button>
</div>
<div class="acl-profile-rule-rows" id="acl-profile-rule-rows"></div>
<details class="acl-profile-bulk" id="acl-profile-bulk">
<summary data-i18n="aclProfileBulkRules">Paste / import rules</summary>
@@ -878,8 +953,8 @@
</details>
</section>
<section class="acl-policy-preview" aria-live="polite">
<div class="acl-policy-preview-head"><span data-i18n="aclProfilePreview">Effective policy</span><b
id="acl-profile-preview-default"></b></div>
<div class="acl-policy-preview-head"><span data-i18n="aclProfilePreview">Effective
policy</span><b id="acl-profile-preview-default"></b></div>
<div class="acl-policy-preview-flow" id="acl-profile-preview-flow"></div>
<p class="fine" id="acl-profile-preview-note"></p>
</section>
@@ -913,6 +988,19 @@
data-certificate-tab="generate" data-i18n="certificateTabGenerate"
id="certificate-tab-generate" role="tab" tabindex="-1" type="button"></button>
</nav>
<article class="card certificate-operation-card certificate-runtime-card" id="acme-runtime-card">
<div class="certificate-operation-heading">
<div>
<span class="small-label" data-i18n="certificateRuntimeTitle">ACME runtime</span>
<strong data-i18n="acme.runtime_checking" id="acme-runtime-message"
role="status">Checking local Python environment…</strong>
</div>
<span class="pill" data-i18n="certificateRuntimePreparing"
id="acme-runtime-state">Preparing</span>
</div>
<p class="fine" hidden="" id="acme-runtime-path"></p>
<p class="fine" id="acme-runtime-components"></p>
</article>
<div class="certificate-pages">
<div aria-labelledby="certificate-tab-installed" class="certificate-page"
data-certificate-page="installed" id="certificate-page-installed" role="tabpanel">
@@ -1320,10 +1408,10 @@
</div>
</details>
<div class="stats-grid cache-stats">
<article class="stat"><span data-i18n="cacheHotObjects" id="cache-hot-objects-label">Hot objects</span><strong
id="cp-entries">-</strong><small id="cp-capacity">-</small><progress
aria-labelledby="cache-hot-objects-label" id="cp-hot-entries-meter" max="100"
value="0"></progress></article>
<article class="stat"><span data-i18n="cacheHotObjects" id="cache-hot-objects-label">Hot
objects</span><strong id="cp-entries">-</strong><small
id="cp-capacity">-</small><progress aria-labelledby="cache-hot-objects-label"
id="cp-hot-entries-meter" max="100" value="0"></progress></article>
<article class="stat"><span data-i18n="cacheHotMemory" id="cache-memory-label">Hot
memory</span><strong id="cp-bytes">-</strong><small
id="cp-memory-detail">-</small><progress aria-labelledby="cache-memory-label"
@@ -1331,13 +1419,14 @@
</article>
<article class="stat"><span data-i18n="cacheTransientMemory">Transient cache RAM</span><strong
id="cp-transient">-</strong><small id="cp-transient-detail">-</small></article>
<article class="stat"><span data-i18n="cacheColdObjects" id="cache-cold-objects-label">Cold objects</span><strong
id="cp-cold-entries">-</strong><small id="cp-cold-capacity">-</small><progress
aria-labelledby="cache-cold-objects-label" id="cp-cold-entries-meter" max="100"
value="0"></progress></article>
<article class="stat"><span data-i18n="cacheColdDisk" id="cache-cold-disk-label">Cold disk</span><strong
id="cp-cold-bytes">-</strong><small id="cp-cold-disk-detail">-</small><progress
aria-labelledby="cache-cold-disk-label" id="cp-cold-disk" max="100" value="0"></progress>
<article class="stat"><span data-i18n="cacheColdObjects" id="cache-cold-objects-label">Cold
objects</span><strong id="cp-cold-entries">-</strong><small
id="cp-cold-capacity">-</small><progress aria-labelledby="cache-cold-objects-label"
id="cp-cold-entries-meter" max="100" value="0"></progress></article>
<article class="stat"><span data-i18n="cacheColdDisk" id="cache-cold-disk-label">Cold
disk</span><strong id="cp-cold-bytes">-</strong><small
id="cp-cold-disk-detail">-</small><progress aria-labelledby="cache-cold-disk-label"
id="cp-cold-disk" max="100" value="0"></progress>
</article>
<article class="stat"><span data-i18n="cacheHitRatio">Hit ratio</span><strong
id="cp-ratio">-</strong><small id="cp-requests">-</small></article>
@@ -1921,7 +2010,8 @@
id="server-mode">
<option data-i18n="serverPreserve" value="preserve">Preserve origin value</option>
<option data-i18n="serverHide" value="hide">Hide completely</option>
<option data-i18n="serverCustom" selected value="custom">Replace / add custom value</option>
<option data-i18n="serverCustom" selected value="custom">Replace / add custom value
</option>
</select>
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
id="server-value" maxlength="256" placeholder="RustProxy" value="RustProxy"
@@ -1941,7 +2031,11 @@
<label data-i18n="compressionMin" for="compress-min">Minimum response size
(bytes)</label><input id="compress-min" max="1048576" min="32" required=""
type="number" />
<p class="fine" data-i18n="compressionHelp">Negotiates Accept-Encoding using client q-values and zstd → br → gzip → deflate as the server preference for equal quality. Cookies, Authorization, Set-Cookie, private and no-store responses may be compressed. Existing Content-Encoding, ranges, SSE, upgrades and no-transform are passed through.</p>
<p class="fine" data-i18n="compressionHelp">Negotiates Accept-Encoding using client
q-values and zstd → br → gzip → deflate as the server preference for equal quality.
Cookies, Authorization, Set-Cookie, private and no-store responses may be
compressed. Existing Content-Encoding, ranges, SSE, upgrades and no-transform are
passed through.</p>
<div class="form-actions"><button class="primary" data-i18n="saveResponse"
type="submit">Apply response settings</button></div>
</form>
@@ -2553,6 +2647,14 @@
id="runtime-data-directory">-</code></div>
<div><span class="small-label" data-i18n="databaseLocation">Database</span><code
id="runtime-database-path">-</code></div>
<div><span class="small-label" data-i18n="runtimeAcmeMode">ACME mode</span><code
id="runtime-acme-mode">-</code></div>
<div><span class="small-label" data-i18n="runtimePython">Python</span><code
id="runtime-python">-</code></div>
<div><span class="small-label" data-i18n="runtimePip">pip</span><code
id="runtime-pip">-</code></div>
<div><span class="small-label" data-i18n="runtimeCertbot">Certbot</span><code
id="runtime-certbot">-</code></div>
</div>
</article>
</div>
@@ -2721,7 +2823,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.27.0</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.27.1</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -3105,12 +3207,38 @@
this controls browser/proxy caching and does not create a second RustProxy object cache.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="delivery" hidden>
<label><span data-i18n="webServiceCompression">Dynamic compression</span><select id="web-service-compression"><option value="" data-i18n="inherit">Inherit</option><option value="on" data-i18n="enabled">Enabled</option><option value="off" data-i18n="notEnabled">Disabled</option></select><small class="fine" data-i18n="webServiceCompressionHelp">Inherits the vhost/global policy by default. Codecs can be filtered per service; globally disabled codecs stay unavailable.</small></label>
<label><span data-i18n="webServiceCompression">Dynamic compression</span><select
id="web-service-compression">
<option value="" data-i18n="inherit">Inherit</option>
<option value="on" data-i18n="enabled">Enabled</option>
<option value="off" data-i18n="notEnabled">Disabled</option>
</select><small class="fine" data-i18n="webServiceCompressionHelp">Inherits the vhost/global
policy by default. Codecs can be filtered per service; globally disabled codecs stay
unavailable.</small></label>
<div class="vh-wide vh-grid" id="web-service-compression-options">
<label><span data-i18n="compressionZstd">Zstandard (zstd)</span><select id="web-service-compression-zstd"><option value="" data-i18n="inherit">Inherit</option><option value="on" data-i18n="enabled">Enabled</option><option value="off" data-i18n="notEnabled">Disabled</option></select></label>
<label><span data-i18n="compressionBr">Brotli (br)</span><select id="web-service-compression-br"><option value="" data-i18n="inherit">Inherit</option><option value="on" data-i18n="enabled">Enabled</option><option value="off" data-i18n="notEnabled">Disabled</option></select></label>
<label><span data-i18n="compressionGzip">gzip</span><select id="web-service-compression-gzip"><option value="" data-i18n="inherit">Inherit</option><option value="on" data-i18n="enabled">Enabled</option><option value="off" data-i18n="notEnabled">Disabled</option></select></label>
<label><span data-i18n="compressionDeflate">deflate</span><select id="web-service-compression-deflate"><option value="" data-i18n="inherit">Inherit</option><option value="on" data-i18n="enabled">Enabled</option><option value="off" data-i18n="notEnabled">Disabled</option></select></label>
<label><span data-i18n="compressionZstd">Zstandard (zstd)</span><select
id="web-service-compression-zstd">
<option value="" data-i18n="inherit">Inherit</option>
<option value="on" data-i18n="enabled">Enabled</option>
<option value="off" data-i18n="notEnabled">Disabled</option>
</select></label>
<label><span data-i18n="compressionBr">Brotli (br)</span><select
id="web-service-compression-br">
<option value="" data-i18n="inherit">Inherit</option>
<option value="on" data-i18n="enabled">Enabled</option>
<option value="off" data-i18n="notEnabled">Disabled</option>
</select></label>
<label><span data-i18n="compressionGzip">gzip</span><select id="web-service-compression-gzip">
<option value="" data-i18n="inherit">Inherit</option>
<option value="on" data-i18n="enabled">Enabled</option>
<option value="off" data-i18n="notEnabled">Disabled</option>
</select></label>
<label><span data-i18n="compressionDeflate">deflate</span><select
id="web-service-compression-deflate">
<option value="" data-i18n="inherit">Inherit</option>
<option value="on" data-i18n="enabled">Enabled</option>
<option value="off" data-i18n="notEnabled">Disabled</option>
</select></label>
</div>
<label class="check"><input id="web-service-precompressed" type="checkbox" /><span
data-i18n="webServicePrecompressed">Serve precompressed sidecars</span></label>
@@ -3439,7 +3567,7 @@ max_execution_time = 30</code></pre>
<article class="card about-card">
<div class="about-product">
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
<div><span class="small-label">RustProxy</span><strong>0.27.0</strong></div>
<div><span class="small-label">RustProxy</span><strong>0.27.1</strong></div>
</div>
<dl class="about-meta">
<div>
@@ -3450,12 +3578,24 @@ max_execution_time = 30</code></pre>
<dt data-i18n="aboutLicense">License</dt>
<dd data-i18n="aboutLicenseValue">Open Source</dd>
</div>
<div>
<dt data-i18n="runtimeAcmeMode">ACME mode</dt>
<dd id="about-acme-mode">-</dd>
</div>
<div>
<dt data-i18n="aboutPythonRuntime">Certificate Python runtime</dt>
<dd id="about-python-runtime">-</dd>
</div>
</dl>
<p data-i18n="aboutOpenSourceText">RustProxy is an open-source project. Its source code is publicly
available for inspection, development and community contribution.</p>
<p class="about-python-details" id="about-python-details"></p>
<a class="outline about-repository" href="https://git.linuxiarz.pl/gru/RustProxy" rel="noopener noreferrer"
target="_blank"><span data-i18n="aboutRepository">Source
target="_blank"><span data-i18n="aboutRepository">Official source
repository</span><span>git.linuxiarz.pl/gru/RustProxy</span></a>
<a class="outline about-repository" href="https://rustproxy.linuxiarz.pl/" rel="noopener noreferrer"
target="_blank"><span data-i18n="aboutPackagesRepository">Official package
repository</span><span>rustproxy.linuxiarz.pl</span></a>
</article>
</dialog>
<div aria-live="polite" class="toast" hidden="" id="toast" popover="manual" role="status"></div>
+2 -1
View File
@@ -20,8 +20,9 @@
"password",
"passwordMismatch",
"setupHelp",
"setupCommandExample",
"setupTitle",
"setupToken",
"signIn",
"username"
]
]
+3 -1
View File
@@ -23,7 +23,9 @@
</div>
<div hidden="" id="setup-fields">
<p class="fine" data-i18n="setupHelp">Create the first administrator using a one-time local
capability.</p><code class="command">rust-proxy --data-dir DATA_DIR admin setup-token</code>
capability.</p><code class="command">rust-proxy --data-dir ./data admin setup-token</code>
<p class="fine" data-i18n="setupCommandExample">Standard service installation example:</p>
<code class="command">rust-proxy --data-dir /var/lib/rustproxy admin setup-token</code>
<label data-i18n="setupToken" for="setup-token">One-time setup token</label><input
autocomplete="off" id="setup-token" maxlength="64" minlength="64" spellcheck="false"
type="password" />
+19 -3
View File
@@ -2,7 +2,12 @@
(() => {
const form = $("redirect-form"), dialog = $("redirect-dialog"), list = $("redirect-list"), rules = $("redirect-rules");
if (!form || !dialog || !list || !rules) return;
let editing = null, revision = null, dirty = false, readRedirectBindings = () => [], readRedirectHosts = () => [];
const sortColumns = ["id", "name", "hostname", "status", "target", "rules", "listeners", "certificate"];
let editing = null, revision = null, dirty = false, readRedirectBindings = () => [], readRedirectHosts = () => [], listPrefs = { sort: "id", descending: false };
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.redirect_list_sort) ? preferences.redirect_list_sort : "id", descending: preferences?.redirect_list_descending === true }; }
function syncSortDirection() { const control = $("redirect-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); }
function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("redirect-sort")) $("redirect-sort").value = listPrefs.sort; syncSortDirection(); render(); }
async function saveListPrefs() { if (!authenticated) return; try { listPrefs = normalizeListPrefs(await window.rustProxyPreferences.update({ redirect_list_sort: listPrefs.sort, redirect_list_descending: listPrefs.descending })); if ($("redirect-sort")) $("redirect-sort").value = listPrefs.sort; syncSortDirection(); } catch (error) { if (error.status !== 401) toast(error.message, true); } }
const syncEnabledControl = window.rustProxyEnableControl($("redirect-enabled"));
const yes = id => $(id).checked;
const value = id => $(id).value.trim();
@@ -100,6 +105,17 @@
function render() {
if (!envelope) return; const query = ($("redirect-search").value || "").trim().toLowerCase();
const entries = (envelope.config.redirects || []).filter(item => !query || [item.id, item.name, ...(item.frontend?.hosts || []), ...(item.rules || []).map(rule => rule.target)].join(" ").toLowerCase().includes(query));
const sortValue = item => ({
id: String(item.id || "").toLowerCase(),
name: String(item.name || item.id || "").toLowerCase(),
hostname: String(item.frontend?.hosts?.[0] || "").toLowerCase(),
status: item.enabled === false ? 1 : 0,
target: (item.rules || []).filter(rule => rule.enabled !== false).map(rule => rule.target || "").join(" ").toLowerCase(),
rules: Number(item.rules?.length || 0),
listeners: (item.frontend?.listener_ids || []).join(" ").toLowerCase(),
certificate: String(item.certificate_id || "").toLowerCase(),
}[listPrefs.sort]);
entries.sort((left, right) => { const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; });
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);
@@ -116,10 +132,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", () => { window.rustProxyDrafts.prepare(); load(); }); $("redirect-search").addEventListener("input", render); $("redirect-rule-add").addEventListener("click", () => { addRule({ id: `rule-${rules.children.length + 1}` }); changed(); });
$("redirect-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); }); $("redirect-search").addEventListener("input", render); $("redirect-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); render(); }); $("redirect-sort-direction")?.addEventListener("click", () => { listPrefs.descending = !listPrefs.descending; syncSortDirection(); void saveListPrefs(); 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 = ""; } };
window.rustProxyRedirects = { render, applyPreferences, reset() { dialog.close(); editing = null; revision = null; dirty = false; listPrefs = { sort: "id", descending: false }; list.replaceChildren(); rules.replaceChildren(); $("redirect-search").value = ""; if ($("redirect-sort")) $("redirect-sort").value = listPrefs.sort; syncSortDirection(); } };
if (envelope) render();
})();
+4
View File
@@ -173,6 +173,10 @@ curl --fail --show-error --request POST \
for (const [id, key] of [["runtime-platform", "platform"], ["runtime-statistics-source", "statistics_source"], ["runtime-data-directory", "data_directory"], ["runtime-database-path", "database_path"]]) {
const node = $(id); if (node) node.textContent = runtime[key] || t("unavailable");
}
const certificateRuntime = runtime.certificate_runtime || {};
const availability = flag => flag === true ? t("runtimeAvailable") : flag === false ? t("runtimeUnavailable") : t("unavailable");
const modeLabel = certificateRuntime.status === "unavailable" ? t("certificateRuntimeUnavailable") : certificateRuntime.status === "ready" && certificateRuntime.mode === "managed" ? t("certificateRuntimeManaged") : certificateRuntime.status === "ready" && certificateRuntime.mode === "system" ? t("certificateRuntimeSystem") : t("certificateRuntimePreparing");
for (const [id, text] of [["runtime-acme-mode", modeLabel], ["runtime-python", availability(certificateRuntime.python)], ["runtime-pip", availability(certificateRuntime.pip)], ["runtime-certbot", availability(certificateRuntime.certbot)]]) { const node = $(id); if (node) node.textContent = text; }
const policy = envelope.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
if (!settingsDirty.has("tls-policy-form")) { $("tls-policy-12").checked = policy.tls12 !== false; $("tls-policy-13").checked = policy.tls13 !== false; $("tls-policy-ciphers").value = (policy.cipher_suites || []).join("\n"); }
+15 -2
View File
@@ -2,8 +2,13 @@
// Layer-4 stream management mirrors the vhost workflow while keeping TCP/UDP
// listeners separate from the HTTP policy stack.
(() => {
let editing = null, revision = null, dirty = false, initialized = false;
const sortColumns = ["name", "id", "protocol", "listen", "upstream", "status", "maxConnections", "idleTimeout"];
let editing = null, revision = null, dirty = false, initialized = false, listPrefs = { sort: "name", descending: false };
const chartHistory = new Map();
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.stream_list_sort) ? preferences.stream_list_sort : "name", descending: preferences?.stream_list_descending === true }; }
function syncSortDirection() { const control = $("stream-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); }
function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("stream-sort")) $("stream-sort").value = listPrefs.sort; syncSortDirection(); render(); }
async function saveListPrefs() { if (!authenticated) return; try { listPrefs = normalizeListPrefs(await window.rustProxyPreferences.update({ stream_list_sort: listPrefs.sort, stream_list_descending: listPrefs.descending })); if ($("stream-sort")) $("stream-sort").value = listPrefs.sort; syncSortDirection(); } catch (error) { if (error.status !== 401) toast(error.message, true); } }
const dialog = $("stream-dialog"), form = $("stream-form"), container = $("stream-fields");
const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
const button = (key, callback, cls = "quiet") => { const node = text("button", key, cls); node.type = "button"; node.addEventListener("click", callback); return node; };
@@ -102,6 +107,12 @@
for (const id of chartHistory.keys()) if (!configuredIds.has(id)) chartHistory.delete(id);
const query = ($("stream-search")?.value || "").trim().toLowerCase();
const streams = configured.filter(stream => !query || [stream.id, stream.name, stream.protocol, stream.listen, stream.upstream].filter(Boolean).join(" ").toLowerCase().includes(query));
const active = new Map((latestStatus?.streams?.active || []).map(item => [item.id, item]));
const sortValue = stream => {
const live = active.get(stream.id), same = live && JSON.stringify(live) === JSON.stringify(stream);
return ({ name: String(stream.name || stream.id || "").toLowerCase(), id: String(stream.id || "").toLowerCase(), protocol: String(stream.protocol || "tcp"), listen: String(stream.listen || "").toLowerCase(), upstream: String(stream.upstream || "").toLowerCase(), status: stream.enabled === false ? 2 : same ? 0 : 1, maxConnections: Number(stream.max_connections || 0), idleTimeout: Number(stream.idle_timeout_seconds || 0) })[listPrefs.sort];
};
streams.sort((left, right) => { const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; });
const cards = streams.map(stream => {
const box = el("article", undefined, "vhost-card stream-card"); box.dataset.streamCard = stream.id;
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
@@ -178,10 +189,12 @@
$("stream-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); });
$("stream-listener-add").addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("listeners"); });
$("stream-search").addEventListener("input", render);
$("stream-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); render(); });
$("stream-sort-direction")?.addEventListener("click", () => { listPrefs.descending = !listPrefs.descending; syncSortDirection(); void saveListPrefs(); 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 => { 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(); } };
window.rustProxyStreams = { render, renderStatus, applyPreferences, reset() { dialog.close(); editing = null; revision = null; dirty = false; initialized = false; listPrefs = { sort: "name", descending: false }; chartHistory.clear(); container.replaceChildren(); $("stream-search").value = ""; $("stream-list").replaceChildren(); if ($("stream-sort")) $("stream-sort").value = listPrefs.sort; syncSortDirection(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
})();
+21 -9
View File
@@ -4966,19 +4966,24 @@ dialog .window-heading {
}
#panel-vhosts .card-heading,
#panel-web-server .card-heading {
#panel-web-server .card-heading,
#panel-redirects .card-heading,
#panel-notfound .card-heading {
flex-wrap: wrap
}
.vhost-list-tools {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-left: auto
}
#vhost-search,
#web-service-search {
#web-service-search,
#redirect-search,
#notfound-search {
width: min(260px, 32vw);
min-width: 180px
}
@@ -5108,7 +5113,9 @@ dialog .window-heading {
flex-direction: column
}
#panel-vhosts .card-heading {
#panel-vhosts .card-heading,
#panel-redirects .card-heading,
#panel-notfound .card-heading {
align-items: stretch;
flex-direction: column
}
@@ -5118,7 +5125,9 @@ dialog .window-heading {
margin-left: 0
}
#vhost-search {
#vhost-search,
#redirect-search,
#notfound-search {
flex: 1 1 auto;
width: auto;
min-width: 0
@@ -7140,7 +7149,8 @@ body[data-density="compact"] .page-footer {
border-left-color: var(--green)
}
#panel-tls .certificate-operation-card[data-state="failed"] {
#panel-tls .certificate-operation-card[data-state="failed"],
#panel-tls .certificate-operation-card[data-state="unavailable"] {
border-left-color: var(--red)
}
@@ -7162,7 +7172,9 @@ body[data-density="compact"] .page-footer {
}
#panel-tls .certificate-operation-card[data-state="failed"] .certificate-operation-heading strong,
#panel-tls .certificate-operation-card[data-state="failed"] .pill {
#panel-tls .certificate-operation-card[data-state="failed"] .pill,
#panel-tls .certificate-operation-card[data-state="unavailable"] .certificate-operation-heading strong,
#panel-tls .certificate-operation-card[data-state="unavailable"] .pill {
color: var(--red)
}
@@ -8596,8 +8608,6 @@ button.config-history-day.is-selected {
}
}
/* Account-scoped appearance preferences. */
.appearance-settings-card {
max-width: 980px;
}
@@ -9612,6 +9622,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
}
@media(max-width:900px) {
.acl-profile-layout,
.acl-policy-modes,
.acl-policy-custom-controls {
@@ -9624,6 +9635,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
}
@media(max-width:640px) {
.acl-policy-step-head,
.acl-policy-preview-head {
align-items: stretch;
@@ -9634,4 +9646,4 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
.acl-policy-step-head>button {
width: 100%
}
}
}
+38 -5
View File
@@ -1,6 +1,8 @@
const poolColumns = ["vhost", "backend", "status", "active", "requests", "errors", "latency", "traffic", "generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"];
const vhostSortColumns = ["id", "hostname", "status", "listeners", "certificate", "backends"];
const defaultPoolTablePrefs = () => ({ sort: "vhost", descending: false, columns: [...poolColumns] });
let poolTablePrefs = defaultPoolTablePrefs(), poolPreferenceSaveRunning = false, poolPreferenceSaveQueued = false;
const defaultVhostListPrefs = () => ({ sort: "id", descending: false });
let poolTablePrefs = defaultPoolTablePrefs(), vhostListPrefs = defaultVhostListPrefs(), poolPreferenceSaveRunning = false, poolPreferenceSaveQueued = false;
function normalizePoolTablePrefs(preferences) {
const selected = Array.isArray(preferences?.backend_table_columns) ? preferences.backend_table_columns.filter(key => poolColumns.includes(key)) : [];
return {
@@ -9,10 +11,27 @@
columns: selected.length ? selected : [...poolColumns],
};
}
function applyPoolPreferences(preferences) {
function normalizeVhostListPrefs(preferences) {
return { sort: vhostSortColumns.includes(preferences?.vhost_list_sort) ? preferences.vhost_list_sort : "id", descending: preferences?.vhost_list_descending === true };
}
function syncVhostSortDirection() {
const control = $("vhost-sort-direction"); if (!control) return;
control.textContent = vhostListPrefs.descending ? "↓" : "↑";
control.setAttribute("aria-label", t(vhostListPrefs.descending ? "backendSortDescending" : "backendSortAscending"));
}
function applyPreferences(preferences) {
poolTablePrefs = normalizePoolTablePrefs(preferences);
const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort;
syncPoolColumns(); syncPoolSortDirection(); applyPoolTableView();
vhostListPrefs = normalizeVhostListPrefs(preferences);
const poolSort = $("pool-sort"); if (poolSort) poolSort.value = poolTablePrefs.sort;
const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort;
syncPoolColumns(); syncPoolSortDirection(); syncVhostSortDirection(); applyPoolTableView(); render();
}
async function saveVhostListPrefs() {
if (!authenticated) return;
try {
const saved = await window.rustProxyPreferences.update({ vhost_list_sort: vhostListPrefs.sort, vhost_list_descending: vhostListPrefs.descending });
vhostListPrefs = normalizeVhostListPrefs(saved); const sort = $("vhost-sort"); if (sort) sort.value = vhostListPrefs.sort; syncVhostSortDirection();
} catch (error) { if (error.status !== 401) toast(error.message, true); }
}
async function savePoolTablePrefs() {
if (!authenticated) return;
@@ -103,6 +122,18 @@
].filter(Boolean).join(" ").toLowerCase();
return haystack.includes(query);
});
const sortValue = v => ({
id: String(v.id || "").toLowerCase(),
hostname: String(v.frontend?.hosts?.[0] || "").toLowerCase(),
status: v.enabled === false ? 1 : 0,
listeners: (v.frontend?.listener_ids || []).join(" ").toLowerCase(),
certificate: String(v.certificate_id || "").toLowerCase(),
backends: Number(v.backends?.length || 0),
}[vhostListPrefs.sort]);
visibleVhosts.sort((left, right) => {
const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" });
return vhostListPrefs.descending ? -result : result;
});
const cards = visibleVhosts.map(v => {
const box = el("article", undefined, "vhost-card"); box.dataset.vhostCard = v.id;
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity");
@@ -324,6 +355,8 @@
});
$("vhost-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); });
$("vhost-search").addEventListener("input", render);
$("vhost-sort")?.addEventListener("change", event => { vhostListPrefs.sort = event.target.value; void saveVhostListPrefs(); render(); });
$("vhost-sort-direction")?.addEventListener("click", () => { vhostListPrefs.descending = !vhostListPrefs.descending; syncVhostSortDirection(); void saveVhostListPrefs(); render(); });
$("vhost-reset").addEventListener("click", async () => {
if (!(await allowDiscard())) return;
const saved = editing ? (envelope?.config.vhosts || []).find(item => item.id === editing) : null;
@@ -333,5 +366,5 @@
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(); } };
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); 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; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);