v0.27.1
This commit is contained in:
1 parent
3a2eb4fe79
commit
e0194fbaff
63 files changed
+1553
-379
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
@@ -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); } };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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(); } };
|
||||
})();
|
||||
@@ -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
@@ -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 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>
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
"password",
|
||||
"passwordMismatch",
|
||||
"setupHelp",
|
||||
"setupCommandExample",
|
||||
"setupTitle",
|
||||
"setupToken",
|
||||
"signIn",
|
||||
"username"
|
||||
]
|
||||
]
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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"); }
|
||||
|
||||
@@ -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
@@ -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%
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user