This commit is contained in:
Mateusz Gruszczyński committed 2026-10-05 12:22:41 +02:00
1 parent e1a59f6e56
commit d168ee2afa
49 files changed
+5608 -4680

No files matched your search

+1 -1
View File
@@ -353,7 +353,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-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.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-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.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 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 [new Date(item.time * 1000).toLocaleString(locale()), 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); }
+1 -1
View File
@@ -6,7 +6,7 @@ function renderSettings() {
if (!settingsDirty.has("response-form")) {
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
$("expose-request-id").checked = !!diagnostics.request_id;
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.6";
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.7";
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
+89 -20
View File
@@ -523,9 +523,75 @@
</article>
</div>
<article class="card pool-card" id="backend-diagnostics-card">
<div class="card-heading">
<h2 data-i18n="vhHealthTitle">Backends</h2><button class="quiet" data-i18n="vhManage"
id="pool-open" type="button">Manage vhosts</button>
<div class="card-heading backend-card-heading">
<h2 data-i18n="vhHealthTitle">Backends</h2>
<div class="backend-table-tools">
<label class="sr-only" data-i18n="backendSearch" for="pool-search">Search
backends</label>
<input autocomplete="off" data-i18n-aria="backendSearch"
data-i18n-placeholder="backendSearchPlaceholder" id="pool-search" type="search" />
<label class="backend-sort-control"><span data-i18n="backendSortBy">Sort
by</span><select data-i18n-aria="backendSortBy" id="pool-sort">
<option data-i18n="vhVhost" value="vhost">Vhost</option>
<option data-i18n="backend" value="backend">Backend</option>
<option data-i18n="status" value="status">Status</option>
<option data-i18n="vhActive" value="active">Active</option>
<option data-i18n="vhRequests" value="requests">Requests</option>
<option data-i18n="vhErrors" value="errors">Errors</option>
<option data-i18n="vhLatency" value="latency">Latency</option>
<option data-i18n="vhTraffic" value="traffic">Traffic/s</option>
<option data-i18n="vhClientGeneration" value="generation">Client gen.</option>
<option data-i18n="vhGenerationRequests" value="generationRequests">Gen.
requests</option>
<option data-i18n="vhGenerationAge" value="generationAge">Gen. age</option>
<option data-i18n="vhClientRotations" value="rotations">Rotations</option>
<option data-i18n="vhLastFailure" value="lastFailure">Last failure</option>
<option data-i18n="vhLastRecovery" value="lastRecovery">Last recovery</option>
</select></label>
<button class="quiet backend-sort-direction" data-i18n-aria="backendSortAscending"
id="pool-sort-direction" type="button">A-Z</button>
<details class="backend-column-picker">
<summary class="quiet" data-i18n="backendColumns">Columns</summary>
<div class="backend-column-options">
<label class="check"><input checked data-pool-column-toggle="vhost"
type="checkbox" /><span data-i18n="vhVhost">Vhost</span></label>
<label class="check"><input checked data-pool-column-toggle="backend"
type="checkbox" /><span data-i18n="backend">Backend</span></label>
<label class="check"><input checked data-pool-column-toggle="status"
type="checkbox" /><span data-i18n="status">Status</span></label>
<label class="check"><input checked data-pool-column-toggle="active"
type="checkbox" /><span data-i18n="vhActive">Active</span></label>
<label class="check"><input checked data-pool-column-toggle="requests"
type="checkbox" /><span data-i18n="vhRequests">Requests</span></label>
<label class="check"><input checked data-pool-column-toggle="errors"
type="checkbox" /><span data-i18n="vhErrors">Errors</span></label>
<label class="check"><input checked data-pool-column-toggle="latency"
type="checkbox" /><span data-i18n="vhLatency">Latency</span></label>
<label class="check"><input checked data-pool-column-toggle="traffic"
type="checkbox" /><span data-i18n="vhTraffic">Traffic/s</span></label>
<label class="check"><input checked data-pool-column-toggle="generation"
type="checkbox" /><span data-i18n="vhClientGeneration">Client
gen.</span></label>
<label class="check"><input checked data-pool-column-toggle="generationRequests"
type="checkbox" /><span data-i18n="vhGenerationRequests">Gen.
requests</span></label>
<label class="check"><input checked data-pool-column-toggle="generationAge"
type="checkbox" /><span data-i18n="vhGenerationAge">Gen.
age</span></label>
<label class="check"><input checked data-pool-column-toggle="rotations"
type="checkbox" /><span
data-i18n="vhClientRotations">Rotations</span></label>
<label class="check"><input checked data-pool-column-toggle="lastFailure"
type="checkbox" /><span data-i18n="vhLastFailure">Last
failure</span></label>
<label class="check"><input checked data-pool-column-toggle="lastRecovery"
type="checkbox" /><span data-i18n="vhLastRecovery">Last
recovery</span></label>
</div>
</details>
<button class="quiet" data-i18n="vhManage" id="pool-open" type="button">Manage
vhosts</button>
</div>
</div>
<p class="fine" data-i18n="vhHealthHint">Live state: leases cover the entire exchange; traffic
is an EWMA of payload bytes.</p>
@@ -533,20 +599,23 @@
<table class="pool-table">
<thead>
<tr>
<th data-i18n="vhVhost">Vhost</th>
<th data-i18n="backend">Backend</th>
<th data-i18n="status">Status</th>
<th data-i18n="vhActive">Active</th>
<th data-i18n="vhRequests">Requests</th>
<th data-i18n="vhErrors">Errors</th>
<th data-i18n="vhLatency">Latency</th>
<th data-i18n="vhTraffic">Traffic/s</th>
<th data-i18n="vhClientGeneration">Client gen.</th>
<th data-i18n="vhGenerationRequests">Gen. requests</th>
<th data-i18n="vhGenerationAge">Gen. age</th>
<th data-i18n="vhClientRotations">Rotations</th>
<th data-i18n="vhLastFailure">Last failure</th>
<th data-i18n="vhLastRecovery">Last recovery</th>
<th data-i18n="vhVhost" data-pool-column="vhost">Vhost</th>
<th data-i18n="backend" data-pool-column="backend">Backend</th>
<th data-i18n="status" data-pool-column="status">Status</th>
<th data-i18n="vhActive" data-pool-column="active">Active</th>
<th data-i18n="vhRequests" data-pool-column="requests">Requests</th>
<th data-i18n="vhErrors" data-pool-column="errors">Errors</th>
<th data-i18n="vhLatency" data-pool-column="latency">Latency</th>
<th data-i18n="vhTraffic" data-pool-column="traffic">Traffic/s</th>
<th data-i18n="vhClientGeneration" data-pool-column="generation">Client gen.
</th>
<th data-i18n="vhGenerationRequests" data-pool-column="generationRequests">Gen.
requests</th>
<th data-i18n="vhGenerationAge" data-pool-column="generationAge">Gen. age</th>
<th data-i18n="vhClientRotations" data-pool-column="rotations">Rotations</th>
<th data-i18n="vhLastFailure" data-pool-column="lastFailure">Last failure</th>
<th data-i18n="vhLastRecovery" data-pool-column="lastRecovery">Last recovery
</th>
</tr>
</thead>
<tbody id="pool-rows"></tbody>
@@ -1598,7 +1667,7 @@
<option data-i18n="serverCustom" 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/v0.25.6"
id="server-value" maxlength="256" placeholder="RustProxy/v0.25.7"
spellcheck="false" />
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
<div class="switch-grid">
@@ -2386,7 +2455,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.25.6</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.25.7</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -3097,7 +3166,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.25.6</strong></div>
<div><span class="small-label">RustProxy</span><strong>0.25.7</strong></div>
</div>
<dl class="about-meta">
<div>
+196 -2
View File
@@ -50,6 +50,9 @@
--shadow-mid: #0006;
--shadow-strong: #0009;
--neutral-soft: rgba(127, 127, 127, .05);
--scrollbar-track: #151a16;
--scrollbar-thumb: #4f5d50;
--scrollbar-thumb-hover: #6b7b69;
--mono: ui-monospace, SFMono-Regular, Consolas, monospace;
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif
}
@@ -97,11 +100,39 @@ html[data-theme="light"] {
--transform-chart: #7d4fa3;
--shadow-mid: rgb(31 39 33 / .16);
--shadow-strong: rgb(31 39 33 / .26);
--neutral-soft: rgba(70, 78, 72, .05)
--neutral-soft: rgba(70, 78, 72, .05);
--scrollbar-track: #e7ece4;
--scrollbar-thumb: #a9b5a6;
--scrollbar-thumb-hover: #879684
}
* {
box-sizing: border-box
box-sizing: border-box;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
scrollbar-width: thin
}
*::-webkit-scrollbar {
width: 10px;
height: 10px
}
*::-webkit-scrollbar-track {
background: var(--scrollbar-track)
}
*::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border: 2px solid var(--scrollbar-track);
border-radius: 999px
}
*::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover)
}
*::-webkit-scrollbar-corner {
background: var(--scrollbar-track)
}
html.app-loading {
@@ -2463,6 +2494,63 @@ td {
flex: 0 0 auto
}
.vh-enable-control {
display: grid !important;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-rows: auto auto;
align-items: center;
gap: 2px 10px;
margin-top: 5px !important;
padding: 11px 12px;
border: 1px solid var(--pill-live-line);
background: var(--panel2);
box-shadow: inset 3px 0 0 var(--green)
}
.vh-enable-control>input {
grid-column: 1;
grid-row: 1/3;
width: 18px;
height: 18px;
accent-color: var(--green)
}
.vh-enable-control>span {
grid-column: 2;
grid-row: 1;
color: var(--text);
font-size: 12px;
font-weight: 700
}
.vh-enable-hint {
grid-column: 2;
grid-row: 2;
color: var(--muted);
font-size: 10px
}
.vh-enable-state {
grid-column: 3;
grid-row: 1/3;
padding: 3px 7px;
border: 1px solid var(--pill-live-line);
color: var(--green);
font: 600 10px var(--mono);
text-transform: uppercase;
letter-spacing: .04em
}
.vh-enable-control.is-disabled {
border-color: var(--danger-line-strong);
box-shadow: inset 3px 0 0 var(--red)
}
.vh-enable-control.is-disabled .vh-enable-state {
border-color: var(--danger-line);
color: var(--red)
}
.vh-wide {
grid-column: 1/-1;
min-width: 0
@@ -2524,6 +2612,78 @@ td {
margin-bottom: 12px
}
.backend-card-heading {
align-items: flex-start;
gap: 10px
}
.backend-table-tools {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 7px;
margin-left: auto
}
#pool-search {
width: min(250px, 30vw);
min-width: 170px
}
.backend-sort-control {
display: flex;
align-items: center;
gap: 6px;
color: var(--muted);
font-size: 11px
}
.backend-sort-control select {
width: auto;
min-width: 120px
}
.backend-sort-direction {
min-width: 48px
}
.backend-column-picker {
position: relative
}
.backend-column-picker>summary {
list-style: none;
cursor: pointer;
user-select: none
}
.backend-column-picker>summary::-webkit-details-marker {
display: none
}
.backend-column-options {
position: absolute;
z-index: 20;
top: calc(100% + 6px);
right: 0;
display: grid;
grid-template-columns: repeat(2, minmax(145px, 1fr));
gap: 1px 12px;
min-width: 330px;
max-height: 330px;
overflow: auto;
padding: 10px 12px 12px;
border: 1px solid var(--line);
background: var(--panel);
box-shadow: 0 12px 28px var(--shadow-strong)
}
.backend-column-options .check {
margin-top: 4px !important;
font-size: 11px
}
.pool-card .fine {
margin: 0 0 8px
}
@@ -4861,6 +5021,16 @@ dialog .window-heading {
margin-top: 8px
}
.vhost-quick-toggle.is-enable {
border-color: var(--pill-live-line);
color: var(--green)
}
.vhost-quick-toggle.is-disable {
border-color: var(--danger-line);
color: var(--red)
}
@media(max-width:980px) {
.logging-grid {
grid-template-columns: 1fr
@@ -4896,6 +5066,30 @@ dialog .window-heading {
min-width: 0
}
.backend-card-heading {
align-items: stretch;
flex-direction: column
}
.backend-table-tools {
width: 100%;
justify-content: flex-start;
margin-left: 0
}
#pool-search {
flex: 1 1 100%;
width: 100%;
min-width: 0
}
.backend-column-options {
right: auto;
left: 0;
grid-template-columns: 1fr;
min-width: min(290px, 82vw)
}
.vhost-card-head .entity-actions {
width: 100%;
justify-content: flex-start
+5 -1
View File
@@ -38,7 +38,11 @@
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
readHosts = hostTagEditor(g, value.frontend?.hosts || []); field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
readHosts = hostTagEditor(g, value.frontend?.hosts || []);
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox"), enabledLabel = enabledControl.closest("label"), enabledState = el("strong", "", "vh-enable-state");
if (enabledLabel) { enabledLabel.classList.add("vh-enable-control", "vh-wide"); enabledLabel.append(text("small", "vhEnableHint", "vh-enable-hint"), enabledState); }
const syncEnabledState = () => { const key = enabledControl.checked ? "vhEnabled" : "vhDisabled"; enabledLabel?.classList.toggle("is-disabled", !enabledControl.checked); enabledLabel?.classList.toggle("is-enabled", enabledControl.checked); enabledState.dataset.i18n = key; enabledState.textContent = t(key); };
enabledControl.addEventListener("change", syncEnabledState); syncEnabledState();
const mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] });
const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || "");
const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource();
+93 -4
View File
@@ -1,3 +1,87 @@
const poolColumns = ["vhost", "backend", "status", "active", "requests", "errors", "latency", "traffic", "generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"];
const defaultPoolTablePrefs = () => ({ sort: "vhost", descending: false, columns: [...poolColumns] });
let poolTablePrefs = defaultPoolTablePrefs(), 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 {
sort: poolColumns.includes(preferences?.backend_table_sort) ? preferences.backend_table_sort : "vhost",
descending: preferences?.backend_table_descending === true,
columns: selected.length ? selected : [...poolColumns],
};
}
function applyPoolPreferences(preferences) {
poolTablePrefs = normalizePoolTablePrefs(preferences);
const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort;
syncPoolColumns(); syncPoolSortDirection(); applyPoolTableView();
}
async function savePoolTablePrefs() {
if (!authenticated) return;
poolPreferenceSaveQueued = true;
if (poolPreferenceSaveRunning) return;
poolPreferenceSaveRunning = true;
try {
while (poolPreferenceSaveQueued && authenticated) {
poolPreferenceSaveQueued = false;
const saved = await window.rustProxyPreferences.update({
backend_table_columns: poolColumns.filter(key => poolTablePrefs.columns.includes(key)),
backend_table_sort: poolTablePrefs.sort,
backend_table_descending: poolTablePrefs.descending,
});
if (!authenticated) return;
if (!poolPreferenceSaveQueued) poolTablePrefs = normalizePoolTablePrefs(saved);
}
syncPoolColumns(); syncPoolSortDirection();
} catch (error) { if (error.status !== 401) toast(error.message, true); }
finally { poolPreferenceSaveRunning = false; }
}
function syncPoolColumns() {
const visible = new Set(poolTablePrefs.columns);
document.querySelectorAll("[data-pool-column]").forEach(node => { node.hidden = !visible.has(node.dataset.poolColumn); });
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => { input.checked = visible.has(input.dataset.poolColumnToggle); });
}
function syncPoolSortDirection() {
const control = $("pool-sort-direction"); if (!control) return;
control.textContent = poolTablePrefs.descending ? "Z-A" : "A-Z";
control.setAttribute("aria-label", t(poolTablePrefs.descending ? "backendSortDescending" : "backendSortAscending"));
}
function applyPoolTableView() {
const body = $("pool-rows"); if (!body) return;
const query = ($("pool-search")?.value || "").trim().toLowerCase();
const key = $("pool-sort")?.value || poolTablePrefs.sort;
const rows = [...body.querySelectorAll("tr")];
rows.sort((left, right) => {
const a = left.poolSort?.[key] ?? "", b = right.poolSort?.[key] ?? "";
const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" });
return poolTablePrefs.descending ? -result : result;
});
body.replaceChildren(...rows);
let visible = 0;
for (const row of rows) { const match = !query || (row.poolSearchValue || "").includes(query); row.hidden = !match; if (match) visible++; }
syncPoolColumns(); syncPoolSortDirection();
const empty = $("pool-empty");
if (empty) { empty.textContent = query && rows.length && !visible ? t("backendSearchEmpty") : t("vhHealthEmpty"); empty.hidden = visible > 0 || (!rows.length && !document.querySelector("#backend-diagnostics-card:not([hidden])")); }
}
function initPoolTableTools() {
const sort = $("pool-sort"), direction = $("pool-sort-direction"), search = $("pool-search");
if (sort) { sort.value = poolTablePrefs.sort; sort.addEventListener("change", () => { poolTablePrefs.sort = sort.value; void savePoolTablePrefs(); applyPoolTableView(); }); }
direction?.addEventListener("click", () => { poolTablePrefs.descending = !poolTablePrefs.descending; void savePoolTablePrefs(); applyPoolTableView(); });
search?.addEventListener("input", applyPoolTableView);
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => input.addEventListener("change", () => {
const visible = new Set(poolTablePrefs.columns);
if (input.checked) visible.add(input.dataset.poolColumnToggle); else visible.delete(input.dataset.poolColumnToggle);
if (!visible.size) { input.checked = true; toast(t("backendColumnsRequired"), true); return; }
poolTablePrefs.columns = poolColumns.filter(key => visible.has(key)); void savePoolTablePrefs(); syncPoolColumns();
}));
syncPoolColumns(); syncPoolSortDirection();
}
async function toggleVhostEnabled(vhost) {
if (!envelope || busy) return;
const next = structuredClone(envelope.config), target = (next.vhosts || []).find(item => item.id === vhost.id);
if (!target) return;
target.enabled = vhost.enabled === false;
if (await saveSection("vhosts", next.vhosts || [])) render();
}
function render() {
if (!envelope) return;
if (!initialized) load(defaults(), [], false);
@@ -33,7 +117,9 @@
const browseTls = configuredEndpoints.some(endpoint => endpoint.binding?.tls === true);
const openButton = button("vhOpenBrowser", () => { if (browseHost) window.open(`${browseTls ? "https" : "http"}://${browseHost}/`, "_blank", "noopener,noreferrer"); });
openButton.disabled = !browseHost || v.enabled === false;
actions.append(openButton, button("edit", async () => { if (await allowDiscard()) load(structuredClone(v)); }), button("remove", async () => {
const quickToggle = button(v.enabled === false ? "vhQuickEnable" : "vhQuickDisable", () => toggleVhostEnabled(v), `quiet vhost-quick-toggle ${v.enabled === false ? "is-enable" : "is-disable"}`);
quickToggle.setAttribute("aria-pressed", String(v.enabled !== false));
actions.append(quickToggle, openButton, button("edit", async () => { if (await allowDiscard()) load(structuredClone(v)); }), button("remove", async () => {
if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return;
const next = structuredClone(envelope.config); next.vhosts = (next.vhosts || []).filter(item => item.id !== v.id);
if (await saveSection("vhosts", next.vhosts || [])) { if (editing === v.id) load(); render(); }
@@ -147,7 +233,9 @@
const generationAgeText = generationAgeRaw == null || !Number.isFinite(generationAge) ? "-" : generationAge >= 3600 ? `${Math.floor(generationAge / 3600)}h ${Math.floor((generationAge % 3600) / 60)}m` : generationAge >= 60 ? `${Math.floor(generationAge / 60)}m ${Math.floor(generationAge % 60)}s` : `${Math.floor(generationAge)}s`;
const row = el("tr"); if (vhostDisabled) row.classList.add("pool-vhost-disabled"); const poolDomain = status.backend_pool_domains?.[pool.id]; const poolLabel = poolDomain ? `${pool.id} (${poolDomain})` : pool.id; const cells = [poolLabel, `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, label, activeLimit, member.requests, member.errors,
member.ewma_latency_ms > 0 ? `${member.ewma_latency_ms.toFixed(1)} ms` : "-", `${rateBytes(memberTraffic)}/s`, member.upstream_client_generation ?? "-", member.upstream_client_generation_requests ?? "-", generationAgeText, member.upstream_client_rotations_total ?? "-", lastFailure, lastRecovery];
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 12 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 12 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
const sortValues = { vhost: poolLabel.toLowerCase(), backend: String(member.id || "").toLowerCase(), status: label.toLowerCase(), active: Number(member.active) || 0, requests: Number(member.requests) || 0, errors: Number(member.errors) || 0, latency: Number(member.ewma_latency_ms) || 0, traffic: memberTraffic, generation: Number(member.upstream_client_generation) || 0, generationRequests: Number(member.upstream_client_generation_requests) || 0, generationAge: Number.isFinite(generationAge) ? generationAge : -1, rotations: Number(member.upstream_client_rotations_total) || 0, lastFailure: Number(member.last_failure_at_ms) || 0, lastRecovery: Number(member.last_recovered_ms) || 0 };
row.poolSort = sortValues; row.poolSearchValue = [poolLabel, member.id, label, member.status, member.last_failure_class, member.last_failure_detail].filter(Boolean).join(" ").toLowerCase();
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.dataset.poolColumn = poolColumns[index]; cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 12 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 12 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic;
const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; }
}
@@ -190,9 +278,9 @@
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
);
$("pool-rows").replaceChildren(...rows);
$("pool-empty").hidden = rows.length > 0 || !hasConfiguredProxySource;
const backendDiagnosticsCard = $("backend-diagnostics-card");
if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource;
applyPoolTableView();
document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty"));
const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; });
fill("vhostHealth", d => d.disabled ? t("vhDisabled") : d.staticOnly ? t("vhUp") : d.healthMode === "off" ? t("vhHealthDisabled") : `${d.up}/${d.total} ${t("vhUp")}`);
@@ -242,6 +330,7 @@
load(saved ? structuredClone(saved) : defaults());
});
$("pool-open").addEventListener("click", () => setPanel("vhosts"));
initPoolTableTools();
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
window.rustProxyVhosts = { render, renderStatus, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); } };
window.rustProxyVhosts = { render, renderStatus, applyPreferences: applyPoolPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; poolTablePrefs = defaultPoolTablePrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; applyPoolTableView(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);